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:
wangjia
2026-06-12 23:58:28 +08:00
parent c6e66baa19
commit fa472a06a7
18 changed files with 1942 additions and 368 deletions
+15 -1
View File
@@ -5,11 +5,25 @@ import { dirname, join } from 'node:path';
const HERE = dirname(fileURLToPath(import.meta.url));
/** 打开(或新建)数据库并应用 schema。schema.sql 在 dev(src) 与 build(dist) 两处都与本文件同目录。 */
/** 表已存在但缺列时补列(轻量迁移,旧库平滑升级)。表不存在则交给 schema.sql 全新建表。 */
function ensureColumn(db: Database.Database, table: string, column: string, ddl: string): void {
const cols = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
if (cols.length === 0) return; // 表不存在:schema.sql 会带新列建表
if (cols.some((c) => c.name === column)) return; // 列已存在
db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
}
/**
* 打开(或新建)数据库并应用 schema。schema.sql 在 dev(src) 与 build(dist) 两处都与本文件同目录。
* 迁移须在 exec(schema) 之前跑:schema.sql 里的 idx_tasks_source 索引引用 source_ref
* 旧库须先补列,否则建索引会失败。
*/
export function openDb(file: string): Database.Database {
const db = new Database(file);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
ensureColumn(db, 'tasks', 'source_ref', 'source_ref TEXT');
ensureColumn(db, 'projects', 'last_sync_at', 'last_sync_at TEXT');
const schema = readFileSync(join(HERE, 'schema.sql'), 'utf8');
db.exec(schema);
return db;
+1 -1
View File
@@ -1,4 +1,4 @@
export { Store, StoreError } from './store.js';
export type { CreateProjectInput, CreateTaskInput } from './store.js';
export type { CreateProjectInput, CreateTaskInput, PatchProjectInput, PatchTaskInput, ActiveRun } from './store.js';
export { openDb } from './db.js';
export type { DB } from './db.js';
+3
View File
@@ -8,12 +8,14 @@ export interface ProjectRow {
id: string; name: string; repo_path: string; default_branch: string;
verify_cmd: string | null; autonomy: string; model: string | null;
concurrency: number; status: string; created_at: string;
last_sync_at: string | null;
}
export interface TaskRow {
id: string; project_id: string; parent_id: string | null; depth: number;
title: string; complexity: string; status: string; priority: number;
deps: string; plan: string | null; spec: string | null; operations: string | null;
result: string | null; assignee: string | null; created_at: string; updated_at: string;
source_ref: string | null;
}
export interface ApprovalRow {
id: string; task_id: string; gate: string; action: string;
@@ -33,6 +35,7 @@ export function rowToProject(r: ProjectRow): Project {
id: r.id, name: r.name, repoPath: r.repo_path, defaultBranch: r.default_branch,
verifyCmd: r.verify_cmd, autonomy: r.autonomy as Autonomy, model: r.model,
concurrency: r.concurrency, status: r.status as Project['status'], createdAt: r.created_at,
lastSyncAt: r.last_sync_at,
};
}
+6 -3
View File
@@ -12,7 +12,8 @@ CREATE TABLE IF NOT EXISTS projects (
model TEXT,
concurrency INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'active', -- active | paused
created_at TEXT NOT NULL
created_at TEXT NOT NULL,
last_sync_at TEXT -- 最近一次 todo.json 同步时间
);
CREATE TABLE IF NOT EXISTS tasks (
@@ -23,7 +24,7 @@ CREATE TABLE IF NOT EXISTS tasks (
title TEXT NOT NULL,
complexity TEXT NOT NULL, -- hard | medium | easy
status TEXT NOT NULL DEFAULT 'init',
priority INTEGER NOT NULL DEFAULT 0,
priority INTEGER NOT NULL DEFAULT 1, -- P0 最高 / P1 中 / P2 最低
deps TEXT NOT NULL DEFAULT '[]', -- JSON array of task ids
plan TEXT, -- Hard:分析 + 拆解
spec TEXT, -- Medium:改动 + 理由
@@ -31,10 +32,12 @@ CREATE TABLE IF NOT EXISTS tasks (
result TEXT, -- JSON TaskResult
assignee TEXT, -- agent | human
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
updated_at TEXT NOT NULL,
source_ref TEXT -- 旧 todo 来源标识(todo:17 / todo:17/1A),项目内唯一
);
CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id, status);
CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id);
CREATE INDEX IF NOT EXISTS idx_tasks_source ON tasks(project_id, source_ref);
CREATE TABLE IF NOT EXISTS approvals (
id TEXT PRIMARY KEY,
+248 -12
View File
@@ -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/2P0 最高,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.syncedpayload=同步统计)。 */
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 但依赖未满足 → blockedblocked 但依赖已满足 → 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);
}
/** 所有进行中的 runstatus='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; // 非叶子跳过