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:
@@ -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('patchTask:title/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('patchTask:complexity 修改 → 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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 的临时仓库目录,返回 repoPath(caller 负责 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: '子A(tier1 也必须 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 → plan,desc 合成
|
||||
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('sync:todo.json 不存在 → StoreError(API 层 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 });
|
||||
});
|
||||
Reference in New Issue
Block a user