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,212 @@
1
+ /** Configurable taskboard data directory and crash-safe three-part migration. */
2
+ import { randomUUID } from 'node:crypto'
3
+ import { constants, copyFile, access, mkdir, open, readFile, readdir, rename, rm, stat } from 'node:fs/promises'
4
+ import { existsSync, readFileSync } from 'node:fs'
5
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
6
+ import type { StorageStatus, StorageMigrationResult } from '../shared/api.ts'
7
+ import type { AssetStore } from './assets.ts'
8
+ import type { TaskStore } from './store.ts'
9
+ import type { TemplateStore } from './templates.ts'
10
+ import { StorageQueue } from './storage-queue.ts'
11
+
12
+ export const STORAGE_CONFIG_FILE = 'dsh-taskboard-storage.json'
13
+ const CONFIG_SCHEMA_VERSION = 1
14
+
15
+ type StorageConfig = { schemaVersion: number; dataDirectory: string }
16
+
17
+ export interface StorageCoordinatorOptions {
18
+ defaultDirectory: string
19
+ configFile: string
20
+ ledgerName: string
21
+ templatesName: string
22
+ assetsName: string
23
+ }
24
+
25
+ function normalized(path: string): string {
26
+ const value = resolve(path.trim())
27
+ return process.platform === 'win32' ? value.toLowerCase() : value
28
+ }
29
+
30
+ function samePath(a: string, b: string): boolean { return normalized(a) === normalized(b) }
31
+
32
+ function inside(parent: string, child: string): boolean {
33
+ const rel = relative(resolve(parent), resolve(child))
34
+ return rel.length === 0 || (!rel.startsWith('..') && !isAbsolute(rel))
35
+ }
36
+
37
+ async function exists(path: string): Promise<boolean> {
38
+ try { await stat(path); return true } catch { return false }
39
+ }
40
+
41
+ async function persistConfig(file: string, config: StorageConfig): Promise<void> {
42
+ await mkdir(dirname(file), { recursive: true })
43
+ const temp = join(dirname(file), `.${basename(file)}.${randomUUID()}.tmp`)
44
+ const handle = await open(temp, 'w')
45
+ try {
46
+ await handle.writeFile(JSON.stringify(config, null, 2), 'utf8')
47
+ await handle.sync()
48
+ } finally { await handle.close() }
49
+ await rename(temp, file)
50
+ }
51
+
52
+ function configuredDirectory(options: StorageCoordinatorOptions): { directory: string; configured: boolean; error?: string } {
53
+ if (!existsSync(options.configFile)) return { directory: resolve(options.defaultDirectory), configured: false }
54
+ try {
55
+ const parsed = JSON.parse(readFileSync(options.configFile, 'utf8')) as Partial<StorageConfig>
56
+ if (parsed.schemaVersion !== CONFIG_SCHEMA_VERSION || typeof parsed.dataDirectory !== 'string' || !isAbsolute(parsed.dataDirectory)) {
57
+ return { directory: resolve(options.defaultDirectory), configured: false, error: 'invalid storage location config; using the default directory' }
58
+ }
59
+ return { directory: resolve(parsed.dataDirectory), configured: true }
60
+ } catch (error) {
61
+ return { directory: resolve(options.defaultDirectory), configured: false, error: `cannot read storage location config: ${error instanceof Error ? error.message : String(error)}` }
62
+ }
63
+ }
64
+
65
+ /** Coordinates all persistent stores so migration cannot race normal writes. */
66
+ export class StorageCoordinator {
67
+ readonly queue = new StorageQueue()
68
+ private currentDirectory: string
69
+ private configured: boolean
70
+ private startupError?: string
71
+ private stores?: { ledger: TaskStore; templates: TemplateStore; assets: AssetStore }
72
+
73
+ constructor(private readonly options: StorageCoordinatorOptions) {
74
+ const selected = configuredDirectory(options)
75
+ this.currentDirectory = selected.directory
76
+ this.configured = selected.configured
77
+ if (selected.error !== undefined) console.warn(`[dsh-taskboard] ${selected.error}`)
78
+ if (selected.configured && !existsSync(selected.directory)) {
79
+ this.startupError = `configured storage directory is unavailable: ${selected.directory}`
80
+ }
81
+ }
82
+
83
+ attach(stores: { ledger: TaskStore; templates: TemplateStore; assets: AssetStore }): void { this.stores = stores }
84
+
85
+ directory(): string { return this.currentDirectory }
86
+ ledgerPath(directory = this.currentDirectory): string { return join(directory, this.options.ledgerName) }
87
+ templatesPath(directory = this.currentDirectory): string { return join(directory, this.options.templatesName) }
88
+ assetsPath(directory = this.currentDirectory): string { return join(directory, this.options.assetsName) }
89
+
90
+ async ready(): Promise<void> {
91
+ if (this.startupError !== undefined) throw new Error(`taskboard_storage_unavailable: ${this.startupError}`)
92
+ await mkdir(this.currentDirectory, { recursive: true })
93
+ await access(this.currentDirectory, constants.R_OK | constants.W_OK)
94
+ }
95
+
96
+ private requireStores(): { ledger: TaskStore; templates: TemplateStore; assets: AssetStore } {
97
+ if (this.stores === undefined) throw new Error('storage coordinator is not attached')
98
+ return this.stores
99
+ }
100
+
101
+ async status(): Promise<StorageStatus> {
102
+ let assetCount = 0
103
+ let assetBytes = 0
104
+ try {
105
+ for (const entry of await readdir(this.assetsPath(), { withFileTypes: true })) {
106
+ if (!entry.isFile()) continue
107
+ assetCount += 1
108
+ try { assetBytes += (await stat(join(this.assetsPath(), entry.name))).size } catch { /* best effort */ }
109
+ }
110
+ } catch { /* no attachment directory yet */ }
111
+ return {
112
+ currentDirectory: this.currentDirectory,
113
+ defaultDirectory: resolve(this.options.defaultDirectory),
114
+ isDefault: samePath(this.currentDirectory, this.options.defaultDirectory),
115
+ configured: this.configured,
116
+ writable: this.startupError === undefined,
117
+ assetCount,
118
+ assetBytes,
119
+ ...(this.startupError === undefined ? {} : { error: this.startupError }),
120
+ }
121
+ }
122
+
123
+ async check(directory: string): Promise<StorageStatus> {
124
+ const target = this.validateTarget(directory)
125
+ await mkdir(target, { recursive: true })
126
+ await this.assertTargetAvailable(target)
127
+ const probe = join(target, `.dsh-taskboard-write-${randomUUID()}.tmp`)
128
+ const handle = await open(probe, 'wx')
129
+ try { await handle.writeFile('ok', 'utf8'); await handle.sync() } finally { await handle.close(); await rm(probe, { force: true }) }
130
+ return { ...(await this.status()), checkedDirectory: target, writable: true }
131
+ }
132
+
133
+ private validateTarget(directory: string): string {
134
+ if (typeof directory !== 'string' || directory.trim().length === 0) return resolve(this.options.defaultDirectory)
135
+ if (!isAbsolute(directory.trim())) throw new Error('storage directory must be an absolute path')
136
+ const target = resolve(directory.trim())
137
+ if (inside(this.assetsPath(), target)) throw new Error('storage directory cannot be inside the current attachment directory')
138
+ return target
139
+ }
140
+
141
+ private async assertTargetAvailable(target: string): Promise<void> {
142
+ if (samePath(target, this.currentDirectory)) return
143
+ for (const path of [this.ledgerPath(target), this.templatesPath(target), this.assetsPath(target)]) {
144
+ if (await exists(path)) throw new Error(`target already contains ${basename(path)}`)
145
+ }
146
+ }
147
+
148
+ async migrate(directory: string): Promise<StorageMigrationResult> {
149
+ const target = this.validateTarget(directory)
150
+ return this.queue.run(async () => {
151
+ // Re-check only after acquiring the shared queue: another migration may
152
+ // have switched to this target while this request was waiting.
153
+ if (samePath(target, this.currentDirectory)) return { ...(await this.status()), migrated: false, warnings: [] }
154
+ if (this.startupError !== undefined) throw new Error(`taskboard_storage_unavailable: ${this.startupError}`)
155
+ const stores = this.requireStores()
156
+ await stores.ledger.load()
157
+ await mkdir(target, { recursive: true })
158
+ await this.assertTargetAvailable(target)
159
+
160
+ const oldDirectory = this.currentDirectory
161
+ const stage = join(target, `.dsh-taskboard-migration-${randomUUID()}`)
162
+ const stageLedger = this.ledgerPath(stage)
163
+ const stageTemplates = this.templatesPath(stage)
164
+ const stageAssets = this.assetsPath(stage)
165
+ const warnings: string[] = []
166
+ try {
167
+ await mkdir(stage, { recursive: true })
168
+ await stores.ledger.writeCopy(stageLedger)
169
+ await stores.templates.writeCopy(stageTemplates)
170
+ await mkdir(stageAssets, { recursive: true })
171
+ try {
172
+ for (const entry of await readdir(stores.assets.location(), { withFileTypes: true })) {
173
+ if (entry.isFile()) await copyFile(join(stores.assets.location(), entry.name), join(stageAssets, entry.name), constants.COPYFILE_EXCL)
174
+ }
175
+ } catch (error) {
176
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
177
+ }
178
+ JSON.parse(await readFile(stageLedger, 'utf8'))
179
+ JSON.parse(await readFile(stageTemplates, 'utf8'))
180
+
181
+ await rename(stageLedger, this.ledgerPath(target))
182
+ await rename(stageTemplates, this.templatesPath(target))
183
+ await rename(stageAssets, this.assetsPath(target))
184
+ if (samePath(target, this.options.defaultDirectory)) await rm(this.options.configFile, { force: true })
185
+ else await persistConfig(this.options.configFile, { schemaVersion: CONFIG_SCHEMA_VERSION, dataDirectory: target })
186
+
187
+ stores.ledger.setLocation(this.ledgerPath(target))
188
+ stores.templates.setLocation(this.templatesPath(target))
189
+ stores.assets.setLocation(this.assetsPath(target))
190
+ this.currentDirectory = target
191
+ this.configured = !samePath(target, this.options.defaultDirectory)
192
+ this.startupError = undefined
193
+
194
+ for (const oldPath of [this.ledgerPath(oldDirectory), this.templatesPath(oldDirectory), this.assetsPath(oldDirectory)]) {
195
+ try { await rm(oldPath, { recursive: true, force: true }) } catch (error) { warnings.push(`could not remove ${oldPath}: ${error instanceof Error ? error.message : String(error)}`) }
196
+ }
197
+ await rm(stage, { recursive: true, force: true })
198
+ return { ...(await this.status()), migrated: true, warnings }
199
+ } catch (error) {
200
+ await rm(stage, { recursive: true, force: true }).catch(() => undefined)
201
+ // The pointer is the commit point. Before it changes, prepared target
202
+ // files are safe to remove and the old directory remains authoritative.
203
+ if (samePath(this.currentDirectory, oldDirectory)) {
204
+ for (const path of [this.ledgerPath(target), this.templatesPath(target), this.assetsPath(target)]) {
205
+ await rm(path, { recursive: true, force: true }).catch(() => undefined)
206
+ }
207
+ }
208
+ throw error
209
+ }
210
+ })
211
+ }
212
+ }
package/src/host/store.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Host-side task ledger: one JSON file under the DSH home, mutated through a
2
+ * Host-side task ledger: one JSON file under the active data directory, mutated through a
3
3
  * serial write queue, published as immutable snapshots with a global
4
4
  * monotonic revision. Change subscribers (P2: SSE route) observe every
5
5
  * committed mutation.
@@ -17,6 +17,7 @@ import {
17
17
  type TaskLedger,
18
18
  type TaskRecord,
19
19
  } from '../shared/protocol.ts'
20
+ import type { StorageQueue } from './storage-queue.ts'
20
21
 
21
22
  /** One committed ledger mutation, handed to change subscribers. */
22
23
  export interface LedgerChange {
@@ -32,6 +33,8 @@ export interface LedgerChange {
32
33
  export interface TaskStoreOptions {
33
34
  /** Absolute ledger file path. */
34
35
  file: string
36
+ /** Optional queue shared with templates/assets and storage migration. */
37
+ queue?: StorageQueue
35
38
  }
36
39
 
37
40
  /**
@@ -40,20 +43,42 @@ export interface TaskStoreOptions {
40
43
  * atomically (temp file + rename), and only then notifies subscribers.
41
44
  */
42
45
  export class TaskStore {
43
- private readonly file: string
46
+ private file: string
47
+ private readonly storageQueue?: StorageQueue
44
48
  private ledger: TaskLedger = emptyLedger()
45
49
  private readonly subscribers = new Set<(change: LedgerChange) => void>()
46
50
  private queue: Promise<unknown> = Promise.resolve()
47
51
  private loaded = false
52
+ private loadPromise: Promise<void> | undefined
48
53
 
49
54
  /** @param options - file location. */
50
55
  constructor(options: TaskStoreOptions) {
51
56
  this.file = options.file
57
+ this.storageQueue = options.queue
52
58
  }
53
59
 
60
+ /** Current absolute ledger path. */
61
+ location(): string { return this.file }
62
+
63
+ /** Persist the live in-memory ledger to another file without switching. */
64
+ async writeCopy(file: string): Promise<void> {
65
+ await this.load()
66
+ await persistAtomic(file, JSON.stringify(this.ledger))
67
+ }
68
+
69
+ /** Switch future writes after a prepared migration commits. */
70
+ setLocation(file: string): void { this.file = file }
71
+
54
72
  /** Load (once) from disk; a missing file starts empty; a corrupt file is quarantined, not thrown. */
55
- async load(): Promise<void> {
56
- if (this.loaded) return
73
+ load(): Promise<void> {
74
+ if (this.loaded) return Promise.resolve()
75
+ if (this.loadPromise !== undefined) return this.loadPromise
76
+ this.loadPromise = this.loadOnce()
77
+ return this.loadPromise
78
+ }
79
+
80
+ /** Perform the single physical ledger read shared by all startup callers. */
81
+ private async loadOnce(): Promise<void> {
57
82
  try {
58
83
  const raw = await readFile(this.file, 'utf8')
59
84
  const parsed = JSON.parse(raw) as TaskLedger
@@ -138,10 +163,13 @@ export class TaskStore {
138
163
  * @returns the backup file path.
139
164
  */
140
165
  async backup(): Promise<string> {
141
- await this.load()
142
- const target = `${this.file}.backup-${Date.now()}`
143
- await persistAtomic(target, JSON.stringify(this.ledger, null, 2))
144
- return target
166
+ const run = async (): Promise<string> => {
167
+ await this.load()
168
+ const target = `${this.file}.backup-${Date.now()}`
169
+ await persistAtomic(target, JSON.stringify(this.ledger, null, 2))
170
+ return target
171
+ }
172
+ return this.storageQueue === undefined ? run() : this.storageQueue.run(run)
145
173
  }
146
174
 
147
175
  /**
@@ -183,8 +211,8 @@ export class TaskStore {
183
211
  changed: changed.map(t => deepFreeze(structuredClone(t))),
184
212
  }
185
213
  }
186
- const result = (this.queue = this.queue.then(run, run)) as ReturnType<typeof run>
187
- return result
214
+ if (this.storageQueue !== undefined) return this.storageQueue.run(run)
215
+ return (this.queue = this.queue.then(run, run)) as ReturnType<typeof run>
188
216
  }
189
217
 
190
218
  /**
@@ -198,8 +226,8 @@ export class TaskStore {
198
226
  await this.load()
199
227
  return fn(deepFreeze(structuredClone(this.ledger)))
200
228
  }
201
- const result = (this.queue = this.queue.then(run, run)) as Promise<T>
202
- return result
229
+ if (this.storageQueue !== undefined) return this.storageQueue.run(run)
230
+ return (this.queue = this.queue.then(run, run)) as Promise<T>
203
231
  }
204
232
  }
205
233
 
@@ -10,66 +10,16 @@
10
10
  */
11
11
  import { readFile } from 'node:fs/promises'
12
12
  import type { TaskTemplate } from '../shared/api.ts'
13
+ import { BUILTIN_TEMPLATE_CONTENT, BUILTIN_TEMPLATE_IDS, type BuiltinTemplateId } from '../shared/builtin-templates.ts'
14
+ import type { StorageQueue } from './storage-queue.ts'
13
15
 
14
- /** The built-in templates seeded when the side file does not exist yet. */
15
- export const BUILTIN_TEMPLATES: ReadonlyArray<{ id: string; name: string; task: TaskTemplate['task'] }> = [
16
- {
17
- id: 'tpl-feature',
18
- name: '新增功能',
19
- task: {
20
- title: '新增:',
21
- prompt: [
22
- '实现以上新功能并按序交接:',
23
- '1. 明确需求边界与验收标准,列出实现要点',
24
- '2. 实现功能(含类型定义与错误处理)',
25
- '3. 补充测试(单测/回归)',
26
- '4. 运行相关测试套件确认通过',
27
- ].join('\n'),
28
- urgency: 'normal',
29
- checklist: ['实现要点已明确(需求边界与验收标准)', '功能已实现并补充测试', '相关测试套件通过'],
30
- },
31
- },
32
- {
33
- id: 'tpl-bugfix',
34
- name: 'Bug 修复',
35
- task: {
36
- title: '修复:',
37
- prompt: [
38
- '修复以上问题并按序交接:',
39
- '1. 复现问题(写最小复现步骤或测试)',
40
- '2. 定位根因,说明为什么会发生',
41
- '3. 修复并补回归测试',
42
- '4. 运行相关测试套件确认无回归',
43
- ].join('\n'),
44
- urgency: 'urgent',
45
- checklist: ['已复现并定位根因', '修复已提交到任务分支', '回归测试通过'],
46
- },
47
- },
48
- {
49
- id: 'tpl-release',
50
- name: '发布检查',
51
- task: {
52
- title: '发布:',
53
- prompt: '执行发布流程:版本号更新、构建、测试、变更记录,完成后按序交接(不要实际推送/发布,等用户确认)。',
54
- urgency: 'normal',
55
- checklist: ['版本号已更新(package.json 与版本常量同步)', '构建通过', '全部测试通过', '变更记录已写'],
56
- },
57
- },
58
- {
59
- id: 'tpl-patrol',
60
- name: '例行巡检',
61
- task: {
62
- title: '巡检:',
63
- prompt: [
64
- '例行巡检:检查依赖更新、失败测试、明显代码问题与未处理的告警。',
65
- '发现的问题逐条列出(严重度/位置/建议),小问题直接修复,大问题只报告不动手。',
66
- '输出巡检摘要(用 {{lastComments}} 可回看上次巡检结论)。',
67
- ].join('\n'),
68
- urgency: 'relaxed',
69
- execution: { mode: 'scheduled', cron: '0 9 * * 1' },
70
- },
71
- },
72
- ]
16
+ /**
17
+ * The built-in templates seeded when the side file does not exist yet.
18
+ * Seeded from the shared zh content (the side file is plain data; the client
19
+ * resolves the active locale at render time — see shared/builtin-templates.ts).
20
+ */
21
+ export const BUILTIN_TEMPLATES: ReadonlyArray<{ id: BuiltinTemplateId; name: string; task: TaskTemplate['task'] }> =
22
+ BUILTIN_TEMPLATE_IDS.map(id => ({ id, name: BUILTIN_TEMPLATE_CONTENT.zh[id].name, task: BUILTIN_TEMPLATE_CONTENT.zh[id].task }))
73
23
 
74
24
  /** Mint a template id. */
75
25
  function newTemplateId(): string {
@@ -83,9 +33,22 @@ function newTemplateId(): string {
83
33
  export class TemplateStore {
84
34
  private templates: TaskTemplate[] | undefined
85
35
  private loaded = false
36
+ private file: string
86
37
 
87
38
  /** @param file - absolute side-file path (next to the ledger). */
88
- constructor(private readonly file: string) {}
39
+ constructor(file: string, private readonly storageQueue?: StorageQueue) { this.file = file }
40
+
41
+ /** Current absolute template-file path. */
42
+ location(): string { return this.file }
43
+
44
+ /** Persist the loaded template set to another file without switching. */
45
+ async writeCopy(file: string): Promise<void> {
46
+ await this.ensure()
47
+ await this.persist(this.templates ?? [], file)
48
+ }
49
+
50
+ /** Switch future writes after a prepared migration commits. */
51
+ setLocation(file: string): void { this.file = file }
89
52
 
90
53
  /** Load once; a missing file seeds the built-ins; a corrupt file resets. */
91
54
  private async ensure(): Promise<void> {
@@ -110,11 +73,11 @@ export class TemplateStore {
110
73
  }
111
74
 
112
75
  /** Atomic persist (temp + fsync + rename — S10, same discipline as the ledger). */
113
- private async persist(templates: TaskTemplate[]): Promise<void> {
76
+ private async persist(templates: TaskTemplate[], file = this.file): Promise<void> {
114
77
  const { mkdir, open, rename } = await import('node:fs/promises')
115
78
  const { dirname, join } = await import('node:path')
116
- await mkdir(dirname(this.file), { recursive: true })
117
- const temp = join(dirname(this.file), `.${Math.random().toString(36).slice(2)}.tmp`)
79
+ await mkdir(dirname(file), { recursive: true })
80
+ const temp = join(dirname(file), `.${Math.random().toString(36).slice(2)}.tmp`)
118
81
  const fh = await open(temp, 'w')
119
82
  try {
120
83
  await fh.writeFile(JSON.stringify({ templates }, null, 2), 'utf8')
@@ -122,13 +85,16 @@ export class TemplateStore {
122
85
  } finally {
123
86
  await fh.close()
124
87
  }
125
- await rename(temp, this.file)
88
+ await rename(temp, file)
126
89
  }
127
90
 
128
91
  /** All templates (oldest first). */
129
92
  async list(): Promise<TaskTemplate[]> {
130
- await this.ensure()
131
- return (this.templates ?? []).slice()
93
+ const run = async (): Promise<TaskTemplate[]> => {
94
+ await this.ensure()
95
+ return (this.templates ?? []).slice()
96
+ }
97
+ return this.storageQueue === undefined ? run() : this.storageQueue.run(run)
132
98
  }
133
99
 
134
100
  /**
@@ -136,6 +102,7 @@ export class TemplateStore {
136
102
  * @returns the stored template.
137
103
  */
138
104
  async upsert(input: { id?: string; name: string; task: TaskTemplate['task'] }): Promise<TaskTemplate> {
105
+ const run = async (): Promise<TaskTemplate> => {
139
106
  await this.ensure()
140
107
  const templates = this.templates ?? []
141
108
  const name = input.name.trim()
@@ -153,10 +120,13 @@ export class TemplateStore {
153
120
  else templates.push(stored)
154
121
  await this.persist(templates)
155
122
  return stored
123
+ }
124
+ return this.storageQueue === undefined ? run() : this.storageQueue.run(run)
156
125
  }
157
126
 
158
127
  /** Delete a template by id; returns whether it existed. */
159
128
  async remove(id: string): Promise<boolean> {
129
+ const run = async (): Promise<boolean> => {
160
130
  await this.ensure()
161
131
  const templates = this.templates ?? []
162
132
  const index = templates.findIndex(t => t.id === id)
@@ -164,5 +134,7 @@ export class TemplateStore {
164
134
  templates.splice(index, 1)
165
135
  await this.persist(templates)
166
136
  return true
137
+ }
138
+ return this.storageQueue === undefined ? run() : this.storageQueue.run(run)
167
139
  }
168
140
  }
package/src/host/tools.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { SessionArchiveResult } from '../shared/api.ts'
2
+ import { archiveTaskSessions } from './archive-sessions.ts'
1
3
  /**
2
4
  * The ten `taskboard_*` agent tools. All writes require a calling agent
3
5
  * session (ownership audit), carry optimistic-version checks, and enforce
@@ -136,6 +138,7 @@ function taskDetail(t: TaskRecord & { effectivePrompt?: string }): string {
136
138
 
137
139
  /** Stable error codes surfaced at the head of tool error messages. */
138
140
  export const ERR = {
141
+ notReady: 'taskboard_not_ready',
139
142
  notFound: 'not_found',
140
143
  versionConflict: 'version_conflict',
141
144
  workspaceMismatch: 'workspace_mismatch',
@@ -162,6 +165,8 @@ export interface WorkspaceFace {
162
165
  get(id: string): { id: string; path: string; title: string } | undefined
163
166
  /** List all workspaces. */
164
167
  list(): Array<{ id: string; path: string; title: string }>
168
+ /** Archive one session durably (when supported by runtime workspaceRegistry). */
169
+ archiveSession?(sessionId: string): Promise<void>
165
170
  }
166
171
 
167
172
  /** Adapt the real registry to the narrow face. */
@@ -178,6 +183,9 @@ export function workspaceFace(registry: WorkspaceRegistry): WorkspaceFace {
178
183
  return ws === undefined ? undefined : { id: ws.id, path: ws.path, title: ws.title }
179
184
  },
180
185
  list: () => registry.list().map(ws => ({ id: ws.id, path: ws.path, title: ws.title })),
186
+ ...(typeof registry.archiveSession === 'function'
187
+ ? { archiveSession: (sessionId: string) => registry.archiveSession(sessionId as Parameters<WorkspaceRegistry['archiveSession']>[0]) }
188
+ : {}),
181
189
  }
182
190
  }
183
191
 
@@ -187,6 +195,8 @@ export interface ToolDeps {
187
195
  workspaces: WorkspaceFace
188
196
  /** Current epoch ms (injectable for tests). */
189
197
  now: () => number
198
+ /** Shared startup barrier; tool definitions stay registered while services initialize. */
199
+ ready?: () => Promise<void>
190
200
  /**
191
201
  * Registered model provider routes (from the host llm runtime), for
192
202
  * advisory validation of pinned models; undefined = runtime unavailable,
@@ -281,16 +291,17 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
281
291
 
282
292
  // Env-gated tool-call tracing (ATB_TRACE=1) — evidence for protocol E2E.
283
293
  const register = (tool: { name: string; execute?: unknown }) => {
284
- if (process.env.ATB_TRACE === '1' && typeof tool.execute === 'function') {
294
+ if (typeof tool.execute === 'function') {
285
295
  const orig = tool.execute as (args: unknown, exec: unknown) => Promise<unknown>
286
296
  tool.execute = async (args: unknown, exec: unknown) => {
287
- console.error(`[atb ▶] ${tool.name}`, JSON.stringify(args).slice(0, 300))
297
+ await deps.ready?.()
298
+ if (process.env.ATB_TRACE === '1') console.error(`[atb ▶] ${tool.name}`, JSON.stringify(args).slice(0, 300))
288
299
  try {
289
300
  const result = await orig(args, exec)
290
- console.error(`[atb ✓] ${tool.name}`, JSON.stringify(result).slice(0, 300))
301
+ if (process.env.ATB_TRACE === '1') console.error(`[atb ✓] ${tool.name}`, JSON.stringify(result).slice(0, 300))
291
302
  return result
292
303
  } catch (error) {
293
- console.error(`[atb ✗] ${tool.name}`, String(error).slice(0, 400))
304
+ if (process.env.ATB_TRACE === '1') console.error(`[atb ✗] ${tool.name}`, String(error).slice(0, 400))
294
305
  throw error
295
306
  }
296
307
  }
@@ -411,7 +422,7 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
411
422
  output: {
412
423
  schema: JSON_OUT,
413
424
  render: (_args, value) => {
414
- const v = value as { task?: { id?: string; status?: string; version?: number } }
425
+ const v = value as { task?: { id?: string; status?: string; version?: number }; sessionArchive?: SessionArchiveResult }
415
426
  const t = v.task
416
427
  return [{ type: 'text', text: t === undefined ? '创建失败。' : `已创建任务 ${t.id} [${t.status}] v${t.version}。写入前先 taskboard_get 读取。` }]
417
428
  },
@@ -501,7 +512,7 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
501
512
  output: {
502
513
  schema: JSON_OUT,
503
514
  render: (_args, value) => {
504
- const v = value as { task?: { id?: string; status?: string; version?: number } }
515
+ const v = value as { task?: { id?: string; status?: string; version?: number }; sessionArchive?: SessionArchiveResult }
505
516
  const t = v.task
506
517
  return [{ type: 'text', text: t === undefined ? '更新失败。' : `已更新任务 ${t.id},当前 v${t.version} [${t.status}]。` }]
507
518
  },
@@ -554,16 +565,17 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
554
565
  id: { type: 'string', required: true, description: 'Task id.' },
555
566
  status: { type: 'string', required: true, description: 'Target status.' },
556
567
  ifVersion: { type: 'number', required: true, description: 'Task version you read; fails on mismatch.' },
568
+ archiveSessions: { type: 'boolean', description: 'When moving to archived: whether to archive associated execution sessions as well. Defaults to false.' },
557
569
  },
558
570
  output: {
559
571
  schema: JSON_OUT,
560
572
  render: (_args, value) => {
561
- const v = value as { task?: { id?: string; status?: string; version?: number } }
573
+ const v = value as { task?: { id?: string; status?: string; version?: number }; sessionArchive?: SessionArchiveResult }
562
574
  const t = v.task
563
- return [{ type: 'text', text: t === undefined ? '移动失败。' : `任务 ${t.id} 已移到 ${t.status},当前 v${t.version}。` }]
575
+ return [{ type: 'text', text: t === undefined ? '移动失败。' : `任务 ${t.id} 已移到 ${t.status},当前 v${t.version}。${v.sessionArchive === undefined ? '' : ` 会话归档结果:${JSON.stringify(v.sessionArchive)}`}` }]
564
576
  },
565
577
  },
566
- async execute(args: { id: string; status: string; ifVersion: number }, exec: unknown) {
578
+ async execute(args: { id: string; status: string; ifVersion: number; archiveSessions?: boolean }, exec: unknown) {
567
579
  try {
568
580
  const { actor } = caller(exec as ToolRunContext)
569
581
  const to = asStatus(args.status)
@@ -575,9 +587,11 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
575
587
  : undefined
576
588
  // R1: every state guard + the write itself run inside the mutation.
577
589
  let next: TaskRecord | undefined
590
+ let beforeTask: TaskRecord | undefined
578
591
  await store.mutate('task-moved', ledger => {
579
592
  const { index, task } = liveTaskAt(ledger, args.id)
580
593
  versionGuard(task, args.ifVersion)
594
+ beforeTask = task
581
595
 
582
596
  // Code-level gate: agents never complete a task.
583
597
  if (to === 'done') {
@@ -607,7 +621,10 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
607
621
  ledger.tasks[index] = next
608
622
  return [next]
609
623
  })
610
- return json({ task: summarize(next!) })
624
+ const sessionArchive = to === 'archived' && args.archiveSessions === true
625
+ ? await archiveTaskSessions(beforeTask ?? next!, deps.workspaces.archiveSession)
626
+ : undefined
627
+ return json({ task: summarize(next!), ...(sessionArchive !== undefined ? { sessionArchive } : {}) })
611
628
  } catch (error) { fail(error) }
612
629
  },
613
630
  })) as () => void)
@@ -929,4 +946,4 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
929
946
  })) as () => void)
930
947
 
931
948
  return disposers
932
- }
949
+ }