dsh-taskboard 0.6.7 → 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.
@@ -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
 
@@ -11,6 +11,7 @@
11
11
  import { readFile } from 'node:fs/promises'
12
12
  import type { TaskTemplate } from '../shared/api.ts'
13
13
  import { BUILTIN_TEMPLATE_CONTENT, BUILTIN_TEMPLATE_IDS, type BuiltinTemplateId } from '../shared/builtin-templates.ts'
14
+ import type { StorageQueue } from './storage-queue.ts'
14
15
 
15
16
  /**
16
17
  * The built-in templates seeded when the side file does not exist yet.
@@ -32,9 +33,22 @@ function newTemplateId(): string {
32
33
  export class TemplateStore {
33
34
  private templates: TaskTemplate[] | undefined
34
35
  private loaded = false
36
+ private file: string
35
37
 
36
38
  /** @param file - absolute side-file path (next to the ledger). */
37
- 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 }
38
52
 
39
53
  /** Load once; a missing file seeds the built-ins; a corrupt file resets. */
40
54
  private async ensure(): Promise<void> {
@@ -59,11 +73,11 @@ export class TemplateStore {
59
73
  }
60
74
 
61
75
  /** Atomic persist (temp + fsync + rename — S10, same discipline as the ledger). */
62
- private async persist(templates: TaskTemplate[]): Promise<void> {
76
+ private async persist(templates: TaskTemplate[], file = this.file): Promise<void> {
63
77
  const { mkdir, open, rename } = await import('node:fs/promises')
64
78
  const { dirname, join } = await import('node:path')
65
- await mkdir(dirname(this.file), { recursive: true })
66
- 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`)
67
81
  const fh = await open(temp, 'w')
68
82
  try {
69
83
  await fh.writeFile(JSON.stringify({ templates }, null, 2), 'utf8')
@@ -71,13 +85,16 @@ export class TemplateStore {
71
85
  } finally {
72
86
  await fh.close()
73
87
  }
74
- await rename(temp, this.file)
88
+ await rename(temp, file)
75
89
  }
76
90
 
77
91
  /** All templates (oldest first). */
78
92
  async list(): Promise<TaskTemplate[]> {
79
- await this.ensure()
80
- 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)
81
98
  }
82
99
 
83
100
  /**
@@ -85,6 +102,7 @@ export class TemplateStore {
85
102
  * @returns the stored template.
86
103
  */
87
104
  async upsert(input: { id?: string; name: string; task: TaskTemplate['task'] }): Promise<TaskTemplate> {
105
+ const run = async (): Promise<TaskTemplate> => {
88
106
  await this.ensure()
89
107
  const templates = this.templates ?? []
90
108
  const name = input.name.trim()
@@ -102,10 +120,13 @@ export class TemplateStore {
102
120
  else templates.push(stored)
103
121
  await this.persist(templates)
104
122
  return stored
123
+ }
124
+ return this.storageQueue === undefined ? run() : this.storageQueue.run(run)
105
125
  }
106
126
 
107
127
  /** Delete a template by id; returns whether it existed. */
108
128
  async remove(id: string): Promise<boolean> {
129
+ const run = async (): Promise<boolean> => {
109
130
  await this.ensure()
110
131
  const templates = this.templates ?? []
111
132
  const index = templates.findIndex(t => t.id === id)
@@ -113,5 +134,7 @@ export class TemplateStore {
113
134
  templates.splice(index, 1)
114
135
  await this.persist(templates)
115
136
  return true
137
+ }
138
+ return this.storageQueue === undefined ? run() : this.storageQueue.run(run)
116
139
  }
117
140
  }
package/src/host/tools.ts CHANGED
@@ -138,6 +138,7 @@ function taskDetail(t: TaskRecord & { effectivePrompt?: string }): string {
138
138
 
139
139
  /** Stable error codes surfaced at the head of tool error messages. */
140
140
  export const ERR = {
141
+ notReady: 'taskboard_not_ready',
141
142
  notFound: 'not_found',
142
143
  versionConflict: 'version_conflict',
143
144
  workspaceMismatch: 'workspace_mismatch',
@@ -194,6 +195,8 @@ export interface ToolDeps {
194
195
  workspaces: WorkspaceFace
195
196
  /** Current epoch ms (injectable for tests). */
196
197
  now: () => number
198
+ /** Shared startup barrier; tool definitions stay registered while services initialize. */
199
+ ready?: () => Promise<void>
197
200
  /**
198
201
  * Registered model provider routes (from the host llm runtime), for
199
202
  * advisory validation of pinned models; undefined = runtime unavailable,
@@ -288,16 +291,17 @@ export function registerTaskboardTools(ctx: ToolContextFace, deps: ToolDeps): Ar
288
291
 
289
292
  // Env-gated tool-call tracing (ATB_TRACE=1) — evidence for protocol E2E.
290
293
  const register = (tool: { name: string; execute?: unknown }) => {
291
- if (process.env.ATB_TRACE === '1' && typeof tool.execute === 'function') {
294
+ if (typeof tool.execute === 'function') {
292
295
  const orig = tool.execute as (args: unknown, exec: unknown) => Promise<unknown>
293
296
  tool.execute = async (args: unknown, exec: unknown) => {
294
- 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))
295
299
  try {
296
300
  const result = await orig(args, exec)
297
- 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))
298
302
  return result
299
303
  } catch (error) {
300
- 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))
301
305
  throw error
302
306
  }
303
307
  }
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Host loader entry for dsh-taskboard.
3
3
  *
4
- * Wiring: the ledger store (one JSON file under the DSH home), the ten
4
+ * Wiring: the configurable local data stores, the ten
5
5
  * `taskboard_*` agent tools, the agent workflow-protocol system-prompt
6
6
  * section, the /taskboard JSON+SSE routes (when a webServer is served),
7
7
  * the host execution service (fresh in-project sessions, pinned models), and
@@ -29,14 +29,19 @@ import { dshHomePath } from './host/sdk.ts'
29
29
  import { TaskStore } from './host/store.ts'
30
30
  import { TemplateStore } from './host/templates.ts'
31
31
  import { ExternalSessionSyncService } from './host/session-sync.ts'
32
- import { registerTaskboardTools, workspaceFace } from './host/tools.ts'
32
+ import { ERR, ToolError, registerTaskboardTools, workspaceFace, type WorkspaceFace } from './host/tools.ts'
33
+ import { AssetStore } from './host/assets.ts'
34
+ import { STORAGE_CONFIG_FILE, StorageCoordinator } from './host/storage.ts'
33
35
 
34
- /** Ledger file name under the DSH home. */
36
+ /** Ledger file name under the active taskboard data directory. */
35
37
  export const LEDGER_FILE = 'dsh-taskboard.json'
36
38
 
37
- /** Task-template side file name under the DSH home (0.4.0). */
39
+ /** Task-template side file name under the active data directory. */
38
40
  export const TEMPLATES_FILE = 'dsh-taskboard-templates.json'
39
41
 
42
+ /** Content-addressed image attachment directory under the active data directory. */
43
+ export const ASSETS_DIR = 'dsh-taskboard-assets'
44
+
40
45
  /** Cordis plugin name. */
41
46
  export const name = 'dsh-taskboard'
42
47
 
@@ -48,14 +53,24 @@ export const inject = ['tools', 'systemPrompt']
48
53
  * @param ctx - the plugin context (tools + systemPrompt injected).
49
54
  */
50
55
  export function apply(ctx: Context): void {
51
- const store = new TaskStore({ file: dshHomePath(LEDGER_FILE) })
52
- const templates = new TemplateStore(dshHomePath(TEMPLATES_FILE))
56
+ const storage = new StorageCoordinator({
57
+ defaultDirectory: dshHomePath(),
58
+ configFile: dshHomePath(STORAGE_CONFIG_FILE),
59
+ ledgerName: LEDGER_FILE,
60
+ templatesName: TEMPLATES_FILE,
61
+ assetsName: ASSETS_DIR,
62
+ })
63
+ const store = new TaskStore({ file: storage.ledgerPath(), queue: storage.queue })
64
+ const templates = new TemplateStore(storage.templatesPath(), storage.queue)
65
+ const assets = new AssetStore(storage.assetsPath(), () => Date.now(), storage.queue)
66
+ storage.attach({ ledger: store, templates, assets })
53
67
  // Eager first load: the tools and most routes read snapshot()/get() without
54
68
  // triggering the lazy load, so a fresh boot used to serve an EMPTY board to
55
69
  // taskboard_list/get until the scheduler catchup tick or the first
56
70
  // GET /state happened to load the file (review P0). load() never throws —
57
71
  // a corrupt ledger is quarantined instead.
58
- void store.load()
72
+ const storeReady = storage.ready().then(() => store.load())
73
+ void storeReady.then(() => assets.cleanup(JSON.stringify(store.snapshot())))
59
74
  const now = () => Date.now()
60
75
  // Global execution concurrency cap (DSH_TASKBOARD_MAX_CONCURRENT overrides).
61
76
  const maxConcurrent = Math.max(1, Number.parseInt(process.env.DSH_TASKBOARD_MAX_CONCURRENT ?? '', 10) || DEFAULT_MAX_CONCURRENT)
@@ -68,29 +83,60 @@ export function apply(ctx: Context): void {
68
83
  })
69
84
  ctx.effect(() => disposeSection, 'dsh-taskboard: protocol section')
70
85
 
71
- // Tools, routes, execution, and the scheduler all come up with the
72
- // workspace registry (claim boundary + project execution need it).
73
- ctx.inject(['workspaceRegistry'], (wsCtx: Context) => {
74
- const disposers: Array<() => void> = []
75
-
76
- // Registered model provider routes (from the host llm runtime), read
77
- // lazily at call time so late availability still applies; undefined when
78
- // the runtime is absent only structural model validation runs.
79
- const modelProviders = (): string[] | undefined => {
80
- try {
81
- const llm = wsCtx.get('llm') as { listProviders?: () => Array<{ id: string }> } | undefined
82
- return llm === undefined || typeof llm.listProviders !== 'function'
83
- ? undefined
84
- : llm.listProviders().map(p => p.id)
85
- } catch { return undefined }
86
+ // Register the complete tool schema in the same synchronous mount as the
87
+ // protocol. Keeping schemas stable from the first request preserves the
88
+ // provider's prefix cache; calls use the live workspace service below.
89
+ let activeWorkspaces: WorkspaceFace | undefined
90
+ let activeWorkspaceContext: Context | undefined
91
+ const requireWorkspaces = (): WorkspaceFace => {
92
+ if (activeWorkspaces === undefined) {
93
+ throw new ToolError(ERR.notReady, 'workspace service is not ready; retry after host startup completes')
86
94
  }
95
+ return activeWorkspaces
96
+ }
97
+ const workspaces: WorkspaceFace = {
98
+ resolveByPath: path => requireWorkspaces().resolveByPath(path),
99
+ get: id => requireWorkspaces().get(id),
100
+ list: () => requireWorkspaces().list(),
101
+ }
102
+ // Preserve the optional archive capability without replacing the stable
103
+ // facade captured by the tool definitions.
104
+ Object.defineProperty(workspaces, 'archiveSession', {
105
+ enumerable: true,
106
+ get: () => activeWorkspaces?.archiveSession === undefined
107
+ ? undefined
108
+ : (sessionId: string) => requireWorkspaces().archiveSession!(sessionId),
109
+ })
110
+ const modelProviders = (): string[] | undefined => {
111
+ try {
112
+ const llm = activeWorkspaceContext?.get('llm') as { listProviders?: () => Array<{ id: string }> } | undefined
113
+ return llm === undefined || typeof llm.listProviders !== 'function'
114
+ ? undefined
115
+ : llm.listProviders().map(p => p.id)
116
+ } catch { return undefined }
117
+ }
118
+ const disposeTools = registerTaskboardTools(ctx, {
119
+ store,
120
+ workspaces,
121
+ now,
122
+ modelProviders,
123
+ ready: async () => {
124
+ if (activeWorkspaces === undefined) {
125
+ throw new ToolError(ERR.notReady, 'workspace service is not ready; retry after host startup completes')
126
+ }
127
+ await storeReady
128
+ },
129
+ })
130
+ ctx.effect(() => () => {
131
+ for (const dispose of disposeTools.splice(0)) dispose()
132
+ }, 'dsh-taskboard: tools')
87
133
 
88
- disposers.push(...registerTaskboardTools(wsCtx, {
89
- store,
90
- workspaces: workspaceFace(wsCtx.workspaceRegistry),
91
- now,
92
- modelProviders,
93
- }))
134
+ // Runtime services come and go with the workspace registry. Tool schemas
135
+ // remain mounted and resolve this current service only when called.
136
+ ctx.inject(['workspaceRegistry'], (wsCtx: Context) => {
137
+ const workspaceDisposers: Array<() => void> = []
138
+ activeWorkspaces = workspaceFace(wsCtx.workspaceRegistry)
139
+ activeWorkspaceContext = wsCtx
94
140
 
95
141
  // Settlement listener over the session event bus.
96
142
  const events: EventsFace = {
@@ -122,7 +168,7 @@ export function apply(ctx: Context): void {
122
168
  },
123
169
  now,
124
170
  })
125
- disposers.push(() => sessionSync.dispose())
171
+ workspaceDisposers.push(() => sessionSync.dispose())
126
172
 
127
173
  // The narrow git face shared by execution (worktree isolation) and the
128
174
  // routes (merge / remove / workspace detection), plus the shared
@@ -131,6 +177,7 @@ export function apply(ctx: Context): void {
131
177
  const scanner = createRepoScanner()
132
178
 
133
179
  wsCtx.inject(['agents'], (agentCtx: Context) => {
180
+ const agentDisposers: Array<() => void> = []
134
181
  agentSessions = agentCtx.get('sessions') as { get?: (id: string) => unknown; list?: () => unknown[] } | undefined
135
182
  const execution = new ExecutionService({
136
183
  store,
@@ -207,6 +254,9 @@ export function apply(ctx: Context): void {
207
254
  git,
208
255
  scanner,
209
256
  templates,
257
+ assets,
258
+ storage,
259
+ ready: async () => { await storeReady },
210
260
  promptCompletions: async () => {
211
261
  try {
212
262
  const skillsService = agentCtx.get('skills') as { list?(options?: unknown): Promise<Array<{ name: string; description?: string }>> } | undefined
@@ -307,19 +357,24 @@ export function apply(ctx: Context): void {
307
357
  // browser open. Shares the execution concurrency cap.
308
358
  const scheduler = new SchedulerService({ store, execution, now, maxConcurrent })
309
359
  scheduler.start()
310
- disposers.push(() => scheduler.dispose())
360
+ agentDisposers.push(() => scheduler.dispose())
311
361
  // Detach the settlement listener with the plugin — a hot reload must
312
362
  // not leave stale services reacting to turn/end errors (review P1).
313
- disposers.push(() => execution.dispose())
363
+ agentDisposers.push(() => execution.dispose())
314
364
 
315
365
  return () => {
316
366
  disposeRoutes?.()
317
- for (const dispose of disposers.splice(0)) dispose()
367
+ agentSessions = undefined
368
+ for (const dispose of agentDisposers.splice(0)) dispose()
318
369
  }
319
370
  })
320
371
 
321
372
  return () => {
322
- for (const dispose of disposers.splice(0)) dispose()
373
+ if (activeWorkspaceContext === wsCtx) {
374
+ activeWorkspaceContext = undefined
375
+ activeWorkspaces = undefined
376
+ }
377
+ for (const dispose of workspaceDisposers.splice(0)) dispose()
323
378
  }
324
379
  })
325
380
  }