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.
- package/README.md +24 -14
- package/lib/client.js +56 -8
- package/lib/host/assets.js +139 -0
- package/lib/host/assets.js.map +1 -0
- package/lib/host/routes.js +87 -0
- package/lib/host/routes.js.map +1 -1
- package/lib/host/storage-queue.js +14 -0
- package/lib/host/storage-queue.js.map +1 -0
- package/lib/host/storage.js +249 -0
- package/lib/host/storage.js.map +1 -0
- package/lib/host/store.js +34 -7
- package/lib/host/store.js.map +1 -1
- package/lib/host/templates.js +62 -38
- package/lib/host/templates.js.map +1 -1
- package/lib/host/tools.js +6 -4
- package/lib/host/tools.js.map +1 -1
- package/lib/index.js +83 -27
- package/lib/index.js.map +1 -1
- package/lib/shared/api.js.map +1 -1
- package/package.json +12 -11
- package/src/client/api.ts +30 -0
- package/src/client/board/SettingsModal.tsx +67 -4
- package/src/client/board/SlashPromptInput.tsx +80 -1
- package/src/client/board/TaskDetail.tsx +71 -8
- package/src/client/board/TaskFormModal.tsx +8 -5
- package/src/client/controller.ts +44 -2
- package/src/client/i18n/en.ts +17 -3
- package/src/client/i18n/zh.ts +17 -3
- package/src/client/image-insert.ts +29 -0
- package/src/client/styles.ts +34 -1
- package/src/host/assets.ts +120 -0
- package/src/host/routes.ts +92 -0
- package/src/host/storage-queue.ts +10 -0
- package/src/host/storage.ts +212 -0
- package/src/host/store.ts +40 -12
- package/src/host/templates.ts +30 -7
- package/src/host/tools.ts +8 -4
- package/src/index.ts +88 -33
- package/src/shared/api.ts +26 -0
- package/src/shared/version.ts +1 -1
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
187
|
-
return
|
|
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
|
-
|
|
202
|
-
return
|
|
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
|
|
package/src/host/templates.ts
CHANGED
|
@@ -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
|
|
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(
|
|
66
|
-
const temp = join(dirname(
|
|
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,
|
|
88
|
+
await rename(temp, file)
|
|
75
89
|
}
|
|
76
90
|
|
|
77
91
|
/** All templates (oldest first). */
|
|
78
92
|
async list(): Promise<TaskTemplate[]> {
|
|
79
|
-
|
|
80
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
363
|
+
agentDisposers.push(() => execution.dispose())
|
|
314
364
|
|
|
315
365
|
return () => {
|
|
316
366
|
disposeRoutes?.()
|
|
317
|
-
|
|
367
|
+
agentSessions = undefined
|
|
368
|
+
for (const dispose of agentDisposers.splice(0)) dispose()
|
|
318
369
|
}
|
|
319
370
|
})
|
|
320
371
|
|
|
321
372
|
return () => {
|
|
322
|
-
|
|
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
|
}
|