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 {
|
||||
|
||||
@@ -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<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
@@ -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;
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user