From 72b174fd4455ad8d632d461370e154a36264c369 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sat, 13 Jun 2026 10:33:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E4=BE=9D=E8=B5=96(de?= =?UTF-8?q?ps)=E5=88=9B=E5=BB=BA=E5=90=8E=E7=BC=96=E8=BE=91=20(tsk=5FUQ7sN?= =?UTF-8?q?RM0Yi1L)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/api/server.ts | 6 +++ src/store/store.ts | 68 ++++++++++++++++++++++++++++----- test/patch.test.ts | 95 ++++++++++++++++++++++++++++++++++++++++++++++ web/app.js | 92 +++++++++++++++++++++++++++++++++++++++++--- web/style.css | 16 ++++++++ 5 files changed, 262 insertions(+), 15 deletions(-) diff --git a/src/api/server.ts b/src/api/server.ts index 91284f5..5288789 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -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); }); diff --git a/src/store/store.ts b/src/store/store.ts index 0495a0e..a41aea9 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -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(); + 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 { diff --git a/test/patch.test.ts b/test/patch.test.ts index 0fc88de..ffa793f 100644 --- a/test/patch.test.ts +++ b/test/patch.test.ts @@ -107,3 +107,98 @@ test('patchTask:执行链路状态下改 complexity 被拒(StoreError → AP 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 }> = []; + 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(); +}); diff --git a/web/app.js b/web/app.js index a11abd4..e436f1c 100644 --- a/web/app.js +++ b/web/app.js @@ -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) { `); } - // 依赖列表:标题 + 状态 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 `
@@ -950,8 +957,11 @@ function renderDetail(t) { ${esc(d.slice(-6))}
`; - }).join(''); - parts.push(`
DEPS · 依赖(全部完成才可执行)
${items}
`); + }).join('') || `
暂无依赖
`; + const editBtn = depsEditable + ? `` + : ''; + parts.push(`
DEPS · 依赖(全部完成才可执行)${editBtn}
${items}
`); } if (!parts.length) parts.push(`
暂无产出与历史 —— 状态:${STATUS_LABEL[t.status]}
`); @@ -990,6 +1000,50 @@ function renderDetail(t) { return `
${parts.join('')}
`; } +/** 选某任务为 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 `
+ ${on ? '☑' : '☐'} + ${statusChip(x.status)} + ${esc(x.title)} + ${esc(x.id.slice(-6))} +
`; + }).join('') || `
本项目暂无其它可选任务
`; + return `
DEPS · 编辑依赖(勾选当前项目其它任务;已排除自身与会成环者)
+
${rows}
+
+ + +
`; +} + // ── 渲染:事件流 ── 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; diff --git a/web/style.css b/web/style.css index 497a893..c4247c9 100644 --- a/web/style.css +++ b/web/style.css @@ -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); }