feat: 支持依赖(deps)创建后编辑 (tsk_UQ7sNRM0Yi1L)

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>
This commit is contained in:
wangjia
2026-06-13 10:33:39 +08:00
parent 872354cf7c
commit 72b174fd44
5 changed files with 262 additions and 15 deletions
+6
View File
@@ -183,6 +183,12 @@ export function buildServer(opts: ApiOptions): FastifyInstance {
if (!isComplexity(b.complexity)) throw new StoreError('complexity 必须是 hard|medium|easy');
patch.complexity = b.complexity;
}
if (b.deps !== undefined) {
if (!Array.isArray(b.deps) || !b.deps.every((d) => typeof d === 'string')) {
throw new StoreError('deps 必须是任务 id 字符串数组');
}
patch.deps = b.deps as string[];
}
return store.patchTask(id, patch);
});
+58 -10
View File
@@ -32,7 +32,7 @@ export interface PatchProjectInput {
maxRetries?: number; timeoutMs?: number;
}
export interface PatchTaskInput {
title?: string; priority?: number; complexity?: Complexity;
title?: string; priority?: number; complexity?: Complexity; deps?: string[];
}
/** 执行中的 run(联 tasks 取标题/项目),供 GET /api/agents 汇总 */
export interface ActiveRun {
@@ -217,11 +217,7 @@ export class Store {
if (input.priority !== undefined) assertPriority(input.priority);
// deps 必须引用本项目已存在的任务(也因此依赖图天然无环:新任务不可能已被引用)
const depsArr = input.deps ?? [];
for (const d of depsArr) {
const dep = this.getTaskRow(d);
if (!dep) throw new StoreError(`依赖任务不存在: ${d}`);
if (dep.project_id !== input.projectId) throw new StoreError(`依赖任务不在同一项目: ${d}`);
}
this.assertDepsRefer(input.projectId, depsArr);
let status = initialNextStatus(input.complexity);
// Easy 直达 ready,但有未完成依赖时落位 blocked
if (status === 'ready' && depsArr.length && !depsArr.every((d) => this.getTaskRow(d)?.status === 'done')) {
@@ -331,15 +327,32 @@ export class Store {
}
/**
* 部分更新任务(title/priority/complexity)。
* complexity 修改仅允许尚未进入执行链路的状态(init/analyzing/speccing/ready/plan_review/spec_review/blocked);
* 改后 status 重置为新复杂度的初始态(initialNextStatus并广播 status.changed。
* 部分更新任务(title/priority/complexity/deps)。
* complexity / deps 修改仅允许尚未进入执行链路的状态(init/analyzing/speccing/ready/plan_review/spec_review/blocked);
* 改 complexity 后 status 重置为新复杂度的初始态(initialNextStatus
* 改 deps 后复用建任务的校验(存在/同项目/无环),再 reconcileDeps 重算 ready↔blocked,均广播 status.changed。
*/
patchTask(taskId: string, patch: PatchTaskInput): Task {
const row = this.getTaskRow(taskId);
let row = this.getTaskRow(taskId);
if (!row) throw new StoreError(`任务不存在: ${taskId}`);
const fields: string[] = [];
let depsChanged = false;
if (patch.deps !== undefined) {
const cur = row.status as TaskStatus;
if (!COMPLEXITY_EDITABLE.has(cur)) {
throw new StoreError(
`当前状态 ${STATUS_LABEL[cur]}(${cur}) 不允许修改依赖(仅限 init/analyzing/speccing/ready/plan_review/spec_review/blocked`,
);
}
this.assertDepsRefer(row.project_id, patch.deps, taskId); // 存在 + 同项目 + 非自身
this.assertNoDepCycle(taskId, patch.deps); // DFS 环检测
this.db.prepare(`UPDATE tasks SET deps = ?, updated_at = ? WHERE id = ?`)
.run(JSON.stringify(patch.deps), now(), taskId);
row = this.getTaskRow(taskId)!; // 刷新,供后续 complexity 落位看到新 deps
fields.push('deps');
depsChanged = true;
}
if (patch.title !== undefined) {
if (!patch.title.trim()) throw new StoreError('title 不能为空');
this.db.prepare(`UPDATE tasks SET title = ?, updated_at = ? WHERE id = ?`).run(patch.title, now(), taskId);
@@ -372,6 +385,8 @@ export class Store {
this.emit(row.project_id, taskId, 'status.changed', { ...statusChange, reason: 'complexity.changed' });
}
}
// deps 变更后重算依赖落位:未满足的 ready→blocked、刚补全的 blocked→readyreconcile 内部广播 status.changed
if (depsChanged) this.reconcileDeps(row.project_id);
return this.getTask(taskId)!;
}
@@ -399,6 +414,39 @@ export class Store {
return map;
}
// ---------- 依赖校验 ----------
/** 每个 dep 必须存在、同项目、且不等于任务自身(建/改任务时复用) */
private assertDepsRefer(projectId: string, deps: string[], selfId?: string): void {
for (const d of deps) {
if (selfId && d === selfId) throw new StoreError('任务不能依赖自己');
const dep = this.getTaskRow(d);
if (!dep) throw new StoreError(`依赖任务不存在: ${d}`);
if (dep.project_id !== projectId) throw new StoreError(`依赖任务不在同一项目: ${d}`);
}
}
/**
* 环检测(DFS):把 taskId 的依赖改为 deps 后,沿现有依赖图从任一 dep 出发若能回到 taskId
* 即构成环(A→B→A),拒绝。仅在编辑既有任务的 deps 时需要(新建任务天然无环)。
*/
private assertNoDepCycle(taskId: string, deps: string[]): void {
const reaches = (from: string): boolean => {
const seen = new Set<string>();
const dfs = (cur: string): boolean => {
if (cur === taskId) return true; // 回到自身 → 成环
if (seen.has(cur)) return false;
seen.add(cur);
const r = this.getTaskRow(cur);
if (!r) return false;
return (JSON.parse(r.deps) as string[]).some(dfs);
};
return dfs(from);
};
for (const d of deps) {
if (reaches(d)) throw new StoreError(`依赖会形成环: ${taskId}${d}`);
}
}
// ---------- 依赖驱动的 ready/blocked 自动管理 ----------
/** 依赖是否全部 done(未知 id 视为未满足) */
private depsMetRow(row: TaskRow): boolean {
+95
View File
@@ -107,3 +107,98 @@ test('patchTask:执行链路状态下改 complexity 被拒(StoreError → AP
assert.throws(() => s.patchTask(t.id, { complexity: 'medium' }), StoreError);
s.close();
});
// ---------- patchTaskdeps 编辑 ----------
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('patchTaskdeps 引用不存在/跨项目任务被拒绝', () => {
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.changedready↔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();
});
+87 -5
View File
@@ -46,6 +46,8 @@ const FILTER_GROUPS = [
];
// 归档态:不进任务树,沉到页面底部的归档区(时间倒序分页)
const ARCHIVED = new Set(['done', 'cancelled']);
// 允许编辑 complexity / deps 的状态(与 store.ts 的 COMPLEXITY_EDITABLE 对齐:尚未进入执行链路)
const STRUCT_EDITABLE = new Set(['init', 'analyzing', 'speccing', 'ready', 'plan_review', 'spec_review', 'blocked']);
// ── 全局状态 ──
const S = {
@@ -67,6 +69,8 @@ const S = {
archive: { page: 1, size: 20 }, // 归档区分页
confirmCancel: null, // 取消确认中的任务 id
confirmDelete: null, // 删除确认中的任务 id
depsEditFor: null, // 正在编辑依赖的任务 id
depsEditSel: null, // 编辑中已选依赖 id 集合(Set)
};
const $ = (sel) => document.querySelector(sel);
@@ -938,9 +942,12 @@ function renderDetail(t) {
</div>`);
}
// 依赖列表:标题 + 状态 chip,未完成的高亮(全部 done 本任务才可被领取)
if (t.deps && t.deps.length) {
const items = t.deps.map((d) => {
// 依赖列表:标题 + 状态 chip,未完成的高亮(全部 done 本任务才可被领取);可编辑状态支持增删
const depsEditable = STRUCT_EDITABLE.has(t.status);
if (S.depsEditFor === t.id) {
parts.push(depsEditorBlock(t));
} else if ((t.deps && t.deps.length) || depsEditable) {
const items = (t.deps || []).map((d) => {
const dt = S.tasks.find((x) => x.id === d);
const ok = dt && dt.status === 'done';
return `<div class="dep-item ${ok ? 'ok' : 'wait'}" data-action="goto-task" data-id="${esc(d)}" title="点击定位到该任务">
@@ -950,8 +957,11 @@ function renderDetail(t) {
<span class="t-id">${esc(d.slice(-6))}</span>
<span class="dep-go">↧</span>
</div>`;
}).join('');
parts.push(`<div><div class="detail-label">DEPS · 依赖(全部完成才可执行)</div><div class="dep-list">${items}</div></div>`);
}).join('') || `<div class="dep-empty">暂无依赖</div>`;
const editBtn = depsEditable
? `<button class="btn btn-xs deps-edit-btn" data-action="deps-edit" data-id="${esc(t.id)}" title="增删本任务的依赖">✎ 编辑</button>`
: '';
parts.push(`<div><div class="detail-label">DEPS · 依赖(全部完成才可执行)${editBtn}</div><div class="dep-list">${items}</div></div>`);
}
if (!parts.length) parts.push(`<div class="proj-meta">暂无产出与历史 —— 状态:${STATUS_LABEL[t.status]}</div>`);
@@ -990,6 +1000,50 @@ function renderDetail(t) {
return `<div class="task-detail"><div class="detail-grid">${parts.join('')}</div></div>`;
}
/** 选某任务为 t 的依赖会成环的候选集合:所有(传递)依赖 t 的任务 id */
function depsWouldCycle(t) {
const byId = new Map(S.tasks.map((x) => [x.id, x]));
const reaches = (id, target, seen) => {
const node = byId.get(id);
if (!node) return false;
for (const d of (node.deps || [])) {
if (d === target) return true;
if (!seen.has(d)) { seen.add(d); if (reaches(d, target, seen)) return true; }
}
return false;
};
const bad = new Set();
for (const x of S.tasks) {
if (x.id === t.id) continue;
if (reaches(x.id, t.id, new Set())) bad.add(x.id);
}
return bad;
}
/** 依赖编辑器:多选当前项目其它任务(排除自己与会成环者),保存走 PATCH deps */
function depsEditorBlock(t) {
const sel = S.depsEditSel || new Set();
const bad = depsWouldCycle(t);
const candidates = S.tasks
.filter((x) => x.id !== t.id && !ARCHIVED.has(x.status) && !bad.has(x.id))
.sort((a, b) => (a.depth - b.depth) || a.title.localeCompare(b.title));
const rows = candidates.map((x) => {
const on = sel.has(x.id);
return `<div class="dep-pick ${on ? 'on' : ''}" data-action="deps-edit-toggle" data-id="${esc(t.id)}" data-dep="${esc(x.id)}">
<span class="dep-check">${on ? '☑' : '☐'}</span>
${statusChip(x.status)}
<span class="dep-title">${esc(x.title)}</span>
<span class="t-id">${esc(x.id.slice(-6))}</span>
</div>`;
}).join('') || `<div class="dep-empty">本项目暂无其它可选任务</div>`;
return `<div><div class="detail-label">DEPS · 编辑依赖(勾选当前项目其它任务;已排除自身与会成环者)</div>
<div class="dep-list dep-pick-list">${rows}</div>
<div class="deps-edit-foot">
<button class="btn btn-xs btn-accept" data-action="deps-edit-save" data-id="${esc(t.id)}">保存依赖(${sel.size}</button>
<button class="btn btn-xs" data-action="deps-edit-cancel" data-id="${esc(t.id)}">取消</button>
</div></div>`;
}
// ── 渲染:事件流 ──
function eventDetail(e) {
const t = S.tasks.find((x) => x.id === e.taskId);
@@ -1235,6 +1289,34 @@ document.addEventListener('click', (ev) => {
break;
}
// ── 依赖编辑 ──
case 'deps-edit': {
const t = S.tasks.find((x) => x.id === id);
S.depsEditFor = id;
S.depsEditSel = new Set((t && t.deps) || []);
renderTree();
break;
}
case 'deps-edit-toggle': {
const dep = el.dataset.dep;
if (!S.depsEditSel) S.depsEditSel = new Set();
S.depsEditSel.has(dep) ? S.depsEditSel.delete(dep) : S.depsEditSel.add(dep);
renderTree();
break;
}
case 'deps-edit-cancel':
S.depsEditFor = null; S.depsEditSel = null;
renderTree();
break;
case 'deps-edit-save': {
const deps = [...(S.depsEditSel || new Set())];
S.depsEditFor = null; S.depsEditSel = null;
act(() => api(`/api/tasks/${id}`, {
method: 'PATCH', body: JSON.stringify({ deps }),
}), `依赖已更新(${deps.length} 项,按依赖重算 ready/blocked`);
break;
}
// ── 任务树筛选 ──
case 'filter-cplx': {
const c = el.dataset.cplx;
+16
View File
@@ -735,6 +735,22 @@ li.ev-updated { --ev: var(--muted); }
.dep-item:hover { background: var(--panel-2); border-color: var(--muted); }
.dep-go { margin-left: auto; color: var(--faint); font-size: 12px; }
.dep-item:hover .dep-go { color: var(--cyan); }
/* 依赖编辑器 */
.deps-edit-btn { margin-left: 8px; vertical-align: middle; }
.dep-empty { font-size: 12px; color: var(--faint); padding: 4px 10px; }
.dep-pick-list { max-height: 240px; overflow-y: auto; }
.dep-pick {
display: flex; align-items: center; gap: 8px;
font-size: 12px; padding: 4px 10px; cursor: pointer;
background: var(--bg-deep); border: 1px solid var(--line-soft);
border-left: 2px solid var(--line-soft);
transition: border-color .12s, background .12s;
}
.dep-pick:hover { background: var(--panel-2); border-color: var(--muted); }
.dep-pick.on { border-left-color: var(--cyan); }
.dep-pick.on .dep-check { color: var(--cyan); }
.dep-check { font-size: 13px; color: var(--faint); }
.deps-edit-foot { display: flex; gap: 8px; margin-top: 6px; }
.task-row.flash { animation: locate-flash 1.8s ease-out; }
@keyframes locate-flash {
0%, 35% { background: var(--amber-dim); box-shadow: inset 2px 0 0 var(--amber); }