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:
@@ -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
@@ -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→ready(reconcile 内部广播 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 {
|
||||
|
||||
Reference in New Issue
Block a user