dsh-taskboard 0.2.2 → 0.4.0

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 (43) hide show
  1. package/README.md +62 -6
  2. package/lib/client.js +1839 -74
  3. package/lib/host/execution.js +199 -54
  4. package/lib/host/execution.js.map +1 -1
  5. package/lib/host/git.js +327 -0
  6. package/lib/host/git.js.map +1 -0
  7. package/lib/host/protocol-text.js +5 -3
  8. package/lib/host/protocol-text.js.map +1 -1
  9. package/lib/host/routes.js +435 -5
  10. package/lib/host/routes.js.map +1 -1
  11. package/lib/host/store.js +12 -0
  12. package/lib/host/store.js.map +1 -1
  13. package/lib/host/templates.js +166 -0
  14. package/lib/host/templates.js.map +1 -0
  15. package/lib/host/tools.js +217 -3
  16. package/lib/host/tools.js.map +1 -1
  17. package/lib/index.js +23 -3
  18. package/lib/index.js.map +1 -1
  19. package/lib/shared/api.js.map +1 -1
  20. package/lib/shared/protocol.js +286 -1
  21. package/lib/shared/protocol.js.map +1 -1
  22. package/package.json +74 -74
  23. package/src/client/api.ts +47 -3
  24. package/src/client/board/ImportModal.tsx +182 -0
  25. package/src/client/board/TaskBoard.tsx +118 -3
  26. package/src/client/board/TaskCard.tsx +9 -0
  27. package/src/client/board/TaskDetail.tsx +360 -4
  28. package/src/client/board/TaskFormModal.tsx +193 -12
  29. package/src/client/board/TemplateManager.tsx +121 -0
  30. package/src/client/controller.ts +238 -11
  31. package/src/client/index.ts +18 -1
  32. package/src/client/styles.ts +198 -0
  33. package/src/host/execution.ts +301 -67
  34. package/src/host/git.ts +370 -0
  35. package/src/host/protocol-text.ts +5 -3
  36. package/src/host/routes.ts +483 -5
  37. package/src/host/store.ts +13 -0
  38. package/src/host/templates.ts +143 -0
  39. package/src/host/tools.ts +215 -2
  40. package/src/index.ts +30 -1
  41. package/src/shared/api.ts +89 -3
  42. package/src/shared/protocol.ts +408 -0
  43. package/src/shared/version.ts +1 -1
@@ -10,25 +10,34 @@
10
10
  * @module dsh-taskboard/host/routes
11
11
  */
12
12
  import type { IncomingMessage, ServerResponse } from 'node:http'
13
+ import { readdir, rm } from 'node:fs/promises'
14
+ import { join } from 'node:path'
13
15
  import type { Context } from '@deepseek-ai/cordis'
14
16
  // Type-only: pulls the webServer Context merge (ctx.webServer).
15
17
  import type {} from '@deepseek-ai/dsh-host-webserver'
16
18
  import {
19
+ asIsolation,
17
20
  asStatus,
18
21
  asUrgency,
19
22
  canTransition,
23
+ checklistFromTexts,
20
24
  newCommentId,
21
25
  newTaskId,
22
26
  normalizeBody,
27
+ normalizeChecklist,
23
28
  normalizeExecution,
24
29
  normalizeModel,
25
30
  normalizePrompt,
26
31
  normalizeTitle,
27
32
  summarize,
28
33
  syncClaim,
34
+ validateLedgerImport,
29
35
  type TaskModel,
30
36
  type TaskRecord,
31
37
  } from '../shared/protocol.ts'
38
+ import { WORKTREE_DIR, worktreePathOf, type GitFace } from './git.ts'
39
+ import type { TaskTemplate } from '../shared/api.ts'
40
+ import type { TemplateStore } from './templates.ts'
32
41
  import { ROUTE_PREFIX, SSE_PATH, type ApiFail, type ApiResult } from '../shared/api.ts'
33
42
  import type { TaskStore } from './store.ts'
34
43
  import type { WorkspaceFace } from './tools.ts'
@@ -36,6 +45,9 @@ import type { WorkspaceFace } from './tools.ts'
36
45
  /** Heartbeat cadence for the SSE stream. */
37
46
  const HEARTBEAT_MS = 20_000
38
47
 
48
+ /** How long a workspace git-detection result stays cached (fail-soft). */
49
+ const GIT_DETECT_TTL_MS = 60_000
50
+
39
51
  /** The workspaces face routes need (same narrow shape as tools). */
40
52
  export type RoutesWorkspaceFace = WorkspaceFace
41
53
 
@@ -44,8 +56,8 @@ export interface TaskboardRoutesOptions {
44
56
  store: TaskStore
45
57
  workspaces: RoutesWorkspaceFace
46
58
  now: () => number
47
- /** Manual-run hook (the execution service); absent → 501. */
48
- run?: (taskId: string) => Promise<{ ok: true; executionId: string; sessionId: string } | { ok: false; error: string }>
59
+ /** Manual-run hook (the execution service); absent → 501. Options carry `reuseWorktree` (续跑). */
60
+ run?: (taskId: string, options?: { reuseWorktree?: boolean }) => Promise<{ ok: true; executionId: string; sessionId: string } | { ok: false; error: string }>
49
61
  /** Cancel hook (the execution service); absent → 501. */
50
62
  cancel?: (taskId: string) => Promise<{ ok: true; executionId: string } | { ok: false; error: string }>
51
63
  /**
@@ -53,6 +65,47 @@ export interface TaskboardRoutesOptions {
53
65
  * advisory validation of pinned models; undefined = runtime unavailable.
54
66
  */
55
67
  modelProviders?: () => string[] | undefined
68
+ /** Git face for worktree actions + workspace git detection; absent → 501 on git actions. */
69
+ git?: GitFace
70
+ /** Task-template store (0.4.0); absent → 501 on template actions. */
71
+ templates?: TemplateStore
72
+ }
73
+
74
+ /** Validate a template's task spec (routes-side, unknown → invalid_input). */
75
+ function normalizeTemplateSpec(raw: unknown): TaskTemplate['task'] {
76
+ if (typeof raw !== 'object' || raw === null) throw new Error('Error: invalid_input: task must be an object')
77
+ const e = raw as Record<string, unknown>
78
+ const spec: TaskTemplate['task'] = {}
79
+ const str = (key: string): string | undefined => {
80
+ const v = e[key]
81
+ if (v === undefined) return undefined
82
+ if (typeof v !== 'string') throw new Error(`Error: invalid_input: task.${key} must be a string`)
83
+ return v
84
+ }
85
+ const title = str('title')
86
+ const description = str('description')
87
+ const prompt = str('prompt')
88
+ const urgency = str('urgency')
89
+ const isolation = str('isolation')
90
+ const presetId = str('presetId')
91
+ if (title !== undefined) spec.title = normalizeTitle(title)
92
+ if (description !== undefined) spec.description = description
93
+ if (prompt !== undefined) spec.prompt = normalizePrompt(prompt)
94
+ if (urgency !== undefined) spec.urgency = asUrgency(urgency)
95
+ if (isolation !== undefined) spec.isolation = asIsolation(isolation)
96
+ if (presetId !== undefined && presetId.trim().length > 0) spec.presetId = presetId.trim()
97
+ if (e.execution !== undefined) {
98
+ spec.execution = normalizeExecution(e.execution as { mode?: string; cron?: string }, Date.now())
99
+ }
100
+ if (e.model !== undefined) spec.model = normalizeModel(e.model)
101
+ if (e.checklist !== undefined) {
102
+ if (!Array.isArray(e.checklist) || e.checklist.some(c => typeof c !== 'string')) {
103
+ throw new Error('Error: invalid_input: task.checklist must be an array of strings')
104
+ }
105
+ checklistFromTexts(e.checklist as string[]) // validates count + texts
106
+ spec.checklist = e.checklist as string[]
107
+ }
108
+ return spec
56
109
  }
57
110
 
58
111
  /** Validate a pinned model: structural check always, provider route when known. */
@@ -108,6 +161,12 @@ function num(body: Record<string, unknown>, key: string): number | undefined | n
108
161
  return typeof v === 'number' && Number.isFinite(v) ? v : null
109
162
  }
110
163
 
164
+ /** Normalize an agent preset id: trimmed, non-empty; empty string → undefined. */
165
+ function normalizePresetId(raw: string | null): string | undefined {
166
+ const t = (raw ?? '').trim()
167
+ return t.length === 0 ? undefined : t
168
+ }
169
+
111
170
  /** Map a thrown domain error to the envelope. */
112
171
  function toFail(error: unknown): { res: ApiFail; status: number } {
113
172
  const message = error instanceof Error ? error.message : String(error)
@@ -137,6 +196,72 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
137
196
  }
138
197
  store.subscribe(broadcast)
139
198
 
199
+ // Workspace git detection, TTL-cached and fail-soft (false on any error):
200
+ // feeds the create-form isolation toggle and the diagnostics panel.
201
+ const gitCache = new Map<string, { value: boolean; at: number }>()
202
+ const gitHinted = new Set<string>()
203
+
204
+ /** Whether <root>/.gitignore (missing file counts as missing) ignores our worktree dir. */
205
+ const gitignoreMissing = async (path: string): Promise<boolean> => {
206
+ try {
207
+ const { readFile } = await import('node:fs/promises')
208
+ const ignore = await readFile(join(path, '.gitignore'), 'utf8')
209
+ return !ignore.split('\n').some(l => {
210
+ const t = l.trim().replace(/\/+$/, '')
211
+ return t === WORKTREE_DIR || t === `/${WORKTREE_DIR}`
212
+ })
213
+ } catch {
214
+ return true // no .gitignore at all (or unreadable) → suggest creating one
215
+ }
216
+ }
217
+
218
+ const gitAvailable = async (path: string): Promise<boolean> => {
219
+ if (options.git === undefined) return false
220
+ const hit = gitCache.get(path)
221
+ if (hit !== undefined && options.now() - hit.at < GIT_DETECT_TTL_MS) return hit.value
222
+ let value = false
223
+ try {
224
+ value = await options.git.detect(path)
225
+ } catch { /* fail-soft → false */ }
226
+ gitCache.set(path, { value, at: options.now() })
227
+ // gitignore 建议 (plan §3.2): suggest (never write) ignoring our
228
+ // worktree directory, once per workspace per host run.
229
+ if (value && !gitHinted.has(path)) {
230
+ gitHinted.add(path)
231
+ if (await gitignoreMissing(path)) {
232
+ console.info(`[dsh-taskboard] 建议在 ${path}/.gitignore 加入一行 ${WORKTREE_DIR}/ 以隐藏任务 worktree 目录(不会自动修改)`)
233
+ }
234
+ }
235
+ return value
236
+ }
237
+
238
+ /** List orphan worktree dirs: entries under <ws>/.dsh-worktrees owned by no ledger task. */
239
+ const listOrphanWorktrees = async (): Promise<Array<{ workspaceId: string; workspacePath: string; taskId: string; path: string }>> => {
240
+ const orphans: Array<{ workspaceId: string; workspacePath: string; taskId: string; path: string }> = []
241
+ const known = new Set(store.snapshot().tasks.map(t => t.id))
242
+ for (const ws of workspaces.list()) {
243
+ let entries: string[] = []
244
+ try {
245
+ const dirents = await readdir(join(ws.path, WORKTREE_DIR), { withFileTypes: true })
246
+ entries = dirents.filter(e => e.isDirectory()).map(e => e.name)
247
+ } catch { /* no worktrees dir → nothing to do */ }
248
+ for (const taskId of entries) {
249
+ if (!known.has(taskId)) orphans.push({ workspaceId: ws.id, workspacePath: ws.path, taskId, path: worktreePathOf(ws.path, taskId) })
250
+ }
251
+ }
252
+ return orphans
253
+ }
254
+
255
+ /** Git-enabled workspaces whose .gitignore does not cover the worktree dir. */
256
+ const listGitignoreSuggestions = async (): Promise<Array<{ workspaceId: string; workspacePath: string }>> => {
257
+ const suggestions: Array<{ workspaceId: string; workspacePath: string }> = []
258
+ for (const ws of workspaces.list()) {
259
+ if (!(await gitAvailable(ws.path))) continue
260
+ if (await gitignoreMissing(ws.path)) suggestions.push({ workspaceId: ws.id, workspacePath: ws.path })
261
+ }
262
+ return suggestions
263
+ }
264
+
140
265
  const handler = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
141
266
  try {
142
267
  const url = new URL(req.url ?? '/', 'http://x')
@@ -150,9 +275,86 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
150
275
  return
151
276
  }
152
277
  if (pathname === `${ROUTE_PREFIX}/workspaces`) {
153
- json(res, { ok: true, value: workspaces.list() })
278
+ const list = workspaces.list()
279
+ const flags = await Promise.all(list.map(ws => gitAvailable(ws.path)))
280
+ json(res, {
281
+ ok: true,
282
+ value: list.map((ws, i) => ({ ...ws, sessionCount: 0, gitAvailable: flags[i] })),
283
+ })
284
+ return
285
+ }
286
+ if (pathname === `${ROUTE_PREFIX}/diagnostics`) {
287
+ const ledger = store.snapshot()
288
+ let staleRunning = 0
289
+ for (const t of ledger.tasks) {
290
+ for (const e of t.executions) if (e.outcome === 'running') staleRunning += 1
291
+ }
292
+ json(res, {
293
+ ok: true,
294
+ value: {
295
+ revision: ledger.revision,
296
+ tasks: ledger.tasks.length,
297
+ staleRunning,
298
+ orphanWorktrees: await listOrphanWorktrees(),
299
+ gitIgnoreSuggestions: await listGitignoreSuggestions(),
300
+ },
301
+ })
154
302
  return
155
303
  }
304
+ // Diff viewer (0.4.0): read-only git show/diff for one execution's
305
+ // commit or changed path. Prefers the live worktree (uncommitted
306
+ // view), falls back to the main repo.
307
+ const diffMatch = pathname.match(new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/diff$`))
308
+ if (diffMatch !== null) {
309
+ try {
310
+ if (options.git === undefined) {
311
+ const f = fail('invalid_input', 'git integration unavailable')
312
+ json(res, f.res, 501)
313
+ return
314
+ }
315
+ const task = store.get(diffMatch[1]!)
316
+ if (task === undefined) throw new Error('Error: not_found: no such task')
317
+ const execution = task.executions.find(e => e.id === url.searchParams.get('execution'))
318
+ if (execution === undefined) throw new Error('Error: not_found: no such execution')
319
+ const commit = url.searchParams.get('commit')
320
+ const filePath = url.searchParams.get('path')
321
+ const ws = workspaces.get(task.workspaceId)
322
+ if (ws === undefined) throw new Error('Error: not_found: unknown workspace')
323
+ const cwd = execution.worktreePath ?? ws.path
324
+ let result = commit !== null
325
+ ? await options.git.showCommit(cwd, commit)
326
+ : filePath !== null ? await options.git.showPathDiff(cwd, filePath, execution.baseCommit) : undefined
327
+ // Fallback: the worktree may be gone — commits and committed
328
+ // ranges still resolve in the main repo.
329
+ if (result === undefined && execution.worktreePath !== undefined && cwd !== ws.path) {
330
+ result = commit !== null
331
+ ? await options.git.showCommit(ws.path, commit)
332
+ : filePath !== null && execution.baseCommit !== undefined
333
+ ? await options.git.showPathDiff(ws.path, filePath, execution.baseCommit)
334
+ : undefined
335
+ }
336
+ if (result === undefined) {
337
+ throw new Error('Error: invalid_input: 无法获取 diff(git 报错、对象不存在,或仅存于已删除的 worktree 且无基线)')
338
+ }
339
+ json(res, { ok: true, value: { diff: result.text, truncated: result.truncated } })
340
+ } catch (error) {
341
+ const f = toFail(error)
342
+ json(res, f.res, f.status)
343
+ }
344
+ return
345
+ }
346
+
347
+ // Templates listing (0.4.0).
348
+ if (pathname === `${ROUTE_PREFIX}/templates`) {
349
+ if (options.templates === undefined) {
350
+ const f = fail('invalid_input', 'template store unavailable')
351
+ json(res, f.res, 501)
352
+ return
353
+ }
354
+ json(res, { ok: true, value: { templates: await options.templates.list() } })
355
+ return
356
+ }
357
+
156
358
  const taskMatch = pathname.match(new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)$`))
157
359
  if (taskMatch !== null) {
158
360
  const task = store.get(taskMatch[1]!)
@@ -194,6 +396,17 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
194
396
  const status = str(body, 'status') === null ? 'todo' as const : asStatus(str(body, 'status')!)
195
397
  const execution = normalizeExecution((body.execution as { mode?: string; cron?: string } | undefined) ?? {}, options.now())
196
398
  const model = body.model === undefined ? undefined : checkModel(body.model, options.modelProviders)
399
+ const isolationRaw = str(body, 'isolation')
400
+ const isolation = isolationRaw === null ? undefined : asIsolation(isolationRaw)
401
+ const presetId = normalizePresetId(str(body, 'presetId'))
402
+ let checklist: TaskRecord['checklist'] = undefined
403
+ if (body.checklist !== undefined) {
404
+ if (!Array.isArray(body.checklist) || body.checklist.some(c => typeof c !== 'string')) {
405
+ throw new Error('Error: invalid_input: checklist must be an array of strings')
406
+ }
407
+ const texts = (body.checklist as string[]).map(c => c.trim()).filter(c => c.length > 0)
408
+ if (texts.length > 0) checklist = checklistFromTexts(texts)
409
+ }
197
410
  const now = options.now()
198
411
  const task: TaskRecord = {
199
412
  id: newTaskId(),
@@ -206,6 +419,9 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
206
419
  blocked: false,
207
420
  execution,
208
421
  model,
422
+ ...(isolation !== undefined ? { isolation } : {}),
423
+ ...(presetId !== undefined ? { presetId } : {}),
424
+ ...(checklist !== undefined ? { checklist } : {}),
209
425
  version: 1,
210
426
  createdAt: now,
211
427
  updatedAt: now,
@@ -227,7 +443,9 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
227
443
  }
228
444
 
229
445
  // ------------------------------------------- POST /tasks/:id/{action}
230
- const actionMatch = pathname.match(new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/(\\w+)$`))
446
+ // (\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-]+)$`))
231
449
  if (actionMatch !== null) {
232
450
  const id = actionMatch[1]!
233
451
  const action = actionMatch[2]!
@@ -258,6 +476,25 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
258
476
  if (body.execution !== undefined) next.execution = normalizeExecution(body.execution as { mode?: string; cron?: string }, options.now())
259
477
  if (body.model === null) next.model = undefined
260
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
+ }
261
498
  next.version = task.version + 1
262
499
  next.updatedAt = options.now()
263
500
  next.updatedBy = { kind: 'user' }
@@ -336,6 +573,34 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
336
573
  const purge = body.purge === true
337
574
  if (purge) {
338
575
  if (task.trashedAt === undefined) throw new Error('Error: invalid_input: purge requires a trashed task (soft-delete first)')
576
+ // Worktree safety before purge (plan §3.3, 0.3.1): refuse while
577
+ // uncommitted work remains; otherwise clean the worktree and
578
+ // the task branch along with the ledger entry.
579
+ if (options.git !== undefined) {
580
+ const ws = workspaces.get(task.workspaceId)
581
+ if (ws !== undefined) {
582
+ const path = worktreePathOf(ws.path, id)
583
+ try {
584
+ await options.git.removeWorktree(ws.path, path)
585
+ } catch (error) {
586
+ const message = error instanceof Error ? error.message : String(error)
587
+ if (message.includes('未提交修改')) {
588
+ throw new Error(`Error: invalid_input: ${message};请先处理这些改动(提交、续跑或手动保存)再物理清除任务`)
589
+ }
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
+ }
596
+ }
597
+ if (task.branch !== undefined) {
598
+ try {
599
+ await options.git.deleteBranch(ws.path, task.branch)
600
+ } catch { /* best effort: the branch may outlive the task */ }
601
+ }
602
+ }
603
+ }
339
604
  await store.mutate('task-deleted', ledger => {
340
605
  ledger.tasks = ledger.tasks.filter(t => t.id !== id)
341
606
  return []
@@ -363,7 +628,10 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
363
628
  json(res, f.res, 501)
364
629
  return
365
630
  }
366
- const result = await options.run(id)
631
+ // `reuse: true` = 续跑: keep a live worktree/branch as-is instead
632
+ // of resetting to a fresh baseline (0.3.1).
633
+ const runOptions = body.reuse === true ? { reuseWorktree: true } : undefined
634
+ const result = await options.run(id, runOptions)
367
635
  if (result.ok) json(res, { ok: true, value: result }, 202)
368
636
  else {
369
637
  const f = fail('invalid_input', result.error)
@@ -385,6 +653,78 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
385
653
  }
386
654
  return
387
655
  }
656
+ if (action === 'merge') {
657
+ // ⇥ 合并 (detail page, user-only): merge the task branch into the
658
+ // main worktree with --no-ff; conflicts are reported verbatim.
659
+ if (options.git === undefined) {
660
+ const f = fail('invalid_input', 'git integration unavailable')
661
+ json(res, f.res, 501)
662
+ return
663
+ }
664
+ if (task.branch === undefined) throw new Error('Error: invalid_input: 该任务还没有 worktree 分支(未隔离执行过)')
665
+ if (task.status === 'in_progress') throw new Error('Error: invalid_input: 任务执行中,不能合并')
666
+ if (task.executions.some(e => e.outcome === 'running')) throw new Error('Error: invalid_input: 任务执行中,不能合并')
667
+ const ws = workspaces.get(task.workspaceId)
668
+ if (ws === undefined) throw new Error('Error: not_found: unknown workspace')
669
+ // No-op detection (0.3.1): a branch with no commits over HEAD
670
+ // merges as "already up to date" — report that instead of landing
671
+ // a bogus 已合并 comment.
672
+ let noop = false
673
+ try {
674
+ noop = await options.git.isAncestor(ws.path, task.branch)
675
+ } catch { /* fail-soft: proceed to the real merge */ }
676
+ if (noop) {
677
+ json(res, { ok: true, value: { merged: false, noop: true, branch: task.branch } })
678
+ return
679
+ }
680
+ try {
681
+ await options.git.merge(ws.path, task.branch)
682
+ } catch (error) {
683
+ throw new Error(`Error: invalid_input: ${error instanceof Error ? error.message : String(error)}`)
684
+ }
685
+ 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()
690
+ await store.mutate('comment-added', ledger => {
691
+ const i = ledger.tasks.findIndex(t => t.id === id)
692
+ ledger.tasks[i] = next
693
+ return [next]
694
+ })
695
+ json(res, { ok: true, value: { merged: true, branch: task.branch } })
696
+ return
697
+ }
698
+ if (action === 'worktree-remove') {
699
+ // 🗑 删除 worktree (detail page): refuses uncommitted changes;
700
+ // optionally deletes the task branch after the worktree is gone.
701
+ if (options.git === undefined) {
702
+ const f = fail('invalid_input', 'git integration unavailable')
703
+ json(res, f.res, 501)
704
+ return
705
+ }
706
+ if (task.executions.some(e => e.outcome === 'running')) throw new Error('Error: invalid_input: 任务执行中,不能删除 worktree')
707
+ const ws = workspaces.get(task.workspaceId)
708
+ if (ws === undefined) throw new Error('Error: not_found: unknown workspace')
709
+ const path = worktreePathOf(ws.path, id)
710
+ try {
711
+ await options.git.removeWorktree(ws.path, path)
712
+ } catch (error) {
713
+ throw new Error(`Error: invalid_input: ${error instanceof Error ? error.message : String(error)}`)
714
+ }
715
+ let branchDeleted = false
716
+ let branchError: string | undefined
717
+ if (body.deleteBranch === true && task.branch !== undefined) {
718
+ try {
719
+ await options.git.deleteBranch(ws.path, task.branch)
720
+ branchDeleted = true
721
+ } catch (error) {
722
+ branchError = error instanceof Error ? error.message : String(error)
723
+ }
724
+ }
725
+ json(res, { ok: true, value: { removed: true, branchDeleted, ...(branchError !== undefined ? { branchError } : {}) } })
726
+ return
727
+ }
388
728
  const f = fail('not_found', `unknown action ${action}`)
389
729
  json(res, f.res, f.status)
390
730
  } catch (error) {
@@ -394,6 +734,144 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
394
734
  return
395
735
  }
396
736
 
737
+ // -------------------------------------- POST /worktree-cleanup (⚙ 诊断)
738
+ if (pathname === `${ROUTE_PREFIX}/worktree-cleanup`) {
739
+ try {
740
+ if (options.git === undefined) {
741
+ const f = fail('invalid_input', 'git integration unavailable')
742
+ json(res, f.res, 501)
743
+ return
744
+ }
745
+ const workspaceId = str(body, 'workspaceId') ?? ''
746
+ const taskId = str(body, 'taskId') ?? ''
747
+ const ws = workspaces.get(workspaceId)
748
+ if (ws === undefined) throw new Error('Error: not_found: unknown workspace')
749
+ // Only dirs owned by NO ledger task may be cleaned here; live tasks
750
+ // remove their worktree from the detail page.
751
+ if (store.get(taskId) !== undefined) throw new Error('Error: invalid_input: 任务仍在看板中,请从任务详情页删除其 worktree')
752
+ const path = worktreePathOf(ws.path, taskId)
753
+ 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)) {
761
+ await rm(path, { recursive: true, force: true })
762
+ } else {
763
+ throw new Error(`Error: invalid_input: ${message}`)
764
+ }
765
+ }
766
+ json(res, { ok: true, value: { cleaned: true, path } })
767
+ } catch (error) {
768
+ const f = toFail(error)
769
+ json(res, f.res, f.status)
770
+ }
771
+ return
772
+ }
773
+
774
+ // ---------------------------------------------- POST /import/preview
775
+ // (0.4.0) Dry-run: classify every task in the uploaded ledger file
776
+ // against the live one; nothing is written.
777
+ if (pathname === `${ROUTE_PREFIX}/import/preview`) {
778
+ try {
779
+ const known = new Set(store.snapshot().tasks.map(t => t.id))
780
+ const plan = validateLedgerImport(body, known, options.now())
781
+ json(res, {
782
+ ok: true,
783
+ value: {
784
+ plan: {
785
+ create: plan.create.map(t => ({ id: t.id, title: t.title, status: t.status })),
786
+ overwrite: plan.overwrite.map(t => ({ id: t.id, title: t.title, status: t.status })),
787
+ invalid: plan.invalid,
788
+ },
789
+ },
790
+ })
791
+ } catch (error) {
792
+ const f = toFail(error)
793
+ json(res, f.res, f.status)
794
+ }
795
+ return
796
+ }
797
+
798
+ // ------------------------------------------------------ POST /import
799
+ // (0.4.0) Commit an import. mode=merge upserts (create + overwrite by
800
+ // id); mode=replace swaps the WHOLE ledger (invalid entries dropped)
801
+ // after writing a timestamped backup of the current one.
802
+ if (pathname === `${ROUTE_PREFIX}/import`) {
803
+ try {
804
+ const mode = str(body, 'mode') === 'replace' ? 'replace' as const : 'merge' as const
805
+ const raw = body.ledger
806
+ const known = new Set(store.snapshot().tasks.map(t => t.id))
807
+ const plan = validateLedgerImport(raw, known, options.now())
808
+ const imported = [...plan.create, ...plan.overwrite]
809
+ if (mode === 'replace' && imported.length === 0) {
810
+ throw new Error('Error: invalid_input: 导入文件没有可导入的任务,已拒绝整册替换')
811
+ }
812
+ let backupFile: string | undefined
813
+ if (mode === 'replace' && store.snapshot().tasks.length > 0) {
814
+ backupFile = await store.backup()
815
+ }
816
+ let replacedTotal: number | undefined
817
+ await store.mutate('task-created', ledger => {
818
+ if (mode === 'replace') {
819
+ replacedTotal = ledger.tasks.length
820
+ ledger.tasks = structuredClone(imported)
821
+ return ledger.tasks
822
+ }
823
+ const byId = new Map(ledger.tasks.map(t => [t.id, t]))
824
+ for (const task of imported) byId.set(task.id, structuredClone(task))
825
+ ledger.tasks = [...byId.values()]
826
+ return structuredClone(imported)
827
+ })
828
+ json(res, {
829
+ ok: true,
830
+ value: {
831
+ mode,
832
+ created: plan.create.length,
833
+ overwritten: plan.overwrite.length,
834
+ ...(mode === 'replace' ? { replacedTotal } : {}),
835
+ ...(backupFile !== undefined ? { backupFile } : {}),
836
+ },
837
+ })
838
+ } catch (error) {
839
+ const f = toFail(error)
840
+ json(res, f.res, f.status)
841
+ }
842
+ return
843
+ }
844
+
845
+ // ------------------------------------------- POST /templates (+delete)
846
+ if (pathname === `${ROUTE_PREFIX}/templates` || pathname === `${ROUTE_PREFIX}/templates/delete`) {
847
+ try {
848
+ if (options.templates === undefined) {
849
+ const f = fail('invalid_input', 'template store unavailable')
850
+ json(res, f.res, 501)
851
+ return
852
+ }
853
+ if (pathname.endsWith('/delete')) {
854
+ const id = str(body, 'id') ?? ''
855
+ if (id.length === 0) throw new Error('Error: invalid_input: id required')
856
+ const deleted = await options.templates.remove(id)
857
+ json(res, { ok: true, value: { deleted } })
858
+ return
859
+ }
860
+ const name = str(body, 'name') ?? ''
861
+ if (name.trim().length === 0) throw new Error('Error: invalid_input: name required')
862
+ const template = await options.templates.upsert({
863
+ id: str(body, 'id') ?? undefined,
864
+ name,
865
+ task: normalizeTemplateSpec(body.task),
866
+ })
867
+ json(res, { ok: true, value: template }, 201)
868
+ } catch (error) {
869
+ const f = toFail(error)
870
+ json(res, f.res, f.status)
871
+ }
872
+ return
873
+ }
874
+
397
875
  res.writeHead(404)
398
876
  res.end()
399
877
  } catch (error) {
package/src/host/store.ts CHANGED
@@ -103,6 +103,19 @@ export class TaskStore {
103
103
  return () => this.subscribers.delete(fn)
104
104
  }
105
105
 
106
+ /**
107
+ * Write a timestamped backup copy of the current ledger next to the live
108
+ * file (import-replace safety, 0.4.0). Never throws the caller's flow —
109
+ * a backup failure fails the import itself.
110
+ * @returns the backup file path.
111
+ */
112
+ async backup(): Promise<string> {
113
+ await this.load()
114
+ const target = `${this.file}.backup-${Date.now()}`
115
+ await persistAtomic(target, JSON.stringify(this.ledger, null, 2))
116
+ return target
117
+ }
118
+
106
119
  /**
107
120
  * Run one mutation inside the serial queue. The mutator works on a
108
121
  * structured clone; returning `undefined` aborts with no write.