72b174fd44
deps 此前仅建任务时可设,本次让 PATCH /api/tasks/:id 受理 deps, 拆解后可随时调整任务依赖,无需删重建。 - API:PATCH /api/tasks/:id 受理 deps:string[](类型校验) - Store.patchTask:写 deps 前复用建任务校验(存在/同项目/非自身) 并加 DFS 环检测(拒绝 A→B→A);越权/缺失/成环 → StoreError(400) - 仅允许在未进入执行链路的状态改 deps(与 complexity 同一组守卫) - 写后调 reconcileDeps 重算:未满足 ready→blocked、刚补全 blocked→ready, 广播 status.changed - 看板:任务详情加依赖编辑器(多选当前项目其它任务,排除自身与会成环者), 保存走 PATCH - 测试:加/删 dep、已 done 依赖放行、环检测、跨项目/缺失拒绝、 执行链路状态拒绝、status.changed 重算(test/patch.test.ts) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
205 lines
8.9 KiB
TypeScript
205 lines
8.9 KiB
TypeScript
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();
|
||
});
|
||
|
||
// ---------- patchTask:deps 编辑 ----------
|
||
|
||
test('patchTask:加 dep → ready 落位 blocked;删 dep → 重回 ready', () => {
|
||
const s = freshStore();
|
||
const p = s.createProject({ name: 'pd', repoPath: '/tmp/pd-' + Math.random() });
|
||
const a = s.createTask({ projectId: p.id, title: 'A', complexity: 'easy' }); // ready
|
||
const b = s.createTask({ projectId: p.id, title: 'B', complexity: 'easy' }); // ready,无依赖
|
||
assert.equal(b.status, 'ready');
|
||
|
||
// 给 B 加未完成依赖 A → B 应被重算为 blocked
|
||
const b1 = s.patchTask(b.id, { deps: [a.id] });
|
||
assert.deepEqual(b1.deps, [a.id]);
|
||
assert.equal(b1.status, 'blocked');
|
||
|
||
// 删除依赖 → 依赖已满足,B 重回 ready
|
||
const b2 = s.patchTask(b.id, { deps: [] });
|
||
assert.deepEqual(b2.deps, []);
|
||
assert.equal(b2.status, 'ready');
|
||
s.close();
|
||
});
|
||
|
||
test('patchTask:补全依赖(dep 已 done)→ blocked 直接放行 ready', () => {
|
||
const s = freshStore();
|
||
const p = s.createProject({ name: 'pd2', repoPath: '/tmp/pd2-' + Math.random() });
|
||
const a = s.createTask({ projectId: p.id, title: 'A', complexity: 'easy' });
|
||
s.forceDone(a.id, 'test'); // A 直接 done
|
||
const b = s.createTask({ projectId: p.id, title: 'B', complexity: 'easy' }); // ready
|
||
// 即便加的是已完成的依赖,B 仍应保持可执行 ready
|
||
const b1 = s.patchTask(b.id, { deps: [a.id] });
|
||
assert.equal(b1.status, 'ready');
|
||
s.close();
|
||
});
|
||
|
||
test('patchTask:环检测拒绝(A→B→A)', () => {
|
||
const s = freshStore();
|
||
const p = s.createProject({ name: 'pcyc', repoPath: '/tmp/pcyc-' + 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→A
|
||
// 让 A 依赖 B 会成环 → 拒绝
|
||
assert.throws(
|
||
() => s.patchTask(a.id, { deps: [b.id] }),
|
||
(e: unknown) => e instanceof StoreError && (e as Error).message.includes('环'),
|
||
);
|
||
// 自依赖也拒绝
|
||
assert.throws(() => s.patchTask(a.id, { deps: [a.id] }), StoreError);
|
||
s.close();
|
||
});
|
||
|
||
test('patchTask:deps 引用不存在/跨项目任务被拒绝', () => {
|
||
const s = freshStore();
|
||
const p1 = s.createProject({ name: 'pj1', repoPath: '/tmp/pj1-' + Math.random() });
|
||
const p2 = s.createProject({ name: 'pj2', repoPath: '/tmp/pj2-' + Math.random() });
|
||
const t = s.createTask({ projectId: p1.id, title: 'T', complexity: 'easy' });
|
||
const other = s.createTask({ projectId: p2.id, title: 'O', complexity: 'easy' });
|
||
assert.throws(() => s.patchTask(t.id, { deps: ['tsk_nope'] }), StoreError);
|
||
assert.throws(() => s.patchTask(t.id, { deps: [other.id] }), StoreError);
|
||
s.close();
|
||
});
|
||
|
||
test('patchTask:执行链路状态(executing/exec_review/done)下改 deps 被拒', () => {
|
||
const s = freshStore();
|
||
const p = s.createProject({ name: 'pdg', repoPath: '/tmp/pdg-' + Math.random() });
|
||
const a = s.createTask({ projectId: p.id, title: 'A', complexity: 'easy' });
|
||
const t = s.createTask({ projectId: p.id, title: 'T', complexity: 'easy' }); // ready
|
||
|
||
s.transition(t.id, 'queued');
|
||
s.transition(t.id, 'executing');
|
||
assert.throws(
|
||
() => s.patchTask(t.id, { deps: [a.id] }),
|
||
(e: unknown) => e instanceof StoreError && (e as Error).message.includes('不允许修改依赖'),
|
||
);
|
||
|
||
s.transition(t.id, 'exec_review');
|
||
assert.throws(() => s.patchTask(t.id, { deps: [a.id] }), StoreError);
|
||
|
||
s.decide(t.id, 'accept', 'user'); // done
|
||
assert.throws(() => s.patchTask(t.id, { deps: [a.id] }), StoreError);
|
||
s.close();
|
||
});
|
||
|
||
test('patchTask:改 deps 广播 status.changed(ready↔blocked 重算)', () => {
|
||
const s = freshStore();
|
||
const events: Array<{ taskId: string | null; type: string; payload: Record<string, unknown> }> = [];
|
||
s.subscribe((e) => events.push({ taskId: e.taskId, type: e.type, payload: e.payload }));
|
||
const p = s.createProject({ name: 'pde', repoPath: '/tmp/pde-' + Math.random() });
|
||
const a = s.createTask({ projectId: p.id, title: 'A', complexity: 'easy' });
|
||
const b = s.createTask({ projectId: p.id, title: 'B', complexity: 'easy' });
|
||
|
||
s.patchTask(b.id, { deps: [a.id] });
|
||
const sc = events.filter((e) => e.taskId === b.id && e.type === 'status.changed').at(-1);
|
||
assert.equal(sc?.payload.from, 'ready');
|
||
assert.equal(sc?.payload.to, 'blocked');
|
||
s.close();
|
||
});
|