dsh-taskboard 0.4.5 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +24 -1
  2. package/lib/client.js +434 -193
  3. package/lib/host/execution.js +80 -33
  4. package/lib/host/execution.js.map +1 -1
  5. package/lib/host/git.js +49 -5
  6. package/lib/host/git.js.map +1 -1
  7. package/lib/host/routes.js +210 -112
  8. package/lib/host/routes.js.map +1 -1
  9. package/lib/host/scheduler.js +50 -28
  10. package/lib/host/scheduler.js.map +1 -1
  11. package/lib/host/sdk.js +7 -2
  12. package/lib/host/sdk.js.map +1 -1
  13. package/lib/host/store.js +41 -8
  14. package/lib/host/store.js.map +1 -1
  15. package/lib/host/templates.js +10 -3
  16. package/lib/host/templates.js.map +1 -1
  17. package/lib/host/tools.js +128 -97
  18. package/lib/host/tools.js.map +1 -1
  19. package/lib/index.js +3 -1
  20. package/lib/index.js.map +1 -1
  21. package/lib/shared/api.js.map +1 -1
  22. package/lib/shared/protocol.js +48 -5
  23. package/lib/shared/protocol.js.map +1 -1
  24. package/package.json +9 -8
  25. package/src/client/api.ts +26 -8
  26. package/src/client/board/ImportModal.tsx +1 -1
  27. package/src/client/board/SettingsModal.tsx +84 -0
  28. package/src/client/board/TaskBoard.tsx +47 -40
  29. package/src/client/board/TaskCard.tsx +3 -5
  30. package/src/client/board/TaskDetail.tsx +30 -21
  31. package/src/client/board/TaskFormModal.tsx +39 -31
  32. package/src/client/board/format.ts +26 -0
  33. package/src/client/board/labels.ts +44 -0
  34. package/src/client/controller.ts +86 -34
  35. package/src/client/index.ts +7 -5
  36. package/src/client/sidebar-entry.ts +5 -1
  37. package/src/client/styles.ts +4 -0
  38. package/src/host/execution.ts +90 -16
  39. package/src/host/git.ts +39 -10
  40. package/src/host/routes.ts +263 -128
  41. package/src/host/scheduler.ts +62 -36
  42. package/src/host/sdk.ts +12 -1
  43. package/src/host/store.ts +53 -7
  44. package/src/host/templates.ts +12 -3
  45. package/src/host/tools.ts +187 -126
  46. package/src/index.ts +10 -1
  47. package/src/shared/api.ts +11 -2
  48. package/src/shared/protocol.ts +83 -6
  49. package/src/shared/version.ts +1 -1
  50. package/src/client/board/NewTaskModal.tsx +0 -8
@@ -1,15 +1,22 @@
1
- import { asIsolation, asStatus, asUrgency, canTransition, checklistFromTexts, newCommentId, newTaskId, normalizeBody, normalizeChecklist, normalizeExecution, normalizeModel, normalizePrompt, normalizeTitle, summarize, syncClaim, validateLedgerImport } from "../shared/protocol.js";
1
+ import { asBoardSettings, asIsolation, asStatus, asUrgency, canTransition, checklistFromTexts, defaultIsolationOf, newCommentId, newTaskId, normalizeBody, normalizeChecklist, normalizeExecution, normalizeModel, normalizePrompt, normalizeTitle, summarize, syncClaim, validateLedgerImport } from "../shared/protocol.js";
2
2
  import { WORKTREE_DIR, worktreePathOf } from "./git.js";
3
3
  import { ROUTE_PREFIX, SSE_PATH } from "../shared/api.js";
4
- import { join } from "node:path";
4
+ import { ERR, ToolError } from "./tools.js";
5
+ import { join, resolve, sep } from "node:path";
5
6
  import { readdir, rm } from "node:fs/promises";
6
7
  //#region src/host/routes.ts
7
8
  /** Heartbeat cadence for the SSE stream. */
8
9
  const HEARTBEAT_MS = 2e4;
10
+ /** Max accepted JSON body bytes (S8: unbounded buffering is a local OOM vector). */
11
+ const MAX_BODY_BYTES = 5 * 1024 * 1024;
12
+ /** Route shapes (T2: compiled once at module load, not on every request). */
13
+ const TASK_DIFF_RE = new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/diff$`);
14
+ const TASK_RE = new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)$`);
15
+ const TASK_ACTION_RE = new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/([\\w-]+)$`);
9
16
  /** How long a workspace git-detection result stays cached (fail-soft). */
10
17
  const GIT_DETECT_TTL_MS = 6e4;
11
18
  /** Validate a template's task spec (routes-side, unknown → invalid_input). */
12
- function normalizeTemplateSpec(raw) {
19
+ function normalizeTemplateSpec(raw, now) {
13
20
  if (typeof raw !== "object" || raw === null) throw new Error("Error: invalid_input: task must be an object");
14
21
  const e = raw;
15
22
  const spec = {};
@@ -31,7 +38,7 @@ function normalizeTemplateSpec(raw) {
31
38
  if (urgency !== void 0) spec.urgency = asUrgency(urgency);
32
39
  if (isolation !== void 0) spec.isolation = asIsolation(isolation);
33
40
  if (presetId !== void 0 && presetId.trim().length > 0) spec.presetId = presetId.trim();
34
- if (e.execution !== void 0) spec.execution = normalizeExecution(e.execution, Date.now());
41
+ if (e.execution !== void 0) spec.execution = normalizeExecution(e.execution, now);
35
42
  if (e.model !== void 0) spec.model = normalizeModel(e.model);
36
43
  if (e.checklist !== void 0) {
37
44
  if (!Array.isArray(e.checklist) || e.checklist.some((c) => typeof c !== "string")) throw new Error("Error: invalid_input: task.checklist must be an array of strings");
@@ -69,10 +76,19 @@ function fail(code, message) {
69
76
  status: code === "invalid_input" || code === "invalid_transition" ? 400 : code === "not_found" ? 404 : code === "version_conflict" ? 409 : code === "forbidden" ? 403 : 500
70
77
  };
71
78
  }
72
- /** Read one JSON body (null on parse failure). */
79
+ /**
80
+ * Read one JSON body (null on parse failure). S8: rejects bodies over
81
+ * MAX_BODY_BYTES by throwing — the local, unauthenticated HTTP surface must
82
+ * not be an unbounded memory sink.
83
+ */
73
84
  async function readBody(req) {
74
85
  const chunks = [];
75
- for await (const chunk of req) chunks.push(chunk);
86
+ let total = 0;
87
+ for await (const chunk of req) {
88
+ total += chunk.length;
89
+ if (total > MAX_BODY_BYTES) throw new Error("body too large");
90
+ chunks.push(chunk);
91
+ }
76
92
  if (chunks.length === 0) return {};
77
93
  try {
78
94
  const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
@@ -92,6 +108,15 @@ function num(body, key) {
92
108
  if (v === void 0) return void 0;
93
109
  return typeof v === "number" && Number.isFinite(v) ? v : null;
94
110
  }
111
+ /** Find a live task INSIDE a mutator (R1: guards run on the fresh draft). */
112
+ function liveTaskAt(ledger, id) {
113
+ const index = ledger.tasks.findIndex((t) => t.id === id);
114
+ if (index < 0 || ledger.tasks[index].trashedAt !== void 0) throw new Error("Error: not_found: no such task");
115
+ return {
116
+ index,
117
+ task: ledger.tasks[index]
118
+ };
119
+ }
95
120
  /** Normalize an agent preset id: trimmed, non-empty; empty string → undefined. */
96
121
  function normalizePresetId(raw) {
97
122
  const t = (raw ?? "").trim();
@@ -100,6 +125,17 @@ function normalizePresetId(raw) {
100
125
  /** Map a thrown domain error to the envelope. */
101
126
  function toFail(error) {
102
127
  const message = error instanceof Error ? error.message : String(error);
128
+ if (error instanceof ToolError) {
129
+ const mapped = error.code === ERR.workspaceMismatch ? "forbidden" : error.code;
130
+ if ([
131
+ "invalid_input",
132
+ "not_found",
133
+ "version_conflict",
134
+ "invalid_transition",
135
+ "forbidden",
136
+ "internal"
137
+ ].includes(mapped)) return fail(mapped, message.slice(7 + error.code.length + 2));
138
+ }
103
139
  const code = message.startsWith("Error: ") ? message.slice(7).split(":")[0] : void 0;
104
140
  if (code !== void 0 && [
105
141
  "invalid_input",
@@ -122,6 +158,12 @@ function registerTaskboardRoutes(ctx, options) {
122
158
  const { store, workspaces } = options;
123
159
  const subscribers = /* @__PURE__ */ new Set();
124
160
  let heartbeat;
161
+ /** R4③: a cleanup/purge target must resolve INSIDE <ws>/.dsh-worktrees — string joining alone is never trusted with an rm. */
162
+ const insideWorktreeScope = (wsPath, target) => {
163
+ const scope = resolve(wsPath, WORKTREE_DIR);
164
+ const resolved = resolve(target);
165
+ return resolved === scope || resolved.startsWith(scope + sep);
166
+ };
125
167
  const broadcast = (change) => {
126
168
  const frame = `event: change\ndata: ${JSON.stringify({
127
169
  revision: change.revision,
@@ -130,7 +172,7 @@ function registerTaskboardRoutes(ctx, options) {
130
172
  })}\n\n`;
131
173
  for (const res of subscribers) res.write(frame);
132
174
  };
133
- store.subscribe(broadcast);
175
+ const unsubscribeBroadcast = store.subscribe(broadcast);
134
176
  const gitCache = /* @__PURE__ */ new Map();
135
177
  const gitHinted = /* @__PURE__ */ new Set();
136
178
  /** Whether <root>/.gitignore (missing file counts as missing) ignores our worktree dir. */
@@ -235,7 +277,7 @@ function registerTaskboardRoutes(ctx, options) {
235
277
  });
236
278
  return;
237
279
  }
238
- const diffMatch = pathname.match(new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/diff$`));
280
+ const diffMatch = pathname.match(TASK_DIFF_RE);
239
281
  if (diffMatch !== null) {
240
282
  try {
241
283
  if (options.git === void 0) {
@@ -278,7 +320,15 @@ function registerTaskboardRoutes(ctx, options) {
278
320
  });
279
321
  return;
280
322
  }
281
- const taskMatch = pathname.match(new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)$`));
323
+ if (pathname === `/dsh-taskboard/settings`) {
324
+ await store.load();
325
+ json(res, {
326
+ ok: true,
327
+ value: store.snapshot().settings ?? {}
328
+ });
329
+ return;
330
+ }
331
+ const taskMatch = pathname.match(TASK_RE);
282
332
  if (taskMatch !== null) {
283
333
  const task = store.get(taskMatch[1]);
284
334
  if (task === void 0) {
@@ -297,7 +347,7 @@ function registerTaskboardRoutes(ctx, options) {
297
347
  return;
298
348
  }
299
349
  if (req.method !== "POST") {
300
- res.writeHead(405);
350
+ res.writeHead(405, { allow: "GET, POST" });
301
351
  res.end();
302
352
  return;
303
353
  }
@@ -305,7 +355,13 @@ function registerTaskboardRoutes(ctx, options) {
305
355
  json(res, fail("invalid_input", "content-type must be application/json").res, 415);
306
356
  return;
307
357
  }
308
- const body = await readBody(req);
358
+ let body;
359
+ try {
360
+ body = await readBody(req);
361
+ } catch {
362
+ json(res, fail("invalid_input", `request body exceeds ${MAX_BODY_BYTES} bytes`).res, 413);
363
+ return;
364
+ }
309
365
  if (body === null) {
310
366
  json(res, fail("invalid_input", "body is not a JSON object").res, 400);
311
367
  return;
@@ -317,10 +373,11 @@ function registerTaskboardRoutes(ctx, options) {
317
373
  if (workspaces.get(workspaceId) === void 0) throw new Error("Error: not_found: unknown workspace");
318
374
  const urgency = asUrgency(str(body, "urgency") ?? "");
319
375
  const status = str(body, "status") === null ? "todo" : asStatus(str(body, "status"));
376
+ if (status !== "backlog" && status !== "todo") throw new Error("Error: invalid_transition: a new task must start as backlog or todo");
320
377
  const execution = normalizeExecution(body.execution ?? {}, options.now());
321
378
  const model = body.model === void 0 ? void 0 : checkModel(body.model, options.modelProviders);
322
379
  const isolationRaw = str(body, "isolation");
323
- const isolation = isolationRaw === null ? void 0 : asIsolation(isolationRaw);
380
+ const isolation = isolationRaw === null ? defaultIsolationOf(store.snapshot().settings) : asIsolation(isolationRaw);
324
381
  const presetId = normalizePresetId(str(body, "presetId"));
325
382
  let checklist = void 0;
326
383
  if (body.checklist !== void 0) {
@@ -340,7 +397,7 @@ function registerTaskboardRoutes(ctx, options) {
340
397
  blocked: false,
341
398
  execution,
342
399
  model,
343
- ...isolation !== void 0 ? { isolation } : {},
400
+ isolation,
344
401
  ...presetId !== void 0 ? { presetId } : {},
345
402
  ...checklist !== void 0 ? { checklist } : {},
346
403
  version: 1,
@@ -365,7 +422,7 @@ function registerTaskboardRoutes(ctx, options) {
365
422
  }
366
423
  return;
367
424
  }
368
- const actionMatch = pathname.match(new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/([\\w-]+)$`));
425
+ const actionMatch = pathname.match(TASK_ACTION_RE);
369
426
  if (actionMatch !== null) {
370
427
  const id = actionMatch[1];
371
428
  const action = actionMatch[2];
@@ -375,44 +432,46 @@ function registerTaskboardRoutes(ctx, options) {
375
432
  if (action === "update") {
376
433
  const ifVersion = num(body, "ifVersion");
377
434
  if (ifVersion === void 0 || ifVersion === null) throw new Error("Error: version_conflict: ifVersion required");
378
- if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`);
379
- const next = structuredClone(task);
380
- const title = str(body, "title");
381
- if (title !== null) next.title = normalizeTitle(title);
382
- const description = str(body, "description");
383
- if (description !== null) next.description = description.trim();
384
- const prompt = str(body, "prompt");
385
- if (prompt !== null) next.prompt = normalizePrompt(prompt);
386
- const urgency = str(body, "urgency");
387
- if (urgency !== null) next.urgency = asUrgency(urgency);
388
- const workspaceId = str(body, "workspaceId");
389
- if (workspaceId !== null) {
390
- if (workspaces.get(workspaceId) === void 0) throw new Error("Error: not_found: unknown workspace");
391
- next.workspaceId = workspaceId;
392
- }
393
- if (typeof body.blocked === "boolean") next.blocked = body.blocked;
394
- if (body.execution !== void 0) next.execution = normalizeExecution(body.execution, options.now());
395
- if (body.model === null) next.model = void 0;
396
- else if (body.model !== void 0) next.model = checkModel(body.model, options.modelProviders);
397
- const isolationRaw = str(body, "isolation");
398
- if (isolationRaw !== null) {
399
- if (task.executions.length > 0 || task.status === "in_progress") throw new Error("Error: invalid_input: isolation 已锁定(任务已有执行记录),不可修改");
400
- next.isolation = asIsolation(isolationRaw);
401
- }
402
- if (body.presetId === null) delete next.presetId;
403
- else if (body.presetId !== void 0) next.presetId = normalizePresetId(str(body, "presetId"));
404
- if (body.checklist === null) delete next.checklist;
405
- else if (body.checklist !== void 0) {
406
- const items = normalizeChecklist(body.checklist);
407
- if (items.length > 0) next.checklist = items;
408
- else delete next.checklist;
409
- }
410
- next.version = task.version + 1;
411
- next.updatedAt = options.now();
412
- next.updatedBy = { kind: "user" };
435
+ let next;
413
436
  await store.mutate("task-updated", (ledger) => {
414
- const i = ledger.tasks.findIndex((t) => t.id === id);
415
- ledger.tasks[i] = next;
437
+ const { index, task } = liveTaskAt(ledger, id);
438
+ if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`);
439
+ if (task.status === "archived") throw new Error("Error: invalid_transition: archived tasks are immutable");
440
+ next = structuredClone(task);
441
+ const title = str(body, "title");
442
+ if (title !== null) next.title = normalizeTitle(title);
443
+ const description = str(body, "description");
444
+ if (description !== null) next.description = description.trim();
445
+ const prompt = str(body, "prompt");
446
+ if (prompt !== null) next.prompt = normalizePrompt(prompt);
447
+ const urgency = str(body, "urgency");
448
+ if (urgency !== null) next.urgency = asUrgency(urgency);
449
+ const workspaceId = str(body, "workspaceId");
450
+ if (workspaceId !== null) {
451
+ if (workspaces.get(workspaceId) === void 0) throw new Error("Error: not_found: unknown workspace");
452
+ next.workspaceId = workspaceId;
453
+ }
454
+ if (typeof body.blocked === "boolean") next.blocked = body.blocked;
455
+ if (body.execution !== void 0) next.execution = normalizeExecution(body.execution, options.now());
456
+ if (body.model === null) next.model = void 0;
457
+ else if (body.model !== void 0) next.model = checkModel(body.model, options.modelProviders);
458
+ const isolationRaw = str(body, "isolation");
459
+ if (isolationRaw !== null) {
460
+ if (task.executions.length > 0 || task.status === "in_progress") throw new Error("Error: invalid_input: isolation 已锁定(任务已有执行记录),不可修改");
461
+ next.isolation = asIsolation(isolationRaw);
462
+ }
463
+ if (body.presetId === null) delete next.presetId;
464
+ else if (body.presetId !== void 0) next.presetId = normalizePresetId(str(body, "presetId"));
465
+ if (body.checklist === null) delete next.checklist;
466
+ else if (body.checklist !== void 0) {
467
+ const items = normalizeChecklist(body.checklist);
468
+ if (items.length > 0) next.checklist = items;
469
+ else delete next.checklist;
470
+ }
471
+ next.version = task.version + 1;
472
+ next.updatedAt = options.now();
473
+ next.updatedBy = { kind: "user" };
474
+ ledger.tasks[index] = next;
416
475
  return [next];
417
476
  });
418
477
  json(res, {
@@ -425,19 +484,20 @@ function registerTaskboardRoutes(ctx, options) {
425
484
  const ifVersion = num(body, "ifVersion");
426
485
  const status = str(body, "status") ?? "";
427
486
  if (ifVersion === void 0 || ifVersion === null) throw new Error("Error: version_conflict: ifVersion required");
428
- if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`);
429
487
  const to = asStatus(status);
430
- if (!canTransition(task.status, to)) throw new Error(`Error: invalid_transition: illegal transition ${task.status} → ${to}`);
431
- const next = structuredClone(task);
432
- next.status = to;
433
- next.version = task.version + 1;
434
- next.updatedAt = options.now();
435
- next.updatedBy = { kind: "user" };
436
- if (task.status === "todo" && to === "in_progress") next.blocked = false;
437
- syncClaim(next, to, options.now());
488
+ let next;
438
489
  await store.mutate("task-moved", (ledger) => {
439
- const i = ledger.tasks.findIndex((t) => t.id === id);
440
- ledger.tasks[i] = next;
490
+ const { index, task } = liveTaskAt(ledger, id);
491
+ if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`);
492
+ if (!canTransition(task.status, to)) throw new Error(`Error: invalid_transition: illegal transition ${task.status} → ${to}`);
493
+ next = structuredClone(task);
494
+ next.status = to;
495
+ next.version = task.version + 1;
496
+ next.updatedAt = options.now();
497
+ next.updatedBy = { kind: "user" };
498
+ if (task.status === "todo" && to === "in_progress") next.blocked = false;
499
+ syncClaim(next, to, options.now());
500
+ ledger.tasks[index] = next;
441
501
  return [next];
442
502
  });
443
503
  json(res, {
@@ -449,24 +509,25 @@ function registerTaskboardRoutes(ctx, options) {
449
509
  if (action === "reject") {
450
510
  const ifVersion = num(body, "ifVersion");
451
511
  if (ifVersion === void 0 || ifVersion === null) throw new Error("Error: version_conflict: ifVersion required");
452
- if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`);
453
- if (!canTransition(task.status, "todo")) throw new Error(`Error: invalid_transition: illegal transition ${task.status} → todo`);
454
- const next = structuredClone(task);
455
- next.status = "todo";
456
- next.version = task.version + 1;
457
- next.updatedAt = options.now();
458
- next.updatedBy = { kind: "user" };
459
- syncClaim(next, "todo", options.now());
460
512
  const commentText = str(body, "body") ?? "";
461
- if (commentText.trim().length > 0) next.comments.push({
462
- id: newCommentId(),
463
- body: normalizeBody(commentText),
464
- version: 1,
465
- createdAt: options.now()
466
- });
513
+ let next;
467
514
  await store.mutate("task-moved", (ledger) => {
468
- const i = ledger.tasks.findIndex((t) => t.id === id);
469
- ledger.tasks[i] = next;
515
+ const { index, task } = liveTaskAt(ledger, id);
516
+ if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`);
517
+ if (!canTransition(task.status, "todo")) throw new Error(`Error: invalid_transition: illegal transition ${task.status} → todo`);
518
+ next = structuredClone(task);
519
+ next.status = "todo";
520
+ next.version = task.version + 1;
521
+ next.updatedAt = options.now();
522
+ next.updatedBy = { kind: "user" };
523
+ syncClaim(next, "todo", options.now());
524
+ if (commentText.trim().length > 0) next.comments.push({
525
+ id: newCommentId(),
526
+ body: normalizeBody(commentText),
527
+ version: 1,
528
+ createdAt: options.now()
529
+ });
530
+ ledger.tasks[index] = next;
470
531
  return [next];
471
532
  });
472
533
  json(res, {
@@ -483,13 +544,14 @@ function registerTaskboardRoutes(ctx, options) {
483
544
  version: 1,
484
545
  createdAt: options.now()
485
546
  };
486
- const next = structuredClone(task);
487
- next.comments.push(comment);
488
- next.version = task.version + 1;
489
- next.updatedAt = options.now();
490
547
  await store.mutate("comment-added", (ledger) => {
491
- const i = ledger.tasks.findIndex((t) => t.id === id);
492
- ledger.tasks[i] = next;
548
+ const { index, task } = liveTaskAt(ledger, id);
549
+ if (task.status === "archived") throw new Error("Error: invalid_transition: archived tasks are immutable");
550
+ const next = structuredClone(task);
551
+ next.comments.push(comment);
552
+ next.version = task.version + 1;
553
+ next.updatedAt = options.now();
554
+ ledger.tasks[index] = next;
493
555
  return [next];
494
556
  });
495
557
  json(res, {
@@ -505,16 +567,16 @@ function registerTaskboardRoutes(ctx, options) {
505
567
  const ws = workspaces.get(task.workspaceId);
506
568
  if (ws !== void 0) {
507
569
  const path = worktreePathOf(ws.path, id);
570
+ if (!insideWorktreeScope(ws.path, path)) throw new Error("Error: invalid_input: 非法的清除路径(不在任务工作目录范围内)");
508
571
  try {
509
- await options.git.removeWorktree(ws.path, path);
510
- } catch (error) {
511
- const message = error instanceof Error ? error.message : String(error);
512
- if (message.includes("未提交修改")) throw new Error(`Error: invalid_input: ${message};请先处理这些改动(提交、续跑或手动保存)再物理清除任务`);
513
- if (/not a working tree|not a working-tree/i.test(message)) await rm(path, {
572
+ if (await options.git.removeWorktree(ws.path, path) === "unregistered") await rm(path, {
514
573
  recursive: true,
515
574
  force: true
516
575
  });
517
- else throw new Error(`Error: invalid_input: ${message}`);
576
+ } catch (error) {
577
+ const message = error instanceof Error ? error.message : String(error);
578
+ if (error.code === "dirty-worktree" || message.includes("未提交修改")) throw new Error(`Error: invalid_input: ${message};请先处理这些改动(提交、续跑或手动保存)再物理清除任务`);
579
+ throw new Error(`Error: invalid_input: ${message}`);
518
580
  }
519
581
  if (task.branch !== void 0) try {
520
582
  await options.git.deleteBranch(ws.path, task.branch);
@@ -533,13 +595,17 @@ function registerTaskboardRoutes(ctx, options) {
533
595
  }
534
596
  const ifVersion = num(body, "ifVersion");
535
597
  if (ifVersion === void 0 || ifVersion === null) throw new Error("Error: version_conflict: ifVersion required");
536
- if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`);
537
- const next = structuredClone(task);
538
- next.trashedAt = options.now();
539
- next.version = task.version + 1;
540
598
  await store.mutate("task-deleted", (ledger) => {
541
- const i = ledger.tasks.findIndex((t) => t.id === id);
542
- ledger.tasks[i] = next;
599
+ const { index, task } = liveTaskAt(ledger, id);
600
+ if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`);
601
+ if (task.executions.some((e) => e.outcome === "running")) throw new Error("Error: invalid_input: 任务有正在运行的执行,请先取消或等它结束再删除");
602
+ const next = structuredClone(task);
603
+ next.trashedAt = options.now();
604
+ next.version = task.version + 1;
605
+ delete next.claimedBy;
606
+ delete next.claimedAt;
607
+ next.blocked = false;
608
+ ledger.tasks[index] = next;
543
609
  return [next];
544
610
  });
545
611
  json(res, {
@@ -620,13 +686,13 @@ function registerTaskboardRoutes(ctx, options) {
620
686
  version: 1,
621
687
  createdAt: options.now()
622
688
  };
623
- const next = structuredClone(task);
624
- next.comments.push(mergedComment);
625
- next.version = task.version + 1;
626
- next.updatedAt = options.now();
627
689
  await store.mutate("comment-added", (ledger) => {
628
- const i = ledger.tasks.findIndex((t) => t.id === id);
629
- ledger.tasks[i] = next;
690
+ const { index, task: fresh } = liveTaskAt(ledger, id);
691
+ const next = structuredClone(fresh);
692
+ next.comments.push(mergedComment);
693
+ next.version = fresh.version + 1;
694
+ next.updatedAt = options.now();
695
+ ledger.tasks[index] = next;
630
696
  return [next];
631
697
  });
632
698
  json(res, {
@@ -647,8 +713,12 @@ function registerTaskboardRoutes(ctx, options) {
647
713
  const ws = workspaces.get(task.workspaceId);
648
714
  if (ws === void 0) throw new Error("Error: not_found: unknown workspace");
649
715
  const path = worktreePathOf(ws.path, id);
716
+ if (!insideWorktreeScope(ws.path, path)) throw new Error("Error: invalid_input: 非法的清除路径(不在任务工作目录范围内)");
650
717
  try {
651
- await options.git.removeWorktree(ws.path, path);
718
+ if (await options.git.removeWorktree(ws.path, path) === "unregistered") await rm(path, {
719
+ recursive: true,
720
+ force: true
721
+ });
652
722
  } catch (error) {
653
723
  throw new Error(`Error: invalid_input: ${error instanceof Error ? error.message : String(error)}`);
654
724
  }
@@ -690,15 +760,14 @@ function registerTaskboardRoutes(ctx, options) {
690
760
  if (ws === void 0) throw new Error("Error: not_found: unknown workspace");
691
761
  if (store.get(taskId) !== void 0) throw new Error("Error: invalid_input: 任务仍在看板中,请从任务详情页删除其 worktree");
692
762
  const path = worktreePathOf(ws.path, taskId);
763
+ if (!insideWorktreeScope(ws.path, path)) throw new Error("Error: invalid_input: 非法的清除路径(不在任务工作目录范围内)");
693
764
  try {
694
- await options.git.removeWorktree(ws.path, path);
695
- } catch (error) {
696
- const message = error instanceof Error ? error.message : String(error);
697
- if (/not a working tree|not a working-tree/i.test(message)) await rm(path, {
765
+ if (await options.git.removeWorktree(ws.path, path) === "unregistered") await rm(path, {
698
766
  recursive: true,
699
767
  force: true
700
768
  });
701
- else throw new Error(`Error: invalid_input: ${message}`);
769
+ } catch (error) {
770
+ throw new Error(`Error: invalid_input: ${error instanceof Error ? error.message : String(error)}`);
702
771
  }
703
772
  json(res, {
704
773
  ok: true,
@@ -715,7 +784,8 @@ function registerTaskboardRoutes(ctx, options) {
715
784
  }
716
785
  if (pathname === `/dsh-taskboard/import/preview`) {
717
786
  try {
718
- const plan = validateLedgerImport(body, new Set(store.snapshot().tasks.map((t) => t.id)), options.now());
787
+ const known = new Set(store.snapshot().tasks.map((t) => t.id));
788
+ const plan = validateLedgerImport(body, known, options.now());
719
789
  json(res, {
720
790
  ok: true,
721
791
  value: { plan: {
@@ -748,14 +818,21 @@ function registerTaskboardRoutes(ctx, options) {
748
818
  let backupFile;
749
819
  if (mode === "replace" && store.snapshot().tasks.length > 0) backupFile = await store.backup();
750
820
  let replacedTotal;
751
- await store.mutate("task-created", (ledger) => {
821
+ await store.mutate("ledger-replaced", (ledger) => {
752
822
  if (mode === "replace") {
823
+ if (ledger.tasks.some((t) => t.executions.some((e) => e.outcome === "running"))) throw new Error("Error: invalid_input: 有任务正在执行,不能整册替换(请先取消或等待结束)");
753
824
  replacedTotal = ledger.tasks.length;
754
825
  ledger.tasks = structuredClone(imported);
826
+ if (plan.settings !== void 0) ledger.settings = structuredClone(plan.settings);
827
+ else delete ledger.settings;
755
828
  return ledger.tasks;
756
829
  }
757
830
  const byId = new Map(ledger.tasks.map((t) => [t.id, t]));
758
- for (const task of imported) byId.set(task.id, structuredClone(task));
831
+ for (const task of imported) {
832
+ const existing = byId.get(task.id);
833
+ if (existing !== void 0 && existing.executions.some((e) => e.outcome === "running")) throw new Error(`Error: invalid_input: 任务 ${task.id} 正在执行,不能被导入覆盖`);
834
+ byId.set(task.id, structuredClone(task));
835
+ }
759
836
  ledger.tasks = [...byId.values()];
760
837
  return structuredClone(imported);
761
838
  });
@@ -797,7 +874,7 @@ function registerTaskboardRoutes(ctx, options) {
797
874
  value: await options.templates.upsert({
798
875
  id: str(body, "id") ?? void 0,
799
876
  name,
800
- task: normalizeTemplateSpec(body.task)
877
+ task: normalizeTemplateSpec(body.task, options.now())
801
878
  })
802
879
  }, 201);
803
880
  } catch (error) {
@@ -806,6 +883,23 @@ function registerTaskboardRoutes(ctx, options) {
806
883
  }
807
884
  return;
808
885
  }
886
+ if (pathname === `/dsh-taskboard/settings/update`) {
887
+ try {
888
+ const next = asBoardSettings(body);
889
+ await store.mutate("settings-updated", (ledger) => {
890
+ ledger.settings = next;
891
+ return [];
892
+ });
893
+ json(res, {
894
+ ok: true,
895
+ value: next
896
+ });
897
+ } catch (error) {
898
+ const f = toFail(error);
899
+ json(res, f.res, f.status);
900
+ }
901
+ return;
902
+ }
809
903
  res.writeHead(404);
810
904
  res.end();
811
905
  } catch (error) {
@@ -822,6 +916,9 @@ function registerTaskboardRoutes(ctx, options) {
822
916
  res.write("retry: 2000\n\n");
823
917
  res.write(`event: hello\ndata: ${JSON.stringify({ revision: store.snapshot().revision })}\n\n`);
824
918
  subscribers.add(res);
919
+ res.on("error", () => {
920
+ subscribers.delete(res);
921
+ });
825
922
  if (heartbeat === void 0) heartbeat = setInterval(() => {
826
923
  for (const current of subscribers) current.write(": ping\n\n");
827
924
  }, HEARTBEAT_MS);
@@ -843,6 +940,7 @@ function registerTaskboardRoutes(ctx, options) {
843
940
  handler: sse
844
941
  })];
845
942
  return () => {
943
+ unsubscribeBroadcast();
846
944
  for (const dispose of disposers) dispose();
847
945
  if (heartbeat !== void 0) clearInterval(heartbeat);
848
946
  for (const res of subscribers) res.end();