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 {