Files
maestro/test/store.test.ts
T
wangjia 0b3ebbd569 feat(api): DELETE /api/projects/:id + store.deleteProject(连带 tasks/runs/events)
新增项目删除能力:store.deleteProject 事务内删 events(无 FK,手动)+ projects(tasks/runs/
approvals 经 schema ON DELETE CASCADE 连带清);DELETE /api/projects/:id 暴露之。
store.test 覆盖连带清除 + 重复删报错。用于清理 cutover 测试遗留的一次性 smoke-test 项目
(本次经直连 DB 删除,未重启打断在跑的 worker;端点下次重启生效)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 14:15:38 +08:00

390 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:');
}
test('project + task creation, complexity routes initial status', () => {
const s = freshStore();
const p = s.createProject({ name: 'demo', repoPath: '/tmp/demo-' + Math.random() });
assert.equal(p.status, 'active');
const hard = s.createTask({ projectId: p.id, title: 'big', complexity: 'hard' });
const medium = s.createTask({ projectId: p.id, title: 'mid', complexity: 'medium' });
const easy = s.createTask({ projectId: p.id, title: 'small', complexity: 'easy' });
assert.equal(hard.status, 'analyzing'); // Hard 强制分析
assert.equal(medium.status, 'speccing'); // Medium 写方案
assert.equal(easy.status, 'ready'); // Easy 直接可执行
s.close();
});
test('Easy 路径:写操作 → ready → nextExecutable 领取', () => {
const s = freshStore();
const p = s.createProject({ name: 'e', repoPath: '/tmp/e-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'tweak', complexity: 'easy' });
s.setOperations(t.id, '改 README 一行');
const next = s.nextExecutable(p.id);
assert.equal(next?.id, t.id);
s.close();
});
test('Medium 闸:spec_review accept → readyreject 必带意见', () => {
const s = freshStore();
const p = s.createProject({ name: 'm', repoPath: '/tmp/m-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'feat', complexity: 'medium' });
s.setSpec(t.id, '加一个接口,因为前端需要');
const inReview = s.transition(t.id, 'spec_review');
assert.equal(inReview.status, 'spec_review');
// reject 无意见 → 报错
assert.throws(() => s.decide(t.id, 'reject', 'user'), StoreError);
// reject 带意见 → 回到 speccing
const back = s.decide(t.id, 'reject', 'user', '方案漏了鉴权');
assert.equal(back.status, 'speccing');
assert.equal(back.approvals.at(-1)?.reason, '方案漏了鉴权');
// 重新评审 → accept → ready
s.transition(t.id, 'spec_review');
const ok = s.decide(t.id, 'accept', 'user');
assert.equal(ok.status, 'ready');
s.close();
});
test('Hard 闸:plan_review accept → decomposed,子任务汇总', () => {
const s = freshStore();
const p = s.createProject({ name: 'h', repoPath: '/tmp/h-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'epic', complexity: 'hard' });
s.setPlan(t.id, '分析…拆 2 个子任务');
s.transition(t.id, 'plan_review');
const decomposed = s.decide(t.id, 'accept', 'user');
assert.equal(decomposed.status, 'decomposed');
const c1 = s.createTask({ projectId: p.id, parentId: t.id, title: '子1', complexity: 'easy' });
assert.equal(c1.depth, 2);
// 拆解后容器不应被 nextExecutable 领取(它有子任务且非 ready)
s.setOperations(c1.id, '做子1');
const next = s.nextExecutable(p.id);
assert.equal(next?.id, c1.id); // 取到叶子,不是容器
s.close();
});
test('层级上限:medium 默认 3 层,第 4 层拒绝', () => {
const s = freshStore();
const p = s.createProject({ name: 'd', repoPath: '/tmp/d-' + Math.random() });
const l1 = s.createTask({ projectId: p.id, title: 'l1', complexity: 'medium' });
const l2 = s.createTask({ projectId: p.id, parentId: l1.id, title: 'l2', complexity: 'medium' });
const l3 = s.createTask({ projectId: p.id, parentId: l2.id, title: 'l3', complexity: 'medium' });
assert.equal(l3.depth, 3);
assert.throws(() => s.createTask({ projectId: p.id, parentId: l3.id, title: 'l4', complexity: 'medium' }), StoreError);
s.close();
});
test('非法状态流转被守卫拒绝', () => {
const s = freshStore();
const p = s.createProject({ name: 'g', repoPath: '/tmp/g-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'x', complexity: 'easy' }); // ready
assert.throws(() => s.transition(t.id, 'done'), StoreError); // ready→done 非法
s.close();
});
test('exec_review 结果闸:accept → done', () => {
const s = freshStore();
const p = s.createProject({ name: 'x', repoPath: '/tmp/x-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'run', complexity: 'easy' });
s.setOperations(t.id, 'op');
s.transition(t.id, 'queued');
s.transition(t.id, 'executing');
s.setResult(t.id, { branch: 'maestro/run', worktree: '/wt', diffSummary: '+1 -0', commits: ['abc'], prUrl: null, summary: null, verdict: null, securitySummary: null, securityVerdict: null });
s.transition(t.id, 'exec_review');
const done = s.decide(t.id, 'accept', 'user');
assert.equal(done.status, 'done');
s.close();
});
test('依赖未满足时不可领取', () => {
const s = freshStore();
const p = s.createProject({ name: 'dep', repoPath: '/tmp/dep-' + 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] });
s.setOperations(a.id, 'a'); s.setOperations(b.id, 'b');
// 优先级相同时 A 先建,但 B 依赖 A 未 done → 只能取到 A
const next = s.nextExecutable(p.id);
assert.equal(next?.id, a.id);
s.close();
});
test('事件订阅:状态变更广播', () => {
const s = freshStore();
const got: string[] = [];
s.subscribe((e) => got.push(e.type));
const p = s.createProject({ name: 'ev', repoPath: '/tmp/ev-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 't', complexity: 'easy' });
s.transition(t.id, 'queued');
assert.ok(got.includes('task.created'));
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();
});
test('score 调度:解锁加权 > 链条惯性 > 自身分;nextExecutable 取最高分', () => {
const s = freshStore();
const p = s.createProject({ name: 'sc', repoPath: '/tmp/sc-' + Math.random() });
// AP1 无依赖,但有两条 P0 blocked 任务等它 → score = 2 + 3 + 3 = 8
const a = s.createTask({ projectId: p.id, title: 'A', complexity: 'easy', priority: 1 });
s.createTask({ projectId: p.id, title: 'W1', complexity: 'easy', priority: 0, deps: [a.id] });
s.createTask({ projectId: p.id, title: 'W2', complexity: 'easy', priority: 0, deps: [a.id] });
// BP0 无依赖无人等 → score = 3
s.createTask({ projectId: p.id, title: 'B', complexity: 'easy', priority: 0 });
const next = s.nextExecutable(p.id);
assert.equal(next?.title, 'A'); // 解锁两条 P0 的 A(8) 压过孤立 P0 的 B(3)
s.close();
});
test('createTaskdeps 引用不存在/跨项目任务被拒绝', () => {
const s = freshStore();
const p1 = s.createProject({ name: 'd1', repoPath: '/tmp/d1-' + Math.random() });
const p2 = s.createProject({ name: 'd2', repoPath: '/tmp/d2-' + Math.random() });
const other = s.createTask({ projectId: p2.id, title: 'x', complexity: 'easy' });
assert.throws(() => s.createTask({ projectId: p1.id, title: 'bad', complexity: 'easy', deps: ['tsk_nope'] }), StoreError);
assert.throws(() => s.createTask({ projectId: p1.id, title: 'bad2', complexity: 'easy', deps: [other.id] }), StoreError);
s.close();
});
test('deleteTask:删除叶子任务,不影响同项目其他任务', () => {
const s = freshStore();
const p = s.createProject({ name: 'del1', repoPath: '/tmp/del1-' + Math.random() });
const a = s.createTask({ projectId: p.id, title: 'A', complexity: 'easy' });
const b = s.createTask({ projectId: p.id, title: 'B', complexity: 'easy' });
const { deleted } = s.deleteTask(a.id);
assert.equal(deleted, 1);
assert.equal(s.getTask(a.id), null); // 已删
assert.ok(s.getTask(b.id)); // 不受影响
s.close();
});
test('deleteTask:级联删除带子任务的父任务', () => {
const s = freshStore();
const p = s.createProject({ name: 'del2', repoPath: '/tmp/del2-' + Math.random() });
const root = s.createTask({ projectId: p.id, title: 'root', complexity: 'hard' });
s.setPlan(root.id, '拆'); s.transition(root.id, 'plan_review'); s.decide(root.id, 'accept', 'user');
const c1 = s.createTask({ projectId: p.id, parentId: root.id, title: 'c1', complexity: 'easy' });
const c2 = s.createTask({ projectId: p.id, parentId: root.id, title: 'c2', complexity: 'easy' });
// c1 下再建孙子
const gc = s.createTask({ projectId: p.id, parentId: c1.id, title: 'gc', complexity: 'easy' });
const { deleted } = s.deleteTask(root.id);
assert.equal(deleted, 4); // root + c1 + c2 + gc
assert.equal(s.getTask(root.id), null);
assert.equal(s.getTask(c1.id), null);
assert.equal(s.getTask(c2.id), null);
assert.equal(s.getTask(gc.id), null);
s.close();
});
test('deleteTask:不存在的任务抛 StoreError', () => {
const s = freshStore();
assert.throws(() => s.deleteTask('tsk_nonexistent'), (e: Error) => e.message.includes('任务不存在'));
s.close();
});
test('transition → cancelled:合法的状态流转(init/ready/executing 等)', () => {
const s = freshStore();
const p = s.createProject({ name: 'can', repoPath: '/tmp/can-' + Math.random() });
const t1 = s.createTask({ projectId: p.id, title: 'init task', complexity: 'easy' });
assert.equal(t1.status, 'ready');
const cancelled = s.transition(t1.id, 'cancelled', { by: 'user' });
assert.equal(cancelled.status, 'cancelled');
// 已取消的任务不可再次流转
assert.throws(() => s.transition(t1.id, 'ready'), (e: Error) => e.message.includes('非法状态流转'));
s.close();
});
test('容器收口:已拆解 Hard 的子任务全 done → 容器自动 done(逐级向上)', () => {
const s = freshStore();
const p = s.createProject({ name: 'cc', repoPath: '/tmp/cc-' + Math.random() });
const root = s.createTask({ projectId: p.id, title: 'epic', complexity: 'hard' });
s.setPlan(root.id, '拆 2 子');
s.transition(root.id, 'plan_review');
s.decide(root.id, 'accept', 'user'); // decomposed
const c1 = s.createTask({ projectId: p.id, parentId: root.id, title: '子1', complexity: 'easy' });
const c2 = s.createTask({ projectId: p.id, parentId: root.id, title: '子2', complexity: 'easy' });
const finish = (tid) => {
s.transition(tid, 'queued'); s.transition(tid, 'executing');
s.transition(tid, 'exec_review'); s.decide(tid, 'accept', 'user');
};
finish(c1.id);
assert.equal(s.getTask(root.id).status, 'decomposed'); // 还有子没完,容器不动
finish(c2.id);
assert.equal(s.getTask(root.id).status, 'done'); // 子全 done → 容器自动 done
s.close();
});
test('合并失败补救:建最高优先级 easy 任务,且幂等不重复建', () => {
const s = freshStore();
const p = s.createProject({ name: 'm', repoPath: '/tmp/m-' + Math.random() });
// 造一个进入 exec_review、带分支结果的原任务
const t = s.createTask({ projectId: p.id, title: '配置面板补 model', complexity: 'easy', priority: 1 });
s.setOperations(t.id, 'op');
s.transition(t.id, 'queued');
s.transition(t.id, 'executing');
s.setResult(t.id, {
branch: 'maestro/' + t.id, worktree: '/tmp/wt', diffSummary: '', commits: [], prUrl: null,
summary: null, verdict: null, securitySummary: null, securityVerdict: null, mergeTaskId: null,
});
s.transition(t.id, 'exec_review');
const rem = s.ensureMergeRemediationTask(t.id, {
branch: 'maestro/' + t.id, targetBranch: 'main', conflictError: '冲突文件:web/app.js',
});
assert.equal(rem.complexity, 'easy');
assert.equal(rem.priority, 0, '补救任务应为最高优先级 P0');
assert.equal(rem.status, 'ready', 'easy 无依赖 → 直达 ready,自治项目可领');
assert.match(rem.title, /合并冲突待解决/);
assert.match(rem.operations ?? '', /git merge maestro\//, 'operations 应含合并步骤');
assert.match(rem.operations ?? '', /web\/app\.js/, 'operations 应含冲突详情');
// 幂等标记写回原任务
assert.equal(s.getTask(t.id).result?.mergeTaskId, rem.id);
// 再次调用 → 复用同一补救任务,不新建
const again = s.ensureMergeRemediationTask(t.id, {
branch: 'maestro/' + t.id, targetBranch: 'main', conflictError: '再次冲突',
});
assert.equal(again.id, rem.id, '已有未结束补救任务 → 复用');
const remCount = s.listTasks(p.id).filter((x) => x.title.includes('合并冲突待解决')).length;
assert.equal(remCount, 1, '不应重复建补救任务');
// 补救任务结束(cancelled)后再调用 → 允许新建
s.transition(rem.id, 'cancelled');
const fresh = s.ensureMergeRemediationTask(t.id, {
branch: 'maestro/' + t.id, targetBranch: 'main', conflictError: '又冲突',
});
assert.notEqual(fresh.id, rem.id, '旧补救任务已结束 → 建新的');
s.close();
});
test('patchProjectmaxRetries/timeoutMs 可配 + 校验', () => {
const s = freshStore();
const p = s.createProject({ name: 'cfg', repoPath: '/tmp/cfg-' + Math.random() });
// 默认值
assert.equal(p.maxRetries, 2);
assert.equal(p.timeoutMs, 1_800_000);
// 更新 maxRetries + timeoutMs
const updated = s.patchProject(p.id, { maxRetries: 5, timeoutMs: 60_000 });
assert.equal(updated.maxRetries, 5);
assert.equal(updated.timeoutMs, 60_000);
// 校验:maxRetries 必须 >=0 的整数
assert.throws(() => s.patchProject(p.id, { maxRetries: -1 }), /maxRetries/);
assert.throws(() => s.patchProject(p.id, { maxRetries: 1.5 }), /maxRetries/);
// 校验:timeoutMs 必须 >=1000
assert.throws(() => s.patchProject(p.id, { timeoutMs: 500 }), /timeoutMs/);
s.close();
});
test('createProjectmaxRetries/timeoutMs 自定义初值', () => {
const s = freshStore();
const p = s.createProject({ name: 'custom', repoPath: '/tmp/custom-' + Math.random(), maxRetries: 0, timeoutMs: 120_000 });
assert.equal(p.maxRetries, 0);
assert.equal(p.timeoutMs, 120_000);
s.close();
});
test('requeueTaskneeds_attention → queued,重置 retryBaseline', () => {
const s = freshStore();
const p = s.createProject({ name: 'rq', repoPath: '/tmp/rq-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'flaky', complexity: 'easy' });
// 推进到 needs_attention(模拟 3 次失败 run
s.transition(t.id, 'queued');
s.transition(t.id, 'executing');
s.transition(t.id, 'failed');
s.transition(t.id, 'queued');
s.transition(t.id, 'executing');
s.transition(t.id, 'failed');
s.transition(t.id, 'queued');
s.transition(t.id, 'executing');
s.transition(t.id, 'failed');
s.transition(t.id, 'needs_attention');
// 模拟 3 条 failed executor run
const r1 = s.startRun(t.id, 'executor');
s.finishRun(r1.id, 'failed', { error: 'boom1' });
const r2 = s.startRun(t.id, 'executor');
s.finishRun(r2.id, 'failed', { error: 'boom2' });
const r3 = s.startRun(t.id, 'executor');
s.finishRun(r3.id, 'failed', { error: 'boom3' });
assert.equal(s.getTask(t.id)!.retryBaseline, 0);
// 一键重投
const requeued = s.requeueTask(t.id);
assert.equal(requeued.status, 'queued');
assert.equal(requeued.retryBaseline, 3); // 基线设为当前失败 run 数
// 只有 needs_attention 状态才可重投
assert.throws(() => s.requeueTask(t.id), /needs_attention/);
s.close();
});
test('deleteProject:连带删 tasks/runs/approvals/events,不可恢复', () => {
const s = freshStore();
const p = s.createProject({ name: 'del', repoPath: '/tmp/del-' + Math.random() });
const t = s.createTask({ projectId: p.id, title: 'x', complexity: 'easy' });
s.setOperations(t.id, 'op');
const r = s.startRun(t.id, 'executor');
s.finishRun(r.id, 'succeeded');
assert.ok(s.listEvents(p.id).length > 0, '删前有事件');
assert.ok(s.listTasks(p.id).length > 0, '删前有任务');
assert.ok(s.listRuns(t.id).length > 0, '删前有 run');
s.deleteProject(p.id);
assert.equal(s.getProject(p.id), null, '项目已删');
assert.equal(s.listTasks(p.id).length, 0, 'tasks 连带清');
assert.equal(s.listRuns(t.id).length, 0, 'runs 连带清(经 tasks cascade');
assert.equal(s.listEvents(p.id).length, 0, 'events 连带清');
assert.throws(() => s.deleteProject(p.id), /不存在/, '重复删 → 报错');
s.close();
});