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.
- package/README.md +305 -288
- package/lib/client.js +985 -931
- package/lib/host/archive-sessions.js +30 -0
- package/lib/host/archive-sessions.js.map +1 -0
- package/lib/host/assets.js +139 -0
- package/lib/host/assets.js.map +1 -0
- package/lib/host/execution.js +3 -0
- package/lib/host/execution.js.map +1 -1
- package/lib/host/locale.js +17 -0
- package/lib/host/locale.js.map +1 -0
- package/lib/host/routes.js +132 -6
- package/lib/host/routes.js.map +1 -1
- package/lib/host/scheduler.js +2 -0
- package/lib/host/scheduler.js.map +1 -1
- package/lib/host/session-sync.js +4 -1
- package/lib/host/session-sync.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 +73 -113
- package/lib/host/templates.js.map +1 -1
- package/lib/host/tools.js +23 -8
- 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/lib/shared/builtin-templates.js +155 -0
- package/lib/shared/builtin-templates.js.map +1 -0
- package/lib/shared/protocol.js +39 -2
- package/lib/shared/protocol.js.map +1 -1
- package/package.json +90 -89
- package/src/client/api.ts +35 -1
- package/src/client/board/SettingsModal.tsx +67 -4
- package/src/client/board/SlashPromptInput.tsx +80 -1
- package/src/client/board/TaskBoard.tsx +16 -11
- package/src/client/board/TaskDetail.tsx +186 -17
- package/src/client/board/TaskFormModal.tsx +8 -5
- package/src/client/board/TemplateManager.tsx +22 -10
- package/src/client/controller.ts +67 -5
- package/src/client/i18n/en.ts +35 -3
- package/src/client/i18n/templates.ts +25 -0
- package/src/client/i18n/zh.ts +35 -3
- package/src/client/image-insert.ts +29 -0
- package/src/client/styles.ts +35 -2
- package/src/host/archive-sessions.ts +18 -0
- package/src/host/assets.ts +120 -0
- package/src/host/execution.ts +4 -1
- package/src/host/locale.ts +44 -0
- package/src/host/routes.ts +132 -7
- package/src/host/scheduler.ts +2 -0
- package/src/host/session-sync.ts +4 -1
- 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 +38 -66
- package/src/host/tools.ts +28 -11
- package/src/index.ts +88 -33
- package/src/shared/api.ts +32 -3
- package/src/shared/builtin-templates.ts +153 -0
- package/src/shared/protocol.ts +64 -0
- 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
|
|
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
|
@@ -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
|
-
/**
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
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(
|
|
117
|
-
const temp = join(dirname(
|
|
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,
|
|
88
|
+
await rename(temp, file)
|
|
126
89
|
}
|
|
127
90
|
|
|
128
91
|
/** All templates (oldest first). */
|
|
129
92
|
async list(): Promise<TaskTemplate[]> {
|
|
130
|
-
|
|
131
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|