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();
});