dsh-taskboard 0.6.6 → 0.7.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 (64) hide show
  1. package/README.md +305 -288
  2. package/lib/client.js +985 -931
  3. package/lib/host/archive-sessions.js +30 -0
  4. package/lib/host/archive-sessions.js.map +1 -0
  5. package/lib/host/assets.js +139 -0
  6. package/lib/host/assets.js.map +1 -0
  7. package/lib/host/execution.js +3 -0
  8. package/lib/host/execution.js.map +1 -1
  9. package/lib/host/locale.js +17 -0
  10. package/lib/host/locale.js.map +1 -0
  11. package/lib/host/routes.js +132 -6
  12. package/lib/host/routes.js.map +1 -1
  13. package/lib/host/scheduler.js +2 -0
  14. package/lib/host/scheduler.js.map +1 -1
  15. package/lib/host/session-sync.js +4 -1
  16. package/lib/host/session-sync.js.map +1 -1
  17. package/lib/host/storage-queue.js +14 -0
  18. package/lib/host/storage-queue.js.map +1 -0
  19. package/lib/host/storage.js +249 -0
  20. package/lib/host/storage.js.map +1 -0
  21. package/lib/host/store.js +34 -7
  22. package/lib/host/store.js.map +1 -1
  23. package/lib/host/templates.js +73 -113
  24. package/lib/host/templates.js.map +1 -1
  25. package/lib/host/tools.js +23 -8
  26. package/lib/host/tools.js.map +1 -1
  27. package/lib/index.js +83 -27
  28. package/lib/index.js.map +1 -1
  29. package/lib/shared/api.js.map +1 -1
  30. package/lib/shared/builtin-templates.js +155 -0
  31. package/lib/shared/builtin-templates.js.map +1 -0
  32. package/lib/shared/protocol.js +39 -2
  33. package/lib/shared/protocol.js.map +1 -1
  34. package/package.json +90 -89
  35. package/src/client/api.ts +35 -1
  36. package/src/client/board/SettingsModal.tsx +67 -4
  37. package/src/client/board/SlashPromptInput.tsx +80 -1
  38. package/src/client/board/TaskBoard.tsx +16 -11
  39. package/src/client/board/TaskDetail.tsx +186 -17
  40. package/src/client/board/TaskFormModal.tsx +8 -5
  41. package/src/client/board/TemplateManager.tsx +22 -10
  42. package/src/client/controller.ts +67 -5
  43. package/src/client/i18n/en.ts +35 -3
  44. package/src/client/i18n/templates.ts +25 -0
  45. package/src/client/i18n/zh.ts +35 -3
  46. package/src/client/image-insert.ts +29 -0
  47. package/src/client/styles.ts +35 -2
  48. package/src/host/archive-sessions.ts +18 -0
  49. package/src/host/assets.ts +120 -0
  50. package/src/host/execution.ts +4 -1
  51. package/src/host/locale.ts +44 -0
  52. package/src/host/routes.ts +132 -7
  53. package/src/host/scheduler.ts +2 -0
  54. package/src/host/session-sync.ts +4 -1
  55. package/src/host/storage-queue.ts +10 -0
  56. package/src/host/storage.ts +212 -0
  57. package/src/host/store.ts +40 -12
  58. package/src/host/templates.ts +38 -66
  59. package/src/host/tools.ts +28 -11
  60. package/src/index.ts +88 -33
  61. package/src/shared/api.ts +32 -3
  62. package/src/shared/builtin-templates.ts +153 -0
  63. package/src/shared/protocol.ts +64 -0
  64. package/src/shared/version.ts +9 -9
@@ -0,0 +1,120 @@
1
+ /** Durable, content-addressed image attachments for task descriptions/comments. */
2
+ import { createHash } from 'node:crypto'
3
+ import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
4
+ import { join } from 'node:path'
5
+ import type { StorageQueue } from './storage-queue.ts'
6
+
7
+ export const MAX_ASSET_BYTES = 5 * 1024 * 1024
8
+ export const MAX_ASSET_STORE_BYTES = 200 * 1024 * 1024
9
+ export const ORPHAN_GRACE_MS = 24 * 60 * 60 * 1000
10
+
11
+ export type ImageKind = { extension: 'png' | 'jpg' | 'gif' | 'webp'; mime: 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp' }
12
+ export type StoredAsset = ImageKind & { id: string; name: string; size: number; url: string }
13
+
14
+ const ASSET_NAME_RE = /^([a-f0-9]{64})\.(png|jpg|gif|webp)$/
15
+
16
+ /** Detect supported images from magic bytes; request MIME and filenames are not trusted. */
17
+ export function detectImage(bytes: Uint8Array): ImageKind | undefined {
18
+ if (bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47
19
+ && bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a) {
20
+ return { extension: 'png', mime: 'image/png' }
21
+ }
22
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
23
+ return { extension: 'jpg', mime: 'image/jpeg' }
24
+ }
25
+ if (bytes.length >= 6) {
26
+ const head = Buffer.from(bytes.subarray(0, 6)).toString('ascii')
27
+ if (head === 'GIF87a' || head === 'GIF89a') return { extension: 'gif', mime: 'image/gif' }
28
+ }
29
+ if (bytes.length >= 12 && Buffer.from(bytes.subarray(0, 4)).toString('ascii') === 'RIFF'
30
+ && Buffer.from(bytes.subarray(8, 12)).toString('ascii') === 'WEBP') {
31
+ return { extension: 'webp', mime: 'image/webp' }
32
+ }
33
+ return undefined
34
+ }
35
+
36
+ /** Files live outside the ledger so state/SSE/tool payloads only carry short Markdown URLs. */
37
+ export class AssetStore {
38
+ private queue: Promise<unknown> = Promise.resolve()
39
+ private root: string
40
+
41
+ constructor(root: string, private readonly now: () => number = () => Date.now(), private readonly storageQueue?: StorageQueue) { this.root = root }
42
+
43
+ /** Current absolute attachment-directory path. */
44
+ location(): string { return this.root }
45
+
46
+ /** Switch reads and future writes after the coordinator copied the directory. */
47
+ setLocation(root: string): void { this.root = root }
48
+
49
+ async put(bytes: Uint8Array, declaredMime?: string): Promise<StoredAsset> {
50
+ const run = () => this.putSerial(bytes, declaredMime)
51
+ if (this.storageQueue !== undefined) return this.storageQueue.run(run)
52
+ return (this.queue = this.queue.then(run, run)) as Promise<StoredAsset>
53
+ }
54
+
55
+ private async putSerial(bytes: Uint8Array, declaredMime?: string): Promise<StoredAsset> {
56
+ if (bytes.length === 0 || bytes.length > MAX_ASSET_BYTES) {
57
+ throw new Error(`image must be 1..${MAX_ASSET_BYTES} bytes`)
58
+ }
59
+ const kind = detectImage(bytes)
60
+ if (kind === undefined) throw new Error('unsupported image; use PNG, JPEG, GIF, or WebP')
61
+ if (declaredMime !== undefined && declaredMime.toLowerCase() !== kind.mime) {
62
+ throw new Error(`image content does not match ${declaredMime}`)
63
+ }
64
+ const id = createHash('sha256').update(bytes).digest('hex')
65
+ const name = `${id}.${kind.extension}`
66
+ await mkdir(this.root, { recursive: true })
67
+ try {
68
+ const current = await stat(join(this.root, name))
69
+ if (current.isFile()) return { id, name, size: current.size, url: `/dsh-taskboard/assets/${name}`, ...kind }
70
+ } catch { /* new content */ }
71
+
72
+ let total = 0
73
+ for (const entry of await readdir(this.root, { withFileTypes: true })) {
74
+ if (!entry.isFile() || !ASSET_NAME_RE.test(entry.name)) continue
75
+ try { total += (await stat(join(this.root, entry.name))).size } catch { /* concurrent cleanup */ }
76
+ }
77
+ if (total + bytes.length > MAX_ASSET_STORE_BYTES) throw new Error('image store quota exceeded')
78
+ try {
79
+ await writeFile(join(this.root, name), bytes, { flag: 'wx' })
80
+ } catch (error) {
81
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
82
+ }
83
+ return { id, name, size: bytes.length, url: `/dsh-taskboard/assets/${name}`, ...kind }
84
+ }
85
+
86
+ async read(name: string): Promise<{ bytes: Buffer; mime: ImageKind['mime'] } | undefined> {
87
+ const run = async (): Promise<{ bytes: Buffer; mime: ImageKind['mime'] } | undefined> => {
88
+ const match = ASSET_NAME_RE.exec(name)
89
+ if (match === null) return undefined
90
+ const mime = match[2] === 'png' ? 'image/png'
91
+ : match[2] === 'jpg' ? 'image/jpeg'
92
+ : match[2] === 'gif' ? 'image/gif' : 'image/webp'
93
+ try {
94
+ return { bytes: await readFile(join(this.root, name)), mime }
95
+ } catch { return undefined }
96
+ }
97
+ return this.storageQueue === undefined ? run() : this.storageQueue.run(run)
98
+ }
99
+
100
+ /** Remove abandoned draft uploads after a grace period; referenced files always survive. */
101
+ async cleanup(referencedContent: string): Promise<number> {
102
+ const run = async (): Promise<number> => {
103
+ let entries
104
+ try { entries = await readdir(this.root, { withFileTypes: true }) } catch { return 0 }
105
+ let removed = 0
106
+ for (const entry of entries) {
107
+ if (!entry.isFile() || !ASSET_NAME_RE.test(entry.name) || referencedContent.includes(entry.name)) continue
108
+ const path = join(this.root, entry.name)
109
+ try {
110
+ const info = await stat(path)
111
+ if (this.now() - info.mtimeMs < ORPHAN_GRACE_MS) continue
112
+ await rm(path, { force: true })
113
+ removed += 1
114
+ } catch { /* best effort */ }
115
+ }
116
+ return removed
117
+ }
118
+ return this.storageQueue === undefined ? run() : this.storageQueue.run(run)
119
+ }
120
+ }
@@ -72,7 +72,7 @@ export interface ExecutionWorkspaceFace {
72
72
 
73
73
  /** Narrow event-bus face for settlement listening. */
74
74
  export interface EventsFace {
75
- onSessionEvent(listener: (sessionId: string, event: { type: string; data?: unknown }, sessionMeta?: { header?: { cwd?: string } }) => void): () => void
75
+ onSessionEvent(listener: (sessionId: string, event: { type: string; data?: unknown }, sessionMeta?: { header?: { cwd?: string } }) => void | Promise<void>): () => void
76
76
  }
77
77
 
78
78
  /** Everything the execution service needs. */
@@ -293,6 +293,8 @@ export class ExecutionService {
293
293
  task.comments.push({
294
294
  id: newCommentId(),
295
295
  body: normalizeBody(`[系统] 执行失败:${message.slice(0, 300)};任务已退回待办。`),
296
+ systemKey: 'sys.execFailed',
297
+ systemParams: { error: message.slice(0, 300) },
296
298
  version: 1,
297
299
  createdAt: this.deps.now(),
298
300
  })
@@ -598,6 +600,7 @@ export class ExecutionService {
598
600
  body: normalizeBody(commented
599
601
  ? '[系统] 执行会话已结束并留有评论,但未移至待验收;系统自动移入待验收。'
600
602
  : '[系统] 执行会话已结束,但未按协议交接(无评论、未移至待验收);系统自动移入待验收,请审查后退回或验收。'),
603
+ systemKey: commented ? 'sys.endedWithComment' : 'sys.endedNoHandoff',
601
604
  version: 1,
602
605
  createdAt: now,
603
606
  })
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Host-side locale reading for the few user-facing HOST log lines (the
3
+ * gitignore suggestion). Everything else the user sees is localized by the
4
+ * GUI (client i18n dictionaries), and host-written ledger comments carry a
5
+ * `systemKey` that the GUI localizes at render — so this module is only for
6
+ * the raw `console.*` lines the host emits itself.
7
+ *
8
+ * Source: the DSH locale plugin persists the explicit language choice as
9
+ * `locale.preference` in `$DSH_HOME/settings.yaml` (loopback pages) and
10
+ * exposes it through the settings service. When the preference is absent the
11
+ * browser delegates, which the host cannot see — so we fall back to `en`,
12
+ * matching the client's own fallback (zh only when something asked for it).
13
+ *
14
+ * @module dsh-taskboard/host/locale
15
+ */
16
+ import type { Context } from '@deepseek-ai/cordis'
17
+
18
+ /** The two locales the taskboard ships. */
19
+ export type HostLocale = 'zh' | 'en'
20
+
21
+ /** Narrow settings-service face this module consumes (stringly-typed soft access). */
22
+ interface SettingsFace {
23
+ get?: (ns: string) => unknown
24
+ }
25
+
26
+ /** The DSH locale settings section (`preference` carries the explicit choice). */
27
+ interface LocaleSettings {
28
+ preference?: unknown
29
+ }
30
+
31
+ /**
32
+ * Read the active GUI locale as a host-side hint. Absent / malformed settings
33
+ * (or no settings service in scope) fall back to `en` and never throw — a
34
+ * cosmetic log line must not break route boot.
35
+ */
36
+ export function activeHostLocale(ctx: Context): HostLocale {
37
+ try {
38
+ const settings = ctx.get('settings') as SettingsFace | undefined
39
+ const locale = settings?.get?.('locale') as LocaleSettings | undefined
40
+ return locale?.preference === 'zh' ? 'zh' : 'en'
41
+ } catch {
42
+ return 'en'
43
+ }
44
+ }
@@ -1,3 +1,4 @@
1
+ import { archiveTaskSessions } from './archive-sessions.ts'
1
2
  /**
2
3
  * /dsh-taskboard routes on the shared DSH webserver: a JSON API for the
3
4
  * GUI's human operations (create/update/move/comment/delete — actor `user`,
@@ -40,12 +41,16 @@ import {
40
41
  type TaskLedger,
41
42
  type TaskModel,
42
43
  type TaskRecord,
44
+ type SystemCommentRow,
43
45
  } from '../shared/protocol.ts'
44
46
  import { WORKTREE_DIR, worktreePathOf, type GitFace } from './git.ts'
45
47
  import { removeMirror, repoMainPath } from './isolation.ts'
46
48
  import { createRepoScanner, type RepoScanner } from './repos.ts'
49
+ import { activeHostLocale } from './locale.ts'
47
50
  import type { CatalogModelItem, CatalogPresetItem, MergeRepoResult, TaskTemplate } from '../shared/api.ts'
48
51
  import type { TemplateStore } from './templates.ts'
52
+ import { MAX_ASSET_BYTES, type AssetStore } from './assets.ts'
53
+ import type { StorageCoordinator } from './storage.ts'
49
54
  import { ROUTE_PREFIX, SSE_PATH, type ApiFail, type ApiResult } from '../shared/api.ts'
50
55
  import type { TaskStore } from './store.ts'
51
56
  import { ERR, ToolError } from './tools.ts'
@@ -61,6 +66,7 @@ const MAX_BODY_BYTES = 5 * 1024 * 1024
61
66
  const TASK_DIFF_RE = new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/diff$`)
62
67
  const TASK_RE = new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)$`)
63
68
  const TASK_ACTION_RE = new RegExp(`^${ROUTE_PREFIX}/tasks/([^/]+)/([\\w-]+)$`)
69
+ const ASSET_RE = new RegExp(`^${ROUTE_PREFIX}/assets/([a-f0-9]{64}\\.(?:png|jpg|gif|webp))$`)
64
70
 
65
71
  /** How long a workspace git-detection result stays cached (fail-soft). */
66
72
  const GIT_DETECT_TTL_MS = 60_000
@@ -88,6 +94,12 @@ export interface TaskboardRoutesOptions {
88
94
  scanner?: RepoScanner
89
95
  /** Task-template store (0.4.0); absent → 501 on template actions. */
90
96
  templates?: TemplateStore
97
+ /** Durable image attachment store (0.7.0); absent → attachment routes unavailable. */
98
+ assets?: AssetStore
99
+ /** Configurable data-directory coordinator (0.7.0). */
100
+ storage?: StorageCoordinator
101
+ /** Initial storage/ledger readiness barrier. */
102
+ ready?: () => Promise<void>
91
103
  /** Prompt completions face (0.5.5; dynamically discovers skills & commands). */
92
104
  promptCompletions?: () => Promise<{
93
105
  skills?: Array<{ name: string; description?: string }>
@@ -189,6 +201,19 @@ async function readBody(req: IncomingMessage): Promise<Record<string, unknown> |
189
201
  }
190
202
  }
191
203
 
204
+ /** Read one bounded binary upload without ever buffering beyond the file cap. */
205
+ async function readBytes(req: IncomingMessage, limit: number): Promise<Buffer> {
206
+ const chunks: Buffer[] = []
207
+ let total = 0
208
+ for await (const chunk of req) {
209
+ const bytes = chunk as Buffer
210
+ total += bytes.length
211
+ if (total > limit) throw new Error('body too large')
212
+ chunks.push(bytes)
213
+ }
214
+ return Buffer.concat(chunks)
215
+ }
216
+
192
217
  /** String field accessor (null when absent/not a string). */
193
218
  function str(body: Record<string, unknown>, key: string): string | null {
194
219
  const v = body[key]
@@ -316,10 +341,15 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
316
341
  } catch { /* fail-soft → false */ }
317
342
  // gitignore 建议 (plan §3.2): suggest (never write) ignoring our
318
343
  // worktree directory, once per workspace per host run. Root repos only.
344
+ // The line is localized from the DSH locale preference (see host/locale.ts).
319
345
  if (rootRepo && !gitHinted.has(path)) {
320
346
  gitHinted.add(path)
321
347
  if (await gitignoreMissing(path)) {
322
- console.info(`[dsh-taskboard] 建议在 ${path}/.gitignore 加入一行 ${WORKTREE_DIR}/ 以隐藏任务 worktree 目录(不会自动修改)`)
348
+ const file = `${path}/.gitignore`
349
+ const hint = activeHostLocale(ctx) === 'zh'
350
+ ? `建议在 ${file} 加入一行 ${WORKTREE_DIR}/ 以隐藏任务 worktree 目录(不会自动修改)`
351
+ : `suggests adding one line to ${file}: ${WORKTREE_DIR}/ to hide the task worktree directory (no automatic edits)`
352
+ console.info(`[dsh-taskboard] ${hint}`)
323
353
  }
324
354
  }
325
355
  // The nested scan always runs: repoCount needs it even when the root
@@ -370,9 +400,28 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
370
400
 
371
401
  // ---------------------------------------------------------------- GET
372
402
  if (req.method === 'GET') {
403
+ if (pathname === `${ROUTE_PREFIX}/storage`) {
404
+ if (options.storage === undefined) { res.writeHead(501); res.end(); return }
405
+ json(res, { ok: true, value: await options.storage.status() })
406
+ return
407
+ }
408
+ await options.ready?.()
409
+ const assetMatch = pathname.match(ASSET_RE)
410
+ if (assetMatch !== null) {
411
+ const asset = await options.assets?.read(assetMatch[1]!)
412
+ if (asset === undefined) { res.writeHead(404); res.end(); return }
413
+ res.writeHead(200, {
414
+ 'content-type': asset.mime,
415
+ 'content-length': asset.bytes.length,
416
+ 'cache-control': 'public, max-age=31536000, immutable',
417
+ 'x-content-type-options': 'nosniff',
418
+ })
419
+ res.end(asset.bytes)
420
+ return
421
+ }
373
422
  if (pathname === `${ROUTE_PREFIX}/state`) {
374
423
  await store.load()
375
- json(res, { ok: true, value: store.snapshot() })
424
+ json(res, { ok: true, value: { ...store.snapshot(), capabilities: { archiveSessions: typeof workspaces.archiveSession === 'function' } } })
376
425
  return
377
426
  }
378
427
  if (pathname === `${ROUTE_PREFIX}/workspaces`) {
@@ -519,6 +568,34 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
519
568
  res.end()
520
569
  return
521
570
  }
571
+ // Content-addressed image upload. The custom header makes this a
572
+ // non-simple cross-origin request, preserving the JSON routes' CSRF fence.
573
+ if (pathname === `${ROUTE_PREFIX}/assets`) {
574
+ await options.ready?.()
575
+ if (options.assets === undefined) {
576
+ const f = fail('invalid_input', 'image attachments unavailable')
577
+ json(res, f.res, 501)
578
+ return
579
+ }
580
+ if (req.headers['x-dsh-taskboard-upload'] !== '1') {
581
+ const f = fail('forbidden', 'missing upload header')
582
+ json(res, f.res, 403)
583
+ return
584
+ }
585
+ const declaredMime = String(req.headers['content-type'] ?? '').split(';', 1)[0]!.trim().toLowerCase()
586
+ try {
587
+ const bytes = await readBytes(req, MAX_ASSET_BYTES)
588
+ await options.assets.cleanup(JSON.stringify(store.snapshot()))
589
+ const asset = await options.assets.put(bytes, declaredMime)
590
+ json(res, { ok: true, value: asset }, 201)
591
+ } catch (error) {
592
+ const message = error instanceof Error ? error.message : String(error)
593
+ const status = message.includes('1..') ? 413 : message.includes('quota') ? 507 : 400
594
+ const f = fail('invalid_input', message)
595
+ json(res, f.res, status)
596
+ }
597
+ return
598
+ }
522
599
  // CSRF fence: cross-site simple requests cannot set application/json.
523
600
  const contentType = req.headers['content-type'] ?? ''
524
601
  if (!contentType.toLowerCase().startsWith('application/json')) {
@@ -540,6 +617,29 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
540
617
  return
541
618
  }
542
619
 
620
+ // Storage location is bootstrap metadata outside the ledger. Keep it on
621
+ // separate routes so JSON imports and whole-ledger replacement cannot
622
+ // redirect host filesystem writes.
623
+ if (pathname === `${ROUTE_PREFIX}/storage/check` || pathname === `${ROUTE_PREFIX}/storage/migrate`) {
624
+ if (options.storage === undefined) {
625
+ const f = fail('invalid_input', 'storage configuration unavailable')
626
+ json(res, f.res, 501)
627
+ return
628
+ }
629
+ try {
630
+ const directory = str(body, 'directory') ?? ''
631
+ const value = pathname.endsWith('/check')
632
+ ? await options.storage.check(directory)
633
+ : await options.storage.migrate(directory)
634
+ json(res, { ok: true, value })
635
+ } catch (error) {
636
+ const f = fail('invalid_input', error instanceof Error ? error.message : String(error))
637
+ json(res, f.res, f.status)
638
+ }
639
+ return
640
+ }
641
+ await options.ready?.()
642
+
543
643
  // ------------------------------------------------- POST /tasks (create)
544
644
  if (pathname === `${ROUTE_PREFIX}/tasks`) {
545
645
  try {
@@ -615,6 +715,12 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
615
715
  try {
616
716
  const task = store.get(id)
617
717
  if (task === undefined) throw new Error('Error: not_found: no such task')
718
+ if (action === 'archive-sessions') {
719
+ if (task.trashedAt !== undefined || task.status !== 'archived') throw new Error('Error: invalid_transition: only archived live tasks can retry session archiving')
720
+ const result = await archiveTaskSessions(task, workspaces.archiveSession)
721
+ json(res, { ok: true, value: result })
722
+ return
723
+ }
618
724
  if (action === 'update') {
619
725
  const ifVersion = num(body, 'ifVersion')
620
726
  if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')
@@ -678,13 +784,16 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
678
784
  if (action === 'move') {
679
785
  const ifVersion = num(body, 'ifVersion')
680
786
  const status = str(body, 'status') ?? ''
787
+ const archiveSessions = body.archiveSessions === true
681
788
  if (ifVersion === undefined || ifVersion === null) throw new Error('Error: version_conflict: ifVersion required')
682
789
  const to = asStatus(status)
683
790
  let next: TaskRecord | undefined
791
+ let beforeTask: TaskRecord | undefined
684
792
  await store.mutate('task-moved', ledger => {
685
793
  const { index, task } = liveTaskAt(ledger, id)
686
794
  if (ifVersion !== task.version) throw new Error(`Error: version_conflict: stale version ${ifVersion} (current ${task.version})`)
687
795
  if (!canTransition(task.status, to)) throw new Error(`Error: invalid_transition: illegal transition ${task.status} → ${to}`)
796
+ beforeTask = task
688
797
  next = structuredClone(task)
689
798
  next.status = to
690
799
  next.version = task.version + 1
@@ -696,7 +805,10 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
696
805
  ledger.tasks[index] = next
697
806
  return [next]
698
807
  })
699
- json(res, { ok: true, value: summarize(next!) })
808
+ const sessionArchive = to === 'archived' && archiveSessions
809
+ ? await archiveTaskSessions(beforeTask ?? next!, workspaces.archiveSession)
810
+ : undefined
811
+ json(res, { ok: true, value: { ...summarize(next!), ...(sessionArchive !== undefined ? { sessionArchive } : {}) } })
700
812
  return
701
813
  }
702
814
  if (action === 'reject') {
@@ -909,11 +1021,21 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
909
1021
  }
910
1022
  // R1: the git merges above are slow — re-find the FRESH task inside
911
1023
  // the mutation so a concurrent comment is never overwritten.
912
- const pushComment = (body: string): Promise<void> =>
1024
+ const pushComment = (body: string, system?: { key: string; params?: Record<string, string>; rows?: SystemCommentRow[] }): Promise<void> =>
913
1025
  store.mutate('comment-added', ledger => {
914
1026
  const { index, task: fresh } = liveTaskAt(ledger, id)
915
1027
  const next = structuredClone(fresh)
916
- next.comments.push({ id: newCommentId(), body: normalizeBody(body), version: 1, createdAt: options.now() })
1028
+ next.comments.push({
1029
+ id: newCommentId(),
1030
+ body: normalizeBody(body),
1031
+ ...(system !== undefined ? {
1032
+ systemKey: system.key,
1033
+ ...(system.params !== undefined ? { systemParams: system.params } : {}),
1034
+ ...(system.rows !== undefined ? { systemRows: system.rows } : {}),
1035
+ } : {}),
1036
+ version: 1,
1037
+ createdAt: options.now(),
1038
+ })
917
1039
  next.version = fresh.version + 1
918
1040
  next.updatedAt = options.now()
919
1041
  ledger.tasks[index] = next
@@ -930,7 +1052,7 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
930
1052
  if (root.outcome === 'failed') {
931
1053
  throw new Error(`Error: invalid_input: ${root.error ?? '合并失败'}`)
932
1054
  }
933
- await pushComment(`[系统] 分支 ${root.branch} 已合并到主工作区(--no-ff)。`)
1055
+ await pushComment(`[系统] 分支 ${root.branch} 已合并到主工作区(--no-ff)。`, { key: 'sys.mergeSingle', params: { branch: root.branch } })
934
1056
  json(res, { ok: true, value: { merged: true, branch: root.branch } })
935
1057
  return
936
1058
  }
@@ -942,7 +1064,10 @@ export function registerTaskboardRoutes(ctx: Context, options: TaskboardRoutesOp
942
1064
  ? `${labelOf(r.repo)} ✓ 已合并`
943
1065
  : r.outcome === 'noop' ? `${labelOf(r.repo)} ⟲ 无新提交` : `${labelOf(r.repo)} ✗ ${(r.error ?? '合并失败').slice(0, 150)}`)
944
1066
  .join(';')
945
- await pushComment(`[系统] 分支已按仓库合并(--no-ff):${summary}`)
1067
+ await pushComment(`[系统] 分支已按仓库合并(--no-ff):${summary}`, {
1068
+ key: 'sys.mergeMulti',
1069
+ rows: results.map(r => ({ repo: r.repo, outcome: r.outcome, ...(r.error !== undefined ? { error: r.error.slice(0, 150) } : {}) })),
1070
+ })
946
1071
  json(res, {
947
1072
  ok: true,
948
1073
  value: {
@@ -136,6 +136,8 @@ export class SchedulerService {
136
136
  task.comments.push({
137
137
  id: newCommentId(),
138
138
  body: normalizeBody(`[系统] 定时表达式 ${deadCron} 在 4 年内没有可触发时间,已停用定时;请修正 cron 后重新开启。`),
139
+ systemKey: 'sys.cronDead',
140
+ systemParams: { cron: deadCron },
139
141
  version: 1,
140
142
  createdAt: now,
141
143
  })
@@ -213,7 +213,7 @@ export class ExternalSessionSyncService {
213
213
 
214
214
  constructor(private readonly deps: SessionSyncDeps) {
215
215
  this.unsubscribe = deps.events.onSessionEvent((sessionId, event, sessionMeta) => {
216
- void this.handleSessionEvent(sessionId, event, sessionMeta)
216
+ return this.handleSessionEvent(sessionId, event, sessionMeta)
217
217
  })
218
218
 
219
219
  const interval = deps.scanIntervalMs ?? DEFAULT_SCAN_INTERVAL_MS
@@ -628,6 +628,8 @@ export class ExternalSessionSyncService {
628
628
  task.comments.push({
629
629
  id: newCommentId(),
630
630
  body: normalizeBody(`[系统] 会话执行异常:${errorMessage.slice(0, 300)};任务已退回待办。`),
631
+ systemKey: 'sys.sessionError',
632
+ systemParams: { error: errorMessage.slice(0, 300) },
631
633
  version: 1,
632
634
  createdAt: now,
633
635
  })
@@ -639,6 +641,7 @@ export class ExternalSessionSyncService {
639
641
  task.comments.push({
640
642
  id: newCommentId(),
641
643
  body: normalizeBody('[系统] 会话执行完毕,已自动进入待验收。'),
644
+ systemKey: 'sys.sessionDone',
642
645
  version: 1,
643
646
  createdAt: now,
644
647
  })
@@ -0,0 +1,10 @@
1
+ /** One process-wide serial queue shared by all taskboard persistence. */
2
+ export class StorageQueue {
3
+ private tail: Promise<unknown> = Promise.resolve()
4
+
5
+ run<T>(operation: () => Promise<T>): Promise<T> {
6
+ const result = this.tail.then(operation, operation)
7
+ this.tail = result.then(() => undefined, () => undefined)
8
+ return result
9
+ }
10
+ }