feat: 同步引擎+Agent配置+依赖自动落位+看板大改(预览/md渲染/筛选/徽章组)
后端: - src/sync/todo-sync.ts: todo.json 单向同步引擎(导入=首次同步,幂等,source_ref 映射, subs 复杂度修正为 easy,旧侧 done 历史事实优先 forceDone) - 依赖自动落位:ready 意图按 deps 落位 blocked,依赖全 done 自动放行, daemon 启动 reconcileDeps 对账,手动绕过会弹回 - 新 API: PATCH projects/:id(autonomy/concurrency)、POST :id/sync、GET /api/agents、 PATCH tasks/:id(title/priority/complexity 重置) - daemon 定时同步(MAESTRO_SYNC_INTERVAL 默认 300s) + project.synced 事件 - priority 语义翻转: P0 最高/P1 默认/P2 最低,取值限 0..2,排序/映射/MCP/CLI 全跟进 - 静态服务发 no-cache 头(修浏览器吃旧 CSS/JS) - schema 迁移: tasks.source_ref / projects.last_sync_at(ensureColumn 平滑升级旧库) 看板: - 全屏预览模式(94vh 读完整方案+就地裁决,Esc/遮罩/裁决自动关闭) - 产出 markdown 渲染为 HTML(零依赖渲染器,转义优先) - 任务树筛选(复杂度/状态分组/关键字)+ 顶栏徽章组(待审批/可执行/执行中,hover 展开) - 依赖可视化:详情 DEPS 区块 + 行内⛓等依赖 + 锚点跳转定位 - 按钮收敛:提交评审/编辑产出移除(CC 经 MCP 操作),界面只留用户动作 - Agent 执行面板 + 项目配置(并发/工作模式)+ 同步按钮 测试:21 个全过(新增 sync 幂等/迁移/patch/依赖落位/对账幂等) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+248
-12
@@ -24,6 +24,32 @@ export interface CreateTaskInput {
|
||||
projectId: string; title: string; complexity: Complexity;
|
||||
parentId?: string | null; priority?: number; deps?: string[];
|
||||
}
|
||||
export interface PatchProjectInput {
|
||||
autonomy?: Autonomy; concurrency?: number; verifyCmd?: string | null;
|
||||
model?: string | null; status?: 'active' | 'paused';
|
||||
}
|
||||
export interface PatchTaskInput {
|
||||
title?: string; priority?: number; complexity?: Complexity;
|
||||
}
|
||||
/** 执行中的 run(联 tasks 取标题/项目),供 GET /api/agents 汇总 */
|
||||
export interface ActiveRun {
|
||||
runId: string; taskId: string; taskTitle: string; kind: string;
|
||||
startedAt: string; projectId: string;
|
||||
}
|
||||
|
||||
const AUTONOMY_VALUES: readonly Autonomy[] = ['manual', 'auto-easy', 'auto-approved'];
|
||||
|
||||
/** 优先级取值:P0 最高 / P1 中(默认)/ P2 最低 */
|
||||
function assertPriority(p: number): void {
|
||||
if (!Number.isInteger(p) || p < 0 || p > 2) {
|
||||
throw new StoreError('priority 必须是 0/1/2(P0 最高,P1 中,P2 最低)');
|
||||
}
|
||||
}
|
||||
|
||||
/** 允许修改 complexity 的状态:尚未进入执行/收尾链路 */
|
||||
const COMPLEXITY_EDITABLE: ReadonlySet<TaskStatus> = new Set<TaskStatus>([
|
||||
'init', 'analyzing', 'speccing', 'ready', 'plan_review', 'spec_review', 'blocked',
|
||||
]);
|
||||
|
||||
type EventListener = (e: Event) => void;
|
||||
|
||||
@@ -67,10 +93,11 @@ export class Store {
|
||||
default_branch: input.defaultBranch ?? 'main', verify_cmd: input.verifyCmd ?? null,
|
||||
autonomy: input.autonomy ?? 'manual', model: input.model ?? null,
|
||||
concurrency: input.concurrency ?? 1, status: 'active', created_at: now(),
|
||||
last_sync_at: null,
|
||||
};
|
||||
this.db.prepare(
|
||||
`INSERT INTO projects (id,name,repo_path,default_branch,verify_cmd,autonomy,model,concurrency,status,created_at)
|
||||
VALUES (@id,@name,@repo_path,@default_branch,@verify_cmd,@autonomy,@model,@concurrency,@status,@created_at)`,
|
||||
`INSERT INTO projects (id,name,repo_path,default_branch,verify_cmd,autonomy,model,concurrency,status,created_at,last_sync_at)
|
||||
VALUES (@id,@name,@repo_path,@default_branch,@verify_cmd,@autonomy,@model,@concurrency,@status,@created_at,@last_sync_at)`,
|
||||
).run(row);
|
||||
this.emit(row.id, null, 'task.created', { kind: 'project', name: row.name });
|
||||
return rowToProject(row);
|
||||
@@ -86,6 +113,51 @@ export class Store {
|
||||
return row ? rowToProject(row) : null;
|
||||
}
|
||||
|
||||
/** 部分更新项目配置(autonomy/concurrency/verifyCmd/model/status),带合法性校验。 */
|
||||
patchProject(projectId: string, patch: PatchProjectInput): Project {
|
||||
const cur = this.getProject(projectId);
|
||||
if (!cur) throw new StoreError(`项目不存在: ${projectId}`);
|
||||
|
||||
const sets: string[] = [];
|
||||
const args: Record<string, unknown> = { id: projectId };
|
||||
if (patch.autonomy !== undefined) {
|
||||
if (!AUTONOMY_VALUES.includes(patch.autonomy)) {
|
||||
throw new StoreError('autonomy 必须是 manual | auto-easy | auto-approved');
|
||||
}
|
||||
sets.push('autonomy = @autonomy'); args.autonomy = patch.autonomy;
|
||||
}
|
||||
if (patch.concurrency !== undefined) {
|
||||
if (!Number.isInteger(patch.concurrency) || patch.concurrency < 1) {
|
||||
throw new StoreError('concurrency 必须是 >=1 的整数');
|
||||
}
|
||||
sets.push('concurrency = @concurrency'); args.concurrency = patch.concurrency;
|
||||
}
|
||||
if (patch.status !== undefined) {
|
||||
if (patch.status !== 'active' && patch.status !== 'paused') {
|
||||
throw new StoreError('status 必须是 active | paused');
|
||||
}
|
||||
sets.push('status = @status'); args.status = patch.status;
|
||||
}
|
||||
if (patch.verifyCmd !== undefined) { sets.push('verify_cmd = @verify_cmd'); args.verify_cmd = patch.verifyCmd; }
|
||||
if (patch.model !== undefined) { sets.push('model = @model'); args.model = patch.model; }
|
||||
|
||||
if (sets.length > 0) {
|
||||
this.db.prepare(`UPDATE projects SET ${sets.join(', ')} WHERE id = @id`).run(args);
|
||||
this.emit(projectId, null, 'task.updated', { kind: 'project', fields: Object.keys(patch) });
|
||||
}
|
||||
return this.getProject(projectId)!;
|
||||
}
|
||||
|
||||
/** 标记项目完成一次 todo.json 同步:写 last_sync_at 并广播 project.synced(payload=同步统计)。 */
|
||||
markSynced(projectId: string, payload: Record<string, unknown> = {}): string {
|
||||
const cur = this.getProject(projectId);
|
||||
if (!cur) throw new StoreError(`项目不存在: ${projectId}`);
|
||||
const at = now();
|
||||
this.db.prepare(`UPDATE projects SET last_sync_at = ? WHERE id = ?`).run(at, projectId);
|
||||
this.emit(projectId, null, 'project.synced', { ...payload, lastSyncAt: at });
|
||||
return at;
|
||||
}
|
||||
|
||||
// ---------- Tasks ----------
|
||||
createTask(input: CreateTaskInput): Task {
|
||||
const project = this.getProject(input.projectId);
|
||||
@@ -101,16 +173,23 @@ export class Store {
|
||||
if (depth > max) throw new StoreError(`层级超限:最多 ${max} 层(父任务 ${parent.complexity})`);
|
||||
}
|
||||
|
||||
const status = initialNextStatus(input.complexity);
|
||||
if (input.priority !== undefined) assertPriority(input.priority);
|
||||
let status = initialNextStatus(input.complexity);
|
||||
// Easy 直达 ready,但有未完成依赖时落位 blocked
|
||||
const depsArr = input.deps ?? [];
|
||||
if (status === 'ready' && depsArr.length && !depsArr.every((d) => this.getTaskRow(d)?.status === 'done')) {
|
||||
status = 'blocked';
|
||||
}
|
||||
const row: TaskRow = {
|
||||
id: id('tsk'), project_id: input.projectId, parent_id: input.parentId ?? null, depth,
|
||||
title: input.title, complexity: input.complexity, status, priority: input.priority ?? 0,
|
||||
title: input.title, complexity: input.complexity, status, priority: input.priority ?? 1,
|
||||
deps: JSON.stringify(input.deps ?? []), plan: null, spec: null, operations: null,
|
||||
result: null, assignee: null, created_at: now(), updated_at: now(),
|
||||
source_ref: null,
|
||||
};
|
||||
this.db.prepare(
|
||||
`INSERT INTO tasks (id,project_id,parent_id,depth,title,complexity,status,priority,deps,plan,spec,operations,result,assignee,created_at,updated_at)
|
||||
VALUES (@id,@project_id,@parent_id,@depth,@title,@complexity,@status,@priority,@deps,@plan,@spec,@operations,@result,@assignee,@created_at,@updated_at)`,
|
||||
`INSERT INTO tasks (id,project_id,parent_id,depth,title,complexity,status,priority,deps,plan,spec,operations,result,assignee,created_at,updated_at,source_ref)
|
||||
VALUES (@id,@project_id,@parent_id,@depth,@title,@complexity,@status,@priority,@deps,@plan,@spec,@operations,@result,@assignee,@created_at,@updated_at,@source_ref)`,
|
||||
).run(row);
|
||||
this.emit(input.projectId, row.id, 'task.created', { title: row.title, complexity: row.complexity, status });
|
||||
return rowToTask(row);
|
||||
@@ -128,14 +207,14 @@ export class Store {
|
||||
|
||||
listTasks(projectId: string): Task[] {
|
||||
const rows = this.db.prepare(
|
||||
`SELECT * FROM tasks WHERE project_id = ? ORDER BY depth, priority DESC, created_at`,
|
||||
`SELECT * FROM tasks WHERE project_id = ? ORDER BY depth, priority ASC, created_at`,
|
||||
).all(projectId) as TaskRow[];
|
||||
return rows.map((r) => rowToTask(r, this.listApprovals(r.id)));
|
||||
}
|
||||
|
||||
childrenOf(taskId: string): Task[] {
|
||||
const rows = this.db.prepare(
|
||||
`SELECT * FROM tasks WHERE parent_id = ? ORDER BY priority DESC, created_at`,
|
||||
`SELECT * FROM tasks WHERE parent_id = ? ORDER BY priority ASC, created_at`,
|
||||
).all(taskId) as TaskRow[];
|
||||
return rows.map((r) => rowToTask(r, this.listApprovals(r.id)));
|
||||
}
|
||||
@@ -161,6 +240,142 @@ export class Store {
|
||||
return this.getTask(taskId)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* 部分更新任务(title/priority/complexity)。
|
||||
* complexity 修改仅允许尚未进入执行链路的状态(init/analyzing/speccing/ready/plan_review/spec_review/blocked);
|
||||
* 改后 status 重置为新复杂度的初始态(initialNextStatus)并广播 status.changed。
|
||||
*/
|
||||
patchTask(taskId: string, patch: PatchTaskInput): Task {
|
||||
const row = this.getTaskRow(taskId);
|
||||
if (!row) throw new StoreError(`任务不存在: ${taskId}`);
|
||||
|
||||
const fields: string[] = [];
|
||||
if (patch.title !== undefined) {
|
||||
if (!patch.title.trim()) throw new StoreError('title 不能为空');
|
||||
this.db.prepare(`UPDATE tasks SET title = ?, updated_at = ? WHERE id = ?`).run(patch.title, now(), taskId);
|
||||
fields.push('title');
|
||||
}
|
||||
if (patch.priority !== undefined) {
|
||||
assertPriority(patch.priority);
|
||||
this.db.prepare(`UPDATE tasks SET priority = ?, updated_at = ? WHERE id = ?`).run(patch.priority, now(), taskId);
|
||||
fields.push('priority');
|
||||
}
|
||||
|
||||
let statusChange: { from: TaskStatus; to: TaskStatus } | null = null;
|
||||
if (patch.complexity !== undefined && patch.complexity !== row.complexity) {
|
||||
const cur = row.status as TaskStatus;
|
||||
if (!COMPLEXITY_EDITABLE.has(cur)) {
|
||||
throw new StoreError(
|
||||
`当前状态 ${STATUS_LABEL[cur]}(${cur}) 不允许修改复杂度(仅限 init/analyzing/speccing/ready/plan_review/spec_review/blocked)`,
|
||||
);
|
||||
}
|
||||
const to = this.resolveReady(row, initialNextStatus(patch.complexity)); // ready 落位时考虑依赖
|
||||
this.db.prepare(`UPDATE tasks SET complexity = ?, status = ?, updated_at = ? WHERE id = ?`)
|
||||
.run(patch.complexity, to, now(), taskId);
|
||||
fields.push('complexity');
|
||||
if (to !== cur) statusChange = { from: cur, to };
|
||||
}
|
||||
|
||||
if (fields.length > 0) {
|
||||
this.emit(row.project_id, taskId, 'task.updated', { fields });
|
||||
if (statusChange) {
|
||||
this.emit(row.project_id, taskId, 'status.changed', { ...statusChange, reason: 'complexity.changed' });
|
||||
}
|
||||
}
|
||||
return this.getTask(taskId)!;
|
||||
}
|
||||
|
||||
// ---------- 旧 todo 来源映射(sync 引擎用) ----------
|
||||
setSourceRef(taskId: string, ref: string): void {
|
||||
const row = this.getTaskRow(taskId);
|
||||
if (!row) throw new StoreError(`任务不存在: ${taskId}`);
|
||||
this.db.prepare(`UPDATE tasks SET source_ref = ? WHERE id = ?`).run(ref, taskId);
|
||||
}
|
||||
|
||||
getTaskBySourceRef(projectId: string, ref: string): Task | null {
|
||||
const row = this.db.prepare(
|
||||
`SELECT * FROM tasks WHERE project_id = ? AND source_ref = ?`,
|
||||
).get(projectId, ref) as TaskRow | undefined;
|
||||
return row ? rowToTask(row, this.listApprovals(row.id)) : null;
|
||||
}
|
||||
|
||||
/** 项目内全部已映射任务:source_ref → Task */
|
||||
sourceRefMap(projectId: string): Map<string, Task> {
|
||||
const rows = this.db.prepare(
|
||||
`SELECT * FROM tasks WHERE project_id = ? AND source_ref IS NOT NULL`,
|
||||
).all(projectId) as TaskRow[];
|
||||
const map = new Map<string, Task>();
|
||||
for (const r of rows) map.set(r.source_ref!, rowToTask(r));
|
||||
return map;
|
||||
}
|
||||
|
||||
// ---------- 依赖驱动的 ready/blocked 自动管理 ----------
|
||||
/** 依赖是否全部 done(未知 id 视为未满足) */
|
||||
private depsMetRow(row: TaskRow): boolean {
|
||||
const deps = JSON.parse(row.deps) as string[];
|
||||
return deps.every((d) => this.getTaskRow(d)?.status === 'done');
|
||||
}
|
||||
|
||||
/** 意图是 ready 时按依赖落位:未满足 → blocked(系统自动管理,不可手动绕过) */
|
||||
private resolveReady(row: TaskRow, to: TaskStatus): TaskStatus {
|
||||
return to === 'ready' && !this.depsMetRow(row) ? 'blocked' : to;
|
||||
}
|
||||
|
||||
/** 某任务 done 后:把同项目内依赖已全部满足的 blocked 任务自动放行为 ready */
|
||||
private releaseDependents(projectId: string): void {
|
||||
const rows = this.db.prepare(
|
||||
`SELECT * FROM tasks WHERE project_id = ? AND status = 'blocked'`,
|
||||
).all(projectId) as TaskRow[];
|
||||
for (const r of rows) {
|
||||
if (!this.depsMetRow(r)) continue;
|
||||
this.db.prepare(`UPDATE tasks SET status = 'ready', updated_at = ? WHERE id = ?`).run(now(), r.id);
|
||||
this.emit(projectId, r.id, 'status.changed', { from: 'blocked', to: 'ready', auto: 'deps-met' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 全量依赖对账(幂等):ready 但依赖未满足 → blocked;blocked 但依赖已满足 → ready。
|
||||
* daemon 启动时调用,纠正存量数据。
|
||||
*/
|
||||
reconcileDeps(projectId?: string): { blocked: number; released: number } {
|
||||
const pids = projectId ? [projectId] : this.listProjects().map((p) => p.id);
|
||||
let nBlocked = 0;
|
||||
let nReleased = 0;
|
||||
for (const pid of pids) {
|
||||
const rows = this.db.prepare(
|
||||
`SELECT * FROM tasks WHERE project_id = ? AND status IN ('ready','blocked')`,
|
||||
).all(pid) as TaskRow[];
|
||||
for (const r of rows) {
|
||||
const met = this.depsMetRow(r);
|
||||
if (r.status === 'ready' && !met) {
|
||||
this.db.prepare(`UPDATE tasks SET status = 'blocked', updated_at = ? WHERE id = ?`).run(now(), r.id);
|
||||
this.emit(pid, r.id, 'status.changed', { from: 'ready', to: 'blocked', auto: 'deps-reconcile' });
|
||||
nBlocked++;
|
||||
} else if (r.status === 'blocked' && met) {
|
||||
this.db.prepare(`UPDATE tasks SET status = 'ready', updated_at = ? WHERE id = ?`).run(now(), r.id);
|
||||
this.emit(pid, r.id, 'status.changed', { from: 'blocked', to: 'ready', auto: 'deps-reconcile' });
|
||||
nReleased++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { blocked: nBlocked, released: nReleased };
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅供导入器:旧系统中已完成的任务直接置 done(绕过执行链路与依赖落位——
|
||||
* 历史事实优先,避免已完成的工作被当作待执行复活),并放行依赖它的任务。
|
||||
*/
|
||||
forceDone(taskId: string, actor: string): Task {
|
||||
const row = this.getTaskRow(taskId);
|
||||
if (!row) throw new StoreError(`任务不存在: ${taskId}`);
|
||||
const from = row.status as TaskStatus;
|
||||
if (from === 'done') return this.getTask(taskId)!;
|
||||
this.db.prepare(`UPDATE tasks SET status = 'done', updated_at = ? WHERE id = ?`).run(now(), taskId);
|
||||
this.emit(row.project_id, taskId, 'status.changed', { from, to: 'done', auto: 'import-done', actor });
|
||||
this.releaseDependents(row.project_id);
|
||||
return this.getTask(taskId)!;
|
||||
}
|
||||
|
||||
/** 受守卫的状态变更:非法流转抛错;记录 status.changed 事件。 */
|
||||
transition(taskId: string, to: TaskStatus, meta: Record<string, unknown> = {}): Task {
|
||||
const row = this.getTaskRow(taskId);
|
||||
@@ -170,8 +385,14 @@ export class Store {
|
||||
if (!canTransition(from, to)) {
|
||||
throw new StoreError(`非法状态流转:${STATUS_LABEL[from]}(${from}) → ${STATUS_LABEL[to]}(${to})`);
|
||||
}
|
||||
this.db.prepare(`UPDATE tasks SET status = ?, updated_at = ? WHERE id = ?`).run(to, now(), taskId);
|
||||
this.emit(row.project_id, taskId, 'status.changed', { from, to, ...meta });
|
||||
// 意图 ready 但依赖未满足 → 系统落位 blocked
|
||||
const actual = this.resolveReady(row, to);
|
||||
if (from === actual) return this.getTask(taskId)!;
|
||||
this.db.prepare(`UPDATE tasks SET status = ?, updated_at = ? WHERE id = ?`).run(actual, now(), taskId);
|
||||
this.emit(row.project_id, taskId, 'status.changed', {
|
||||
from, to: actual, ...(actual !== to ? { requested: to, auto: 'deps-unmet' } : {}), ...meta,
|
||||
});
|
||||
if (actual === 'done') this.releaseDependents(row.project_id);
|
||||
return this.getTask(taskId)!;
|
||||
}
|
||||
|
||||
@@ -207,9 +428,10 @@ export class Store {
|
||||
throw new StoreError('reject 必须填写改进意见');
|
||||
}
|
||||
|
||||
const to: TaskStatus = action === 'accept'
|
||||
const intended: TaskStatus = action === 'accept'
|
||||
? ({ plan: 'decomposed', spec: 'ready', exec: 'done' } as const)[gate]
|
||||
: ({ plan: 'analyzing', spec: 'speccing', exec: 'ready' } as const)[gate];
|
||||
const to = this.resolveReady(row, intended); // spec accept / exec reject → ready 时按依赖落位
|
||||
|
||||
const txn = this.db.transaction(() => {
|
||||
const ap: ApprovalRow = {
|
||||
@@ -222,6 +444,7 @@ export class Store {
|
||||
});
|
||||
txn();
|
||||
this.emit(row.project_id, taskId, action === 'accept' ? 'approval.granted' : 'approval.rejected', { gate, from, to, reason: reason ?? null });
|
||||
if (to === 'done') this.releaseDependents(row.project_id); // 完成 → 自动放行依赖它的任务
|
||||
return this.getTask(taskId)!;
|
||||
}
|
||||
|
||||
@@ -252,6 +475,19 @@ export class Store {
|
||||
return rowToRun(this.db.prepare(`SELECT * FROM runs WHERE id = ?`).get(runId) as RunRow);
|
||||
}
|
||||
|
||||
/** 所有进行中的 run(status='started'),联 tasks 取任务标题与项目。 */
|
||||
activeRuns(): ActiveRun[] {
|
||||
const rows = this.db.prepare(
|
||||
`SELECT r.id AS run_id, r.task_id, r.kind, r.started_at, t.title, t.project_id
|
||||
FROM runs r JOIN tasks t ON t.id = r.task_id
|
||||
WHERE r.status = 'started' ORDER BY r.started_at`,
|
||||
).all() as Array<{ run_id: string; task_id: string; kind: string; started_at: string; title: string; project_id: string }>;
|
||||
return rows.map((r) => ({
|
||||
runId: r.run_id, taskId: r.task_id, taskTitle: r.title,
|
||||
kind: r.kind, startedAt: r.started_at, projectId: r.project_id,
|
||||
}));
|
||||
}
|
||||
|
||||
listRuns(taskId: string): Run[] {
|
||||
const rows = this.db.prepare(`SELECT * FROM runs WHERE task_id = ? ORDER BY started_at`).all(taskId) as RunRow[];
|
||||
return rows.map(rowToRun);
|
||||
@@ -271,7 +507,7 @@ export class Store {
|
||||
*/
|
||||
nextExecutable(projectId: string): Task | null {
|
||||
const rows = this.db.prepare(
|
||||
`SELECT * FROM tasks WHERE project_id = ? AND status = 'ready' ORDER BY priority DESC, created_at`,
|
||||
`SELECT * FROM tasks WHERE project_id = ? AND status = 'ready' ORDER BY priority ASC, created_at`,
|
||||
).all(projectId) as TaskRow[];
|
||||
for (const r of rows) {
|
||||
if (this.childrenOf(r.id).length > 0) continue; // 非叶子跳过
|
||||
|
||||
Reference in New Issue
Block a user