fa472a06a7
后端: - 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>
190 lines
8.1 KiB
TypeScript
190 lines
8.1 KiB
TypeScript
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 });
|
||
});
|