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
+109
View File
@@ -0,0 +1,109 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Store, StoreError } from '../src/store/index.js';
function freshStore(): Store {
return new Store(':memory:');
}
// ---------- patchProject ----------
test('patchProject:更新 autonomy/concurrency/verifyCmd/model/status', () => {
const s = freshStore();
const p = s.createProject({ name: 'pp', repoPath: '/tmp/pp-' + Math.random() });
assert.equal(p.lastSyncAt, null);
const updated = s.patchProject(p.id, {
autonomy: 'auto-easy', concurrency: 3, verifyCmd: 'npm test', model: 'sonnet', status: 'paused',
});
assert.equal(updated.autonomy, 'auto-easy');
assert.equal(updated.concurrency, 3);
assert.equal(updated.verifyCmd, 'npm test');
assert.equal(updated.model, 'sonnet');
assert.equal(updated.status, 'paused');
// 部分字段更新不影响其余字段;null 清空
const again = s.patchProject(p.id, { verifyCmd: null, status: 'active' });
assert.equal(again.verifyCmd, null);
assert.equal(again.autonomy, 'auto-easy');
assert.equal(again.status, 'active');
s.close();
});
test('patchProject:非法值被拒(autonomy/concurrency/status', () => {
const s = freshStore();
const p = s.createProject({ name: 'pv', repoPath: '/tmp/pv-' + Math.random() });
assert.throws(() => s.patchProject(p.id, { autonomy: 'yolo' as never }), StoreError);
assert.throws(() => s.patchProject(p.id, { concurrency: 0 }), StoreError);
assert.throws(() => s.patchProject(p.id, { concurrency: 1.5 }), StoreError);
assert.throws(() => s.patchProject(p.id, { status: 'stopped' as never }), StoreError);
assert.throws(() => s.patchProject('prj_nope', { concurrency: 1 }), StoreError);
s.close();
});
// ---------- patchTask ----------
test('patchTasktitle/priority 更新', () => {
const s = freshStore();
const p = s.createProject({ name: 'pt', repoPath: '/tmp/pt-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: '原标题', complexity: 'easy' });
assert.equal(t.priority, 1); // 默认 P1(中)
const u = s.patchTask(t.id, { title: '新标题', priority: 0 });
assert.equal(u.title, '新标题');
assert.equal(u.priority, 0); // P0 最高
assert.equal(u.status, 'ready'); // 未动 complexity 不重置状态
assert.throws(() => s.patchTask(t.id, { title: ' ' }), StoreError);
assert.throws(() => s.patchTask(t.id, { priority: 5 }), StoreError); // 超出 0..2
assert.throws(() => s.patchTask(t.id, { priority: -1 }), StoreError);
s.close();
});
test('patchTaskcomplexity 修改 → status 重置为新初始态 + status.changed 事件', () => {
const s = freshStore();
const events: Array<{ type: string; payload: Record<string, unknown> }> = [];
s.subscribe((e) => events.push({ type: e.type, payload: e.payload }));
const p = s.createProject({ name: 'pc', repoPath: '/tmp/pc-' + Math.random() });
// easy(ready) → hard:重置为 analyzing
const t = s.createTask({ projectId: p.id, title: 'x', complexity: 'easy' });
const u = s.patchTask(t.id, { complexity: 'hard' });
assert.equal(u.complexity, 'hard');
assert.equal(u.status, 'analyzing');
const sc = events.filter((e) => e.type === 'status.changed').at(-1);
assert.equal(sc?.payload.from, 'ready');
assert.equal(sc?.payload.to, 'analyzing');
// hard(analyzing) → medium:重置为 speccing
const u2 = s.patchTask(t.id, { complexity: 'medium' });
assert.equal(u2.status, 'speccing');
// 同值修改 = no-op,不重置
s.transition(t.id, 'spec_review');
const u3 = s.patchTask(t.id, { complexity: 'medium' });
assert.equal(u3.status, 'spec_review');
// spec_review 在允许列表内:可改
const u4 = s.patchTask(t.id, { complexity: 'easy' });
assert.equal(u4.status, 'ready');
s.close();
});
test('patchTask:执行链路状态下改 complexity 被拒(StoreError → API 400', () => {
const s = freshStore();
const p = s.createProject({ name: 'pr', repoPath: '/tmp/pr-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'y', complexity: 'easy' }); // ready
s.transition(t.id, 'queued');
assert.throws(
() => s.patchTask(t.id, { complexity: 'hard' }),
(e: unknown) => e instanceof StoreError && (e as Error).message.includes('不允许修改复杂度'),
);
// queued 下 title 仍可改
assert.equal(s.patchTask(t.id, { title: 'z' }).title, 'z');
// done(终态)同样拒绝
s.transition(t.id, 'executing');
s.transition(t.id, 'exec_review');
s.decide(t.id, 'accept', 'user');
assert.throws(() => s.patchTask(t.id, { complexity: 'medium' }), StoreError);
s.close();
});
+38
View File
@@ -126,3 +126,41 @@ test('事件订阅:状态变更广播', () => {
assert.ok(got.includes('status.changed'));
s.close();
});
test('依赖自动落位:建任务带未完成依赖 → blocked;依赖 done → 自动放行 ready', () => {
const s = freshStore();
const p = s.createProject({ name: 'ab', repoPath: '/tmp/ab-' + Math.random() });
const a = s.createTask({ projectId: p.id, title: 'A', complexity: 'easy' });
const b = s.createTask({ projectId: p.id, title: 'B', complexity: 'easy', deps: [a.id] });
assert.equal(b.status, 'blocked'); // 建即落位 blocked,而非假 ready
// 手动把 blocked 拉成 ready 也会被按依赖弹回(仍 blocked)
assert.equal(s.transition(b.id, 'ready').status, 'blocked');
// A 走完整闭环到 done → B 自动放行
const evts = [];
s.subscribe((e) => evts.push(e));
s.transition(a.id, 'queued');
s.transition(a.id, 'executing');
s.transition(a.id, 'exec_review');
s.decide(a.id, 'accept', 'user');
assert.equal(s.getTask(b.id).status, 'ready'); // 自动 blocked→ready
const auto = evts.find((e) => e.taskId === b.id && e.payload.auto === 'deps-met');
assert.ok(auto, '应有 deps-met 自动放行事件');
s.close();
});
test('reconcileDeps:存量 ready 但依赖未满足 → 纠正为 blocked(幂等)', () => {
const s = freshStore();
const p = s.createProject({ name: 'rc', repoPath: '/tmp/rc-' + Math.random() });
const a = s.createTask({ projectId: p.id, title: 'A', complexity: 'easy' });
const b = s.createTask({ projectId: p.id, title: 'B', complexity: 'easy', deps: [a.id] });
// 模拟旧库脏数据:B 被直接写成 ready
s.db.prepare(`UPDATE tasks SET status = 'ready' WHERE id = ?`).run(b.id);
const r1 = s.reconcileDeps(p.id);
assert.equal(r1.blocked, 1);
assert.equal(s.getTask(b.id).status, 'blocked');
const r2 = s.reconcileDeps(p.id); // 幂等
assert.equal(r2.blocked + r2.released, 0);
s.close();
});
+189
View File
@@ -0,0 +1,189 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import Database from 'better-sqlite3';
import { Store, StoreError, openDb } from '../src/store/index.js';
import { syncProject, todoJsonPath, hasTodoJson } from '../src/sync/todo-sync.js';
// ---------- fixtures ----------
/** 造一个带 todo/todo.json 的临时仓库目录,返回 repoPathcaller 负责 rmSync */
function makeRepo(items: unknown[]): string {
const repo = mkdtempSync(join(tmpdir(), 'maestro-sync-'));
mkdirSync(join(repo, 'todo'), { recursive: true });
writeFileSync(todoJsonPath(repo), JSON.stringify({ meta: { title: 't' }, seq: items.length, items }, null, 2));
return repo;
}
const FIXTURE_ITEMS = [
{
id: 1, title: '硬任务带子任务', tier: 1, level: 'high', status: 'doing',
desc: '描述文本', tags: ['后端'],
subtasks: [
{ sid: '1A', title: '子Atier1 也必须 easy', tier: 1, deps: [], status: 'open' },
{ sid: '1B', title: '子B', tier: 2, deps: ['1A'], status: 'done' },
],
gate: { kind: 'plan', note: '方案说明', ref: 'doc/x.md', approval: 'pending' },
},
{ id: 2, title: '中任务已验收', tier: 2, level: 'mid', status: 'accepted', version: 'v1.0.0' },
{ id: 3, title: '易任务待验收', tier: 3, level: 'low', status: 'done' },
{ id: 4, title: '缺 tier 默认 medium', status: 'open' },
];
// ---------- 同步引擎 ----------
test('sync:首次导入计数 / subs 一律 easy / 产出字段 / source_ref 映射', () => {
const repo = makeRepo(FIXTURE_ITEMS);
const s = new Store(':memory:');
const events: string[] = [];
s.subscribe((e) => events.push(e.type));
const p = s.createProject({ name: 'sync1', repoPath: repo });
const r = syncProject(s, p.id);
assert.equal(r.created, 6); // 4 items + 2 subs
assert.equal(r.skipped, 0);
// 旧侧已完成:#2(accepted) + #3(done) + 子1B(done) = 3
assert.equal(r.doneAdvanced, 3);
assert.ok(r.lastSyncAt);
// 缺 tier 警告(#4
assert.ok(r.warnings.some((w) => w.includes('缺少有效 tier')));
// project.synced 事件已广播
assert.ok(events.includes('project.synced'));
// last_sync_at 已落库
assert.equal(s.getProject(p.id)!.lastSyncAt, r.lastSyncAt);
// subs 一律 easy(即使旧 tier=1
const subA = s.getTaskBySourceRef(p.id, 'todo:1/1A');
const subB = s.getTaskBySourceRef(p.id, 'todo:1/1B');
assert.equal(subA?.complexity, 'easy');
assert.equal(subB?.complexity, 'easy');
assert.equal(subB?.status, 'done'); // 旧 done → 推到 done
assert.equal(subA?.status, 'ready'); // 旧 open → easy 初始态
assert.equal(subB?.deps[0], subA?.id); // sid 依赖 → 新任务 id
// 顶层复杂度映射 + 产出字段
const t1 = s.getTaskBySourceRef(p.id, 'todo:1');
const t2 = s.getTaskBySourceRef(p.id, 'todo:2');
const t3 = s.getTaskBySourceRef(p.id, 'todo:3');
const t4 = s.getTaskBySourceRef(p.id, 'todo:4');
assert.equal(t1?.complexity, 'hard');
assert.equal(t2?.complexity, 'medium');
assert.equal(t3?.complexity, 'easy');
assert.equal(t4?.complexity, 'medium'); // 缺省 medium
assert.ok(t1?.plan?.includes('描述文本')); // hard → plandesc 合成
assert.ok(t1?.plan?.includes('方案说明')); // gate note 合成
assert.equal(t2?.status, 'done');
assert.equal(t3?.status, 'done');
assert.equal(t1?.status, 'analyzing'); // doing → 初始态,不推进
s.close();
rmSync(repo, { recursive: true, force: true });
});
test('sync:幂等——第二次 created=0、全部 skipped、doneAdvanced=0', () => {
const repo = makeRepo(FIXTURE_ITEMS);
const s = new Store(':memory:');
const p = s.createProject({ name: 'sync2', repoPath: repo });
const first = syncProject(s, p.id);
assert.equal(first.created, 6);
const second = syncProject(s, p.id);
assert.equal(second.created, 0);
assert.equal(second.skipped, 6);
assert.equal(second.doneAdvanced, 0);
s.close();
rmSync(repo, { recursive: true, force: true });
});
test('sync:增量——新 item 只建新的;源里消失的记 warning 不删', () => {
const repo = makeRepo(FIXTURE_ITEMS);
const s = new Store(':memory:');
const p = s.createProject({ name: 'sync3', repoPath: repo });
syncProject(s, p.id);
// 源变化:删掉 #4,新增 #5 + 给 #1 加一个子任务
const items = [
{ ...FIXTURE_ITEMS[0], subtasks: [...(FIXTURE_ITEMS[0] as { subtasks: unknown[] }).subtasks, { sid: '1C', title: '新子C', tier: 1, status: 'open' }] },
FIXTURE_ITEMS[1], FIXTURE_ITEMS[2],
{ id: 5, title: '新增任务', tier: 3, status: 'open' },
];
writeFileSync(todoJsonPath(repo), JSON.stringify({ items }));
const r = syncProject(s, p.id);
assert.equal(r.created, 2); // 1C + #5
assert.ok(r.warnings.some((w) => w.includes('todo:4') && w.includes('保留')));
assert.ok(s.getTaskBySourceRef(p.id, 'todo:4')); // 没删
assert.equal(s.getTaskBySourceRef(p.id, 'todo:1/1C')?.complexity, 'easy');
s.close();
rmSync(repo, { recursive: true, force: true });
});
test('synctodo.json 不存在 → StoreErrorAPI 层 400),消息含路径', () => {
const repo = mkdtempSync(join(tmpdir(), 'maestro-norepo-'));
const s = new Store(':memory:');
const p = s.createProject({ name: 'sync4', repoPath: repo });
assert.equal(hasTodoJson(repo), false);
assert.throws(
() => syncProject(s, p.id),
(e: unknown) => e instanceof StoreError && (e as Error).message === `未找到 todo/todo.json${todoJsonPath(repo)}`,
);
s.close();
rmSync(repo, { recursive: true, force: true });
});
// ---------- 旧库轻量迁移 ----------
test('迁移:旧版库(无 source_ref/last_sync_at)打开后补列且数据完好', () => {
const dir = mkdtempSync(join(tmpdir(), 'maestro-mig-'));
const file = join(dir, 'old.sqlite');
// 用 v0.1 schema 手工造旧库(不含新列)
const old = new Database(file);
old.exec(`
CREATE TABLE projects (
id TEXT PRIMARY KEY, name TEXT NOT NULL, repo_path TEXT NOT NULL UNIQUE,
default_branch TEXT NOT NULL DEFAULT 'main', verify_cmd TEXT,
autonomy TEXT NOT NULL DEFAULT 'manual', model TEXT,
concurrency INTEGER NOT NULL DEFAULT 1, status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL
);
CREATE TABLE tasks (
id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT, depth INTEGER NOT NULL DEFAULT 1,
title TEXT NOT NULL, complexity TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'init',
priority INTEGER NOT NULL DEFAULT 0, deps TEXT NOT NULL DEFAULT '[]',
plan TEXT, spec TEXT, operations TEXT, result TEXT, assignee TEXT,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
);
INSERT INTO projects (id,name,repo_path,created_at) VALUES ('prj_old','旧项目','/tmp/old-repo','2026-01-01T00:00:00Z');
INSERT INTO tasks (id,project_id,title,complexity,status,created_at,updated_at)
VALUES ('tsk_old','prj_old','旧任务','easy','ready','2026-01-01T00:00:00Z','2026-01-01T00:00:00Z');
`);
old.close();
// 新代码打开 → 自动补列
const db = openDb(file);
const projCols = (db.prepare(`PRAGMA table_info(projects)`).all() as Array<{ name: string }>).map((c) => c.name);
const taskCols = (db.prepare(`PRAGMA table_info(tasks)`).all() as Array<{ name: string }>).map((c) => c.name);
assert.ok(projCols.includes('last_sync_at'));
assert.ok(taskCols.includes('source_ref'));
db.close();
// Store 能正常读旧数据,新字段为 null
const s = new Store(file);
const projects = s.listProjects();
assert.equal(projects.length, 1);
assert.equal(projects[0].name, '旧项目');
assert.equal(projects[0].lastSyncAt, null);
const t = s.getTask('tsk_old');
assert.equal(t?.title, '旧任务');
// 新方法在迁移后的旧库上可用
s.setSourceRef('tsk_old', 'todo:99');
assert.equal(s.getTaskBySourceRef('prj_old', 'todo:99')?.id, 'tsk_old');
s.close();
rmSync(dir, { recursive: true, force: true });
});