dsh-taskboard 0.5.0 → 0.5.2

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 +25 -1
  2. package/lib/client.js +242 -174
  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 +180 -109
  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 +124 -93
  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 +23 -2
  23. package/lib/shared/protocol.js.map +1 -1
  24. package/package.json +3 -2
  25. package/src/client/api.ts +19 -9
  26. package/src/client/board/ImportModal.tsx +1 -1
  27. package/src/client/board/TaskBoard.tsx +7 -38
  28. package/src/client/board/TaskCard.tsx +3 -5
  29. package/src/client/board/TaskDetail.tsx +30 -21
  30. package/src/client/board/TaskFormModal.tsx +30 -23
  31. package/src/client/board/format.ts +26 -0
  32. package/src/client/board/labels.ts +44 -0
  33. package/src/client/board-mount.tsx +9 -6
  34. package/src/client/controller.ts +60 -13
  35. package/src/client/index.ts +7 -5
  36. package/src/client/sidebar-entry.ts +16 -5
  37. package/src/client/styles.ts +5 -3
  38. package/src/host/execution.ts +90 -16
  39. package/src/host/git.ts +39 -10
  40. package/src/host/routes.ts +227 -126
  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 +180 -123
  46. package/src/index.ts +10 -1
  47. package/src/shared/api.ts +1 -1
  48. package/src/shared/protocol.ts +35 -1
  49. package/src/shared/version.ts +1 -1
  50. package/src/client/board/NewTaskModal.tsx +0 -8
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import type { IncomingMessage, ServerResponse } from 'node:http'
13
13
  import { readdir, rm } from 'node:fs/promises'
14
- import { join } from 'node:path'
14
+ import { join, resolve, sep } from 'node:path'
15
15
  import type { Context } from '@deepseek-ai/cordis'
16
16
  // Type-only: pulls the webServer Context merge (ctx.webServer).
17
17
  import type {} from '@deepseek-ai/dsh-host-webserver'
@@ -34,6 +34,7 @@ import {
34
34
  summarize,
35
35
  syncClaim,
36
36
  validateLedgerImport,
37
+ type TaskLedger,
37
38
  type TaskModel,
38
39
  type TaskRecord,
39
40
  } from '../shared/protocol.ts'
@@ -42,11 +43,20 @@ import type { TaskTemplate } from '../shared/api.ts'
42
43
  import type { TemplateStore } from './templates.ts'
43
44
  import { ROUTE_PREFIX, SSE_PATH, type ApiFail, type ApiResult } from '../shared/api.ts'
44
45
  import type { TaskStore } from './store.ts'
46
+ import { ERR, ToolError } from './tools.ts'
45
47
  import type { WorkspaceFace } from './tools.ts'
46
48
 
47
49
  /** Heartbeat cadence for the SSE stream. */
48
50
  const HEARTBEAT_MS = 20_000
49
51
 
52
+ /** Max accepted JSON body bytes (S8: unbounded buffering is a local OOM vector). */
53
+ const MAX_BODY_BYTES = 5 * 1024 * 1024
54
+
55
+ /** Route shapes (T2: compiled once at module load, not on every request). */
56
+ const TASK_DIFF_RE = new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/diff$`)
57
+ const TASK_RE = new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)$`)
58
+ const TASK_ACTION_RE = new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/([\\w-]+)$`)
59
+
50
60
  /** How long a workspace git-detection result stays cached (fail-soft). */
51
61
  const GIT_DETECT_TTL_MS = 60_000
52
62
 
@@ -74,7 +84,7 @@ export interface TaskboardRoutesOptions {
74
84
  }
75
85
 
76
86
  /** Validate a template's task spec (routes-side, unknown → invalid_input). */
77
- function normalizeTemplateSpec(raw: unknown): TaskTemplate['task'] {
87
+ function normalizeTemplateSpec(raw: unknown, now: number): TaskTemplate['task'] {
78
88
  if (typeof raw !== 'object' || raw === null) throw new Error('Error: invalid_input: task must be an object')
79
89
  const e = raw as Record<string, unknown>
80
90
  const spec: TaskTemplate['task'] = {}
@@ -97,7 +107,7 @@ function normalizeTemplateSpec(raw: unknown): TaskTemplate['task'] {
97
107
  if (isolation !== undefined) spec.isolation = asIsolation(isolation)
98
108
  if (presetId !== undefined && presetId.trim().length > 0) spec.presetId = presetId.trim()
99
109
  if (e.execution !== undefined) {
100
- spec.execution = normalizeExecution(e.execution as { mode?: string; cron?: string }, Date.now())
110
+ spec.execution = normalizeExecution(e.execution as { mode?: string; cron?: string }, now)
101
111
  }
102
112
  if (e.model !== undefined) spec.model = normalizeModel(e.model)
103
113
  if (e.checklist !== undefined) {
@@ -137,10 +147,19 @@ function fail(code: ApiFail['error']['code'], message: string): { res: ApiFail;
137
147
  return { res: { ok: false, error: { code, message } }, status }
138
148
  }
139
149
 
140
- /** Read one JSON body (null on parse failure). */
150
+ /**
151
+ * Read one JSON body (null on parse failure). S8: rejects bodies over
152
+ * MAX_BODY_BYTES by throwing — the local, unauthenticated HTTP surface must
153
+ * not be an unbounded memory sink.
154
+ */
141
155
  async function readBody(req: IncomingMessage): Promise<Record<string, unknown> | null> {
142
156
  const chunks: Buffer[] = []
143
- for await (const chunk of req) chunks.push(chunk as Buffer)
157
+ let total = 0
158
+ for await (const chunk of req) {
159
+ total += (chunk as Buffer).length
160
+ if (total > MAX_BODY_BYTES) throw new Error('body too large')
161
+ chunks.push(chunk as Buffer)
162
+ }
144
163
  if (chunks.length === 0) return {}
145
164
  try {
146
165
  const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
@@ -163,6 +182,13 @@ function num(body: Record<string, unknown>, key: string): number | undefined | n
163
182
  return typeof v === 'number' && Number.isFinite(v) ? v : null
164
183
  }
165
184
 
185
+ /** Find a live task INSIDE a mutator (R1: guards run on the fresh draft). */
186
+ function liveTaskAt(ledger: TaskLedger, id: string): { index: number; task: TaskRecord } {
187
+ const index = ledger.tasks.findIndex(t => t.id === id)
188
+ if (index < 0 || ledger.tasks[index]!.trashedAt !== undefined) throw new Error('Error: not_found: no such task')
189
+ return { index, task: ledger.tasks[index]! }
190
+ }
191
+
166
192
  /** Normalize an agent preset id: trimmed, non-empty; empty string → undefined. */
167
193
  function normalizePresetId(raw: string | null): string | undefined {
168
194
  const t = (raw ?? '').trim()
@@ -172,9 +198,18 @@ function normalizePresetId(raw: string | null): string | undefined {
172
198
  /** Map a thrown domain error to the envelope. */
173
199
  function toFail(error: unknown): { res: ApiFail; status: number } {
174
200
  const message = error instanceof Error ? error.message : String(error)
201
+ // Structured path first (review P2): ToolError carries its code — no need
202
+ // to parse the 'Error: <code>: …' prefix it also renders into the message.
203
+ if (error instanceof ToolError) {
204
+ const mapped = error.code === ERR.workspaceMismatch ? 'forbidden' : error.code
205
+ const known: ApiFail['error']['code'][] = ['invalid_input', 'not_found', 'version_conflict', 'invalid_transition', 'forbidden', 'internal']
206
+ if ((known as string[]).includes(mapped)) {
207
+ return fail(mapped as ApiFail['error']['code'], message.slice(7 + error.code.length + 2))
208
+ }
209
+ }
175
210
  const code = message.startsWith('Error: ') ? message.slice(7).split(':')[0] : undefined
176
- const known: ApiFail['error']['code'][] = ['invalid_input', 'not_found', 'version_conflict', 'invalid_transition', 'forbidden', 'internal']
177
- if (code !== undefined && (known as string[]).includes(code)) {
211
+ const known2: ApiFail['error']['code'][] = ['invalid_input', 'not_found', 'version_conflict', 'invalid_transition', 'forbidden', 'internal']
212
+ if (code !== undefined && (known2 as string[]).includes(code)) {
178
213
  return fail(code as ApiFail['error']['code'], message.slice(7 + code.length + 2))
179
214
  }
180
215
  if (code === 'workspace_mismatch') return fail('forbidden', message.slice(7 + code.length + 2))
@@ -192,11 +227,18 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
192
227
  const subscribers = new Set<ServerResponse>()
193
228
  let heartbeat: NodeJS.Timeout | undefined
194
229
 
230
+ /** R4③: a cleanup/purge target must resolve INSIDE <ws>/.dsh-worktrees — string joining alone is never trusted with an rm. */
231
+ const insideWorktreeScope = (wsPath: string, target: string): boolean => {
232
+ const scope = resolve(wsPath, WORKTREE_DIR)
233
+ const resolved = resolve(target)
234
+ return resolved === scope || resolved.startsWith(scope + sep)
235
+ }
236
+
195
237
  const broadcast = (change: { revision: number; kind: string; tasks: readonly TaskRecord[] }): void => {
196
238
  const frame = `event: change\ndata: ${JSON.stringify({ revision: change.revision, kind: change.kind, tasks: change.tasks.map(summarize) })}\n\n`
197
239
  for (const res of subscribers) res.write(frame)
198
240
  }
199
- store.subscribe(broadcast)
241
+ const unsubscribeBroadcast = store.subscribe(broadcast)
200
242
 
201
243
  // Workspace git detection, TTL-cached and fail-soft (false on any error):
202
244
  // feeds the create-form isolation toggle and the diagnostics panel.
@@ -306,7 +348,7 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
306
348
  // Diff viewer (0.4.0): read-only git show/diff for one execution's
307
349
  // commit or changed path. Prefers the live worktree (uncommitted
308
350
  // view), falls back to the main repo.
309
- const diffMatch = pathname.match(new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/diff$`))
351
+ const diffMatch = pathname.match(TASK_DIFF_RE)
310
352
  if (diffMatch !== null) {
311
353
  try {
312
354
  if (options.git === undefined) {
@@ -364,7 +406,7 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
364
406
  return
365
407
  }
366
408
 
367
- const taskMatch = pathname.match(new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)$`))
409
+ const taskMatch = pathname.match(TASK_RE)
368
410
  if (taskMatch !== null) {
369
411
  const task = store.get(taskMatch[1]!)
370
412
  if (task === undefined) { const f = fail('not_found', 'no such task'); json(res, f.res, f.status); return }
@@ -377,7 +419,7 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
377
419
  }
378
420
 
379
421
  if (req.method !== 'POST') {
380
- res.writeHead(405)
422
+ res.writeHead(405, { allow: 'GET, POST' })
381
423
  res.end()
382
424
  return
383
425
  }
@@ -388,7 +430,14 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
388
430
  json(res, f.res, 415)
389
431
  return
390
432
  }
391
- const body = await readBody(req)
433
+ let body: Record<string, unknown> | null
434
+ try {
435
+ body = await readBody(req)
436
+ } catch {
437
+ const f = fail('invalid_input', `request body exceeds ${MAX_BODY_BYTES} bytes`)
438
+ json(res, f.res, 413)
439
+ return
440
+ }
392
441
  if (body === null) {
393
442
  const f = fail('invalid_input', 'body is not a JSON object')
394
443
  json(res, f.res, 400)
@@ -403,6 +452,9 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
403
452
  if (workspaces.get(workspaceId) === undefined) throw new Error('Error: not_found: unknown workspace')
404
453
  const urgency = asUrgency(str(body, 'urgency') ?? '')
405
454
  const status = str(body, 'status') === null ? 'todo' as const : asStatus(str(body, 'status')!)
455
+ if (status !== 'backlog' && status !== 'todo') {
456
+ throw new Error('Error: invalid_transition: a new task must start as backlog or todo')
457
+ }
406
458
  const execution = normalizeExecution((body.execution as { mode?: string; cron?: string } | undefined) ?? {}, options.now())
407
459
  const model = body.model === undefined ? undefined : checkModel(body.model, options.modelProviders)
408
460
  const isolationRaw = str(body, 'isolation')
@@ -456,8 +508,8 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
456
508
 
457
509
  // ------------------------------------------- POST /tasks/:id/{action}
458
510
  // (\w+ after the id would not match hyphenated actions like
459
- // worktree-remove, hence the explicit class.)
460
- const actionMatch = pathname.match(new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/([\\w-]+)$`))
511
+ // worktree-remove, hence the explicit class in the hoisted pattern.)
512
+ const actionMatch = pathname.match(TASK_ACTION_RE)
461
513
  if (actionMatch !== null) {
462
514
  const id = actionMatch[1]!
463
515
  const action = actionMatch[2]!
@@ -467,78 +519,82 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
467
519
  if (action === 'update') {
468
520
  const ifVersion = num(body, 'ifVersion')
469
521
  if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')
470
- if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)
471
- const next = structuredClone(task)
472
- const title = str(body, 'title')
473
- if (title !== null) next.title = normalizeTitle(title)
474
- const description = str(body, 'description')
475
- if (description !== null) next.description = description.trim()
476
- const prompt = str(body, 'prompt')
477
- if (prompt !== null) next.prompt = normalizePrompt(prompt)
478
- const urgency = str(body, 'urgency')
479
- if (urgency !== null) next.urgency = asUrgency(urgency)
480
- // GUI-only rebind to another project; validated against the workspace registry.
481
- const workspaceId = str(body, 'workspaceId')
482
- if (workspaceId !== null) {
483
- if (workspaces.get(workspaceId) === undefined) throw new Error('Error: not_found: unknown workspace')
484
- next.workspaceId = workspaceId
485
- }
486
- if (typeof body.blocked === 'boolean') next.blocked = body.blocked
487
- // The GUI (task owner surface) may edit model/execution; null clears the model.
488
- if (body.execution !== undefined) next.execution = normalizeExecution(body.execution as { mode?: string; cron?: string }, options.now())
489
- if (body.model === null) next.model = undefined
490
- else if (body.model !== undefined) next.model = checkModel(body.model, options.modelProviders)
491
- // Isolation may change only before the first execution (分支与基线
492
- // 取决于该选择 — plan §3.1: 执行开始后锁定).
493
- const isolationRaw = str(body, 'isolation')
494
- if (isolationRaw !== null) {
495
- if (task.executions.length > 0 || task.status === 'in_progress') {
496
- throw new Error('Error: invalid_input: isolation 已锁定(任务已有执行记录),不可修改')
497
- }
498
- next.isolation = asIsolation(isolationRaw)
499
- }
500
- // Preset may change any time: each run composes fresh.
501
- if (body.presetId === null) delete next.presetId
502
- else if (body.presetId !== undefined) next.presetId = normalizePresetId(str(body, 'presetId'))!
503
- // Checklist (0.4.0): the GUI replaces the whole list; null clears.
504
- if (body.checklist === null) delete next.checklist
505
- else if (body.checklist !== undefined) {
506
- const items = normalizeChecklist(body.checklist)
507
- if (items.length > 0) next.checklist = items
508
- else delete next.checklist
509
- }
510
- next.version = task.version + 1
511
- next.updatedAt = options.now()
512
- next.updatedBy = { kind: 'user' }
522
+ // R1: version guard + write inside the mutation, on the fresh draft.
523
+ let next: TaskRecord | undefined
513
524
  await store.mutate('task-updated', ledger => {
514
- const i = ledger.tasks.findIndex(t => t.id === id)
515
- ledger.tasks[i] = next
525
+ const { index, task } = liveTaskAt(ledger, id)
526
+ if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)
527
+ if (task.status === 'archived') throw new Error('Error: invalid_transition: archived tasks are immutable')
528
+ next = structuredClone(task)
529
+ const title = str(body, 'title')
530
+ if (title !== null) next.title = normalizeTitle(title)
531
+ const description = str(body, 'description')
532
+ if (description !== null) next.description = description.trim()
533
+ const prompt = str(body, 'prompt')
534
+ if (prompt !== null) next.prompt = normalizePrompt(prompt)
535
+ const urgency = str(body, 'urgency')
536
+ if (urgency !== null) next.urgency = asUrgency(urgency)
537
+ // GUI-only rebind to another project; validated against the workspace registry.
538
+ const workspaceId = str(body, 'workspaceId')
539
+ if (workspaceId !== null) {
540
+ if (workspaces.get(workspaceId) === undefined) throw new Error('Error: not_found: unknown workspace')
541
+ next.workspaceId = workspaceId
542
+ }
543
+ if (typeof body.blocked === 'boolean') next.blocked = body.blocked
544
+ // The GUI (task owner surface) may edit model/execution; null clears the model.
545
+ if (body.execution !== undefined) next.execution = normalizeExecution(body.execution as { mode?: string; cron?: string }, options.now())
546
+ if (body.model === null) next.model = undefined
547
+ else if (body.model !== undefined) next.model = checkModel(body.model, options.modelProviders)
548
+ // Isolation may change only before the first execution (分支与基线
549
+ // 取决于该选择 — plan §3.1: 执行开始后锁定).
550
+ const isolationRaw = str(body, 'isolation')
551
+ if (isolationRaw !== null) {
552
+ if (task.executions.length > 0 || task.status === 'in_progress') {
553
+ throw new Error('Error: invalid_input: isolation 已锁定(任务已有执行记录),不可修改')
554
+ }
555
+ next.isolation = asIsolation(isolationRaw)
556
+ }
557
+ // Preset may change any time: each run composes fresh.
558
+ if (body.presetId === null) delete next.presetId
559
+ else if (body.presetId !== undefined) next.presetId = normalizePresetId(str(body, 'presetId'))!
560
+ // Checklist (0.4.0): the GUI replaces the whole list; null clears.
561
+ if (body.checklist === null) delete next.checklist
562
+ else if (body.checklist !== undefined) {
563
+ const items = normalizeChecklist(body.checklist)
564
+ if (items.length > 0) next.checklist = items
565
+ else delete next.checklist
566
+ }
567
+ next.version = task.version + 1
568
+ next.updatedAt = options.now()
569
+ next.updatedBy = { kind: 'user' }
570
+ ledger.tasks[index] = next
516
571
  return [next]
517
572
  })
518
- json(res, { ok: true, value: summarize(next) })
573
+ json(res, { ok: true, value: summarize(next!) })
519
574
  return
520
575
  }
521
576
  if (action === 'move') {
522
577
  const ifVersion = num(body, 'ifVersion')
523
578
  const status = str(body, 'status') ?? ''
524
579
  if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')
525
- if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)
526
580
  const to = asStatus(status)
527
- if (!canTransition(task.status, to)) throw new Error(`Error: invalid_transition: illegal transition ${task.status} → ${to}`)
528
- const next = structuredClone(task)
529
- next.status = to
530
- next.version = task.version + 1
531
- next.updatedAt = options.now()
532
- next.updatedBy = { kind: 'user' }
533
- if (task.status === 'todo' && to === 'in_progress') next.blocked = false
534
- // A user move records no holder; leaving in_progress releases any hold.
535
- syncClaim(next, to, options.now())
581
+ let next: TaskRecord | undefined
536
582
  await store.mutate('task-moved', ledger => {
537
- const i = ledger.tasks.findIndex(t => t.id === id)
538
- ledger.tasks[i] = next
583
+ const { index, task } = liveTaskAt(ledger, id)
584
+ if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)
585
+ if (!canTransition(task.status, to)) throw new Error(`Error: invalid_transition: illegal transition ${task.status} → ${to}`)
586
+ next = structuredClone(task)
587
+ next.status = to
588
+ next.version = task.version + 1
589
+ next.updatedAt = options.now()
590
+ next.updatedBy = { kind: 'user' }
591
+ if (task.status === 'todo' && to === 'in_progress') next.blocked = false
592
+ // A user move records no holder; leaving in_progress releases any hold.
593
+ syncClaim(next, to, options.now())
594
+ ledger.tasks[index] = next
539
595
  return [next]
540
596
  })
541
- json(res, { ok: true, value: summarize(next) })
597
+ json(res, { ok: true, value: summarize(next!) })
542
598
  return
543
599
  }
544
600
  if (action === 'reject') {
@@ -546,36 +602,38 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
546
602
  // atomic mutation (a failed move never strands an orphan comment).
547
603
  const ifVersion = num(body, 'ifVersion')
548
604
  if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')
549
- if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)
550
- if (!canTransition(task.status, 'todo')) throw new Error(`Error: invalid_transition: illegal transition ${task.status} → todo`)
551
- const next = structuredClone(task)
552
- next.status = 'todo'
553
- next.version = task.version + 1
554
- next.updatedAt = options.now()
555
- next.updatedBy = { kind: 'user' }
556
- syncClaim(next, 'todo', options.now())
557
605
  const commentText = str(body, 'body') ?? ''
558
- if (commentText.trim().length > 0) {
559
- next.comments.push({ id: newCommentId(), body: normalizeBody(commentText), version: 1, createdAt: options.now() })
560
- }
606
+ let next: TaskRecord | undefined
561
607
  await store.mutate('task-moved', ledger => {
562
- const i = ledger.tasks.findIndex(t => t.id === id)
563
- ledger.tasks[i] = next
608
+ const { index, task } = liveTaskAt(ledger, id)
609
+ if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)
610
+ if (!canTransition(task.status, 'todo')) throw new Error(`Error: invalid_transition: illegal transition ${task.status} → todo`)
611
+ next = structuredClone(task)
612
+ next.status = 'todo'
613
+ next.version = task.version + 1
614
+ next.updatedAt = options.now()
615
+ next.updatedBy = { kind: 'user' }
616
+ syncClaim(next, 'todo', options.now())
617
+ if (commentText.trim().length > 0) {
618
+ next.comments.push({ id: newCommentId(), body: normalizeBody(commentText), version: 1, createdAt: options.now() })
619
+ }
620
+ ledger.tasks[index] = next
564
621
  return [next]
565
622
  })
566
- json(res, { ok: true, value: summarize(next) })
623
+ json(res, { ok: true, value: summarize(next!) })
567
624
  return
568
625
  }
569
626
  if (action === 'comment') {
570
627
  const bodyText = str(body, 'body') ?? ''
571
628
  const comment = { id: newCommentId(), body: normalizeBody(bodyText), version: 1, createdAt: options.now() }
572
- const next = structuredClone(task)
573
- next.comments.push(comment)
574
- next.version = task.version + 1
575
- next.updatedAt = options.now()
576
629
  await store.mutate('comment-added', ledger => {
577
- const i = ledger.tasks.findIndex(t => t.id === id)
578
- ledger.tasks[i] = next
630
+ const { index, task } = liveTaskAt(ledger, id)
631
+ if (task.status === 'archived') throw new Error('Error: invalid_transition: archived tasks are immutable')
632
+ const next = structuredClone(task)
633
+ next.comments.push(comment)
634
+ next.version = task.version + 1
635
+ next.updatedAt = options.now()
636
+ ledger.tasks[index] = next
579
637
  return [next]
580
638
  })
581
639
  json(res, { ok: true, value: comment }, 201)
@@ -592,19 +650,25 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
592
650
  const ws = workspaces.get(task.workspaceId)
593
651
  if (ws !== undefined) {
594
652
  const path = worktreePathOf(ws.path, id)
653
+ if (!insideWorktreeScope(ws.path, path)) {
654
+ throw new Error('Error: invalid_input: 非法的清除路径(不在任务工作目录范围内)')
655
+ }
595
656
  try {
596
- await options.git.removeWorktree(ws.path, path)
657
+ // S3: 'unregistered' (an orphaned dir git forgot) is a
658
+ // structured outcome, not a parsed stderr message.
659
+ if (await options.git.removeWorktree(ws.path, path) === 'unregistered') {
660
+ // An unregistered leftover dir: plain fs removal.
661
+ await rm(path, { recursive: true, force: true })
662
+ }
597
663
  } catch (error) {
598
664
  const message = error instanceof Error ? error.message : String(error)
599
- if (message.includes('未提交修改')) {
665
+ // Structured classification (review P2): git tags its dirty
666
+ // rejections with a code; the keyword stays as a fallback.
667
+ const dirty = (error as { code?: string }).code === 'dirty-worktree' || message.includes('未提交修改')
668
+ if (dirty) {
600
669
  throw new Error(`Error: invalid_input: ${message};请先处理这些改动(提交、续跑或手动保存)再物理清除任务`)
601
670
  }
602
- if (/not a working tree|not a working-tree/i.test(message)) {
603
- // An unregistered leftover dir: plain fs removal.
604
- await rm(path, { recursive: true, force: true })
605
- } else {
606
- throw new Error(`Error: invalid_input: ${message}`)
607
- }
671
+ throw new Error(`Error: invalid_input: ${message}`)
608
672
  }
609
673
  if (task.branch !== undefined) {
610
674
  try {
@@ -622,13 +686,21 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
622
686
  }
623
687
  const ifVersion = num(body, 'ifVersion')
624
688
  if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')
625
- if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)
626
- const next = structuredClone(task)
627
- next.trashedAt = options.now()
628
- next.version = task.version + 1
629
689
  await store.mutate('task-deleted', ledger => {
630
- const i = ledger.tasks.findIndex(t => t.id === id)
631
- ledger.tasks[i] = next
690
+ const { index, task } = liveTaskAt(ledger, id)
691
+ if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)
692
+ // S5: a running execution keeps writing to the task — refuse the
693
+ // soft-delete until it is cancelled or settled. T8: clear residue.
694
+ if (task.executions.some(e => e.outcome === 'running')) {
695
+ throw new Error('Error: invalid_input: 任务有正在运行的执行,请先取消或等它结束再删除')
696
+ }
697
+ const next = structuredClone(task)
698
+ next.trashedAt = options.now()
699
+ next.version = task.version + 1
700
+ delete next.claimedBy
701
+ delete next.claimedAt
702
+ next.blocked = false
703
+ ledger.tasks[index] = next
632
704
  return [next]
633
705
  })
634
706
  json(res, { ok: true, value: { trashed: true } })
@@ -695,13 +767,15 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
695
767
  throw new Error(`Error: invalid_input: ${error instanceof Error ? error.message : String(error)}`)
696
768
  }
697
769
  const mergedComment = { id: newCommentId(), body: normalizeBody(`[系统] 分支 ${task.branch} 已合并到主工作区(--no-ff)。`), version: 1, createdAt: options.now() }
698
- const next = structuredClone(task)
699
- next.comments.push(mergedComment)
700
- next.version = task.version + 1
701
- next.updatedAt = options.now()
770
+ // R1: the git merge above is slow — re-find the FRESH task inside
771
+ // the mutation so a concurrent comment is never overwritten.
702
772
  await store.mutate('comment-added', ledger => {
703
- const i = ledger.tasks.findIndex(t => t.id === id)
704
- ledger.tasks[i] = next
773
+ const { index, task: fresh } = liveTaskAt(ledger, id)
774
+ const next = structuredClone(fresh)
775
+ next.comments.push(mergedComment)
776
+ next.version = fresh.version + 1
777
+ next.updatedAt = options.now()
778
+ ledger.tasks[index] = next
705
779
  return [next]
706
780
  })
707
781
  json(res, { ok: true, value: { merged: true, branch: task.branch } })
@@ -719,8 +793,15 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
719
793
  const ws = workspaces.get(task.workspaceId)
720
794
  if (ws === undefined) throw new Error('Error: not_found: unknown workspace')
721
795
  const path = worktreePathOf(ws.path, id)
796
+ if (!insideWorktreeScope(ws.path, path)) {
797
+ throw new Error('Error: invalid_input: 非法的清除路径(不在任务工作目录范围内)')
798
+ }
722
799
  try {
723
- await options.git.removeWorktree(ws.path, path)
800
+ // S3: an unregistered leftover at the task's own path is
801
+ // removed from the filesystem directly.
802
+ if (await options.git.removeWorktree(ws.path, path) === 'unregistered') {
803
+ await rm(path, { recursive: true, force: true })
804
+ }
724
805
  } catch (error) {
725
806
  throw new Error(`Error: invalid_input: ${error instanceof Error ? error.message : String(error)}`)
726
807
  }
@@ -761,19 +842,21 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
761
842
  // Only dirs owned by NO ledger task may be cleaned here; live tasks
762
843
  // remove their worktree from the detail page.
763
844
  if (store.get(taskId) !== undefined) throw new Error('Error: invalid_input: 任务仍在看板中,请从任务详情页删除其 worktree')
845
+ // worktreePathOf throws on an illegal id charset (R4②); the resolved
846
+ // target must additionally stay inside the plugin's own worktree
847
+ // scope — the taskId is fully attacker-controlled body input (R4③).
764
848
  const path = worktreePathOf(ws.path, taskId)
849
+ if (!insideWorktreeScope(ws.path, path)) {
850
+ throw new Error('Error: invalid_input: 非法的清除路径(不在任务工作目录范围内)')
851
+ }
765
852
  try {
766
- await options.git.removeWorktree(ws.path, path)
767
- } catch (error) {
768
- const message = error instanceof Error ? error.message : String(error)
769
- // An unregistered leftover (git no longer knows this worktree):
770
- // fall back to direct fs removal — the dir lives inside the
771
- // plugin's own .dsh-worktrees scope.
772
- if (/not a working tree|not a working-tree/i.test(message)) {
853
+ // S3: 'unregistered' = git no longer knows this worktree; remove
854
+ // the leftover dir directly (scope-verified above).
855
+ if (await options.git.removeWorktree(ws.path, path) === 'unregistered') {
773
856
  await rm(path, { recursive: true, force: true })
774
- } else {
775
- throw new Error(`Error: invalid_input: ${message}`)
776
857
  }
858
+ } catch (error) {
859
+ throw new Error(`Error: invalid_input: ${error instanceof Error ? error.message : String(error)}`)
777
860
  }
778
861
  json(res, { ok: true, value: { cleaned: true, path } })
779
862
  } catch (error) {
@@ -826,8 +909,13 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
826
909
  backupFile = await store.backup()
827
910
  }
828
911
  let replacedTotal: number | undefined
829
- await store.mutate('task-created', ledger => {
912
+ await store.mutate('ledger-replaced', ledger => {
830
913
  if (mode === 'replace') {
914
+ // S6: a whole-ledger swap must not strand live executions — the
915
+ // running sessions keep working with no task to settle into.
916
+ if (ledger.tasks.some(t => t.executions.some(e => e.outcome === 'running'))) {
917
+ throw new Error('Error: invalid_input: 有任务正在执行,不能整册替换(请先取消或等待结束)')
918
+ }
831
919
  replacedTotal = ledger.tasks.length
832
920
  ledger.tasks = structuredClone(imported)
833
921
  // Replace is a whole-ledger swap (0.5.0): board settings ride
@@ -837,7 +925,15 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
837
925
  return ledger.tasks
838
926
  }
839
927
  const byId = new Map(ledger.tasks.map(t => [t.id, t]))
840
- for (const task of imported) byId.set(task.id, structuredClone(task))
928
+ for (const task of imported) {
929
+ // S6: merging over a task whose execution is live would orphan
930
+ // that run the same way — refuse the overwrite.
931
+ const existing = byId.get(task.id)
932
+ if (existing !== undefined && existing.executions.some(e => e.outcome === 'running')) {
933
+ throw new Error(`Error: invalid_input: 任务 ${task.id} 正在执行,不能被导入覆盖`)
934
+ }
935
+ byId.set(task.id, structuredClone(task))
936
+ }
841
937
  ledger.tasks = [...byId.values()]
842
938
  return structuredClone(imported)
843
939
  })
@@ -878,7 +974,7 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
878
974
  const template = await options.templates.upsert({
879
975
  id: str(body, 'id') ?? undefined,
880
976
  name,
881
- task: normalizeTemplateSpec(body.task),
977
+ task: normalizeTemplateSpec(body.task, options.now()),
882
978
  })
883
979
  json(res, { ok: true, value: template }, 201)
884
980
  } catch (error) {
@@ -924,6 +1020,10 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
924
1020
  // Baseline frame: the client reconciles by revision and refetches state on gaps.
925
1021
  res.write(`event: hello\ndata: ${JSON.stringify({ revision: store.snapshot().revision })}\n\n`)
926
1022
  subscribers.add(res)
1023
+ // S2: a socket that dies between 'close' detection and the next write
1024
+ // would emit 'error' on the response — unhandled, that escalates to an
1025
+ // uncaughtException. Drop the subscriber instead.
1026
+ res.on('error', () => { subscribers.delete(res) })
927
1027
  if (heartbeat === undefined) {
928
1028
  heartbeat = setInterval(() => {
929
1029
  for (const current of subscribers) current.write(': ping\n\n')
@@ -943,6 +1043,7 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
943
1043
  ctx.webServer.register({ kind: 'exact', path: SSE_PATH, handler: sse }),
944
1044
  ]
945
1045
  return () => {
1046
+ unsubscribeBroadcast()
946
1047
  for (const dispose of disposers) dispose()
947
1048
  if (heartbeat !== undefined) clearInterval(heartbeat)
948
1049
  for (const res of subscribers) res.end()