diff --git a/src/api/server.ts b/src/api/server.ts
index 2e584d2..294ba36 100644
--- a/src/api/server.ts
+++ b/src/api/server.ts
@@ -203,6 +203,18 @@ export function buildServer(opts: ApiOptions): FastifyInstance {
return store.transition(id, b.to as TaskStatus, b.meta ?? {});
});
+ // 取消任务(transition → cancelled;状态机已守卫合法流转)
+ app.post('/api/tasks/:id/cancel', (req) => {
+ const { id } = req.params as { id: string };
+ return store.transition(id, 'cancelled', { by: 'user' });
+ });
+
+ // 彻底删除任务及其子孙(不可恢复,cascade)
+ app.delete('/api/tasks/:id', (req) => {
+ const { id } = req.params as { id: string };
+ return store.deleteTask(id);
+ });
+
// 审批闸:accept / reject(reject 必带 reason)
// exec 闸的 accept = 通过并合并(PR 闭环):先 merge 再 decide;merge 失败 → 400,任务保留在审核闸。
app.post('/api/tasks/:id/decide', async (req) => {
diff --git a/src/store/store.ts b/src/store/store.ts
index 0f74ccb..0665354 100644
--- a/src/store/store.ts
+++ b/src/store/store.ts
@@ -552,6 +552,43 @@ export class Store {
return rows.map(rowToEvent).reverse();
}
+ /**
+ * 彻底删除任务及其全部子孙(runs/approvals 由 FK CASCADE 自动清除,events 手动清理)。
+ * 不可恢复——调用方须在上层做二次确认。
+ */
+ deleteTask(taskId: string): { deleted: number } {
+ const root = this.getTaskRow(taskId);
+ if (!root) throw new StoreError(`任务不存在: ${taskId}`);
+
+ // BFS 收集所有子孙 id(用于清理 events,其他表有 FK ON DELETE CASCADE)
+ const toDelete: string[] = [];
+ const queue: string[] = [taskId];
+ while (queue.length) {
+ const tid = queue.shift()!;
+ toDelete.push(tid);
+ const kids = this.db.prepare(
+ `SELECT id FROM tasks WHERE parent_id = ?`,
+ ).all(tid) as Array<{ id: string }>;
+ for (const k of kids) queue.push(k.id);
+ }
+
+ const projectId = root.project_id;
+ const txn = this.db.transaction(() => {
+ // events 没有 FK,逐条清理
+ for (const tid of toDelete) {
+ this.db.prepare(`DELETE FROM events WHERE task_id = ?`).run(tid);
+ }
+ // 删根节点:子孙、runs、approvals 均 ON DELETE CASCADE
+ this.db.prepare(`DELETE FROM tasks WHERE id = ?`).run(taskId);
+ });
+ txn();
+
+ this.emit(projectId, null, 'task.updated', {
+ kind: 'delete', taskId, count: toDelete.length,
+ });
+ return { deleted: toDelete.length };
+ }
+
/**
* 取下一个可执行任务(叶子、ready、依赖全部 done)。供编排器领取。
* 被拆解的 Hard 容器任务不会是 ready(停在 decomposed),天然排除。
diff --git a/test/store.test.ts b/test/store.test.ts
index 3749a36..2bb2950 100644
--- a/test/store.test.ts
+++ b/test/store.test.ts
@@ -189,6 +189,56 @@ test('createTask:deps 引用不存在/跨项目任务被拒绝', () => {
s.close();
});
+test('deleteTask:删除叶子任务,不影响同项目其他任务', () => {
+ const s = freshStore();
+ const p = s.createProject({ name: 'del1', repoPath: '/tmp/del1-' + Math.random() });
+ const a = s.createTask({ projectId: p.id, title: 'A', complexity: 'easy' });
+ const b = s.createTask({ projectId: p.id, title: 'B', complexity: 'easy' });
+ const { deleted } = s.deleteTask(a.id);
+ assert.equal(deleted, 1);
+ assert.equal(s.getTask(a.id), null); // 已删
+ assert.ok(s.getTask(b.id)); // 不受影响
+ s.close();
+});
+
+test('deleteTask:级联删除带子任务的父任务', () => {
+ const s = freshStore();
+ const p = s.createProject({ name: 'del2', repoPath: '/tmp/del2-' + Math.random() });
+ const root = s.createTask({ projectId: p.id, title: 'root', complexity: 'hard' });
+ s.setPlan(root.id, '拆'); s.transition(root.id, 'plan_review'); s.decide(root.id, 'accept', 'user');
+ const c1 = s.createTask({ projectId: p.id, parentId: root.id, title: 'c1', complexity: 'easy' });
+ const c2 = s.createTask({ projectId: p.id, parentId: root.id, title: 'c2', complexity: 'easy' });
+ // c1 下再建孙子
+ const gc = s.createTask({ projectId: p.id, parentId: c1.id, title: 'gc', complexity: 'easy' });
+ const { deleted } = s.deleteTask(root.id);
+ assert.equal(deleted, 4); // root + c1 + c2 + gc
+ assert.equal(s.getTask(root.id), null);
+ assert.equal(s.getTask(c1.id), null);
+ assert.equal(s.getTask(c2.id), null);
+ assert.equal(s.getTask(gc.id), null);
+ s.close();
+});
+
+test('deleteTask:不存在的任务抛 StoreError', () => {
+ const s = freshStore();
+ assert.throws(() => s.deleteTask('tsk_nonexistent'), (e: Error) => e.message.includes('任务不存在'));
+ s.close();
+});
+
+test('transition → cancelled:合法的状态流转(init/ready/executing 等)', () => {
+ const s = freshStore();
+ const p = s.createProject({ name: 'can', repoPath: '/tmp/can-' + Math.random() });
+
+ const t1 = s.createTask({ projectId: p.id, title: 'init task', complexity: 'easy' });
+ assert.equal(t1.status, 'ready');
+ const cancelled = s.transition(t1.id, 'cancelled', { by: 'user' });
+ assert.equal(cancelled.status, 'cancelled');
+
+ // 已取消的任务不可再次流转
+ assert.throws(() => s.transition(t1.id, 'ready'), (e: Error) => e.message.includes('非法状态流转'));
+ s.close();
+});
+
test('容器收口:已拆解 Hard 的子任务全 done → 容器自动 done(逐级向上)', () => {
const s = freshStore();
const p = s.createProject({ name: 'cc', repoPath: '/tmp/cc-' + Math.random() });
diff --git a/web/app.js b/web/app.js
index 733f9d5..7cb7dde 100644
--- a/web/app.js
+++ b/web/app.js
@@ -65,6 +65,8 @@ const S = {
previewId: null, // 全局预览中的任务 id
previewReject: false, // 预览层内驳回意见框是否展开
archive: { page: 1, size: 20 }, // 归档区分页
+ confirmCancel: null, // 取消确认中的任务 id
+ confirmDelete: null, // 删除确认中的任务 id
};
const $ = (sel) => document.querySelector(sel);
@@ -628,6 +630,10 @@ function renderArchiveModal(t, runs, events) {
${attrs}${docs}${runRows}${resultBlock}${approvalRows}${timeline}
+
`;
}
@@ -858,6 +864,8 @@ function renderTree() {
P${t.priority}
${cplxBadge(t.complexity, t.id)}
${statusChip(t.status)}
+ ${!ARCHIVED.has(t.status) ? `` : ''}
+
${expanded ? renderDetail(t) : ''}
${kids.length && !collapsed ? `${kids.map(renderNode).join('')}
` : ''}
@@ -940,6 +948,35 @@ function renderDetail(t) {
parts.push(`id ${esc(t.id)} · 深度 ${t.depth} · 创建 ${fmtTime(t.createdAt)} · 更新 ${fmtTime(t.updatedAt)}
`);
+ // ── 取消 / 删除操作区 ──
+ const m = childrenMap(S.tasks);
+ const childCount = (m.get(t.id) || []).length;
+ const cancelSection = !ARCHIVED.has(t.status) ? (() => {
+ if (S.confirmCancel === t.id) {
+ return `
+ 确定取消任务「${esc(t.title)}」?(将进入归档,子任务保持原状)
+
+
+
`;
+ }
+ return ``;
+ })() : '';
+ const deleteSection = (() => {
+ if (S.confirmDelete === t.id) {
+ const cascadeNote = childCount ? `(含 ${childCount} 个直接子任务,及其全部子孙)` : '';
+ return `
+ 彻底删除「${esc(t.title)}」${esc(cascadeNote)}?此操作不可恢复!
+
+
+
`;
+ }
+ const cascadeHint = childCount ? `(含 ${childCount} 子任务)` : '';
+ return ``;
+ })();
+ if (cancelSection || deleteSection) {
+ parts.push(`${cancelSection}${deleteSection}
`);
+ }
+
return ``;
}
@@ -1020,7 +1057,7 @@ document.addEventListener('click', (ev) => {
if (S.currentProjectId !== id) {
S.currentProjectId = id;
S.expanded.clear(); S.collapsed.clear(); S.rejectOpen.clear();
- S.cplxMenuFor = null;
+ S.cplxMenuFor = null; S.confirmCancel = null; S.confirmDelete = null;
$('#configPanel').hidden = true;
refresh();
}
@@ -1050,7 +1087,14 @@ document.addEventListener('click', (ev) => {
case 'toggle-detail': {
// 点的是 caret 时由上面分支处理(stopPropagation);输入区内点击不折叠
if (ev.target.closest('.task-detail')) break;
- S.expanded.has(id) ? S.expanded.delete(id) : S.expanded.add(id);
+ if (S.expanded.has(id)) {
+ S.expanded.delete(id);
+ // 折叠时清除行内确认状态
+ if (S.confirmCancel === id) S.confirmCancel = null;
+ if (S.confirmDelete === id) S.confirmDelete = null;
+ } else {
+ S.expanded.add(id);
+ }
renderTree();
break;
}
@@ -1134,6 +1178,24 @@ document.addEventListener('click', (ev) => {
$('#archiveModalRoot').innerHTML = '';
break;
+ case 'archive-delete': {
+ const t = S.tasks.find((x) => x.id === id);
+ const m = childrenMap(S.tasks);
+ let descCount = 0;
+ const countDesc = (tid) => { for (const c of m.get(tid) || []) { descCount++; countDesc(c.id); } };
+ countDesc(id);
+ const title = t ? t.title : id;
+ const hint = descCount ? `及其 ${descCount} 个子孙任务` : '';
+ if (!confirm(`彻底删除「${title}」${hint}?\n此操作不可恢复,所有相关数据将一并删除。`)) break;
+ $('#archiveModalRoot').hidden = true;
+ $('#archiveModalRoot').innerHTML = '';
+ act(async () => {
+ await api(`/api/tasks/${id}`, { method: 'DELETE' });
+ S.expanded.delete(id);
+ }, `归档任务已删除${descCount ? `(含 ${descCount} 子孙)` : ''}`);
+ break;
+ }
+
case 'archive-size':
S.archive.size = Number(el.dataset.size) || 20;
S.archive.page = 1;
@@ -1235,6 +1297,86 @@ document.addEventListener('click', (ev) => {
break;
}
+ // ── 任务行:取消(带确认) ──
+ case 'row-cancel': {
+ ev.stopPropagation();
+ const t = S.tasks.find((x) => x.id === id);
+ if (!t) break;
+ const m = childrenMap(S.tasks);
+ const childCount = (m.get(id) || []).length;
+ const hint = childCount ? `(含 ${childCount} 个直接子任务,子任务保持原状)` : '';
+ if (!confirm(`确定取消任务「${t.title}」${hint}?\n取消后任务进入归档,不影响子任务执行。`)) break;
+ act(() => api(`/api/tasks/${id}/cancel`, { method: 'POST', body: '{}' }), '任务已取消');
+ break;
+ }
+
+ // ── 任务行:删除(带级联确认) ──
+ case 'row-delete': {
+ ev.stopPropagation();
+ const t = S.tasks.find((x) => x.id === id);
+ const m = childrenMap(S.tasks);
+ // 递归统计子孙数
+ let descCount = 0;
+ const countDesc = (tid) => { for (const c of m.get(tid) || []) { descCount++; countDesc(c.id); } };
+ countDesc(id);
+ const title = t ? t.title : id;
+ const hint = descCount ? `及其 ${descCount} 个子孙任务` : '';
+ if (!confirm(`彻底删除「${title}」${hint}?\n此操作不可恢复,所有相关数据(执行历史、审批记录)将一并删除。`)) break;
+ act(async () => {
+ await api(`/api/tasks/${id}`, { method: 'DELETE' });
+ S.expanded.delete(id);
+ S.collapsed.delete(id);
+ }, `任务已删除${descCount ? `(含 ${descCount} 子孙)` : ''}`);
+ break;
+ }
+
+ // ── 详情:取消 — 显示行内确认 ──
+ case 'task-cancel': {
+ ev.stopPropagation();
+ S.confirmCancel = id;
+ S.confirmDelete = null;
+ renderTree();
+ break;
+ }
+ case 'task-cancel-abort': {
+ ev.stopPropagation();
+ S.confirmCancel = null;
+ renderTree();
+ break;
+ }
+ case 'task-cancel-confirm': {
+ ev.stopPropagation();
+ S.confirmCancel = null;
+ act(() => api(`/api/tasks/${id}/cancel`, { method: 'POST', body: '{}' }), '任务已取消');
+ break;
+ }
+
+ // ── 详情:删除 — 显示行内确认 ──
+ case 'task-delete': {
+ ev.stopPropagation();
+ S.confirmDelete = id;
+ S.confirmCancel = null;
+ renderTree();
+ break;
+ }
+ case 'task-delete-abort': {
+ ev.stopPropagation();
+ S.confirmDelete = null;
+ renderTree();
+ break;
+ }
+ case 'task-delete-confirm': {
+ ev.stopPropagation();
+ const delId = id;
+ S.confirmDelete = null;
+ act(async () => {
+ await api(`/api/tasks/${delId}`, { method: 'DELETE' });
+ S.expanded.delete(delId);
+ S.collapsed.delete(delId);
+ }, '任务已删除');
+ break;
+ }
+
}
});
diff --git a/web/style.css b/web/style.css
index 871d497..c23a523 100644
--- a/web/style.css
+++ b/web/style.css
@@ -608,7 +608,8 @@ li.ev-updated { --ev: var(--muted); }
max-height: none; overflow-y: visible; /* 解除闸卡片 pre.doc 的 280px 限高,由 .preview-body 统一滚动 */
}
.preview-body .gate-doc-label { margin-top: 14px; }
-.preview-foot {
+.preview-foot,
+.archive-modal-actions {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
padding: 12px 16px; border-top: 1px solid var(--line);
background: var(--panel-2);
@@ -770,3 +771,37 @@ li.ev-updated { --ev: var(--muted); }
/* 配置面板内的同步行 */
.cfg-sync-row { margin-top: 10px; padding-top: 10px; border-top: 1px dashed var(--line-soft); align-items: center; gap: 10px; }
+
+/* ── 任务行:取消 / 删除快捷按钮(默认隐藏,鼠标悬停行时显示) ── */
+.task-row-cancel,
+.task-row-delete {
+ flex: none; opacity: 0; transition: opacity .15s;
+ pointer-events: none;
+}
+.task-row:hover .task-row-cancel,
+.task-row:hover .task-row-delete {
+ opacity: 1; pointer-events: auto;
+}
+.task-row-cancel { color: var(--red); }
+.task-row-delete { color: var(--faint); }
+.task-row-delete:hover { color: var(--red) !important; }
+
+/* ── 任务详情:取消 / 删除操作区 ── */
+.detail-actions {
+ display: flex; align-items: flex-start; gap: 10px;
+ flex-wrap: wrap;
+ padding-top: 6px;
+ border-top: 1px dashed var(--line-soft);
+}
+.detail-action-confirm {
+ display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
+ padding: 6px 10px;
+ background: rgba(255,93,93,.06);
+ border: 1px solid var(--red-dim);
+}
+.detail-action-confirm-del {
+ background: rgba(255,93,93,.09);
+}
+.detail-confirm-msg {
+ font-size: 12px; color: var(--ink);
+}