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
@@ -11,16 +11,18 @@
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'
18
18
  import {
19
+ asBoardSettings,
19
20
  asIsolation,
20
21
  asStatus,
21
22
  asUrgency,
22
23
  canTransition,
23
24
  checklistFromTexts,
25
+ defaultIsolationOf,
24
26
  newCommentId,
25
27
  newTaskId,
26
28
  normalizeBody,
@@ -32,6 +34,7 @@ import {
32
34
  summarize,
33
35
  syncClaim,
34
36
  validateLedgerImport,
37
+ type TaskLedger,
35
38
  type TaskModel,
36
39
  type TaskRecord,
37
40
  } from '../shared/protocol.ts'
@@ -40,11 +43,20 @@ import type { TaskTemplate } from '../shared/api.ts'
40
43
  import type { TemplateStore } from './templates.ts'
41
44
  import { ROUTE_PREFIX, SSE_PATH, type ApiFail, type ApiResult } from '../shared/api.ts'
42
45
  import type { TaskStore } from './store.ts'
46
+ import { ERR, ToolError } from './tools.ts'
43
47
  import type { WorkspaceFace } from './tools.ts'
44
48
 
45
49
  /** Heartbeat cadence for the SSE stream. */
46
50
  const HEARTBEAT_MS = 20_000
47
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
+
48
60
  /** How long a workspace git-detection result stays cached (fail-soft). */
49
61
  const GIT_DETECT_TTL_MS = 60_000
50
62
 
@@ -72,7 +84,7 @@ export interface TaskboardRoutesOptions {
72
84
  }
73
85
 
74
86
  /** Validate a template's task spec (routes-side, unknown → invalid_input). */
75
- function normalizeTemplateSpec(raw: unknown): TaskTemplate['task'] {
87
+ function normalizeTemplateSpec(raw: unknown, now: number): TaskTemplate['task'] {
76
88
  if (typeof raw !== 'object' || raw === null) throw new Error('Error: invalid_input: task must be an object')
77
89
  const e = raw as Record<string, unknown>
78
90
  const spec: TaskTemplate['task'] = {}
@@ -95,7 +107,7 @@ function normalizeTemplateSpec(raw: unknown): TaskTemplate['task'] {
95
107
  if (isolation !== undefined) spec.isolation = asIsolation(isolation)
96
108
  if (presetId !== undefined && presetId.trim().length > 0) spec.presetId = presetId.trim()
97
109
  if (e.execution !== undefined) {
98
- spec.execution = normalizeExecution(e.execution as { mode?: string; cron?: string }, Date.now())
110
+ spec.execution = normalizeExecution(e.execution as { mode?: string; cron?: string }, now)
99
111
  }
100
112
  if (e.model !== undefined) spec.model = normalizeModel(e.model)
101
113
  if (e.checklist !== undefined) {
@@ -135,10 +147,19 @@ function fail(code: ApiFail['error']['code'], message: string): { res: ApiFail;
135
147
  return { res: { ok: false, error: { code, message } }, status }
136
148
  }
137
149
 
138
- /** 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
+ */
139
155
  async function readBody(req: IncomingMessage): Promise<Record<string, unknown> | null> {
140
156
  const chunks: Buffer[] = []
141
- 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
+ }
142
163
  if (chunks.length === 0) return {}
143
164
  try {
144
165
  const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
@@ -161,6 +182,13 @@ function num(body: Record<string, unknown>, key: string): number | undefined | n
161
182
  return typeof v === 'number' && Number.isFinite(v) ? v : null
162
183
  }
163
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
+
164
192
  /** Normalize an agent preset id: trimmed, non-empty; empty string → undefined. */
165
193
  function normalizePresetId(raw: string | null): string | undefined {
166
194
  const t = (raw ?? '').trim()
@@ -170,9 +198,18 @@ function normalizePresetId(raw: string | null): string | undefined {
170
198
  /** Map a thrown domain error to the envelope. */
171
199
  function toFail(error: unknown): { res: ApiFail; status: number } {
172
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
+ }
173
210
  const code = message.startsWith('Error: ') ? message.slice(7).split(':')[0] : undefined
174
- const known: ApiFail['error']['code'][] = ['invalid_input', 'not_found', 'version_conflict', 'invalid_transition', 'forbidden', 'internal']
175
- 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)) {
176
213
  return fail(code as ApiFail['error']['code'], message.slice(7 + code.length + 2))
177
214
  }
178
215
  if (code === 'workspace_mismatch') return fail('forbidden', message.slice(7 + code.length + 2))
@@ -190,11 +227,18 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
190
227
  const subscribers = new Set<ServerResponse>()
191
228
  let heartbeat: NodeJS.Timeout | undefined
192
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
+
193
237
  const broadcast = (change: { revision: number; kind: string; tasks: readonly TaskRecord[] }): void => {
194
238
  const frame = `event: change\ndata: ${JSON.stringify({ revision: change.revision, kind: change.kind, tasks: change.tasks.map(summarize) })}\n\n`
195
239
  for (const res of subscribers) res.write(frame)
196
240
  }
197
- store.subscribe(broadcast)
241
+ const unsubscribeBroadcast = store.subscribe(broadcast)
198
242
 
199
243
  // Workspace git detection, TTL-cached and fail-soft (false on any error):
200
244
  // feeds the create-form isolation toggle and the diagnostics panel.
@@ -304,7 +348,7 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
304
348
  // Diff viewer (0.4.0): read-only git show/diff for one execution's
305
349
  // commit or changed path. Prefers the live worktree (uncommitted
306
350
  // view), falls back to the main repo.
307
- const diffMatch = pathname.match(new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/diff$`))
351
+ const diffMatch = pathname.match(TASK_DIFF_RE)
308
352
  if (diffMatch !== null) {
309
353
  try {
310
354
  if (options.git === undefined) {
@@ -355,7 +399,14 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
355
399
  return
356
400
  }
357
401
 
358
- const taskMatch = pathname.match(new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)$`))
402
+ // Board settings (0.5.0): absent fields follow factory defaults.
403
+ if (pathname === `${ROUTE_PREFIX}/settings`) {
404
+ await store.load()
405
+ json(res, { ok: true, value: store.snapshot().settings ?? {} })
406
+ return
407
+ }
408
+
409
+ const taskMatch = pathname.match(TASK_RE)
359
410
  if (taskMatch !== null) {
360
411
  const task = store.get(taskMatch[1]!)
361
412
  if (task === undefined) { const f = fail('not_found', 'no such task'); json(res, f.res, f.status); return }
@@ -368,7 +419,7 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
368
419
  }
369
420
 
370
421
  if (req.method !== 'POST') {
371
- res.writeHead(405)
422
+ res.writeHead(405, { allow: 'GET, POST' })
372
423
  res.end()
373
424
  return
374
425
  }
@@ -379,7 +430,14 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
379
430
  json(res, f.res, 415)
380
431
  return
381
432
  }
382
- 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
+ }
383
441
  if (body === null) {
384
442
  const f = fail('invalid_input', 'body is not a JSON object')
385
443
  json(res, f.res, 400)
@@ -394,10 +452,16 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
394
452
  if (workspaces.get(workspaceId) === undefined) throw new Error('Error: not_found: unknown workspace')
395
453
  const urgency = asUrgency(str(body, 'urgency') ?? '')
396
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
+ }
397
458
  const execution = normalizeExecution((body.execution as { mode?: string; cron?: string } | undefined) ?? {}, options.now())
398
459
  const model = body.model === undefined ? undefined : checkModel(body.model, options.modelProviders)
399
460
  const isolationRaw = str(body, 'isolation')
400
- const isolation = isolationRaw === null ? undefined : asIsolation(isolationRaw)
461
+ // 0.5.0: an omitted isolation is MATERIALIZED from the board
462
+ // setting (看板设置) at creation, so later setting changes never
463
+ // rewrite existing tasks.
464
+ const isolation = isolationRaw === null ? defaultIsolationOf(store.snapshot().settings) : asIsolation(isolationRaw)
401
465
  const presetId = normalizePresetId(str(body, 'presetId'))
402
466
  let checklist: TaskRecord['checklist'] = undefined
403
467
  if (body.checklist !== undefined) {
@@ -419,7 +483,7 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
419
483
  blocked: false,
420
484
  execution,
421
485
  model,
422
- ...(isolation !== undefined ? { isolation } : {}),
486
+ isolation,
423
487
  ...(presetId !== undefined ? { presetId } : {}),
424
488
  ...(checklist !== undefined ? { checklist } : {}),
425
489
  version: 1,
@@ -444,8 +508,8 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
444
508
 
445
509
  // ------------------------------------------- POST /tasks/:id/{action}
446
510
  // (\w+ after the id would not match hyphenated actions like
447
- // worktree-remove, hence the explicit class.)
448
- 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)
449
513
  if (actionMatch !== null) {
450
514
  const id = actionMatch[1]!
451
515
  const action = actionMatch[2]!
@@ -455,78 +519,82 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
455
519
  if (action === 'update') {
456
520
  const ifVersion = num(body, 'ifVersion')
457
521
  if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')
458
- if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)
459
- const next = structuredClone(task)
460
- const title = str(body, 'title')
461
- if (title !== null) next.title = normalizeTitle(title)
462
- const description = str(body, 'description')
463
- if (description !== null) next.description = description.trim()
464
- const prompt = str(body, 'prompt')
465
- if (prompt !== null) next.prompt = normalizePrompt(prompt)
466
- const urgency = str(body, 'urgency')
467
- if (urgency !== null) next.urgency = asUrgency(urgency)
468
- // GUI-only rebind to another project; validated against the workspace registry.
469
- const workspaceId = str(body, 'workspaceId')
470
- if (workspaceId !== null) {
471
- if (workspaces.get(workspaceId) === undefined) throw new Error('Error: not_found: unknown workspace')
472
- next.workspaceId = workspaceId
473
- }
474
- if (typeof body.blocked === 'boolean') next.blocked = body.blocked
475
- // The GUI (task owner surface) may edit model/execution; null clears the model.
476
- if (body.execution !== undefined) next.execution = normalizeExecution(body.execution as { mode?: string; cron?: string }, options.now())
477
- if (body.model === null) next.model = undefined
478
- else if (body.model !== undefined) next.model = checkModel(body.model, options.modelProviders)
479
- // Isolation may change only before the first execution (分支与基线
480
- // 取决于该选择 — plan §3.1: 执行开始后锁定).
481
- const isolationRaw = str(body, 'isolation')
482
- if (isolationRaw !== null) {
483
- if (task.executions.length > 0 || task.status === 'in_progress') {
484
- throw new Error('Error: invalid_input: isolation 已锁定(任务已有执行记录),不可修改')
485
- }
486
- next.isolation = asIsolation(isolationRaw)
487
- }
488
- // Preset may change any time: each run composes fresh.
489
- if (body.presetId === null) delete next.presetId
490
- else if (body.presetId !== undefined) next.presetId = normalizePresetId(str(body, 'presetId'))!
491
- // Checklist (0.4.0): the GUI replaces the whole list; null clears.
492
- if (body.checklist === null) delete next.checklist
493
- else if (body.checklist !== undefined) {
494
- const items = normalizeChecklist(body.checklist)
495
- if (items.length > 0) next.checklist = items
496
- else delete next.checklist
497
- }
498
- next.version = task.version + 1
499
- next.updatedAt = options.now()
500
- next.updatedBy = { kind: 'user' }
522
+ // R1: version guard + write inside the mutation, on the fresh draft.
523
+ let next: TaskRecord | undefined
501
524
  await store.mutate('task-updated', ledger => {
502
- const i = ledger.tasks.findIndex(t => t.id === id)
503
- 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
504
571
  return [next]
505
572
  })
506
- json(res, { ok: true, value: summarize(next) })
573
+ json(res, { ok: true, value: summarize(next!) })
507
574
  return
508
575
  }
509
576
  if (action === 'move') {
510
577
  const ifVersion = num(body, 'ifVersion')
511
578
  const status = str(body, 'status') ?? ''
512
579
  if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')
513
- if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)
514
580
  const to = asStatus(status)
515
- if (!canTransition(task.status, to)) throw new Error(`Error: invalid_transition: illegal transition ${task.status} → ${to}`)
516
- const next = structuredClone(task)
517
- next.status = to
518
- next.version = task.version + 1
519
- next.updatedAt = options.now()
520
- next.updatedBy = { kind: 'user' }
521
- if (task.status === 'todo' && to === 'in_progress') next.blocked = false
522
- // A user move records no holder; leaving in_progress releases any hold.
523
- syncClaim(next, to, options.now())
581
+ let next: TaskRecord | undefined
524
582
  await store.mutate('task-moved', ledger => {
525
- const i = ledger.tasks.findIndex(t => t.id === id)
526
- 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
527
595
  return [next]
528
596
  })
529
- json(res, { ok: true, value: summarize(next) })
597
+ json(res, { ok: true, value: summarize(next!) })
530
598
  return
531
599
  }
532
600
  if (action === 'reject') {
@@ -534,36 +602,38 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
534
602
  // atomic mutation (a failed move never strands an orphan comment).
535
603
  const ifVersion = num(body, 'ifVersion')
536
604
  if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')
537
- if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)
538
- if (!canTransition(task.status, 'todo')) throw new Error(`Error: invalid_transition: illegal transition ${task.status} → todo`)
539
- const next = structuredClone(task)
540
- next.status = 'todo'
541
- next.version = task.version + 1
542
- next.updatedAt = options.now()
543
- next.updatedBy = { kind: 'user' }
544
- syncClaim(next, 'todo', options.now())
545
605
  const commentText = str(body, 'body') ?? ''
546
- if (commentText.trim().length > 0) {
547
- next.comments.push({ id: newCommentId(), body: normalizeBody(commentText), version: 1, createdAt: options.now() })
548
- }
606
+ let next: TaskRecord | undefined
549
607
  await store.mutate('task-moved', ledger => {
550
- const i = ledger.tasks.findIndex(t => t.id === id)
551
- 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
552
621
  return [next]
553
622
  })
554
- json(res, { ok: true, value: summarize(next) })
623
+ json(res, { ok: true, value: summarize(next!) })
555
624
  return
556
625
  }
557
626
  if (action === 'comment') {
558
627
  const bodyText = str(body, 'body') ?? ''
559
628
  const comment = { id: newCommentId(), body: normalizeBody(bodyText), version: 1, createdAt: options.now() }
560
- const next = structuredClone(task)
561
- next.comments.push(comment)
562
- next.version = task.version + 1
563
- next.updatedAt = options.now()
564
629
  await store.mutate('comment-added', ledger => {
565
- const i = ledger.tasks.findIndex(t => t.id === id)
566
- 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
567
637
  return [next]
568
638
  })
569
639
  json(res, { ok: true, value: comment }, 201)
@@ -580,19 +650,25 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
580
650
  const ws = workspaces.get(task.workspaceId)
581
651
  if (ws !== undefined) {
582
652
  const path = worktreePathOf(ws.path, id)
653
+ if (!insideWorktreeScope(ws.path, path)) {
654
+ throw new Error('Error: invalid_input: 非法的清除路径(不在任务工作目录范围内)')
655
+ }
583
656
  try {
584
- 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
+ }
585
663
  } catch (error) {
586
664
  const message = error instanceof Error ? error.message : String(error)
587
- 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) {
588
669
  throw new Error(`Error: invalid_input: ${message};请先处理这些改动(提交、续跑或手动保存)再物理清除任务`)
589
670
  }
590
- if (/not a working tree|not a working-tree/i.test(message)) {
591
- // An unregistered leftover dir: plain fs removal.
592
- await rm(path, { recursive: true, force: true })
593
- } else {
594
- throw new Error(`Error: invalid_input: ${message}`)
595
- }
671
+ throw new Error(`Error: invalid_input: ${message}`)
596
672
  }
597
673
  if (task.branch !== undefined) {
598
674
  try {
@@ -610,13 +686,21 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
610
686
  }
611
687
  const ifVersion = num(body, 'ifVersion')
612
688
  if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')
613
- if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)
614
- const next = structuredClone(task)
615
- next.trashedAt = options.now()
616
- next.version = task.version + 1
617
689
  await store.mutate('task-deleted', ledger => {
618
- const i = ledger.tasks.findIndex(t => t.id === id)
619
- 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
620
704
  return [next]
621
705
  })
622
706
  json(res, { ok: true, value: { trashed: true } })
@@ -683,13 +767,15 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
683
767
  throw new Error(`Error: invalid_input: ${error instanceof Error ? error.message : String(error)}`)
684
768
  }
685
769
  const mergedComment = { id: newCommentId(), body: normalizeBody(`[系统] 分支 ${task.branch} 已合并到主工作区(--no-ff)。`), version: 1, createdAt: options.now() }
686
- const next = structuredClone(task)
687
- next.comments.push(mergedComment)
688
- next.version = task.version + 1
689
- 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.
690
772
  await store.mutate('comment-added', ledger => {
691
- const i = ledger.tasks.findIndex(t => t.id === id)
692
- 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
693
779
  return [next]
694
780
  })
695
781
  json(res, { ok: true, value: { merged: true, branch: task.branch } })
@@ -707,8 +793,15 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
707
793
  const ws = workspaces.get(task.workspaceId)
708
794
  if (ws === undefined) throw new Error('Error: not_found: unknown workspace')
709
795
  const path = worktreePathOf(ws.path, id)
796
+ if (!insideWorktreeScope(ws.path, path)) {
797
+ throw new Error('Error: invalid_input: 非法的清除路径(不在任务工作目录范围内)')
798
+ }
710
799
  try {
711
- 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
+ }
712
805
  } catch (error) {
713
806
  throw new Error(`Error: invalid_input: ${error instanceof Error ? error.message : String(error)}`)
714
807
  }
@@ -749,19 +842,21 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
749
842
  // Only dirs owned by NO ledger task may be cleaned here; live tasks
750
843
  // remove their worktree from the detail page.
751
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③).
752
848
  const path = worktreePathOf(ws.path, taskId)
849
+ if (!insideWorktreeScope(ws.path, path)) {
850
+ throw new Error('Error: invalid_input: 非法的清除路径(不在任务工作目录范围内)')
851
+ }
753
852
  try {
754
- await options.git.removeWorktree(ws.path, path)
755
- } catch (error) {
756
- const message = error instanceof Error ? error.message : String(error)
757
- // An unregistered leftover (git no longer knows this worktree):
758
- // fall back to direct fs removal — the dir lives inside the
759
- // plugin's own .dsh-worktrees scope.
760
- 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') {
761
856
  await rm(path, { recursive: true, force: true })
762
- } else {
763
- throw new Error(`Error: invalid_input: ${message}`)
764
857
  }
858
+ } catch (error) {
859
+ throw new Error(`Error: invalid_input: ${error instanceof Error ? error.message : String(error)}`)
765
860
  }
766
861
  json(res, { ok: true, value: { cleaned: true, path } })
767
862
  } catch (error) {
@@ -814,14 +909,31 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
814
909
  backupFile = await store.backup()
815
910
  }
816
911
  let replacedTotal: number | undefined
817
- await store.mutate('task-created', ledger => {
912
+ await store.mutate('ledger-replaced', ledger => {
818
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
+ }
819
919
  replacedTotal = ledger.tasks.length
820
920
  ledger.tasks = structuredClone(imported)
921
+ // Replace is a whole-ledger swap (0.5.0): board settings ride
922
+ // along when the file carries them; merge keeps the live ones.
923
+ if (plan.settings !== undefined) ledger.settings = structuredClone(plan.settings)
924
+ else delete ledger.settings
821
925
  return ledger.tasks
822
926
  }
823
927
  const byId = new Map(ledger.tasks.map(t => [t.id, t]))
824
- 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
+ }
825
937
  ledger.tasks = [...byId.values()]
826
938
  return structuredClone(imported)
827
939
  })
@@ -862,7 +974,7 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
862
974
  const template = await options.templates.upsert({
863
975
  id: str(body, 'id') ?? undefined,
864
976
  name,
865
- task: normalizeTemplateSpec(body.task),
977
+ task: normalizeTemplateSpec(body.task, options.now()),
866
978
  })
867
979
  json(res, { ok: true, value: template }, 201)
868
980
  } catch (error) {
@@ -872,6 +984,24 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
872
984
  return
873
985
  }
874
986
 
987
+ // ------------------------------------------ POST /settings/update
988
+ // (0.5.0) Whole-object replace semantics: omitted fields fall back to
989
+ // their factory defaults. Affects only tasks created AFTER the change.
990
+ if (pathname === `${ROUTE_PREFIX}/settings/update`) {
991
+ try {
992
+ const next = asBoardSettings(body)
993
+ await store.mutate('settings-updated', ledger => {
994
+ ledger.settings = next
995
+ return []
996
+ })
997
+ json(res, { ok: true, value: next })
998
+ } catch (error) {
999
+ const f = toFail(error)
1000
+ json(res, f.res, f.status)
1001
+ }
1002
+ return
1003
+ }
1004
+
875
1005
  res.writeHead(404)
876
1006
  res.end()
877
1007
  } catch (error) {
@@ -890,6 +1020,10 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
890
1020
  // Baseline frame: the client reconciles by revision and refetches state on gaps.
891
1021
  res.write(`event: hello\ndata: ${JSON.stringify({ revision: store.snapshot().revision })}\n\n`)
892
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) })
893
1027
  if (heartbeat === undefined) {
894
1028
  heartbeat = setInterval(() => {
895
1029
  for (const current of subscribers) current.write(': ping\n\n')
@@ -909,6 +1043,7 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
909
1043
  ctx.webServer.register({ kind: 'exact', path: SSE_PATH, handler: sse }),
910
1044
  ]
911
1045
  return () => {
1046
+ unsubscribeBroadcast()
912
1047
  for (const dispose of disposers) dispose()
913
1048
  if (heartbeat !== undefined) clearInterval(heartbeat)
914
1049
  for (const res of subscribers) res.end()