@gestaltrun/dsh-workspace-bundle 0.0.0-stage → 0.1.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/lib/index.js ADDED
@@ -0,0 +1,1368 @@
1
+ import { basename, dirname, isAbsolute, join, normalize, posix, sep } from "node:path";
2
+ import { brandString } from "@deepseek-ai/dsh-brand";
3
+ import Schema from "@deepseek-ai/schemastery";
4
+ import { Service } from "@deepseek-ai/cordis";
5
+ import { createHash } from "node:crypto";
6
+ import { constants, existsSync, lstatSync, readFileSync, readdirSync } from "node:fs";
7
+ import { access, chmod, copyFile, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, writeFile } from "node:fs/promises";
8
+ import { z } from "zod";
9
+ import { gunzipSync } from "node:zlib";
10
+ import semver from "semver";
11
+ //#region lib/types/errors.js
12
+ /** Misconfiguration of the workspace-bundle row; plugin load fails with it. */
13
+ var WorkspaceBundlesConfigError = class extends Error {
14
+ code;
15
+ name = "WorkspaceBundlesConfigError";
16
+ /**
17
+ * @param code - stable failure code.
18
+ * @param message - diagnostic naming the offending field.
19
+ */
20
+ constructor(code, message) {
21
+ super(`workspace-bundle: ${message} (${code})`);
22
+ this.code = code;
23
+ }
24
+ };
25
+ /** Template, path, digest, or upgrade failure; the Host reports it and does not fall back. */
26
+ var WorkspaceBundleError = class extends Error {
27
+ code;
28
+ name = "WorkspaceBundleError";
29
+ /**
30
+ * @param code - stable failure code.
31
+ * @param message - diagnostic.
32
+ */
33
+ constructor(code, message) {
34
+ super(`workspace-bundle: ${message} (${code})`);
35
+ this.code = code;
36
+ }
37
+ };
38
+ //#endregion
39
+ //#region lib/types/config.js
40
+ /** workspace-bundle Config: Schemastery field validation and explicit resolution into a Spec. */
41
+ /**
42
+ * Field types and bounds. Path fields are optional here so a missing value reaches
43
+ * {@link resolveWorkspaceBundlesSpec} and fails with its named diagnostic.
44
+ */
45
+ const Config = Schema.object({
46
+ materializeRoot: Schema.string().description("Absolute parent of bundled Workspace roots"),
47
+ templateCacheRoot: Schema.string().description("Absolute parent of immutable template copies"),
48
+ journalRoot: Schema.string().description("Absolute parent of upgrade journals"),
49
+ quiesceTimeoutMs: Schema.number().min(1e3).max(6e5).step(1).default(3e4),
50
+ recycleTimeoutMs: Schema.number().min(0).max(6e5).step(1).default(6e4),
51
+ agentConfigPollIntervalMs: Schema.number().min(1e3).max(864e5).step(1).default(3e5),
52
+ agentConfigRequestTimeoutMs: Schema.number().min(100).max(6e5).step(1).default(3e4),
53
+ agentConfigMaxDownloadBytes: Schema.number().min(1024).max(268435456).step(1).default(67108864),
54
+ agentConfigMaxExpandedBytes: Schema.number().min(1024).max(536870912).step(1).default(134217728),
55
+ allowLoopbackAgentConfigHttp: Schema.boolean().default(false)
56
+ });
57
+ function absoluteDirectory(value, field) {
58
+ if (value === void 0 || value.trim() === "") throw new WorkspaceBundlesConfigError(field === "materializeRoot" ? "WORKSPACE_BUNDLES_MATERIALIZE_ROOT_MISSING" : field === "templateCacheRoot" ? "WORKSPACE_BUNDLES_TEMPLATE_CACHE_ROOT_MISSING" : "WORKSPACE_BUNDLES_JOURNAL_ROOT_MISSING", `${field} is required`);
59
+ if (!isAbsolute(value) || value.includes("\0") || normalize(value).includes(`..${sep}`) || normalize(value).endsWith(`${sep}..`)) throw new WorkspaceBundlesConfigError("WORKSPACE_BUNDLES_PATH_INVALID", `${field} ${JSON.stringify(value)} must be an absolute path without parent segments`);
60
+ return normalize(value);
61
+ }
62
+ /**
63
+ * Resolve raw settings into the Spec the Provider runs with.
64
+ * @param config - raw row settings.
65
+ * @returns every field set, with roots normalized.
66
+ * @throws {WorkspaceBundlesConfigError} When a required path is missing or not absolute.
67
+ */
68
+ function resolveWorkspaceBundlesSpec(config = {}) {
69
+ const resolved = Config(config);
70
+ return {
71
+ materializeRoot: absoluteDirectory(resolved.materializeRoot, "materializeRoot"),
72
+ templateCacheRoot: absoluteDirectory(resolved.templateCacheRoot, "templateCacheRoot"),
73
+ journalRoot: absoluteDirectory(resolved.journalRoot, "journalRoot"),
74
+ quiesceTimeoutMs: resolved.quiesceTimeoutMs,
75
+ recycleTimeoutMs: resolved.recycleTimeoutMs,
76
+ agentConfigPollIntervalMs: resolved.agentConfigPollIntervalMs,
77
+ agentConfigRequestTimeoutMs: resolved.agentConfigRequestTimeoutMs,
78
+ agentConfigMaxDownloadBytes: resolved.agentConfigMaxDownloadBytes,
79
+ agentConfigMaxExpandedBytes: resolved.agentConfigMaxExpandedBytes,
80
+ allowLoopbackAgentConfigHttp: resolved.allowLoopbackAgentConfigHttp
81
+ };
82
+ }
83
+ //#endregion
84
+ //#region lib/types/definition.js
85
+ /** `WorkspaceBundles` Service Definition: Host-only template registration and stable Workspace materialize. */
86
+ /**
87
+ * Host-only capability: register a packaged template, materialize a stable hidden Workspace, and
88
+ * replace its tree as a whole. Registration is an effect; the returned disposer forbids later acquire.
89
+ */
90
+ var WorkspaceBundles = class extends Service {
91
+ /** @param ctx - context owning this Provider. */
92
+ constructor(ctx) {
93
+ super(ctx, "workspaceBundles");
94
+ }
95
+ };
96
+ //#endregion
97
+ //#region lib/types/paths.js
98
+ /** Controlled encoding of branded Workspace bundle identities into Home path segments. */
99
+ const SEGMENT = /^[a-z0-9][a-z0-9._-]{0,63}$/u;
100
+ /**
101
+ * Encode one branded identity as a single path segment.
102
+ * @param value - bundle id or workspace key.
103
+ * @param field - diagnostic name.
104
+ * @returns the same spelling when it is a controlled segment.
105
+ * @throws {WorkspaceBundleError} When the value could traverse or collide with `.` / `..`.
106
+ */
107
+ function encodeBundleSegment(value, field) {
108
+ if (!SEGMENT.test(value) || value === "." || value === ".." || value.includes(sep) || value.includes(posix.sep) || value.includes("\0")) throw new WorkspaceBundleError("WORKSPACE_BUNDLE_IDENTITY_INVALID", `${field} ${JSON.stringify(value)} is not a controlled path segment`);
109
+ return value;
110
+ }
111
+ /**
112
+ * Join a parent with `{bundleId}/{workspaceKey}`.
113
+ * @param parent - absolute parent already validated by Config.
114
+ * @param identity - branded pair.
115
+ * @returns the joined path using the host separator.
116
+ */
117
+ function bundledDirectory(parent, identity) {
118
+ return join(parent, encodeBundleSegment(identity.bundleId, "bundleId"), encodeBundleSegment(identity.workspaceKey, "workspaceKey"));
119
+ }
120
+ /**
121
+ * Reject a template-relative path that leaves the root, is absolute, or uses a reserved segment.
122
+ * @param relativePath - POSIX relative path from the manifest.
123
+ * @returns the same spelling.
124
+ * @throws {WorkspaceBundleError} On traversal, absolute paths, empty segments, or backslashes.
125
+ */
126
+ function assertTemplateRelativePath(relativePath) {
127
+ if (relativePath === "" || relativePath.startsWith("/") || relativePath.includes("\\") || relativePath.includes("\0")) throw new WorkspaceBundleError("WORKSPACE_BUNDLE_PATH_TRAVERSAL", `template path ${JSON.stringify(relativePath)} is not a relative POSIX path`);
128
+ if (relativePath.split("/").some((part) => part === "" || part === "." || part === "..")) throw new WorkspaceBundleError("WORKSPACE_BUNDLE_PATH_TRAVERSAL", `template path ${JSON.stringify(relativePath)} contains a reserved segment`);
129
+ return relativePath;
130
+ }
131
+ /** @param bundleId - branded bundle identity. @param workspaceKey - branded workspace key. */
132
+ function identityOf(bundleId, workspaceKey) {
133
+ encodeBundleSegment(bundleId, "bundleId");
134
+ encodeBundleSegment(workspaceKey, "workspaceKey");
135
+ return {
136
+ bundleId,
137
+ workspaceKey
138
+ };
139
+ }
140
+ //#endregion
141
+ //#region lib/types/digest.js
142
+ /** Template manifest parsing and per-file / overall digest verification. */
143
+ const SHA256 = /^[0-9a-f]{64}$/u;
144
+ function fileDigest(bytes) {
145
+ return brandString(createHash("sha256").update(bytes).digest("hex"));
146
+ }
147
+ /**
148
+ * Digest of the ordered path+file-digest catalog.
149
+ * @param files - sorted catalog.
150
+ */
151
+ function overallDigest(files) {
152
+ const hash = createHash("sha256");
153
+ for (const file of files) hash.update(`${file.path}\0${file.digest}\n`);
154
+ return brandString(hash.update("").digest("hex"));
155
+ }
156
+ function sourceOf(value) {
157
+ if (value === void 0) return void 0;
158
+ if (typeof value !== "object" || value === null) throw new WorkspaceBundleError("WORKSPACE_BUNDLE_TEMPLATE_INVALID", "manifest source must be an object");
159
+ const record = value;
160
+ if (typeof record.repository !== "string" || typeof record.commit !== "string") throw new WorkspaceBundleError("WORKSPACE_BUNDLE_TEMPLATE_INVALID", "manifest source must name repository and commit");
161
+ return {
162
+ repository: record.repository,
163
+ commit: record.commit
164
+ };
165
+ }
166
+ /**
167
+ * Parse and validate a template manifest document.
168
+ * @param value - JSON value.
169
+ */
170
+ function parseTemplateManifest(value) {
171
+ if (typeof value !== "object" || value === null) throw new WorkspaceBundleError("WORKSPACE_BUNDLE_TEMPLATE_INVALID", "manifest must be an object");
172
+ const record = value;
173
+ if (record.formatVersion !== 1) throw new WorkspaceBundleError("WORKSPACE_BUNDLE_TEMPLATE_INVALID", "manifest formatVersion must be 1");
174
+ if (typeof record.pluginVersion !== "string" || record.pluginVersion === "") throw new WorkspaceBundleError("WORKSPACE_BUNDLE_TEMPLATE_INVALID", "manifest pluginVersion is required");
175
+ if (!Array.isArray(record.files)) throw new WorkspaceBundleError("WORKSPACE_BUNDLE_TEMPLATE_INVALID", "manifest files must be an array");
176
+ const files = record.files.map((entry, index) => {
177
+ if (typeof entry !== "object" || entry === null) throw new WorkspaceBundleError("WORKSPACE_BUNDLE_TEMPLATE_INVALID", `manifest files[${index}] must be an object`);
178
+ const file = entry;
179
+ if (typeof file.path !== "string" || typeof file.bytes !== "number" || typeof file.digest !== "string" || !SHA256.test(file.digest)) throw new WorkspaceBundleError("WORKSPACE_BUNDLE_TEMPLATE_INVALID", `manifest files[${index}] is incomplete`);
180
+ assertTemplateRelativePath(file.path);
181
+ return {
182
+ path: file.path,
183
+ bytes: file.bytes,
184
+ digest: brandString(file.digest)
185
+ };
186
+ });
187
+ const sorted = [...files].sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
188
+ if (sorted.some((file, index) => file.path !== files[index]?.path)) throw new WorkspaceBundleError("WORKSPACE_BUNDLE_TEMPLATE_INVALID", "manifest files must be sorted by path");
189
+ if (sorted.some((file, index) => index > 0 && file.path === sorted[index - 1]?.path)) throw new WorkspaceBundleError("WORKSPACE_BUNDLE_TEMPLATE_INVALID", "manifest files must not repeat a path");
190
+ if (typeof record.digest !== "string" || !SHA256.test(record.digest)) throw new WorkspaceBundleError("WORKSPACE_BUNDLE_TEMPLATE_INVALID", "manifest digest must be a SHA-256 hex string");
191
+ const digest = brandString(record.digest);
192
+ if (digest !== overallDigest(sorted)) throw new WorkspaceBundleError("WORKSPACE_BUNDLE_DIGEST_MISMATCH", "manifest overall digest does not match its files");
193
+ const source = sourceOf(record.source);
194
+ return source === void 0 ? {
195
+ formatVersion: 1,
196
+ pluginVersion: record.pluginVersion,
197
+ files: sorted,
198
+ digest
199
+ } : {
200
+ formatVersion: 1,
201
+ pluginVersion: record.pluginVersion,
202
+ source,
203
+ files: sorted,
204
+ digest
205
+ };
206
+ }
207
+ /**
208
+ * Read every regular file under `root` as a POSIX-relative catalog.
209
+ * @param fs - filesystem.
210
+ * @param root - directory to walk.
211
+ */
212
+ async function listRegularFiles(fs, root) {
213
+ const acc = [];
214
+ const walk = async (directory, prefix) => {
215
+ let names;
216
+ try {
217
+ names = await fs.readdir(directory);
218
+ } catch (error) {
219
+ if (error.code === "ENOENT") throw new WorkspaceBundleError("WORKSPACE_BUNDLE_MISSING_ASSET", `template root ${JSON.stringify(root)} is missing`);
220
+ throw error;
221
+ }
222
+ for (const name of [...names].sort()) {
223
+ const path = join(directory, name);
224
+ const relative = prefix === "" ? name : `${prefix}/${name}`;
225
+ const stat = await fs.lstat(path);
226
+ if (stat.isSymbolicLink()) throw new WorkspaceBundleError("WORKSPACE_BUNDLE_SYMLINK_REJECTED", `template path ${JSON.stringify(relative)} is a symbolic link`);
227
+ if (stat.isDirectory()) await walk(path, relative);
228
+ else if (stat.isFile()) {
229
+ assertTemplateRelativePath(relative);
230
+ acc.push(relative);
231
+ } else throw new WorkspaceBundleError("WORKSPACE_BUNDLE_TEMPLATE_INVALID", `template path ${JSON.stringify(relative)} is not a regular file`);
232
+ }
233
+ };
234
+ await walk(root, "");
235
+ return acc;
236
+ }
237
+ /**
238
+ * Verify every listed file and the overall digest against a directory.
239
+ * @param fs - filesystem.
240
+ * @param root - directory holding the files.
241
+ * @param manifest - expected catalog.
242
+ */
243
+ async function verifyTemplateTree(fs, root, manifest) {
244
+ const present = new Set(await listRegularFiles(fs, root));
245
+ present.delete("manifest.json");
246
+ for (const file of manifest.files) {
247
+ if (!present.has(file.path)) throw new WorkspaceBundleError("WORKSPACE_BUNDLE_MISSING_ASSET", `template is missing ${JSON.stringify(file.path)}`);
248
+ const bytes = await fs.readFile(join(root, ...file.path.split("/")));
249
+ if (bytes.length !== file.bytes || fileDigest(bytes) !== file.digest) throw new WorkspaceBundleError("WORKSPACE_BUNDLE_DIGEST_MISMATCH", `template file ${JSON.stringify(file.path)} digest mismatch`);
250
+ present.delete(file.path);
251
+ }
252
+ if (present.size > 0) throw new WorkspaceBundleError("WORKSPACE_BUNDLE_TEMPLATE_INVALID", `template has unexpected files ${[...present].join(", ")}`);
253
+ }
254
+ /**
255
+ * Load `manifest.json` from a template directory and verify the tree.
256
+ * @param fs - filesystem.
257
+ * @param root - template directory.
258
+ */
259
+ async function loadVerifiedManifest(fs, root) {
260
+ let raw;
261
+ try {
262
+ raw = await fs.readFile(join(root, "manifest.json"));
263
+ } catch (error) {
264
+ if (error.code === "ENOENT") throw new WorkspaceBundleError("WORKSPACE_BUNDLE_MISSING_ASSET", "template is missing manifest.json");
265
+ throw error;
266
+ }
267
+ let value;
268
+ try {
269
+ value = JSON.parse(raw.toString("utf8"));
270
+ } catch {
271
+ throw new WorkspaceBundleError("WORKSPACE_BUNDLE_TEMPLATE_INVALID", "manifest.json is not JSON");
272
+ }
273
+ const manifest = parseTemplateManifest(value);
274
+ await verifyTemplateTree(fs, root, manifest);
275
+ return manifest;
276
+ }
277
+ /** @param bytes - file contents. */
278
+ function digestOf(bytes) {
279
+ return fileDigest(bytes);
280
+ }
281
+ /**
282
+ * Load and verify a packaged template at plugin construction. A missing or
283
+ * invalid tree fails load instead of registering a half-checked catalog.
284
+ * @param root - template directory.
285
+ */
286
+ function loadVerifiedManifestSync(root) {
287
+ const manifestPath = join(root, "manifest.json");
288
+ if (!existsSync(manifestPath)) throw new WorkspaceBundleError("WORKSPACE_BUNDLE_MISSING_ASSET", "template is missing manifest.json");
289
+ let value;
290
+ try {
291
+ value = JSON.parse(readFileSync(manifestPath, "utf8"));
292
+ } catch {
293
+ throw new WorkspaceBundleError("WORKSPACE_BUNDLE_TEMPLATE_INVALID", "manifest.json is not JSON");
294
+ }
295
+ const manifest = parseTemplateManifest(value);
296
+ const present = /* @__PURE__ */ new Set();
297
+ const walk = (directory, prefix) => {
298
+ let names;
299
+ try {
300
+ names = readdirSync(directory);
301
+ } catch (error) {
302
+ if (error.code === "ENOENT") throw new WorkspaceBundleError("WORKSPACE_BUNDLE_MISSING_ASSET", `template root ${JSON.stringify(root)} is missing`);
303
+ throw error;
304
+ }
305
+ for (const name of [...names].sort()) {
306
+ const path = join(directory, name);
307
+ const relative = prefix === "" ? name : `${prefix}/${name}`;
308
+ const stat = lstatSync(path);
309
+ if (stat.isSymbolicLink()) throw new WorkspaceBundleError("WORKSPACE_BUNDLE_SYMLINK_REJECTED", `template path ${JSON.stringify(relative)} is a symbolic link`);
310
+ if (stat.isDirectory()) walk(path, relative);
311
+ else if (stat.isFile()) {
312
+ assertTemplateRelativePath(relative);
313
+ present.add(relative);
314
+ } else throw new WorkspaceBundleError("WORKSPACE_BUNDLE_TEMPLATE_INVALID", `template path ${JSON.stringify(relative)} is not a regular file`);
315
+ }
316
+ };
317
+ walk(root, "");
318
+ present.delete("manifest.json");
319
+ for (const file of manifest.files) {
320
+ if (!present.has(file.path)) throw new WorkspaceBundleError("WORKSPACE_BUNDLE_MISSING_ASSET", `template is missing ${JSON.stringify(file.path)}`);
321
+ const bytes = readFileSync(join(root, ...file.path.split("/")));
322
+ if (bytes.length !== file.bytes || fileDigest(bytes) !== file.digest) throw new WorkspaceBundleError("WORKSPACE_BUNDLE_DIGEST_MISMATCH", `template file ${JSON.stringify(file.path)} digest mismatch`);
323
+ present.delete(file.path);
324
+ }
325
+ if (present.size > 0) throw new WorkspaceBundleError("WORKSPACE_BUNDLE_TEMPLATE_INVALID", `template has unexpected files ${[...present].join(", ")}`);
326
+ return manifest;
327
+ }
328
+ //#endregion
329
+ //#region lib/types/filesystem.js
330
+ /** Node filesystem adapter and test crash injection. */
331
+ /**
332
+ * Node.js implementation of {@link WorkspaceBundleFs}.
333
+ * @param options.onPoint - optional test hook; throwing aborts the current step.
334
+ */
335
+ function createNodeFs(options = {}) {
336
+ return {
337
+ async mkdir(path) {
338
+ await mkdir(path, { recursive: true });
339
+ },
340
+ async writeFile(path, data) {
341
+ await mkdir(dirname(path), { recursive: true });
342
+ await writeFile(path, data);
343
+ },
344
+ readFile(path) {
345
+ return readFile(path);
346
+ },
347
+ rename(from, to) {
348
+ return rename(from, to);
349
+ },
350
+ rm(path, options) {
351
+ return rm(path, {
352
+ recursive: options?.recursive === true,
353
+ force: true
354
+ });
355
+ },
356
+ lstat(path) {
357
+ return lstat(path);
358
+ },
359
+ async readdir(path) {
360
+ return readdir(path);
361
+ },
362
+ realpath(path) {
363
+ return realpath(path);
364
+ },
365
+ copyFile(from, to) {
366
+ return copyFile(from, to);
367
+ },
368
+ async exists(path) {
369
+ try {
370
+ await access(path, constants.F_OK);
371
+ return true;
372
+ } catch {
373
+ return false;
374
+ }
375
+ }
376
+ };
377
+ }
378
+ /**
379
+ * Wrap an fs with a crash injector used only by tests.
380
+ * @param inner - real or fake filesystem.
381
+ * @param crashAt - the single point that throws {@link WorkspaceBundleError} `WORKSPACE_BUNDLE_CRASH_INJECTED`.
382
+ */
383
+ function crashableFs(inner, crashAt) {
384
+ return {
385
+ ...inner,
386
+ crash(point) {
387
+ if (crashAt === point) throw new WorkspaceBundleError("WORKSPACE_BUNDLE_CRASH_INJECTED", point);
388
+ }
389
+ };
390
+ }
391
+ //#endregion
392
+ //#region lib/types/journal.js
393
+ /** Durable upgrade journal: staging → updating → swap → verify → commit, with crash recovery. */
394
+ const IDLE = {
395
+ phase: "idle",
396
+ fromDigest: null,
397
+ toDigest: null,
398
+ pluginVersion: null,
399
+ stagingPath: null,
400
+ backupPath: null,
401
+ swapComplete: false
402
+ };
403
+ /** @returns a journal with no in-flight replacement. */
404
+ function idleJournal() {
405
+ return IDLE;
406
+ }
407
+ /**
408
+ * @param parent - journal root from Spec.
409
+ * @param identity - bundled Workspace.
410
+ */
411
+ function recordPath(parent, identity) {
412
+ return join(bundledDirectory(parent, identity), "record.json");
413
+ }
414
+ async function writeJson(fs, path, value) {
415
+ const tmp = `${path}.tmp`;
416
+ await fs.writeFile(tmp, `${JSON.stringify(value)}\n`);
417
+ await fs.rename(tmp, path);
418
+ }
419
+ /**
420
+ * Persist the product record.
421
+ * @param fs - filesystem.
422
+ * @param path - record.json path.
423
+ * @param record - value.
424
+ */
425
+ async function writeRecord(fs, path, record) {
426
+ await writeJson(fs, path, record);
427
+ }
428
+ /**
429
+ * Read a product record when present.
430
+ * @param fs - filesystem.
431
+ * @param path - record.json path.
432
+ */
433
+ async function readRecord(fs, path) {
434
+ if (!await fs.exists(path)) return void 0;
435
+ const value = JSON.parse((await fs.readFile(path)).toString("utf8"));
436
+ if (value.version !== 1) throw new Error(`workspace-bundle: unsupported record version ${String(value.version)}`);
437
+ return value;
438
+ }
439
+ async function canonicalPath(fs, path) {
440
+ try {
441
+ return await fs.realpath(path);
442
+ } catch (error) {
443
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT" || dirname(path) === path) throw error;
444
+ return join(await canonicalPath(fs, dirname(path)), basename(path));
445
+ }
446
+ }
447
+ /**
448
+ * Reject stored paths whose canonical locations differ from the identity's controlled directories.
449
+ * @param fs - filesystem used by recovery, including realpath resolution.
450
+ * @param record - stored product record.
451
+ * @param root - materialize directory derived from the requested identity and current Spec.
452
+ * @returns fulfills when every stored path has the same canonical location as its controlled directory.
453
+ * @throws {WorkspaceBundleError} Before recovery mutates files when a stored path differs.
454
+ */
455
+ async function assertRecordPaths(fs, record, root) {
456
+ for (const [stored, controlled] of [
457
+ [record.path, root],
458
+ [record.journal.stagingPath, `${root}.staging`],
459
+ [record.journal.backupPath, `${root}.backup`]
460
+ ]) if (stored !== null && await canonicalPath(fs, stored) !== await canonicalPath(fs, controlled)) throw new WorkspaceBundleError("WORKSPACE_BUNDLE_PATH_TRAVERSAL", `stored Workspace paths must match the canonical controlled directory ${controlled}`);
461
+ }
462
+ /**
463
+ * Persist the journal as part of the product record.
464
+ * @param fs - filesystem.
465
+ * @param path - record.json path.
466
+ * @param record - current record.
467
+ * @param journal - next journal.
468
+ */
469
+ async function writeJournal(fs, path, record, journal) {
470
+ const next = {
471
+ ...record,
472
+ journal
473
+ };
474
+ await writeRecord(fs, path, next);
475
+ return next;
476
+ }
477
+ /**
478
+ * Mark a replacement as committed: idle journal, new digest and plugin version.
479
+ * @param record - current record.
480
+ * @param digest - verified tree digest.
481
+ * @param pluginVersion - packaged plugin version.
482
+ */
483
+ function committedRecord(record, digest, pluginVersion) {
484
+ return {
485
+ ...record,
486
+ pluginVersion,
487
+ templateDigest: digest,
488
+ journal: idleJournal(),
489
+ lastFailure: null
490
+ };
491
+ }
492
+ //#endregion
493
+ //#region lib/types/agent-config-format.js
494
+ /** 内置 Workspace Agent 配置的文件校验;目录和压缩字节的大小均由部署配置限制。 */
495
+ /** 本 Host 实现的 Agent 配置接口版本;白名单与写操作授权由 Host 保持。 */
496
+ const HOST_AGENT_CONFIG_API_VERSION = "1.0.0";
497
+ /** Agent 配置 SHA-256 的十六进制编码。 */
498
+ const AgentConfigSha256 = z.string().regex(/^[a-f0-9]{64}$/u);
499
+ /** 内置 Workspace 种类;不含路径分隔符。 */
500
+ const WorkspaceKind = z.string().regex(/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u).max(80);
501
+ /** @param value - 完整 Agent 配置版本。 @returns 是否为保留构建元数据的规范 SemVer;优先级由 semver 按规范比较。 */
502
+ function isAgentConfigVersion(value) {
503
+ const parsed = semver.parse(value);
504
+ return parsed !== null && `${parsed.version}${parsed.build.length === 0 ? "" : `+${parsed.build.join(".")}`}` === value;
505
+ }
506
+ /** server 分配中可公开的包信息,未携带商家凭据。 */
507
+ const AgentConfigDescriptorSchema = z.strictObject({
508
+ workspaceKind: WorkspaceKind,
509
+ version: z.string().refine(isAgentConfigVersion),
510
+ sha256: AgentConfigSha256,
511
+ sizeBytes: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
512
+ downloadUrl: z.url(),
513
+ hostAgentConfigApiRange: z.string().refine((value) => semver.validRange(value) !== null),
514
+ releaseNotes: z.string().min(1).max(2e4)
515
+ });
516
+ /** 包内逐文件摘要与字节数,文件路径相对版本目录。 */
517
+ const AgentConfigManifestSchema = z.strictObject({
518
+ formatVersion: z.literal(1),
519
+ workspaceKind: WorkspaceKind,
520
+ version: z.string().refine(isAgentConfigVersion),
521
+ hostAgentConfigApiRange: z.string().refine((value) => semver.validRange(value) !== null),
522
+ files: z.array(z.strictObject({
523
+ path: z.string(),
524
+ sizeBytes: z.number().int().nonnegative(),
525
+ sha256: AgentConfigSha256
526
+ })).min(1).max(1e4)
527
+ });
528
+ /** 确定性 gzip JSON Agent 配置:清单和按同序排列的 base64 文件。 */
529
+ const AgentConfigDocumentSchema = z.strictObject({
530
+ manifest: AgentConfigManifestSchema,
531
+ files: z.array(z.strictObject({
532
+ path: z.string(),
533
+ base64: z.string()
534
+ })).min(1).max(1e4)
535
+ });
536
+ /** 有界内容更新的结构化失败。 */
537
+ var AgentConfigError = class extends Error {
538
+ code;
539
+ /** @param code - 失败类别。 */
540
+ constructor(code) {
541
+ super({
542
+ AGENT_CONFIG_QUERY_FAILED: "Agent 配置版本查询失败,保留当前版本;请检查服务器连接。",
543
+ AGENT_CONFIG_INVALID: "Agent 配置文件或清单无效,保留当前版本;请重新打包发布。",
544
+ AGENT_CONFIG_INCOMPATIBLE: "Agent 配置不兼容当前 Host,保留当前版本;请分配兼容版本或升级客户端。",
545
+ AGENT_CONFIG_DIGEST_MISMATCH: "Agent 配置摘要不匹配,保留当前版本;请核对发布登记。",
546
+ AGENT_CONFIG_DOWNLOAD_FAILED: "Agent 配置下载失败,保留当前版本;请检查文件服务连接。",
547
+ AGENT_CONFIG_APPLY_FAILED: "Agent 配置应用失败;请检查工作区恢复状态后再继续。",
548
+ AGENT_CONFIG_SCOPE_CHANGED: "Agent 配置版本来源已变更,已丢弃旧候选;保留当前版本并重新查询。"
549
+ }[code]);
550
+ this.code = code;
551
+ this.name = "AgentConfigError";
552
+ }
553
+ };
554
+ /** @param bytes - 完整文件字节。 @returns SHA-256 小写十六进制编码。 */
555
+ function agentConfigSha256(bytes) {
556
+ return createHash("sha256").update(bytes).digest("hex");
557
+ }
558
+ /**
559
+ * 拒绝无法在各 Host 文件系统安全表达的相对路径。
560
+ * @param path - 包内 POSIX 路径。
561
+ * @returns 原始路径。
562
+ */
563
+ function contentPath(path) {
564
+ assertTemplateRelativePath(path);
565
+ if (path === "manifest.json" || path.split("/").some((part) => /[<>:"|?*\x00-\x1f]/u.test(part) || /[. ]$/u.test(part) || /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu.test(part))) throw new AgentConfigError("AGENT_CONFIG_INVALID");
566
+ return path;
567
+ }
568
+ /**
569
+ * 同时认证压缩包和逐文件字节,任何失败均发生在版本目录写入前。
570
+ * @param bytes - 下载的完整 gzip 字节。
571
+ * @param descriptor - server 经鉴权查询给出的登记信息。
572
+ * @param maxExpandedBytes - 解压 JSON 和文件总字节上限。
573
+ * @returns 清单和已校验文件。
574
+ */
575
+ function decodeAgentConfig(bytes, descriptor, maxExpandedBytes) {
576
+ if (bytes.length !== descriptor.sizeBytes || agentConfigSha256(bytes) !== descriptor.sha256) throw new AgentConfigError("AGENT_CONFIG_DIGEST_MISMATCH");
577
+ if (!semver.satisfies("1.0.0", descriptor.hostAgentConfigApiRange, { includePrerelease: true })) throw new AgentConfigError("AGENT_CONFIG_INCOMPATIBLE");
578
+ let document;
579
+ try {
580
+ document = AgentConfigDocumentSchema.parse(JSON.parse(gunzipSync(bytes, { maxOutputLength: maxExpandedBytes }).toString("utf8")));
581
+ } catch (error) {
582
+ throw new AgentConfigError("AGENT_CONFIG_INVALID");
583
+ }
584
+ const { manifest } = document;
585
+ if (manifest.workspaceKind !== descriptor.workspaceKind || manifest.version !== descriptor.version || manifest.hostAgentConfigApiRange !== descriptor.hostAgentConfigApiRange || document.files.length !== manifest.files.length) throw new AgentConfigError("AGENT_CONFIG_INVALID");
586
+ const seen = /* @__PURE__ */ new Set();
587
+ let total = 0;
588
+ let previous = "";
589
+ return {
590
+ manifest,
591
+ files: document.files.map((file, index) => {
592
+ const entry = manifest.files[index];
593
+ const path = contentPath(file.path);
594
+ const normalized = path.toLowerCase();
595
+ if (entry.path !== path || path <= previous || seen.has(normalized) || [...seen].some((parent) => normalized.startsWith(`${parent}/`) || parent.startsWith(`${normalized}/`))) throw new AgentConfigError("AGENT_CONFIG_INVALID");
596
+ seen.add(normalized);
597
+ previous = path;
598
+ const data = Buffer.from(file.base64, "base64");
599
+ total += data.length;
600
+ if (total > maxExpandedBytes || data.toString("base64") !== file.base64 || data.length !== entry.sizeBytes || agentConfigSha256(data) !== entry.sha256) throw new AgentConfigError("AGENT_CONFIG_DIGEST_MISMATCH");
601
+ return {
602
+ path,
603
+ bytes: data
604
+ };
605
+ })
606
+ };
607
+ }
608
+ //#endregion
609
+ //#region lib/types/agent-config-source.js
610
+ /** 内容来源的有界拉取和只读版本缓存;此模块不替换运行中的 Workspace。 */
611
+ const AssignmentSchema = z.discriminatedUnion("source", [z.object({
612
+ source: z.literal("builtin"),
613
+ workspaceKind: z.string()
614
+ }), z.object({
615
+ source: z.literal("registry"),
616
+ workspaceKind: z.string(),
617
+ agentConfig: AgentConfigDescriptorSchema
618
+ })]);
619
+ const ReceiptSchema = AgentConfigDescriptorSchema.omit({
620
+ downloadUrl: true,
621
+ releaseNotes: true
622
+ });
623
+ function publicVersion(template, sha256) {
624
+ return {
625
+ workspaceKind: template.bundleId,
626
+ version: template.manifest.pluginVersion,
627
+ sha256,
628
+ hostAgentConfigApiVersion: HOST_AGENT_CONFIG_API_VERSION,
629
+ templateDigest: template.manifest.digest,
630
+ sourceRoot: template.sourceRoot
631
+ };
632
+ }
633
+ async function freezeTree(root) {
634
+ for (const entry of await readdir(root, { withFileTypes: true })) {
635
+ const path = join(root, entry.name);
636
+ if (entry.isDirectory()) await freezeTree(path);
637
+ else await chmod(path, 292);
638
+ }
639
+ await chmod(root, 365);
640
+ }
641
+ /** 一个注册来源的缓存、轮询和候选版本;卸载必须等待所有网络与文件工作结束。 */
642
+ var WorkspaceAgentConfigSourceOwner = class {
643
+ registration;
644
+ spec;
645
+ report;
646
+ download;
647
+ lifetime = new AbortController();
648
+ inFlight;
649
+ requestAbort;
650
+ requestRevision;
651
+ preparedRevisions = /* @__PURE__ */ new WeakMap();
652
+ timer;
653
+ selected;
654
+ candidate;
655
+ failure = null;
656
+ root;
657
+ /** @param registration - 内置模板及鉴权查询回调。 @param spec - 部署预算。 @param report - 固定失败诊断的报告者。 @param download - 文件 HTTP 传输。 */
658
+ constructor(registration, spec, report, download = fetch) {
659
+ this.registration = registration;
660
+ this.spec = spec;
661
+ this.report = report;
662
+ this.download = download;
663
+ this.selected = {
664
+ template: registration.template,
665
+ sha256: null
666
+ };
667
+ this.root = bundledDirectory(join(spec.templateCacheRoot, "agent-config-versions"), registration.source.identity);
668
+ }
669
+ /** @returns 已应用版本、候选版本和最近一次失败。 */
670
+ state() {
671
+ if (this.candidate !== void 0 && this.candidate.revision !== this.registration.source.revision()) this.candidate = void 0;
672
+ return {
673
+ current: publicVersion(this.selected.template, this.selected.sha256),
674
+ pending: this.candidate === void 0 ? null : publicVersion(this.candidate.template, this.candidate.sha256),
675
+ failure: this.failure
676
+ };
677
+ }
678
+ /**
679
+ * 从 journal 已提交的摘要定位只读缓存,不从 server 的无分配回答推导降级。
680
+ * @param digest - 已提交 Workspace 模板摘要;undefined 表示首次使用。
681
+ * @param version - 已提交完整版本,包括构建元数据。
682
+ * @param sha256 - 已提交包摘要;undefined 支持旧内置记录。
683
+ * @returns 对应完整模板;缺失已安装缓存时拒绝继续。
684
+ */
685
+ async recover(digest, version, sha256) {
686
+ if (digest === void 0 || sha256 === void 0 || sha256 === null) {
687
+ this.selected = {
688
+ template: this.registration.template,
689
+ sha256: null
690
+ };
691
+ return this.selected.template;
692
+ }
693
+ if (digest === this.selected.template.manifest.digest && (version === void 0 || version === this.selected.template.manifest.pluginVersion) && sha256 === this.selected.sha256) return this.selected.template;
694
+ if (!/^[0-9a-f]{64}$/u.test(sha256)) throw new AgentConfigError("AGENT_CONFIG_INVALID");
695
+ const fs = createNodeFs();
696
+ const receipt = ReceiptSchema.parse(JSON.parse(await readFile(join(this.root, sha256, "receipt.json"), "utf8")));
697
+ const directory = this.versionDirectory(receipt.version, receipt.sha256);
698
+ if (receipt.sha256 !== sha256 || receipt.workspaceKind !== this.registration.template.bundleId || (await fs.lstat(directory)).isSymbolicLink() || (await fs.lstat(join(directory, receipt.version))).isSymbolicLink()) throw new AgentConfigError("AGENT_CONFIG_INVALID");
699
+ const manifest = await loadVerifiedManifest(fs, join(directory, receipt.version));
700
+ if (manifest.digest !== digest || version !== void 0 && receipt.version !== version || manifest.pluginVersion !== receipt.version) throw new AgentConfigError("AGENT_CONFIG_INVALID");
701
+ this.selected = {
702
+ template: {
703
+ ...this.registration.template,
704
+ sourceRoot: join(directory, receipt.version),
705
+ manifest,
706
+ agentConfigSha256: receipt.sha256
707
+ },
708
+ sha256: receipt.sha256
709
+ };
710
+ if (this.candidate?.sha256 === receipt.sha256) this.candidate = void 0;
711
+ return this.selected.template;
712
+ }
713
+ /** 注册者调用一次以启动查询,并按配置间隔继续。 */
714
+ start() {
715
+ const tick = async () => {
716
+ await this.refresh();
717
+ if (!this.lifetime.signal.aborted) this.timer = setTimeout(() => {
718
+ tick();
719
+ }, this.spec.agentConfigPollIntervalMs);
720
+ };
721
+ tick();
722
+ }
723
+ /** @returns 本次查询和完整候选下载结束;失败通过 state 与 report 保留可观察诊断。 */
724
+ refresh() {
725
+ if (this.lifetime.signal.aborted) return Promise.resolve();
726
+ const revision = this.registration.source.revision();
727
+ if (this.candidate !== void 0 && this.candidate.revision !== revision) this.candidate = void 0;
728
+ if (this.inFlight !== void 0) {
729
+ if (revision === this.requestRevision && !this.requestAbort?.signal.aborted) return this.inFlight;
730
+ this.requestAbort?.abort();
731
+ this.failure = null;
732
+ return this.inFlight.then(() => this.refresh());
733
+ }
734
+ const abort = new AbortController();
735
+ this.requestAbort = abort;
736
+ this.requestRevision = revision;
737
+ const run = this.fetchCandidate(revision, abort.signal).catch((error) => {
738
+ if (this.lifetime.signal.aborted || abort.signal.aborted || revision !== this.registration.source.revision()) return;
739
+ this.candidate = void 0;
740
+ const failure = error instanceof AgentConfigError ? error : new AgentConfigError("AGENT_CONFIG_INVALID");
741
+ this.failure = {
742
+ code: failure.code,
743
+ message: failure.message
744
+ };
745
+ this.report(failure);
746
+ }).finally(() => {
747
+ this.inFlight = void 0;
748
+ this.requestAbort = void 0;
749
+ this.requestRevision = void 0;
750
+ });
751
+ this.inFlight = run;
752
+ return run;
753
+ }
754
+ /** @returns 下一个 turn 可应用的候选;没有候选时使用已安装版本。 */
755
+ template() {
756
+ if (this.candidate !== void 0 && this.candidate.revision !== this.registration.source.revision()) this.candidate = void 0;
757
+ return this.candidate?.template ?? this.selected.template;
758
+ }
759
+ /** @param template - 即将整体替换的候选。 @throws 来源代次已经改变或已撤销时拒绝应用。 */
760
+ assertApplicable(template) {
761
+ const revision = this.preparedRevisions.get(template);
762
+ if (this.lifetime.signal.aborted || revision !== void 0 && revision !== this.registration.source.revision()) throw new AgentConfigError("AGENT_CONFIG_SCOPE_CHANGED");
763
+ }
764
+ /** @param template - 已通过 Workspace 整体替换提交的模板。 */
765
+ async committed(template) {
766
+ await this.recover(template.manifest.digest, template.manifest.pluginVersion, template.agentConfigSha256 ?? null);
767
+ }
768
+ /**
769
+ * 清除未提交候选,报告整体替换失败;已提交状态由 journal 恢复结果决定。
770
+ * @param template - 未能完成提交的模板。
771
+ * @param cause - 保留来源失效分类;其他原因报告固定应用失败诊断。
772
+ */
773
+ failedApply(template, cause) {
774
+ if (this.candidate?.template === template) this.candidate = void 0;
775
+ const error = cause instanceof AgentConfigError && cause.code === "AGENT_CONFIG_SCOPE_CHANGED" ? cause : new AgentConfigError("AGENT_CONFIG_APPLY_FAILED");
776
+ this.failure = {
777
+ code: error.code,
778
+ message: error.message
779
+ };
780
+ this.report(error);
781
+ }
782
+ /** 撤销查询、清除后续定时器,并等待下载和目录写入完成。 */
783
+ async dispose() {
784
+ this.lifetime.abort();
785
+ this.candidate = void 0;
786
+ if (this.timer !== void 0) clearTimeout(this.timer);
787
+ await this.inFlight;
788
+ this.candidate = void 0;
789
+ }
790
+ versionDirectory(version, sha256) {
791
+ if (!AgentConfigDescriptorSchema.shape.version.safeParse(version).success || !/^[0-9a-f]{64}$/u.test(sha256)) throw new AgentConfigError("AGENT_CONFIG_INVALID");
792
+ return join(this.root, sha256);
793
+ }
794
+ async fetchCandidate(revision, requestSignal) {
795
+ const signal = AbortSignal.any([
796
+ this.lifetime.signal,
797
+ requestSignal,
798
+ AbortSignal.timeout(this.spec.agentConfigRequestTimeoutMs)
799
+ ]);
800
+ let value;
801
+ try {
802
+ value = await this.registration.source.query(signal);
803
+ } catch (error) {
804
+ throw new AgentConfigError("AGENT_CONFIG_QUERY_FAILED");
805
+ }
806
+ const assignment = AssignmentSchema.safeParse(value);
807
+ if (!assignment.success || assignment.data.workspaceKind !== this.registration.template.bundleId) throw new AgentConfigError("AGENT_CONFIG_INVALID");
808
+ if (assignment.data.source === "builtin") {
809
+ this.candidate = void 0;
810
+ this.failure = null;
811
+ return;
812
+ }
813
+ const descriptor = assignment.data.agentConfig;
814
+ if (descriptor.workspaceKind !== assignment.data.workspaceKind) throw new AgentConfigError("AGENT_CONFIG_INVALID");
815
+ if (descriptor.sha256 === this.selected.sha256) {
816
+ this.candidate = void 0;
817
+ this.failure = null;
818
+ return;
819
+ }
820
+ if (descriptor.sizeBytes > this.spec.agentConfigMaxDownloadBytes) throw new AgentConfigError("AGENT_CONFIG_INVALID");
821
+ const url = new URL(descriptor.downloadUrl);
822
+ if (url.username !== "" || url.password !== "" || url.protocol !== "https:" && !(this.spec.allowLoopbackAgentConfigHttp && url.protocol === "http:" && [
823
+ "localhost",
824
+ "127.0.0.1",
825
+ "[::1]"
826
+ ].includes(url.hostname))) throw new AgentConfigError("AGENT_CONFIG_INVALID");
827
+ let bytes;
828
+ try {
829
+ const response = await this.download(url, {
830
+ signal,
831
+ redirect: "error"
832
+ });
833
+ if (!response.ok || response.body === null || response.headers.get("content-length") !== null && Number(response.headers.get("content-length")) !== descriptor.sizeBytes) throw new AgentConfigError("AGENT_CONFIG_DOWNLOAD_FAILED");
834
+ const chunks = [];
835
+ let count = 0;
836
+ for await (const chunk of response.body) {
837
+ count += chunk.byteLength;
838
+ if (count > descriptor.sizeBytes) {
839
+ await response.body.cancel().catch(() => void 0);
840
+ throw new AgentConfigError("AGENT_CONFIG_DOWNLOAD_FAILED");
841
+ }
842
+ chunks.push(chunk);
843
+ }
844
+ bytes = Buffer.concat(chunks);
845
+ } catch (error) {
846
+ throw new AgentConfigError("AGENT_CONFIG_DOWNLOAD_FAILED");
847
+ }
848
+ const content = decodeAgentConfig(bytes, descriptor, this.spec.agentConfigMaxExpandedBytes);
849
+ if (signal.aborted || revision !== this.registration.source.revision()) return;
850
+ await mkdir(this.root, { recursive: true });
851
+ const directory = this.versionDirectory(descriptor.version, descriptor.sha256);
852
+ const tree = join(directory, descriptor.version);
853
+ const fs = createNodeFs();
854
+ if (!await fs.exists(directory)) {
855
+ const staging = await mkdtemp(join(this.root, ".staging-"));
856
+ try {
857
+ const stagingTree = join(staging, descriptor.version);
858
+ await mkdir(stagingTree);
859
+ for (const file of content.files) {
860
+ const path = join(stagingTree, ...file.path.split("/"));
861
+ await mkdir(dirname(path), { recursive: true });
862
+ await writeFile(path, file.bytes, { flag: "wx" });
863
+ }
864
+ const files = content.manifest.files.map((file) => ({
865
+ path: file.path,
866
+ bytes: file.sizeBytes,
867
+ digest: brandString(file.sha256)
868
+ }));
869
+ await writeFile(join(stagingTree, "manifest.json"), JSON.stringify({
870
+ formatVersion: 1,
871
+ pluginVersion: descriptor.version,
872
+ files,
873
+ digest: overallDigest(files)
874
+ }) + "\n");
875
+ const { downloadUrl: _downloadUrl, releaseNotes: _releaseNotes, ...receipt } = descriptor;
876
+ await writeFile(join(staging, "receipt.json"), JSON.stringify(ReceiptSchema.parse(receipt)) + "\n");
877
+ await rename(staging, directory);
878
+ await freezeTree(tree);
879
+ await chmod(join(directory, "receipt.json"), 292);
880
+ } finally {
881
+ await rm(staging, {
882
+ recursive: true,
883
+ force: true
884
+ });
885
+ }
886
+ }
887
+ const manifest = await loadVerifiedManifest(fs, tree);
888
+ const files = content.manifest.files.map((file) => ({
889
+ path: file.path,
890
+ bytes: file.sizeBytes,
891
+ digest: brandString(file.sha256)
892
+ }));
893
+ if (manifest.pluginVersion !== descriptor.version || manifest.digest !== overallDigest(files)) throw new AgentConfigError("AGENT_CONFIG_DIGEST_MISMATCH");
894
+ if (signal.aborted || revision !== this.registration.source.revision()) return;
895
+ const template = {
896
+ ...this.registration.template,
897
+ sourceRoot: tree,
898
+ manifest,
899
+ agentConfigSha256: descriptor.sha256
900
+ };
901
+ this.preparedRevisions.set(template, revision);
902
+ this.candidate = {
903
+ template,
904
+ sha256: descriptor.sha256,
905
+ revision
906
+ };
907
+ this.failure = null;
908
+ }
909
+ };
910
+ //#endregion
911
+ //#region lib/types/provider.js
912
+ /**
913
+ * Host Provider of `WorkspaceBundles`: register a packaged template, materialize a stable
914
+ * hidden Workspace, and replace its tree as a whole with crash recovery.
915
+ */
916
+ function keyOf(identity) {
917
+ return `${identity.bundleId}/${identity.workspaceKey}`;
918
+ }
919
+ function originOf(identity) {
920
+ return {
921
+ kind: "bundle",
922
+ bundleId: identity.bundleId,
923
+ workspaceKey: identity.workspaceKey
924
+ };
925
+ }
926
+ async function copyTree(fs, from, to, files) {
927
+ await fs.rm(to, { recursive: true });
928
+ await fs.mkdir(to);
929
+ for (const file of files) {
930
+ const relative = file.path.split("/");
931
+ const destination = join(to, ...relative);
932
+ await fs.mkdir(join(destination, ".."));
933
+ await fs.copyFile(join(from, ...relative), destination);
934
+ }
935
+ }
936
+ /** Provider for packaged Workspace templates. */
937
+ var GestaltWorkspaceBundles = class extends WorkspaceBundles {
938
+ static inject = ["workspaceRegistry"];
939
+ static Config = Config;
940
+ /** Deployment settings resolved at load. */
941
+ spec;
942
+ fs;
943
+ templates = /* @__PURE__ */ new Map();
944
+ agentConfigSources = /* @__PURE__ */ new Map();
945
+ records = /* @__PURE__ */ new Map();
946
+ leases = /* @__PURE__ */ new Map();
947
+ waiters = /* @__PURE__ */ new Map();
948
+ chain = Promise.resolve();
949
+ recycle = /* @__PURE__ */ new Map();
950
+ recycleWork = /* @__PURE__ */ new Map();
951
+ closed = false;
952
+ download;
953
+ /**
954
+ * @param ctx - Host context with the Workspace registry.
955
+ * @param config - raw row settings.
956
+ * @param options.fs - filesystem; tests inject crashes through `crashAt`.
957
+ * @param options.crashAt - single crash point; production omits it.
958
+ * @param options.fetch - 文件 HTTP 传输;默认使用 Host fetch。
959
+ */
960
+ constructor(ctx, config = {}, options = {}) {
961
+ super(ctx);
962
+ this.spec = resolveWorkspaceBundlesSpec(config);
963
+ this.fs = crashableFs(options.fs ?? createNodeFs(), options.crashAt);
964
+ this.download = options.fetch ?? fetch;
965
+ ctx.effect(() => async () => {
966
+ this.closed = true;
967
+ for (const timer of this.recycle.values()) clearTimeout(timer);
968
+ this.recycle.clear();
969
+ await Promise.all([...this.agentConfigSources.values()].map((source) => source.dispose()));
970
+ await this.chain;
971
+ await Promise.all(this.recycleWork.values());
972
+ this.agentConfigSources.clear();
973
+ }, "workspace-bundle: recycle timers");
974
+ }
975
+ /** @inheritdoc */
976
+ register(template) {
977
+ if (this.closed) throw new WorkspaceBundleError("WORKSPACE_BUNDLE_NOT_REGISTERED", "Workspace 内容服务已停止");
978
+ identityOf(template.bundleId, template.workspaceKey);
979
+ const key = keyOf(template);
980
+ this.templates.set(key, template);
981
+ const record = this.records.get(key);
982
+ if (record !== void 0) this.records.set(key, {
983
+ ...record,
984
+ registered: true
985
+ });
986
+ return () => {
987
+ if (this.templates.get(key) !== template) return;
988
+ this.templates.delete(key);
989
+ const current = this.records.get(key);
990
+ if (current !== void 0) this.records.set(key, {
991
+ ...current,
992
+ registered: false
993
+ });
994
+ };
995
+ }
996
+ /** @inheritdoc */
997
+ ensure(identity) {
998
+ if (this.closed) return Promise.reject(new WorkspaceBundleError("WORKSPACE_BUNDLE_NOT_REGISTERED", "Workspace 内容服务已停止"));
999
+ identityOf(identity.bundleId, identity.workspaceKey);
1000
+ return this.enqueue(() => this.ensureOne(identity));
1001
+ }
1002
+ /** @inheritdoc */
1003
+ registerAgentConfigSource(source) {
1004
+ if (this.closed) throw new WorkspaceBundleError("WORKSPACE_BUNDLE_NOT_REGISTERED", "Workspace 内容服务已停止");
1005
+ const key = keyOf(source.identity);
1006
+ const template = this.requireTemplate(source.identity);
1007
+ if (this.agentConfigSources.has(key)) throw new WorkspaceBundleError("WORKSPACE_BUNDLE_TEMPLATE_INVALID", "Agent 配置版本来源已注册");
1008
+ const owner = new WorkspaceAgentConfigSourceOwner({
1009
+ template,
1010
+ source
1011
+ }, this.spec, (error) => {
1012
+ this.ctx.logger.warn("%s:%s", error.code, error.message);
1013
+ }, this.download);
1014
+ this.agentConfigSources.set(key, owner);
1015
+ owner.start();
1016
+ return async () => {
1017
+ await owner.dispose();
1018
+ if (this.agentConfigSources.get(key) === owner) this.agentConfigSources.delete(key);
1019
+ };
1020
+ }
1021
+ /** @inheritdoc */
1022
+ agentConfig(identity) {
1023
+ return this.agentConfigSources.get(keyOf(identity))?.state();
1024
+ }
1025
+ /** @inheritdoc */
1026
+ async refreshAgentConfig(identity) {
1027
+ const source = this.agentConfigSources.get(keyOf(identity));
1028
+ if (source === void 0) throw new WorkspaceBundleError("WORKSPACE_BUNDLE_NOT_REGISTERED", "Agent 配置版本来源未注册");
1029
+ await source.refresh();
1030
+ }
1031
+ /** @inheritdoc */
1032
+ async acquire(workspaceId) {
1033
+ if (this.closed) throw new WorkspaceBundleError("WORKSPACE_BUNDLE_NOT_REGISTERED", "Workspace 内容服务已停止");
1034
+ if ([...this.records.values()].some((record) => record.workspaceId === workspaceId && record.journal.phase === "updating")) throw new WorkspaceBundleError("WORKSPACE_BUNDLE_NOT_REGISTERED", `workspace ${workspaceId} is updating`);
1035
+ return this.enqueue(() => this.acquireOne(workspaceId));
1036
+ }
1037
+ async acquireOne(workspaceId) {
1038
+ const record = [...this.records.values()].find((item) => item.workspaceId === workspaceId);
1039
+ if (record === void 0 || !record.registered) throw new WorkspaceBundleError("WORKSPACE_BUNDLE_NOT_REGISTERED", `workspace ${workspaceId} is not an acquired bundle Workspace`);
1040
+ if (record.journal.phase === "updating") throw new WorkspaceBundleError("WORKSPACE_BUNDLE_NOT_REGISTERED", `workspace ${workspaceId} is updating`);
1041
+ if ((this.leases.get(workspaceId)?.size ?? 0) === 0 && this.agentConfigSources.has(keyOf(record))) await this.ensureOne(record);
1042
+ const token = Symbol("workspace-bundle-lease");
1043
+ let leases = this.leases.get(workspaceId);
1044
+ if (leases === void 0) {
1045
+ leases = /* @__PURE__ */ new Set();
1046
+ this.leases.set(workspaceId, leases);
1047
+ }
1048
+ leases.add(token);
1049
+ return () => {
1050
+ leases.delete(token);
1051
+ if (leases.size === 0) this.leases.delete(workspaceId);
1052
+ const waiters = this.waiters.get(workspaceId);
1053
+ if (waiters !== void 0) for (const waiter of waiters) waiter();
1054
+ };
1055
+ }
1056
+ /** @inheritdoc */
1057
+ record(identity) {
1058
+ return this.records.get(keyOf(identity));
1059
+ }
1060
+ /** @inheritdoc */
1061
+ pathOf(identity) {
1062
+ identityOf(identity.bundleId, identity.workspaceKey);
1063
+ return bundledDirectory(this.spec.materializeRoot, identity);
1064
+ }
1065
+ /**
1066
+ * Replay an interrupted journal before serving the Workspace.
1067
+ * @param identity - branded pair.
1068
+ * @throws {WorkspaceBundleError} Before any rename or deletion if stored paths differ from this identity's controlled paths.
1069
+ */
1070
+ async recover(identity) {
1071
+ return this.enqueue(() => this.recoverOne(identity));
1072
+ }
1073
+ enqueue(work) {
1074
+ const run = this.chain.then(work, work);
1075
+ this.chain = run.then(() => void 0, () => void 0);
1076
+ return run;
1077
+ }
1078
+ async ensureOne(identity) {
1079
+ let template = this.requireTemplate(identity);
1080
+ const source = this.agentConfigSources.get(keyOf(identity));
1081
+ const stored = this.records.get(keyOf(identity)) ?? await this.loadStored(identity);
1082
+ if (source !== void 0) template = await source.recover(stored?.templateDigest, stored?.pluginVersion, stored?.agentConfigSha256);
1083
+ const recovered = await this.recoverOne(identity);
1084
+ const record = this.records.get(keyOf(identity));
1085
+ if (source !== void 0) template = await source.recover(record?.templateDigest, record?.pluginVersion, record?.agentConfigSha256);
1086
+ if (source !== void 0 && (record?.workspaceId === null || record?.workspaceId === void 0 || (this.leases.get(record.workspaceId)?.size ?? 0) === 0)) template = source.template();
1087
+ if (recovered !== void 0 && record !== void 0 && record.templateDigest === template.manifest.digest && record.pluginVersion === template.manifest.pluginVersion && (record.agentConfigSha256 ?? null) === (template.agentConfigSha256 ?? null) && record.journal.phase === "idle") return recovered;
1088
+ try {
1089
+ const workspace = await this.replace(template, record, source === void 0 ? void 0 : () => source.assertApplicable(template));
1090
+ await source?.committed(template);
1091
+ return workspace;
1092
+ } catch (error) {
1093
+ if (source === void 0) throw error;
1094
+ if (error instanceof AgentConfigError && error.code === "AGENT_CONFIG_SCOPE_CHANGED") await this.rollbackAgentConfig(identity, record);
1095
+ else await this.recoverOne(identity);
1096
+ const restored = this.records.get(keyOf(identity));
1097
+ await source.recover(restored?.templateDigest, restored?.pluginVersion, restored?.agentConfigSha256);
1098
+ source.failedApply(template, error);
1099
+ if (error instanceof AgentConfigError && error.code === "AGENT_CONFIG_SCOPE_CHANGED" && !this.closed) return (restored === void 0 || !await this.fs.exists(this.pathOf(identity)) ? void 0 : await this.workspaceOf(restored)) ?? this.replace(this.requireTemplate(identity), restored);
1100
+ this.ctx.logger.warn("Agent 配置应用失败,已尝试按 journal 恢复;当前版本未确认。");
1101
+ throw error;
1102
+ }
1103
+ }
1104
+ async rollbackAgentConfig(identity, previous) {
1105
+ const current = this.records.get(keyOf(identity));
1106
+ if (current === void 0) {
1107
+ await this.fs.rm(this.stagingOf(identity), { recursive: true });
1108
+ return;
1109
+ }
1110
+ if (current === previous && current.journal.phase === "idle") {
1111
+ await this.fs.rm(this.stagingOf(identity), { recursive: true });
1112
+ return;
1113
+ }
1114
+ const builtin = this.requireTemplate(identity);
1115
+ const rollback = {
1116
+ ...previous ?? {
1117
+ ...current,
1118
+ pluginVersion: builtin.manifest.pluginVersion,
1119
+ templateDigest: brandString("0".repeat(64)),
1120
+ agentConfigSha256: null
1121
+ },
1122
+ journal: {
1123
+ ...current.journal,
1124
+ phase: "updating",
1125
+ fromDigest: previous?.templateDigest ?? null,
1126
+ toDigest: current.templateDigest,
1127
+ backupPath: await this.fs.exists(this.backupOf(identity)) ? this.backupOf(identity) : null,
1128
+ stagingPath: this.stagingOf(identity),
1129
+ swapComplete: false
1130
+ }
1131
+ };
1132
+ this.records.set(keyOf(identity), rollback);
1133
+ await writeRecord(this.fs, this.recordFile(identity), rollback);
1134
+ await this.restoreOld(identity, rollback);
1135
+ }
1136
+ requireTemplate(identity) {
1137
+ const template = this.templates.get(keyOf(identity));
1138
+ if (template === void 0) throw new WorkspaceBundleError("WORKSPACE_BUNDLE_NOT_REGISTERED", `${keyOf(identity)} is not registered`);
1139
+ return template;
1140
+ }
1141
+ stagingOf(identity) {
1142
+ return `${this.pathOf(identity)}.staging`;
1143
+ }
1144
+ backupOf(identity) {
1145
+ return `${this.pathOf(identity)}.backup`;
1146
+ }
1147
+ recordFile(identity) {
1148
+ return recordPath(this.spec.journalRoot, identity);
1149
+ }
1150
+ async loadStored(identity) {
1151
+ const stored = await readRecord(this.fs, this.recordFile(identity));
1152
+ if (stored !== void 0) this.records.set(keyOf(identity), stored);
1153
+ return stored;
1154
+ }
1155
+ async recoverOne(identity) {
1156
+ const stored = this.records.get(keyOf(identity)) ?? await this.loadStored(identity);
1157
+ if (stored === void 0) return void 0;
1158
+ await assertRecordPaths(this.fs, stored, this.pathOf(identity));
1159
+ if (stored.journal.phase === "idle") return this.workspaceOf(stored);
1160
+ return stored.journal.swapComplete ? this.finishNew(identity, stored) : this.restoreOld(identity, stored);
1161
+ }
1162
+ async workspaceOf(record) {
1163
+ if (record.workspaceId === null) return void 0;
1164
+ return this.ctx.workspaceRegistry.get(record.workspaceId) ?? await this.ctx.workspaceRegistry.create(record.path, void 0, originOf({
1165
+ bundleId: record.bundleId,
1166
+ workspaceKey: record.workspaceKey
1167
+ }));
1168
+ }
1169
+ async restoreOld(identity, record) {
1170
+ const root = this.pathOf(identity);
1171
+ const backup = record.journal.backupPath ?? this.backupOf(identity);
1172
+ const staging = record.journal.stagingPath ?? this.stagingOf(identity);
1173
+ if (await this.fs.exists(backup)) {
1174
+ if (await this.fs.exists(root)) await this.fs.rm(root, { recursive: true });
1175
+ await this.fs.rename(backup, root);
1176
+ } else if (record.journal.fromDigest === null && await this.fs.exists(root)) await this.fs.rm(root, { recursive: true });
1177
+ if (await this.fs.exists(staging)) await this.fs.rm(staging, { recursive: true });
1178
+ const next = {
1179
+ ...record,
1180
+ journal: idleJournal(),
1181
+ lastFailure: record.lastFailure
1182
+ };
1183
+ await writeRecord(this.fs, this.recordFile(identity), next);
1184
+ this.records.set(keyOf(identity), next);
1185
+ return this.workspaceOf(next);
1186
+ }
1187
+ async finishNew(identity, record) {
1188
+ let template = this.templates.get(keyOf(identity));
1189
+ const root = this.pathOf(identity);
1190
+ const toDigest = record.journal.toDigest;
1191
+ try {
1192
+ const source = this.agentConfigSources.get(keyOf(identity));
1193
+ if (source !== void 0 && toDigest !== null) template = await source.recover(toDigest, record.journal.pluginVersion ?? void 0, record.journal.agentConfigSha256);
1194
+ if (template !== void 0 && toDigest === template.manifest.digest) {
1195
+ await verifyTemplateTree(this.fs, root, template.manifest);
1196
+ return this.commit(identity, record, template);
1197
+ }
1198
+ if (toDigest !== null && template === void 0) {
1199
+ const cached = bundledDirectory(this.spec.templateCacheRoot, identity);
1200
+ const manifest = await loadVerifiedManifest(this.fs, cached);
1201
+ if (manifest.digest === toDigest) {
1202
+ await verifyTemplateTree(this.fs, root, manifest);
1203
+ const synthetic = {
1204
+ bundleId: identity.bundleId,
1205
+ workspaceKey: identity.workspaceKey,
1206
+ title: "Workspace",
1207
+ sourceRoot: cached,
1208
+ manifest
1209
+ };
1210
+ return this.commit(identity, record, synthetic);
1211
+ }
1212
+ }
1213
+ } catch {}
1214
+ return this.restoreOld(identity, record);
1215
+ }
1216
+ async replace(template, existing, assertApplicable) {
1217
+ const identity = identityOf(template.bundleId, template.workspaceKey);
1218
+ assertApplicable?.();
1219
+ await loadVerifiedManifest(this.fs, template.sourceRoot);
1220
+ const cache = bundledDirectory(this.spec.templateCacheRoot, identity);
1221
+ await copyTree(this.fs, template.sourceRoot, cache, [...template.manifest.files, { path: "manifest.json" }]);
1222
+ const staging = this.stagingOf(identity);
1223
+ await copyTree(this.fs, template.sourceRoot, staging, template.manifest.files);
1224
+ assertApplicable?.();
1225
+ this.fs.crash("after-staging");
1226
+ const root = this.pathOf(identity);
1227
+ const backup = this.backupOf(identity);
1228
+ await this.prepareBackup(backup);
1229
+ if (await this.fs.exists(backup)) await this.fs.rm(backup, { recursive: true });
1230
+ const record = existing ?? {
1231
+ version: 1,
1232
+ bundleId: identity.bundleId,
1233
+ workspaceKey: identity.workspaceKey,
1234
+ workspaceId: null,
1235
+ path: root,
1236
+ pluginVersion: template.manifest.pluginVersion,
1237
+ templateDigest: brandString("0".repeat(64)),
1238
+ registered: true,
1239
+ journal: idleJournal(),
1240
+ lastFailure: null
1241
+ };
1242
+ const journal = {
1243
+ phase: "updating",
1244
+ fromDigest: existing?.templateDigest ?? null,
1245
+ toDigest: template.manifest.digest,
1246
+ pluginVersion: template.manifest.pluginVersion,
1247
+ stagingPath: staging,
1248
+ backupPath: await this.fs.exists(root) ? backup : null,
1249
+ swapComplete: false,
1250
+ ...template.agentConfigSha256 === void 0 ? {} : { agentConfigSha256: template.agentConfigSha256 }
1251
+ };
1252
+ const updating = await writeJournal(this.fs, this.recordFile(identity), {
1253
+ ...record,
1254
+ registered: true,
1255
+ path: root
1256
+ }, journal);
1257
+ this.records.set(keyOf(identity), updating);
1258
+ this.fs.crash("after-journal-updating");
1259
+ if (updating.workspaceId !== null) await this.ctx.serial("workspace-bundle/quiesce", updating.workspaceId);
1260
+ await this.quiesce(updating.workspaceId);
1261
+ assertApplicable?.();
1262
+ this.fs.crash("after-quiesce");
1263
+ await this.fs.mkdir(join(root, ".."));
1264
+ if (await this.fs.exists(root)) {
1265
+ if (await this.fs.exists(backup)) await this.fs.rm(backup, { recursive: true });
1266
+ await this.fs.rename(root, backup);
1267
+ }
1268
+ this.fs.crash("after-rename-backup");
1269
+ assertApplicable?.();
1270
+ await this.fs.rename(staging, root);
1271
+ const swapped = await writeJournal(this.fs, this.recordFile(identity), updating, {
1272
+ ...journal,
1273
+ swapComplete: true,
1274
+ stagingPath: null
1275
+ });
1276
+ this.records.set(keyOf(identity), swapped);
1277
+ this.fs.crash("after-rename-staging");
1278
+ await verifyTemplateTree(this.fs, root, template.manifest);
1279
+ this.fs.crash("after-verify");
1280
+ assertApplicable?.();
1281
+ const workspace = await this.commit(identity, swapped, template);
1282
+ assertApplicable?.();
1283
+ this.fs.crash("after-journal-commit");
1284
+ this.scheduleRecycle(backup);
1285
+ return workspace;
1286
+ }
1287
+ async commit(identity, record, template) {
1288
+ const workspace = await this.ctx.workspaceRegistry.create(record.path, template.title, originOf(identity));
1289
+ const next = committedRecord({
1290
+ ...record,
1291
+ workspaceId: workspace.id,
1292
+ path: workspace.path,
1293
+ registered: true,
1294
+ ...this.agentConfigSources.has(keyOf(identity)) ? { agentConfigSha256: template.agentConfigSha256 ?? null } : {}
1295
+ }, template.manifest.digest, template.manifest.pluginVersion);
1296
+ await writeRecord(this.fs, this.recordFile(identity), next);
1297
+ this.records.set(keyOf(identity), next);
1298
+ return workspace;
1299
+ }
1300
+ async quiesce(workspaceId) {
1301
+ if (workspaceId === null) return;
1302
+ const deadline = Date.now() + this.spec.quiesceTimeoutMs;
1303
+ while ((this.leases.get(workspaceId)?.size ?? 0) > 0) {
1304
+ if (Date.now() >= deadline) throw new WorkspaceBundleError("WORKSPACE_BUNDLE_QUIESCE_TIMEOUT", `workspace ${workspaceId} still has active leases`);
1305
+ await new Promise((resolve) => {
1306
+ let waiters = this.waiters.get(workspaceId);
1307
+ if (waiters === void 0) {
1308
+ waiters = /* @__PURE__ */ new Set();
1309
+ this.waiters.set(workspaceId, waiters);
1310
+ }
1311
+ const timer = setTimeout(resolve, 25);
1312
+ const waiter = () => {
1313
+ clearTimeout(timer);
1314
+ waiters.delete(waiter);
1315
+ resolve();
1316
+ };
1317
+ waiters.add(waiter);
1318
+ });
1319
+ }
1320
+ }
1321
+ scheduleRecycle(backup) {
1322
+ if (this.closed) return;
1323
+ const timer = setTimeout(() => {
1324
+ this.recycle.delete(backup);
1325
+ const work = this.fs.rm(backup, { recursive: true }).catch((error) => {
1326
+ this.ctx.logger.warn("旧 Workspace 备份清理失败,请检查磁盘;当前版本保持可用。");
1327
+ }).finally(() => {
1328
+ this.recycleWork.delete(backup);
1329
+ });
1330
+ this.recycleWork.set(backup, work);
1331
+ }, this.spec.recycleTimeoutMs);
1332
+ this.recycle.set(backup, timer);
1333
+ }
1334
+ async prepareBackup(backup) {
1335
+ const timer = this.recycle.get(backup);
1336
+ if (timer !== void 0) clearTimeout(timer);
1337
+ this.recycle.delete(backup);
1338
+ await this.recycleWork.get(backup);
1339
+ }
1340
+ };
1341
+ //#endregion
1342
+ //#region lib/types/session-events.js
1343
+ /**
1344
+ * 在每个 Session 首次使用一个不同 Agent 配置版本的 turn 写入身份,重复 step 不重复记录。
1345
+ * @param session - 即将请求模型的 Session。
1346
+ * @param current - 当前租约固定的完整版本。
1347
+ */
1348
+ function recordWorkspaceAgentConfigVersion(session, current) {
1349
+ const previous = session.snapshotEvents().findLast((event) => event.type === "workspace-bundle/agent-config-version");
1350
+ if (previous?.type === "workspace-bundle/agent-config-version" && previous.data.version === current.version && previous.data.agentConfigSha256 === current.sha256 && previous.data.templateDigest === current.templateDigest) return;
1351
+ session.append("workspace-bundle/agent-config-version", {
1352
+ workspaceKind: current.workspaceKind,
1353
+ version: current.version,
1354
+ previousVersion: previous?.type === "workspace-bundle/agent-config-version" ? previous.data.version : null,
1355
+ templateDigest: current.templateDigest,
1356
+ agentConfigSha256: current.sha256,
1357
+ hostAgentConfigApiVersion: current.hostAgentConfigApiVersion
1358
+ });
1359
+ }
1360
+ //#endregion
1361
+ //#region lib/types/index.js
1362
+ /**
1363
+ * `WorkspaceBundles` Service Definition (`ctx.workspaceBundles`) and its Provider.
1364
+ * The Provider resolves its Config at load, so missing materialize roots fail plugin load.
1365
+ */
1366
+ var types_default = GestaltWorkspaceBundles;
1367
+ //#endregion
1368
+ export { AgentConfigDescriptorSchema, AgentConfigError, Config, GestaltWorkspaceBundles, HOST_AGENT_CONFIG_API_VERSION, WorkspaceBundleError, WorkspaceBundles, WorkspaceBundlesConfigError, agentConfigSha256, assertTemplateRelativePath, bundledDirectory, crashableFs, createNodeFs, decodeAgentConfig, types_default as default, digestOf, encodeBundleSegment, identityOf, loadVerifiedManifest, loadVerifiedManifestSync, overallDigest, parseTemplateManifest, recordWorkspaceAgentConfigVersion, resolveWorkspaceBundlesSpec, verifyTemplateTree };