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,249 @@
1
+ import { StorageQueue } from "./storage-queue.js";
2
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
3
+ import { access, constants, copyFile, mkdir, open, readFile, readdir, rename, rm, stat } from "node:fs/promises";
4
+ import { randomUUID } from "node:crypto";
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ //#region src/host/storage.ts
7
+ /** Configurable taskboard data directory and crash-safe three-part migration. */
8
+ const STORAGE_CONFIG_FILE = "dsh-taskboard-storage.json";
9
+ const CONFIG_SCHEMA_VERSION = 1;
10
+ function normalized(path) {
11
+ const value = resolve(path.trim());
12
+ return process.platform === "win32" ? value.toLowerCase() : value;
13
+ }
14
+ function samePath(a, b) {
15
+ return normalized(a) === normalized(b);
16
+ }
17
+ function inside(parent, child) {
18
+ const rel = relative(resolve(parent), resolve(child));
19
+ return rel.length === 0 || !rel.startsWith("..") && !isAbsolute(rel);
20
+ }
21
+ async function exists(path) {
22
+ try {
23
+ await stat(path);
24
+ return true;
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+ async function persistConfig(file, config) {
30
+ await mkdir(dirname(file), { recursive: true });
31
+ const temp = join(dirname(file), `.${basename(file)}.${randomUUID()}.tmp`);
32
+ const handle = await open(temp, "w");
33
+ try {
34
+ await handle.writeFile(JSON.stringify(config, null, 2), "utf8");
35
+ await handle.sync();
36
+ } finally {
37
+ await handle.close();
38
+ }
39
+ await rename(temp, file);
40
+ }
41
+ function configuredDirectory(options) {
42
+ if (!existsSync(options.configFile)) return {
43
+ directory: resolve(options.defaultDirectory),
44
+ configured: false
45
+ };
46
+ try {
47
+ const parsed = JSON.parse(readFileSync(options.configFile, "utf8"));
48
+ if (parsed.schemaVersion !== CONFIG_SCHEMA_VERSION || typeof parsed.dataDirectory !== "string" || !isAbsolute(parsed.dataDirectory)) return {
49
+ directory: resolve(options.defaultDirectory),
50
+ configured: false,
51
+ error: "invalid storage location config; using the default directory"
52
+ };
53
+ return {
54
+ directory: resolve(parsed.dataDirectory),
55
+ configured: true
56
+ };
57
+ } catch (error) {
58
+ return {
59
+ directory: resolve(options.defaultDirectory),
60
+ configured: false,
61
+ 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
+ var StorageCoordinator = class {
67
+ options;
68
+ queue = new StorageQueue();
69
+ currentDirectory;
70
+ configured;
71
+ startupError;
72
+ stores;
73
+ constructor(options) {
74
+ this.options = options;
75
+ const selected = configuredDirectory(options);
76
+ this.currentDirectory = selected.directory;
77
+ this.configured = selected.configured;
78
+ if (selected.error !== void 0) console.warn(`[dsh-taskboard] ${selected.error}`);
79
+ if (selected.configured && !existsSync(selected.directory)) this.startupError = `configured storage directory is unavailable: ${selected.directory}`;
80
+ }
81
+ attach(stores) {
82
+ this.stores = stores;
83
+ }
84
+ directory() {
85
+ return this.currentDirectory;
86
+ }
87
+ ledgerPath(directory = this.currentDirectory) {
88
+ return join(directory, this.options.ledgerName);
89
+ }
90
+ templatesPath(directory = this.currentDirectory) {
91
+ return join(directory, this.options.templatesName);
92
+ }
93
+ assetsPath(directory = this.currentDirectory) {
94
+ return join(directory, this.options.assetsName);
95
+ }
96
+ async ready() {
97
+ if (this.startupError !== void 0) throw new Error(`taskboard_storage_unavailable: ${this.startupError}`);
98
+ await mkdir(this.currentDirectory, { recursive: true });
99
+ await access(this.currentDirectory, constants.R_OK | constants.W_OK);
100
+ }
101
+ requireStores() {
102
+ if (this.stores === void 0) throw new Error("storage coordinator is not attached");
103
+ return this.stores;
104
+ }
105
+ async status() {
106
+ let assetCount = 0;
107
+ let assetBytes = 0;
108
+ try {
109
+ for (const entry of await readdir(this.assetsPath(), { withFileTypes: true })) {
110
+ if (!entry.isFile()) continue;
111
+ assetCount += 1;
112
+ try {
113
+ assetBytes += (await stat(join(this.assetsPath(), entry.name))).size;
114
+ } catch {}
115
+ }
116
+ } catch {}
117
+ return {
118
+ currentDirectory: this.currentDirectory,
119
+ defaultDirectory: resolve(this.options.defaultDirectory),
120
+ isDefault: samePath(this.currentDirectory, this.options.defaultDirectory),
121
+ configured: this.configured,
122
+ writable: this.startupError === void 0,
123
+ assetCount,
124
+ assetBytes,
125
+ ...this.startupError === void 0 ? {} : { error: this.startupError }
126
+ };
127
+ }
128
+ async check(directory) {
129
+ const target = this.validateTarget(directory);
130
+ await mkdir(target, { recursive: true });
131
+ await this.assertTargetAvailable(target);
132
+ const probe = join(target, `.dsh-taskboard-write-${randomUUID()}.tmp`);
133
+ const handle = await open(probe, "wx");
134
+ try {
135
+ await handle.writeFile("ok", "utf8");
136
+ await handle.sync();
137
+ } finally {
138
+ await handle.close();
139
+ await rm(probe, { force: true });
140
+ }
141
+ return {
142
+ ...await this.status(),
143
+ checkedDirectory: target,
144
+ writable: true
145
+ };
146
+ }
147
+ validateTarget(directory) {
148
+ if (typeof directory !== "string" || directory.trim().length === 0) return resolve(this.options.defaultDirectory);
149
+ if (!isAbsolute(directory.trim())) throw new Error("storage directory must be an absolute path");
150
+ const target = resolve(directory.trim());
151
+ if (inside(this.assetsPath(), target)) throw new Error("storage directory cannot be inside the current attachment directory");
152
+ return target;
153
+ }
154
+ async assertTargetAvailable(target) {
155
+ if (samePath(target, this.currentDirectory)) return;
156
+ for (const path of [
157
+ this.ledgerPath(target),
158
+ this.templatesPath(target),
159
+ this.assetsPath(target)
160
+ ]) if (await exists(path)) throw new Error(`target already contains ${basename(path)}`);
161
+ }
162
+ async migrate(directory) {
163
+ const target = this.validateTarget(directory);
164
+ return this.queue.run(async () => {
165
+ if (samePath(target, this.currentDirectory)) return {
166
+ ...await this.status(),
167
+ migrated: false,
168
+ warnings: []
169
+ };
170
+ if (this.startupError !== void 0) throw new Error(`taskboard_storage_unavailable: ${this.startupError}`);
171
+ const stores = this.requireStores();
172
+ await stores.ledger.load();
173
+ await mkdir(target, { recursive: true });
174
+ await this.assertTargetAvailable(target);
175
+ const oldDirectory = this.currentDirectory;
176
+ const stage = join(target, `.dsh-taskboard-migration-${randomUUID()}`);
177
+ const stageLedger = this.ledgerPath(stage);
178
+ const stageTemplates = this.templatesPath(stage);
179
+ const stageAssets = this.assetsPath(stage);
180
+ const warnings = [];
181
+ try {
182
+ await mkdir(stage, { recursive: true });
183
+ await stores.ledger.writeCopy(stageLedger);
184
+ await stores.templates.writeCopy(stageTemplates);
185
+ await mkdir(stageAssets, { recursive: true });
186
+ try {
187
+ for (const entry of await readdir(stores.assets.location(), { withFileTypes: true })) if (entry.isFile()) await copyFile(join(stores.assets.location(), entry.name), join(stageAssets, entry.name), constants.COPYFILE_EXCL);
188
+ } catch (error) {
189
+ if (error.code !== "ENOENT") throw error;
190
+ }
191
+ JSON.parse(await readFile(stageLedger, "utf8"));
192
+ JSON.parse(await readFile(stageTemplates, "utf8"));
193
+ await rename(stageLedger, this.ledgerPath(target));
194
+ await rename(stageTemplates, this.templatesPath(target));
195
+ await rename(stageAssets, this.assetsPath(target));
196
+ if (samePath(target, this.options.defaultDirectory)) await rm(this.options.configFile, { force: true });
197
+ else await persistConfig(this.options.configFile, {
198
+ schemaVersion: CONFIG_SCHEMA_VERSION,
199
+ dataDirectory: target
200
+ });
201
+ stores.ledger.setLocation(this.ledgerPath(target));
202
+ stores.templates.setLocation(this.templatesPath(target));
203
+ stores.assets.setLocation(this.assetsPath(target));
204
+ this.currentDirectory = target;
205
+ this.configured = !samePath(target, this.options.defaultDirectory);
206
+ this.startupError = void 0;
207
+ for (const oldPath of [
208
+ this.ledgerPath(oldDirectory),
209
+ this.templatesPath(oldDirectory),
210
+ this.assetsPath(oldDirectory)
211
+ ]) try {
212
+ await rm(oldPath, {
213
+ recursive: true,
214
+ force: true
215
+ });
216
+ } catch (error) {
217
+ warnings.push(`could not remove ${oldPath}: ${error instanceof Error ? error.message : String(error)}`);
218
+ }
219
+ await rm(stage, {
220
+ recursive: true,
221
+ force: true
222
+ });
223
+ return {
224
+ ...await this.status(),
225
+ migrated: true,
226
+ warnings
227
+ };
228
+ } catch (error) {
229
+ await rm(stage, {
230
+ recursive: true,
231
+ force: true
232
+ }).catch(() => void 0);
233
+ if (samePath(this.currentDirectory, oldDirectory)) for (const path of [
234
+ this.ledgerPath(target),
235
+ this.templatesPath(target),
236
+ this.assetsPath(target)
237
+ ]) await rm(path, {
238
+ recursive: true,
239
+ force: true
240
+ }).catch(() => void 0);
241
+ throw error;
242
+ }
243
+ });
244
+ }
245
+ };
246
+ //#endregion
247
+ export { STORAGE_CONFIG_FILE, StorageCoordinator };
248
+
249
+ //# sourceMappingURL=storage.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"storage.js","names":[],"sources":["../../src/host/storage.ts"],"sourcesContent":["/** Configurable taskboard data directory and crash-safe three-part migration. */\nimport { randomUUID } from 'node:crypto'\nimport { constants, copyFile, access, mkdir, open, readFile, readdir, rename, rm, stat } from 'node:fs/promises'\nimport { existsSync, readFileSync } from 'node:fs'\nimport { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'\nimport type { StorageStatus, StorageMigrationResult } from '../shared/api.ts'\nimport type { AssetStore } from './assets.ts'\nimport type { TaskStore } from './store.ts'\nimport type { TemplateStore } from './templates.ts'\nimport { StorageQueue } from './storage-queue.ts'\n\nexport const STORAGE_CONFIG_FILE = 'dsh-taskboard-storage.json'\nconst CONFIG_SCHEMA_VERSION = 1\n\ntype StorageConfig = { schemaVersion: number; dataDirectory: string }\n\nexport interface StorageCoordinatorOptions {\n defaultDirectory: string\n configFile: string\n ledgerName: string\n templatesName: string\n assetsName: string\n}\n\nfunction normalized(path: string): string {\n const value = resolve(path.trim())\n return process.platform === 'win32' ? value.toLowerCase() : value\n}\n\nfunction samePath(a: string, b: string): boolean { return normalized(a) === normalized(b) }\n\nfunction inside(parent: string, child: string): boolean {\n const rel = relative(resolve(parent), resolve(child))\n return rel.length === 0 || (!rel.startsWith('..') && !isAbsolute(rel))\n}\n\nasync function exists(path: string): Promise<boolean> {\n try { await stat(path); return true } catch { return false }\n}\n\nasync function persistConfig(file: string, config: StorageConfig): Promise<void> {\n await mkdir(dirname(file), { recursive: true })\n const temp = join(dirname(file), `.${basename(file)}.${randomUUID()}.tmp`)\n const handle = await open(temp, 'w')\n try {\n await handle.writeFile(JSON.stringify(config, null, 2), 'utf8')\n await handle.sync()\n } finally { await handle.close() }\n await rename(temp, file)\n}\n\nfunction configuredDirectory(options: StorageCoordinatorOptions): { directory: string; configured: boolean; error?: string } {\n if (!existsSync(options.configFile)) return { directory: resolve(options.defaultDirectory), configured: false }\n try {\n const parsed = JSON.parse(readFileSync(options.configFile, 'utf8')) as Partial<StorageConfig>\n if (parsed.schemaVersion !== CONFIG_SCHEMA_VERSION || typeof parsed.dataDirectory !== 'string' || !isAbsolute(parsed.dataDirectory)) {\n return { directory: resolve(options.defaultDirectory), configured: false, error: 'invalid storage location config; using the default directory' }\n }\n return { directory: resolve(parsed.dataDirectory), configured: true }\n } catch (error) {\n return { directory: resolve(options.defaultDirectory), configured: false, error: `cannot read storage location config: ${error instanceof Error ? error.message : String(error)}` }\n }\n}\n\n/** Coordinates all persistent stores so migration cannot race normal writes. */\nexport class StorageCoordinator {\n readonly queue = new StorageQueue()\n private currentDirectory: string\n private configured: boolean\n private startupError?: string\n private stores?: { ledger: TaskStore; templates: TemplateStore; assets: AssetStore }\n\n constructor(private readonly options: StorageCoordinatorOptions) {\n const selected = configuredDirectory(options)\n this.currentDirectory = selected.directory\n this.configured = selected.configured\n if (selected.error !== undefined) console.warn(`[dsh-taskboard] ${selected.error}`)\n if (selected.configured && !existsSync(selected.directory)) {\n this.startupError = `configured storage directory is unavailable: ${selected.directory}`\n }\n }\n\n attach(stores: { ledger: TaskStore; templates: TemplateStore; assets: AssetStore }): void { this.stores = stores }\n\n directory(): string { return this.currentDirectory }\n ledgerPath(directory = this.currentDirectory): string { return join(directory, this.options.ledgerName) }\n templatesPath(directory = this.currentDirectory): string { return join(directory, this.options.templatesName) }\n assetsPath(directory = this.currentDirectory): string { return join(directory, this.options.assetsName) }\n\n async ready(): Promise<void> {\n if (this.startupError !== undefined) throw new Error(`taskboard_storage_unavailable: ${this.startupError}`)\n await mkdir(this.currentDirectory, { recursive: true })\n await access(this.currentDirectory, constants.R_OK | constants.W_OK)\n }\n\n private requireStores(): { ledger: TaskStore; templates: TemplateStore; assets: AssetStore } {\n if (this.stores === undefined) throw new Error('storage coordinator is not attached')\n return this.stores\n }\n\n async status(): Promise<StorageStatus> {\n let assetCount = 0\n let assetBytes = 0\n try {\n for (const entry of await readdir(this.assetsPath(), { withFileTypes: true })) {\n if (!entry.isFile()) continue\n assetCount += 1\n try { assetBytes += (await stat(join(this.assetsPath(), entry.name))).size } catch { /* best effort */ }\n }\n } catch { /* no attachment directory yet */ }\n return {\n currentDirectory: this.currentDirectory,\n defaultDirectory: resolve(this.options.defaultDirectory),\n isDefault: samePath(this.currentDirectory, this.options.defaultDirectory),\n configured: this.configured,\n writable: this.startupError === undefined,\n assetCount,\n assetBytes,\n ...(this.startupError === undefined ? {} : { error: this.startupError }),\n }\n }\n\n async check(directory: string): Promise<StorageStatus> {\n const target = this.validateTarget(directory)\n await mkdir(target, { recursive: true })\n await this.assertTargetAvailable(target)\n const probe = join(target, `.dsh-taskboard-write-${randomUUID()}.tmp`)\n const handle = await open(probe, 'wx')\n try { await handle.writeFile('ok', 'utf8'); await handle.sync() } finally { await handle.close(); await rm(probe, { force: true }) }\n return { ...(await this.status()), checkedDirectory: target, writable: true }\n }\n\n private validateTarget(directory: string): string {\n if (typeof directory !== 'string' || directory.trim().length === 0) return resolve(this.options.defaultDirectory)\n if (!isAbsolute(directory.trim())) throw new Error('storage directory must be an absolute path')\n const target = resolve(directory.trim())\n if (inside(this.assetsPath(), target)) throw new Error('storage directory cannot be inside the current attachment directory')\n return target\n }\n\n private async assertTargetAvailable(target: string): Promise<void> {\n if (samePath(target, this.currentDirectory)) return\n for (const path of [this.ledgerPath(target), this.templatesPath(target), this.assetsPath(target)]) {\n if (await exists(path)) throw new Error(`target already contains ${basename(path)}`)\n }\n }\n\n async migrate(directory: string): Promise<StorageMigrationResult> {\n const target = this.validateTarget(directory)\n return this.queue.run(async () => {\n // Re-check only after acquiring the shared queue: another migration may\n // have switched to this target while this request was waiting.\n if (samePath(target, this.currentDirectory)) return { ...(await this.status()), migrated: false, warnings: [] }\n if (this.startupError !== undefined) throw new Error(`taskboard_storage_unavailable: ${this.startupError}`)\n const stores = this.requireStores()\n await stores.ledger.load()\n await mkdir(target, { recursive: true })\n await this.assertTargetAvailable(target)\n\n const oldDirectory = this.currentDirectory\n const stage = join(target, `.dsh-taskboard-migration-${randomUUID()}`)\n const stageLedger = this.ledgerPath(stage)\n const stageTemplates = this.templatesPath(stage)\n const stageAssets = this.assetsPath(stage)\n const warnings: string[] = []\n try {\n await mkdir(stage, { recursive: true })\n await stores.ledger.writeCopy(stageLedger)\n await stores.templates.writeCopy(stageTemplates)\n await mkdir(stageAssets, { recursive: true })\n try {\n for (const entry of await readdir(stores.assets.location(), { withFileTypes: true })) {\n if (entry.isFile()) await copyFile(join(stores.assets.location(), entry.name), join(stageAssets, entry.name), constants.COPYFILE_EXCL)\n }\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error\n }\n JSON.parse(await readFile(stageLedger, 'utf8'))\n JSON.parse(await readFile(stageTemplates, 'utf8'))\n\n await rename(stageLedger, this.ledgerPath(target))\n await rename(stageTemplates, this.templatesPath(target))\n await rename(stageAssets, this.assetsPath(target))\n if (samePath(target, this.options.defaultDirectory)) await rm(this.options.configFile, { force: true })\n else await persistConfig(this.options.configFile, { schemaVersion: CONFIG_SCHEMA_VERSION, dataDirectory: target })\n\n stores.ledger.setLocation(this.ledgerPath(target))\n stores.templates.setLocation(this.templatesPath(target))\n stores.assets.setLocation(this.assetsPath(target))\n this.currentDirectory = target\n this.configured = !samePath(target, this.options.defaultDirectory)\n this.startupError = undefined\n\n for (const oldPath of [this.ledgerPath(oldDirectory), this.templatesPath(oldDirectory), this.assetsPath(oldDirectory)]) {\n try { await rm(oldPath, { recursive: true, force: true }) } catch (error) { warnings.push(`could not remove ${oldPath}: ${error instanceof Error ? error.message : String(error)}`) }\n }\n await rm(stage, { recursive: true, force: true })\n return { ...(await this.status()), migrated: true, warnings }\n } catch (error) {\n await rm(stage, { recursive: true, force: true }).catch(() => undefined)\n // The pointer is the commit point. Before it changes, prepared target\n // files are safe to remove and the old directory remains authoritative.\n if (samePath(this.currentDirectory, oldDirectory)) {\n for (const path of [this.ledgerPath(target), this.templatesPath(target), this.assetsPath(target)]) {\n await rm(path, { recursive: true, force: true }).catch(() => undefined)\n }\n }\n throw error\n }\n })\n }\n}\n"],"mappings":"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"}
package/lib/host/store.js CHANGED
@@ -3,7 +3,7 @@ import { dirname, join } from "node:path";
3
3
  import { mkdir, open, readFile, rename } from "node:fs/promises";
4
4
  //#region src/host/store.ts
5
5
  /**
6
- * Host-side task ledger: one JSON file under the DSH home, mutated through a
6
+ * Host-side task ledger: one JSON file under the active data directory, mutated through a
7
7
  * serial write queue, published as immutable snapshots with a global
8
8
  * monotonic revision. Change subscribers (P2: SSE route) observe every
9
9
  * committed mutation.
@@ -17,17 +17,39 @@ import { mkdir, open, readFile, rename } from "node:fs/promises";
17
17
  */
18
18
  var TaskStore = class {
19
19
  file;
20
+ storageQueue;
20
21
  ledger = emptyLedger();
21
22
  subscribers = /* @__PURE__ */ new Set();
22
23
  queue = Promise.resolve();
23
24
  loaded = false;
25
+ loadPromise;
24
26
  /** @param options - file location. */
25
27
  constructor(options) {
26
28
  this.file = options.file;
29
+ this.storageQueue = options.queue;
30
+ }
31
+ /** Current absolute ledger path. */
32
+ location() {
33
+ return this.file;
34
+ }
35
+ /** Persist the live in-memory ledger to another file without switching. */
36
+ async writeCopy(file) {
37
+ await this.load();
38
+ await persistAtomic(file, JSON.stringify(this.ledger));
39
+ }
40
+ /** Switch future writes after a prepared migration commits. */
41
+ setLocation(file) {
42
+ this.file = file;
27
43
  }
28
44
  /** Load (once) from disk; a missing file starts empty; a corrupt file is quarantined, not thrown. */
29
- async load() {
30
- if (this.loaded) return;
45
+ load() {
46
+ if (this.loaded) return Promise.resolve();
47
+ if (this.loadPromise !== void 0) return this.loadPromise;
48
+ this.loadPromise = this.loadOnce();
49
+ return this.loadPromise;
50
+ }
51
+ /** Perform the single physical ledger read shared by all startup callers. */
52
+ async loadOnce() {
31
53
  try {
32
54
  const raw = await readFile(this.file, "utf8");
33
55
  const parsed = JSON.parse(raw);
@@ -92,10 +114,13 @@ var TaskStore = class {
92
114
  * @returns the backup file path.
93
115
  */
94
116
  async backup() {
95
- await this.load();
96
- const target = `${this.file}.backup-${Date.now()}`;
97
- await persistAtomic(target, JSON.stringify(this.ledger, null, 2));
98
- return target;
117
+ const run = async () => {
118
+ await this.load();
119
+ const target = `${this.file}.backup-${Date.now()}`;
120
+ await persistAtomic(target, JSON.stringify(this.ledger, null, 2));
121
+ return target;
122
+ };
123
+ return this.storageQueue === void 0 ? run() : this.storageQueue.run(run);
99
124
  }
100
125
  /**
101
126
  * Run one mutation inside the serial queue. The mutator works on a
@@ -130,6 +155,7 @@ var TaskStore = class {
130
155
  changed: changed.map((t) => deepFreeze(structuredClone(t)))
131
156
  };
132
157
  };
158
+ if (this.storageQueue !== void 0) return this.storageQueue.run(run);
133
159
  return this.queue = this.queue.then(run, run);
134
160
  }
135
161
  /**
@@ -143,6 +169,7 @@ var TaskStore = class {
143
169
  await this.load();
144
170
  return fn(deepFreeze(structuredClone(this.ledger)));
145
171
  };
172
+ if (this.storageQueue !== void 0) return this.storageQueue.run(run);
146
173
  return this.queue = this.queue.then(run, run);
147
174
  }
148
175
  };
@@ -1 +1 @@
1
- {"version":3,"file":"store.js","names":[],"sources":["../../src/host/store.ts"],"sourcesContent":["/**\n * Host-side task ledger: one JSON file under the DSH home, mutated through a\n * serial write queue, published as immutable snapshots with a global\n * monotonic revision. Change subscribers (P2: SSE route) observe every\n * committed mutation.\n *\n * @module dsh-taskboard/host/store\n */\nimport { mkdir, open, readFile, rename } from 'node:fs/promises'\nimport { dirname, join } from 'node:path'\nimport {\n LEDGER_SCHEMA_VERSION,\n asBoardSettings,\n emptyLedger,\n isPlausibleTaskRecord,\n pruneExecutions,\n type TaskLedger,\n type TaskRecord,\n} from '../shared/protocol.ts'\n\n/** One committed ledger mutation, handed to change subscribers. */\nexport interface LedgerChange {\n /** Revision after the mutation. */\n revision: number\n /** The mutated tasks, if any (a comment purge may touch none). */\n tasks: readonly TaskRecord[]\n /** What kind of mutation this was (for SSE event naming later). */\n kind: 'task-created' | 'task-updated' | 'task-moved' | 'task-deleted' | 'comment-added' | 'execution-recorded' | 'settings-updated' | 'ledger-replaced'\n}\n\n/** Options for {@link TaskStore}. */\nexport interface TaskStoreOptions {\n /** Absolute ledger file path. */\n file: string\n}\n\n/**\n * The durable ledger. All mutations run through {@link mutate}, which:\n * validates the resulting document, bumps the global revision, persists\n * atomically (temp file + rename), and only then notifies subscribers.\n */\nexport class TaskStore {\n private readonly file: string\n private ledger: TaskLedger = emptyLedger()\n private readonly subscribers = new Set<(change: LedgerChange) => void>()\n private queue: Promise<unknown> = Promise.resolve()\n private loaded = false\n\n /** @param options - file location. */\n constructor(options: TaskStoreOptions) {\n this.file = options.file\n }\n\n /** Load (once) from disk; a missing file starts empty; a corrupt file is quarantined, not thrown. */\n async load(): Promise<void> {\n if (this.loaded) return\n try {\n const raw = await readFile(this.file, 'utf8')\n const parsed = JSON.parse(raw) as TaskLedger\n if (typeof parsed.revision === 'number' && Array.isArray(parsed.tasks)) {\n // S11: trust no record wholesale — drop structurally broken entries\n // (including R4's traversal-shaped ids from a hand-edited file) with\n // a notice instead of letting them reach the path-building layers.\n const plausible: TaskRecord[] = []\n for (const entry of parsed.tasks as unknown[]) {\n if (!isPlausibleTaskRecord(entry)) {\n const rawId = (entry as { id?: unknown })?.id\n const id = typeof rawId === 'string' ? rawId.slice(0, 60) : String(rawId)\n console.warn('[dsh-taskboard] dropping implausible ledger entry on load:', id)\n continue\n }\n plausible.push(entry as TaskRecord)\n }\n const tasks = plausible\n // Migration from pre-claim-field ledgers: an agent-held in_progress\n // task carried its holder in updatedBy — backfill the explicit claim\n // fields so the hold survives user edits (updatedBy is audit-only).\n for (const task of tasks) {\n if (task.status === 'in_progress' && task.claimedBy === undefined\n && task.updatedBy?.kind === 'agent' && typeof task.updatedBy.sessionId === 'string') {\n task.claimedBy = task.updatedBy.sessionId\n task.claimedAt = task.updatedAt\n }\n }\n let settings = undefined\n if (parsed.settings !== undefined) {\n try {\n settings = asBoardSettings(parsed.settings)\n } catch {\n console.warn('[dsh-taskboard] dropping invalid board settings on load')\n }\n }\n this.ledger = {\n schemaVersion: LEDGER_SCHEMA_VERSION,\n revision: parsed.revision,\n tasks,\n ...(settings !== undefined ? { settings } : {}),\n }\n }\n } catch (error) {\n const code = (error as NodeJS.ErrnoException).code\n if (code !== 'ENOENT') {\n // Quarantine a corrupt ledger: rename it aside, start fresh. Never\n // take the host down over ledger damage.\n try {\n await rename(this.file, `${this.file}.corrupt-${Date.now()}`)\n } catch { /* best effort */ }\n }\n }\n this.loaded = true\n }\n\n /**\n * The current snapshot — a deep-frozen clone. Mutating the returned value\n * throws (strict mode) instead of silently bypassing the revision/persist\n * path; internal state is never handed out.\n */\n snapshot(): TaskLedger {\n return deepFreeze(structuredClone(this.ledger))\n }\n\n /** Find a task by id (frozen clone; internal state is never handed out). */\n get(id: string): TaskRecord | undefined {\n const task = this.ledger.tasks.find(t => t.id === id)\n return task === undefined ? undefined : deepFreeze(structuredClone(task))\n }\n\n /** Subscribe to committed changes; returns the unsubscribe. */\n subscribe(fn: (change: LedgerChange) => void): () => void {\n this.subscribers.add(fn)\n return () => this.subscribers.delete(fn)\n }\n\n /**\n * Write a timestamped backup copy of the current ledger next to the live\n * file (import-replace safety, 0.4.0). Never throws the caller's flow —\n * a backup failure fails the import itself.\n * @returns the backup file path.\n */\n async backup(): Promise<string> {\n await this.load()\n const target = `${this.file}.backup-${Date.now()}`\n await persistAtomic(target, JSON.stringify(this.ledger, null, 2))\n return target\n }\n\n /**\n * Run one mutation inside the serial queue. The mutator works on a\n * structured clone; returning `undefined` aborts with no write.\n * @param kind - change kind for subscribers.\n * @param mutator - receives the cloned ledger; mutate tasks in place; return the touched tasks.\n */\n async mutate(\n kind: LedgerChange['kind'],\n mutator: (ledger: TaskLedger) => TaskRecord[] | undefined,\n ): Promise<{ ledger: TaskLedger; changed: readonly TaskRecord[] }> {\n const run = async (): Promise<{ ledger: TaskLedger; changed: readonly TaskRecord[] }> => {\n await this.load()\n const draft: TaskLedger = structuredClone(this.ledger)\n const changed = mutator(draft)\n if (changed === undefined) {\n // S9 parity: even a no-op mutation hands out a frozen clone — never\n // the live internal ledger.\n return { ledger: deepFreeze(structuredClone(this.ledger)), changed: [] }\n }\n // Retention cap: every committed mutation re-checks the touched tasks,\n // so execution history can never grow unbounded (SSE state payload).\n for (const task of changed) pruneExecutions(task)\n draft.revision += 1\n const json = JSON.stringify(draft)\n await persistAtomic(this.file, json)\n this.ledger = draft\n const change: LedgerChange = { revision: draft.revision, tasks: changed, kind }\n for (const fn of this.subscribers) {\n try {\n fn(change)\n } catch { /* subscriber errors never abort the write */ }\n }\n // S9: hand out frozen clones — the return value used to BE the new\n // internal ledger; callers must never mutate internal state in place.\n return {\n ledger: deepFreeze(structuredClone(draft)),\n changed: changed.map(t => deepFreeze(structuredClone(t))),\n }\n }\n const result = (this.queue = this.queue.then(run, run)) as ReturnType<typeof run>\n return result\n }\n\n /**\n * Run a read INSIDE the serial queue (R3): observes exactly the ledger\n * state after all previously enqueued mutations — immune to the\n * write-then-publish window around `mutate`'s persistence. Read-only: the\n * callback receives a frozen deep clone and nothing is written.\n */\n async read<T>(fn: (ledger: TaskLedger) => T): Promise<T> {\n const run = async (): Promise<T> => {\n await this.load()\n return fn(deepFreeze(structuredClone(this.ledger)))\n }\n const result = (this.queue = this.queue.then(run, run)) as Promise<T>\n return result\n }\n}\n\n/** Recursively freeze a plain-data value (defense in depth for handed-out snapshots). */\nfunction deepFreeze<T>(value: T): T {\n if (value !== null && typeof value === 'object') {\n if (!Object.isFrozen(value)) Object.freeze(value)\n for (const key of Object.keys(value as Record<string, unknown>)) {\n deepFreeze((value as Record<string, unknown>)[key])\n }\n }\n return value\n}\n\n/**\n * Atomic file persist: write temp, fsync, then rename over the target (S10:\n * without the sync, a power loss after rename can leave a zero-length file —\n * the next load would quarantine the ledger and start empty).\n */\nasync function persistAtomic(file: string, contents: string): Promise<void> {\n await mkdir(dirname(file), { recursive: true })\n const temp = join(dirname(file), `.${Math.random().toString(36).slice(2)}.tmp`)\n const fh = await open(temp, 'w')\n try {\n await fh.writeFile(contents, 'utf8')\n await fh.sync()\n } finally {\n await fh.close()\n }\n await rename(temp, file)\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"store.js","names":[],"sources":["../../src/host/store.ts"],"sourcesContent":["/**\n * Host-side task ledger: one JSON file under the active data directory, mutated through a\n * serial write queue, published as immutable snapshots with a global\n * monotonic revision. Change subscribers (P2: SSE route) observe every\n * committed mutation.\n *\n * @module dsh-taskboard/host/store\n */\nimport { mkdir, open, readFile, rename } from 'node:fs/promises'\nimport { dirname, join } from 'node:path'\nimport {\n LEDGER_SCHEMA_VERSION,\n asBoardSettings,\n emptyLedger,\n isPlausibleTaskRecord,\n pruneExecutions,\n type TaskLedger,\n type TaskRecord,\n} from '../shared/protocol.ts'\nimport type { StorageQueue } from './storage-queue.ts'\n\n/** One committed ledger mutation, handed to change subscribers. */\nexport interface LedgerChange {\n /** Revision after the mutation. */\n revision: number\n /** The mutated tasks, if any (a comment purge may touch none). */\n tasks: readonly TaskRecord[]\n /** What kind of mutation this was (for SSE event naming later). */\n kind: 'task-created' | 'task-updated' | 'task-moved' | 'task-deleted' | 'comment-added' | 'execution-recorded' | 'settings-updated' | 'ledger-replaced'\n}\n\n/** Options for {@link TaskStore}. */\nexport interface TaskStoreOptions {\n /** Absolute ledger file path. */\n file: string\n /** Optional queue shared with templates/assets and storage migration. */\n queue?: StorageQueue\n}\n\n/**\n * The durable ledger. All mutations run through {@link mutate}, which:\n * validates the resulting document, bumps the global revision, persists\n * atomically (temp file + rename), and only then notifies subscribers.\n */\nexport class TaskStore {\n private file: string\n private readonly storageQueue?: StorageQueue\n private ledger: TaskLedger = emptyLedger()\n private readonly subscribers = new Set<(change: LedgerChange) => void>()\n private queue: Promise<unknown> = Promise.resolve()\n private loaded = false\n private loadPromise: Promise<void> | undefined\n\n /** @param options - file location. */\n constructor(options: TaskStoreOptions) {\n this.file = options.file\n this.storageQueue = options.queue\n }\n\n /** Current absolute ledger path. */\n location(): string { return this.file }\n\n /** Persist the live in-memory ledger to another file without switching. */\n async writeCopy(file: string): Promise<void> {\n await this.load()\n await persistAtomic(file, JSON.stringify(this.ledger))\n }\n\n /** Switch future writes after a prepared migration commits. */\n setLocation(file: string): void { this.file = file }\n\n /** Load (once) from disk; a missing file starts empty; a corrupt file is quarantined, not thrown. */\n load(): Promise<void> {\n if (this.loaded) return Promise.resolve()\n if (this.loadPromise !== undefined) return this.loadPromise\n this.loadPromise = this.loadOnce()\n return this.loadPromise\n }\n\n /** Perform the single physical ledger read shared by all startup callers. */\n private async loadOnce(): Promise<void> {\n try {\n const raw = await readFile(this.file, 'utf8')\n const parsed = JSON.parse(raw) as TaskLedger\n if (typeof parsed.revision === 'number' && Array.isArray(parsed.tasks)) {\n // S11: trust no record wholesale — drop structurally broken entries\n // (including R4's traversal-shaped ids from a hand-edited file) with\n // a notice instead of letting them reach the path-building layers.\n const plausible: TaskRecord[] = []\n for (const entry of parsed.tasks as unknown[]) {\n if (!isPlausibleTaskRecord(entry)) {\n const rawId = (entry as { id?: unknown })?.id\n const id = typeof rawId === 'string' ? rawId.slice(0, 60) : String(rawId)\n console.warn('[dsh-taskboard] dropping implausible ledger entry on load:', id)\n continue\n }\n plausible.push(entry as TaskRecord)\n }\n const tasks = plausible\n // Migration from pre-claim-field ledgers: an agent-held in_progress\n // task carried its holder in updatedBy — backfill the explicit claim\n // fields so the hold survives user edits (updatedBy is audit-only).\n for (const task of tasks) {\n if (task.status === 'in_progress' && task.claimedBy === undefined\n && task.updatedBy?.kind === 'agent' && typeof task.updatedBy.sessionId === 'string') {\n task.claimedBy = task.updatedBy.sessionId\n task.claimedAt = task.updatedAt\n }\n }\n let settings = undefined\n if (parsed.settings !== undefined) {\n try {\n settings = asBoardSettings(parsed.settings)\n } catch {\n console.warn('[dsh-taskboard] dropping invalid board settings on load')\n }\n }\n this.ledger = {\n schemaVersion: LEDGER_SCHEMA_VERSION,\n revision: parsed.revision,\n tasks,\n ...(settings !== undefined ? { settings } : {}),\n }\n }\n } catch (error) {\n const code = (error as NodeJS.ErrnoException).code\n if (code !== 'ENOENT') {\n // Quarantine a corrupt ledger: rename it aside, start fresh. Never\n // take the host down over ledger damage.\n try {\n await rename(this.file, `${this.file}.corrupt-${Date.now()}`)\n } catch { /* best effort */ }\n }\n }\n this.loaded = true\n }\n\n /**\n * The current snapshot — a deep-frozen clone. Mutating the returned value\n * throws (strict mode) instead of silently bypassing the revision/persist\n * path; internal state is never handed out.\n */\n snapshot(): TaskLedger {\n return deepFreeze(structuredClone(this.ledger))\n }\n\n /** Find a task by id (frozen clone; internal state is never handed out). */\n get(id: string): TaskRecord | undefined {\n const task = this.ledger.tasks.find(t => t.id === id)\n return task === undefined ? undefined : deepFreeze(structuredClone(task))\n }\n\n /** Subscribe to committed changes; returns the unsubscribe. */\n subscribe(fn: (change: LedgerChange) => void): () => void {\n this.subscribers.add(fn)\n return () => this.subscribers.delete(fn)\n }\n\n /**\n * Write a timestamped backup copy of the current ledger next to the live\n * file (import-replace safety, 0.4.0). Never throws the caller's flow —\n * a backup failure fails the import itself.\n * @returns the backup file path.\n */\n async backup(): Promise<string> {\n const run = async (): Promise<string> => {\n await this.load()\n const target = `${this.file}.backup-${Date.now()}`\n await persistAtomic(target, JSON.stringify(this.ledger, null, 2))\n return target\n }\n return this.storageQueue === undefined ? run() : this.storageQueue.run(run)\n }\n\n /**\n * Run one mutation inside the serial queue. The mutator works on a\n * structured clone; returning `undefined` aborts with no write.\n * @param kind - change kind for subscribers.\n * @param mutator - receives the cloned ledger; mutate tasks in place; return the touched tasks.\n */\n async mutate(\n kind: LedgerChange['kind'],\n mutator: (ledger: TaskLedger) => TaskRecord[] | undefined,\n ): Promise<{ ledger: TaskLedger; changed: readonly TaskRecord[] }> {\n const run = async (): Promise<{ ledger: TaskLedger; changed: readonly TaskRecord[] }> => {\n await this.load()\n const draft: TaskLedger = structuredClone(this.ledger)\n const changed = mutator(draft)\n if (changed === undefined) {\n // S9 parity: even a no-op mutation hands out a frozen clone — never\n // the live internal ledger.\n return { ledger: deepFreeze(structuredClone(this.ledger)), changed: [] }\n }\n // Retention cap: every committed mutation re-checks the touched tasks,\n // so execution history can never grow unbounded (SSE state payload).\n for (const task of changed) pruneExecutions(task)\n draft.revision += 1\n const json = JSON.stringify(draft)\n await persistAtomic(this.file, json)\n this.ledger = draft\n const change: LedgerChange = { revision: draft.revision, tasks: changed, kind }\n for (const fn of this.subscribers) {\n try {\n fn(change)\n } catch { /* subscriber errors never abort the write */ }\n }\n // S9: hand out frozen clones — the return value used to BE the new\n // internal ledger; callers must never mutate internal state in place.\n return {\n ledger: deepFreeze(structuredClone(draft)),\n changed: changed.map(t => deepFreeze(structuredClone(t))),\n }\n }\n if (this.storageQueue !== undefined) return this.storageQueue.run(run)\n return (this.queue = this.queue.then(run, run)) as ReturnType<typeof run>\n }\n\n /**\n * Run a read INSIDE the serial queue (R3): observes exactly the ledger\n * state after all previously enqueued mutations — immune to the\n * write-then-publish window around `mutate`'s persistence. Read-only: the\n * callback receives a frozen deep clone and nothing is written.\n */\n async read<T>(fn: (ledger: TaskLedger) => T): Promise<T> {\n const run = async (): Promise<T> => {\n await this.load()\n return fn(deepFreeze(structuredClone(this.ledger)))\n }\n if (this.storageQueue !== undefined) return this.storageQueue.run(run)\n return (this.queue = this.queue.then(run, run)) as Promise<T>\n }\n}\n\n/** Recursively freeze a plain-data value (defense in depth for handed-out snapshots). */\nfunction deepFreeze<T>(value: T): T {\n if (value !== null && typeof value === 'object') {\n if (!Object.isFrozen(value)) Object.freeze(value)\n for (const key of Object.keys(value as Record<string, unknown>)) {\n deepFreeze((value as Record<string, unknown>)[key])\n }\n }\n return value\n}\n\n/**\n * Atomic file persist: write temp, fsync, then rename over the target (S10:\n * without the sync, a power loss after rename can leave a zero-length file —\n * the next load would quarantine the ledger and start empty).\n */\nasync function persistAtomic(file: string, contents: string): Promise<void> {\n await mkdir(dirname(file), { recursive: true })\n const temp = join(dirname(file), `.${Math.random().toString(36).slice(2)}.tmp`)\n const fh = await open(temp, 'w')\n try {\n await fh.writeFile(contents, 'utf8')\n await fh.sync()\n } finally {\n await fh.close()\n }\n await rename(temp, file)\n}\n"],"mappings":"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"}
@@ -30,11 +30,26 @@ function newTemplateId() {
30
30
  * writes are rare, human-paced GUI operations; last-write-wins is fine).
31
31
  */
32
32
  var TemplateStore = class {
33
- file;
33
+ storageQueue;
34
34
  templates;
35
35
  loaded = false;
36
+ file;
36
37
  /** @param file - absolute side-file path (next to the ledger). */
37
- constructor(file) {
38
+ constructor(file, storageQueue) {
39
+ this.storageQueue = storageQueue;
40
+ this.file = file;
41
+ }
42
+ /** Current absolute template-file path. */
43
+ location() {
44
+ return this.file;
45
+ }
46
+ /** Persist the loaded template set to another file without switching. */
47
+ async writeCopy(file) {
48
+ await this.ensure();
49
+ await this.persist(this.templates ?? [], file);
50
+ }
51
+ /** Switch future writes after a prepared migration commits. */
52
+ setLocation(file) {
38
53
  this.file = file;
39
54
  }
40
55
  /** Load once; a missing file seeds the built-ins; a corrupt file resets. */
@@ -63,11 +78,11 @@ var TemplateStore = class {
63
78
  this.loaded = true;
64
79
  }
65
80
  /** Atomic persist (temp + fsync + rename — S10, same discipline as the ledger). */
66
- async persist(templates) {
81
+ async persist(templates, file = this.file) {
67
82
  const { mkdir, open, rename } = await import("node:fs/promises");
68
83
  const { dirname, join } = await import("node:path");
69
- await mkdir(dirname(this.file), { recursive: true });
70
- const temp = join(dirname(this.file), `.${Math.random().toString(36).slice(2)}.tmp`);
84
+ await mkdir(dirname(file), { recursive: true });
85
+ const temp = join(dirname(file), `.${Math.random().toString(36).slice(2)}.tmp`);
71
86
  const fh = await open(temp, "w");
72
87
  try {
73
88
  await fh.writeFile(JSON.stringify({ templates }, null, 2), "utf8");
@@ -75,52 +90,61 @@ var TemplateStore = class {
75
90
  } finally {
76
91
  await fh.close();
77
92
  }
78
- await rename(temp, this.file);
93
+ await rename(temp, file);
79
94
  }
80
95
  /** All templates (oldest first). */
81
96
  async list() {
82
- await this.ensure();
83
- return (this.templates ?? []).slice();
97
+ const run = async () => {
98
+ await this.ensure();
99
+ return (this.templates ?? []).slice();
100
+ };
101
+ return this.storageQueue === void 0 ? run() : this.storageQueue.run(run);
84
102
  }
85
103
  /**
86
104
  * Create or replace a template by id (a body without id creates).
87
105
  * @returns the stored template.
88
106
  */
89
107
  async upsert(input) {
90
- await this.ensure();
91
- const templates = this.templates ?? [];
92
- const name = input.name.trim();
93
- if (name.length === 0 || name.length > 60) throw new Error("模板名必须 1..60 字符");
94
- const now = Date.now();
95
- const existing = input.id !== void 0 ? templates.find((t) => t.id === input.id) : void 0;
96
- if (existing?.builtin === true) throw new Error("内置模板不可覆盖;可删除后另建,或以新名称存为新模板");
97
- const stored = existing !== void 0 ? {
98
- ...existing,
99
- name,
100
- task: input.task,
101
- updatedAt: now
102
- } : {
103
- id: input.id ?? newTemplateId(),
104
- name,
105
- task: input.task,
106
- createdAt: now,
107
- updatedAt: now
108
+ const run = async () => {
109
+ await this.ensure();
110
+ const templates = this.templates ?? [];
111
+ const name = input.name.trim();
112
+ if (name.length === 0 || name.length > 60) throw new Error("模板名必须 1..60 字符");
113
+ const now = Date.now();
114
+ const existing = input.id !== void 0 ? templates.find((t) => t.id === input.id) : void 0;
115
+ if (existing?.builtin === true) throw new Error("内置模板不可覆盖;可删除后另建,或以新名称存为新模板");
116
+ const stored = existing !== void 0 ? {
117
+ ...existing,
118
+ name,
119
+ task: input.task,
120
+ updatedAt: now
121
+ } : {
122
+ id: input.id ?? newTemplateId(),
123
+ name,
124
+ task: input.task,
125
+ createdAt: now,
126
+ updatedAt: now
127
+ };
128
+ const index = existing !== void 0 ? templates.indexOf(existing) : -1;
129
+ if (index >= 0) templates[index] = stored;
130
+ else templates.push(stored);
131
+ await this.persist(templates);
132
+ return stored;
108
133
  };
109
- const index = existing !== void 0 ? templates.indexOf(existing) : -1;
110
- if (index >= 0) templates[index] = stored;
111
- else templates.push(stored);
112
- await this.persist(templates);
113
- return stored;
134
+ return this.storageQueue === void 0 ? run() : this.storageQueue.run(run);
114
135
  }
115
136
  /** Delete a template by id; returns whether it existed. */
116
137
  async remove(id) {
117
- await this.ensure();
118
- const templates = this.templates ?? [];
119
- const index = templates.findIndex((t) => t.id === id);
120
- if (index < 0) return false;
121
- templates.splice(index, 1);
122
- await this.persist(templates);
123
- return true;
138
+ const run = async () => {
139
+ await this.ensure();
140
+ const templates = this.templates ?? [];
141
+ const index = templates.findIndex((t) => t.id === id);
142
+ if (index < 0) return false;
143
+ templates.splice(index, 1);
144
+ await this.persist(templates);
145
+ return true;
146
+ };
147
+ return this.storageQueue === void 0 ? run() : this.storageQueue.run(run);
124
148
  }
125
149
  };
126
150
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"templates.js","names":[],"sources":["../../src/host/templates.ts"],"sourcesContent":["/**\n * Host-side task-template store (0.4.0): one JSON side file next to the\n * ledger, seeded with the built-in templates on first load, mutated through\n * the same atomic persist discipline as the ledger.\n *\n * Pure data, no Cordis deps — the routes layer owns it and tests drive it\n * directly against a temp dir.\n *\n * @module dsh-taskboard/host/templates\n */\nimport { readFile } from 'node:fs/promises'\nimport type { TaskTemplate } from '../shared/api.ts'\nimport { BUILTIN_TEMPLATE_CONTENT, BUILTIN_TEMPLATE_IDS, type BuiltinTemplateId } from '../shared/builtin-templates.ts'\n\n/**\n * The built-in templates seeded when the side file does not exist yet.\n * Seeded from the shared zh content (the side file is plain data; the client\n * resolves the active locale at render time — see shared/builtin-templates.ts).\n */\nexport const BUILTIN_TEMPLATES: ReadonlyArray<{ id: BuiltinTemplateId; name: string; task: TaskTemplate['task'] }> =\n BUILTIN_TEMPLATE_IDS.map(id => ({ id, name: BUILTIN_TEMPLATE_CONTENT.zh[id].name, task: BUILTIN_TEMPLATE_CONTENT.zh[id].task }))\n\n/** Mint a template id. */\nfunction newTemplateId(): string {\n return `tpl-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`\n}\n\n/**\n * The template store. NOT thread-synchronized like the ledger (template\n * writes are rare, human-paced GUI operations; last-write-wins is fine).\n */\nexport class TemplateStore {\n private templates: TaskTemplate[] | undefined\n private loaded = false\n\n /** @param file - absolute side-file path (next to the ledger). */\n constructor(private readonly file: string) {}\n\n /** Load once; a missing file seeds the built-ins; a corrupt file resets. */\n private async ensure(): Promise<void> {\n if (this.loaded) return\n let parsed: TaskTemplate[] | undefined\n try {\n const raw = await readFile(this.file, 'utf8')\n const value = JSON.parse(raw) as { templates?: unknown }\n if (Array.isArray(value.templates)) {\n parsed = value.templates.filter((t): t is TaskTemplate =>\n typeof t === 'object' && t !== null && typeof (t as TaskTemplate).id === 'string'\n && typeof (t as TaskTemplate).name === 'string' && typeof (t as TaskTemplate).task === 'object')\n }\n } catch { /* missing or corrupt → seed */ }\n if (parsed === undefined) {\n const now = Date.now()\n parsed = BUILTIN_TEMPLATES.map((t, i) => ({ ...t, task: { ...t.task }, builtin: true, createdAt: now, updatedAt: now + i }))\n try { await this.persist(parsed) } catch { /* best effort — the seed returns in-memory */ }\n }\n this.templates = parsed\n this.loaded = true\n }\n\n /** Atomic persist (temp + fsync + rename — S10, same discipline as the ledger). */\n private async persist(templates: TaskTemplate[]): Promise<void> {\n const { mkdir, open, rename } = await import('node:fs/promises')\n const { dirname, join } = await import('node:path')\n await mkdir(dirname(this.file), { recursive: true })\n const temp = join(dirname(this.file), `.${Math.random().toString(36).slice(2)}.tmp`)\n const fh = await open(temp, 'w')\n try {\n await fh.writeFile(JSON.stringify({ templates }, null, 2), 'utf8')\n await fh.sync()\n } finally {\n await fh.close()\n }\n await rename(temp, this.file)\n }\n\n /** All templates (oldest first). */\n async list(): Promise<TaskTemplate[]> {\n await this.ensure()\n return (this.templates ?? []).slice()\n }\n\n /**\n * Create or replace a template by id (a body without id creates).\n * @returns the stored template.\n */\n async upsert(input: { id?: string; name: string; task: TaskTemplate['task'] }): Promise<TaskTemplate> {\n await this.ensure()\n const templates = this.templates ?? []\n const name = input.name.trim()\n if (name.length === 0 || name.length > 60) throw new Error('模板名必须 1..60 字符')\n const now = Date.now()\n const existing = input.id !== undefined ? templates.find(t => t.id === input.id) : undefined\n // T12: built-ins are factory content — editable only by delete + recreate\n // (deleting stays allowed), never silently overwritten in place.\n if (existing?.builtin === true) throw new Error('内置模板不可覆盖;可删除后另建,或以新名称存为新模板')\n const stored: TaskTemplate = existing !== undefined\n ? { ...existing, name, task: input.task, updatedAt: now }\n : { id: input.id ?? newTemplateId(), name, task: input.task, createdAt: now, updatedAt: now }\n const index = existing !== undefined ? templates.indexOf(existing) : -1\n if (index >= 0) templates[index] = stored\n else templates.push(stored)\n await this.persist(templates)\n return stored\n }\n\n /** Delete a template by id; returns whether it existed. */\n async remove(id: string): Promise<boolean> {\n await this.ensure()\n const templates = this.templates ?? []\n const index = templates.findIndex(t => t.id === id)\n if (index < 0) return false\n templates.splice(index, 1)\n await this.persist(templates)\n return true\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"templates.js","names":[],"sources":["../../src/host/templates.ts"],"sourcesContent":["/**\n * Host-side task-template store (0.4.0): one JSON side file next to the\n * ledger, seeded with the built-in templates on first load, mutated through\n * the same atomic persist discipline as the ledger.\n *\n * Pure data, no Cordis deps — the routes layer owns it and tests drive it\n * directly against a temp dir.\n *\n * @module dsh-taskboard/host/templates\n */\nimport { readFile } from 'node:fs/promises'\nimport type { TaskTemplate } from '../shared/api.ts'\nimport { BUILTIN_TEMPLATE_CONTENT, BUILTIN_TEMPLATE_IDS, type BuiltinTemplateId } from '../shared/builtin-templates.ts'\nimport type { StorageQueue } from './storage-queue.ts'\n\n/**\n * The built-in templates seeded when the side file does not exist yet.\n * Seeded from the shared zh content (the side file is plain data; the client\n * resolves the active locale at render time — see shared/builtin-templates.ts).\n */\nexport const BUILTIN_TEMPLATES: ReadonlyArray<{ id: BuiltinTemplateId; name: string; task: TaskTemplate['task'] }> =\n BUILTIN_TEMPLATE_IDS.map(id => ({ id, name: BUILTIN_TEMPLATE_CONTENT.zh[id].name, task: BUILTIN_TEMPLATE_CONTENT.zh[id].task }))\n\n/** Mint a template id. */\nfunction newTemplateId(): string {\n return `tpl-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`\n}\n\n/**\n * The template store. NOT thread-synchronized like the ledger (template\n * writes are rare, human-paced GUI operations; last-write-wins is fine).\n */\nexport class TemplateStore {\n private templates: TaskTemplate[] | undefined\n private loaded = false\n private file: string\n\n /** @param file - absolute side-file path (next to the ledger). */\n constructor(file: string, private readonly storageQueue?: StorageQueue) { this.file = file }\n\n /** Current absolute template-file path. */\n location(): string { return this.file }\n\n /** Persist the loaded template set to another file without switching. */\n async writeCopy(file: string): Promise<void> {\n await this.ensure()\n await this.persist(this.templates ?? [], file)\n }\n\n /** Switch future writes after a prepared migration commits. */\n setLocation(file: string): void { this.file = file }\n\n /** Load once; a missing file seeds the built-ins; a corrupt file resets. */\n private async ensure(): Promise<void> {\n if (this.loaded) return\n let parsed: TaskTemplate[] | undefined\n try {\n const raw = await readFile(this.file, 'utf8')\n const value = JSON.parse(raw) as { templates?: unknown }\n if (Array.isArray(value.templates)) {\n parsed = value.templates.filter((t): t is TaskTemplate =>\n typeof t === 'object' && t !== null && typeof (t as TaskTemplate).id === 'string'\n && typeof (t as TaskTemplate).name === 'string' && typeof (t as TaskTemplate).task === 'object')\n }\n } catch { /* missing or corrupt → seed */ }\n if (parsed === undefined) {\n const now = Date.now()\n parsed = BUILTIN_TEMPLATES.map((t, i) => ({ ...t, task: { ...t.task }, builtin: true, createdAt: now, updatedAt: now + i }))\n try { await this.persist(parsed) } catch { /* best effort — the seed returns in-memory */ }\n }\n this.templates = parsed\n this.loaded = true\n }\n\n /** Atomic persist (temp + fsync + rename — S10, same discipline as the ledger). */\n private async persist(templates: TaskTemplate[], file = this.file): Promise<void> {\n const { mkdir, open, rename } = await import('node:fs/promises')\n const { dirname, join } = await import('node:path')\n await mkdir(dirname(file), { recursive: true })\n const temp = join(dirname(file), `.${Math.random().toString(36).slice(2)}.tmp`)\n const fh = await open(temp, 'w')\n try {\n await fh.writeFile(JSON.stringify({ templates }, null, 2), 'utf8')\n await fh.sync()\n } finally {\n await fh.close()\n }\n await rename(temp, file)\n }\n\n /** All templates (oldest first). */\n async list(): Promise<TaskTemplate[]> {\n const run = async (): Promise<TaskTemplate[]> => {\n await this.ensure()\n return (this.templates ?? []).slice()\n }\n return this.storageQueue === undefined ? run() : this.storageQueue.run(run)\n }\n\n /**\n * Create or replace a template by id (a body without id creates).\n * @returns the stored template.\n */\n async upsert(input: { id?: string; name: string; task: TaskTemplate['task'] }): Promise<TaskTemplate> {\n const run = async (): Promise<TaskTemplate> => {\n await this.ensure()\n const templates = this.templates ?? []\n const name = input.name.trim()\n if (name.length === 0 || name.length > 60) throw new Error('模板名必须 1..60 字符')\n const now = Date.now()\n const existing = input.id !== undefined ? templates.find(t => t.id === input.id) : undefined\n // T12: built-ins are factory content — editable only by delete + recreate\n // (deleting stays allowed), never silently overwritten in place.\n if (existing?.builtin === true) throw new Error('内置模板不可覆盖;可删除后另建,或以新名称存为新模板')\n const stored: TaskTemplate = existing !== undefined\n ? { ...existing, name, task: input.task, updatedAt: now }\n : { id: input.id ?? newTemplateId(), name, task: input.task, createdAt: now, updatedAt: now }\n const index = existing !== undefined ? templates.indexOf(existing) : -1\n if (index >= 0) templates[index] = stored\n else templates.push(stored)\n await this.persist(templates)\n return stored\n }\n return this.storageQueue === undefined ? run() : this.storageQueue.run(run)\n }\n\n /** Delete a template by id; returns whether it existed. */\n async remove(id: string): Promise<boolean> {\n const run = async (): Promise<boolean> => {\n await this.ensure()\n const templates = this.templates ?? []\n const index = templates.findIndex(t => t.id === id)\n if (index < 0) return false\n templates.splice(index, 1)\n await this.persist(templates)\n return true\n }\n return this.storageQueue === undefined ? run() : this.storageQueue.run(run)\n }\n}\n"],"mappings":"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"}
package/lib/host/tools.js CHANGED
@@ -60,6 +60,7 @@ function taskDetail(t) {
60
60
  }
61
61
  /** Stable error codes surfaced at the head of tool error messages. */
62
62
  const ERR = {
63
+ notReady: "taskboard_not_ready",
63
64
  notFound: "not_found",
64
65
  versionConflict: "version_conflict",
65
66
  workspaceMismatch: "workspace_mismatch",
@@ -169,16 +170,17 @@ function registerTaskboardTools(ctx, deps) {
169
170
  const disposers = [];
170
171
  const { store, workspaces } = deps;
171
172
  const register = (tool) => {
172
- if (process.env.ATB_TRACE === "1" && typeof tool.execute === "function") {
173
+ if (typeof tool.execute === "function") {
173
174
  const orig = tool.execute;
174
175
  tool.execute = async (args, exec) => {
175
- console.error(`[atb ▶] ${tool.name}`, JSON.stringify(args).slice(0, 300));
176
+ await deps.ready?.();
177
+ if (process.env.ATB_TRACE === "1") console.error(`[atb ▶] ${tool.name}`, JSON.stringify(args).slice(0, 300));
176
178
  try {
177
179
  const result = await orig(args, exec);
178
- console.error(`[atb ✓] ${tool.name}`, JSON.stringify(result).slice(0, 300));
180
+ if (process.env.ATB_TRACE === "1") console.error(`[atb ✓] ${tool.name}`, JSON.stringify(result).slice(0, 300));
179
181
  return result;
180
182
  } catch (error) {
181
- console.error(`[atb ✗] ${tool.name}`, String(error).slice(0, 400));
183
+ if (process.env.ATB_TRACE === "1") console.error(`[atb ✗] ${tool.name}`, String(error).slice(0, 400));
182
184
  throw error;
183
185
  }
184
186
  };