@gestaltrun/dsh-workspace-bundle 0.0.0-stage → 0.1.1

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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.i18n.yaml +37 -0
  3. package/README.md +92 -2
  4. package/README.zh.md +93 -0
  5. package/lib/index.js +2868 -0
  6. package/lib/types/agent-config-contract/agent-configs.d.ts +621 -0
  7. package/lib/types/agent-config-contract/agent-configs.js +68 -0
  8. package/lib/types/agent-config-contract/cs-hosting.d.ts +1333 -0
  9. package/lib/types/agent-config-contract/cs-hosting.js +163 -0
  10. package/lib/types/agent-config-contract/errors.d.ts +224 -0
  11. package/lib/types/agent-config-contract/errors.js +202 -0
  12. package/lib/types/agent-config-contract/ids.d.ts +65 -0
  13. package/lib/types/agent-config-contract/ids.js +35 -0
  14. package/lib/types/agent-config-contract/route.d.ts +56 -0
  15. package/lib/types/agent-config-contract/route.js +1 -0
  16. package/lib/types/agent-config-contract/test-account-claim.d.ts +24 -0
  17. package/lib/types/agent-config-contract/test-account-claim.js +5 -0
  18. package/lib/types/agent-config-format.d.ts +86 -0
  19. package/lib/types/agent-config-format.js +119 -0
  20. package/lib/types/agent-config-observation.d.ts +64 -0
  21. package/lib/types/agent-config-observation.js +448 -0
  22. package/lib/types/agent-config-source.d.ts +78 -0
  23. package/lib/types/agent-config-source.js +411 -0
  24. package/lib/types/agent-config-types.d.ts +67 -0
  25. package/lib/types/agent-config-types.js +1 -0
  26. package/lib/types/bundled-agent-config.d.ts +29 -0
  27. package/lib/types/bundled-agent-config.js +47 -0
  28. package/lib/types/config.d.ts +34 -0
  29. package/lib/types/config.js +53 -0
  30. package/lib/types/definition.d.ts +82 -0
  31. package/lib/types/definition.js +20 -0
  32. package/lib/types/digest.d.ts +38 -0
  33. package/lib/types/digest.js +243 -0
  34. package/lib/types/errors.d.ts +22 -0
  35. package/lib/types/errors.js +26 -0
  36. package/lib/types/filesystem.d.ts +16 -0
  37. package/lib/types/filesystem.js +64 -0
  38. package/lib/types/index.d.ts +28 -0
  39. package/lib/types/index.js +19 -0
  40. package/lib/types/journal.d.ts +51 -0
  41. package/lib/types/journal.js +109 -0
  42. package/lib/types/local-host-identity.d.ts +65 -0
  43. package/lib/types/local-host-identity.js +87 -0
  44. package/lib/types/local-host-replacement.d.ts +49 -0
  45. package/lib/types/local-host-replacement.js +112 -0
  46. package/lib/types/paths.d.ts +26 -0
  47. package/lib/types/paths.js +48 -0
  48. package/lib/types/provider.d.ts +83 -0
  49. package/lib/types/provider.js +560 -0
  50. package/lib/types/session-events.d.ts +23 -0
  51. package/lib/types/session-events.js +14 -0
  52. package/lib/types/types.d.ts +126 -0
  53. package/lib/types/types.js +1 -0
  54. package/locale/en.json +6 -0
  55. package/locale/zh.json +6 -0
  56. package/package.json +69 -3
@@ -0,0 +1,560 @@
1
+ /**
2
+ * Host Provider of `WorkspaceBundles`: register a packaged template, materialize a stable
3
+ * hidden Workspace, and replace its tree as a whole with crash recovery.
4
+ */
5
+ import { join } from 'node:path';
6
+ import { journalAgentConfigSha256 } from "./bundled-agent-config.js";
7
+ import { brandString } from '@deepseek-ai/dsh-brand';
8
+ import { Config, resolveWorkspaceBundlesSpec } from "./config.js";
9
+ import { WorkspaceBundles } from "./definition.js";
10
+ import { loadVerifiedManifest, verifyTemplateTree } from "./digest.js";
11
+ import { WorkspaceBundleError } from "./errors.js";
12
+ import { crashableFs, createNodeFs } from "./filesystem.js";
13
+ import { assertRecordPaths, committedRecord, idleJournal, readRecord, recordPath, writeJournal, writeRecord } from "./journal.js";
14
+ import { bundledDirectory, identityOf } from "./paths.js";
15
+ import { WorkspaceAgentConfigSourceOwner } from "./agent-config-source.js";
16
+ import { AgentConfigError, HOST_AGENT_CONFIG_API_VERSION } from "./agent-config-format.js";
17
+ function keyOf(identity) {
18
+ return `${identity.bundleId}/${identity.workspaceKey}`;
19
+ }
20
+ function originOf(identity) {
21
+ return { kind: 'bundle', bundleId: identity.bundleId, workspaceKey: identity.workspaceKey };
22
+ }
23
+ async function copyTree(fs, from, to, files) {
24
+ await fs.rm(to, { recursive: true });
25
+ await fs.mkdir(to);
26
+ for (const file of files) {
27
+ const relative = file.path.split('/');
28
+ const destination = join(to, ...relative);
29
+ await fs.mkdir(join(destination, '..'));
30
+ await fs.copyFile(join(from, ...relative), destination);
31
+ }
32
+ }
33
+ /** Provider for packaged Workspace templates. */
34
+ export class GestaltWorkspaceBundles extends WorkspaceBundles {
35
+ static inject = ['workspaceRegistry'];
36
+ static Config = Config;
37
+ /** Deployment settings resolved at load. */
38
+ spec;
39
+ fs;
40
+ templates = new Map();
41
+ agentConfigSources = new Map();
42
+ records = new Map();
43
+ leases = new Map();
44
+ waiters = new Map();
45
+ chain = Promise.resolve();
46
+ recycle = new Map();
47
+ recycleWork = new Map();
48
+ contentDrains = new Set();
49
+ closed = false;
50
+ download;
51
+ /**
52
+ * @param ctx - Host context with the Workspace registry.
53
+ * @param config - raw row settings.
54
+ * @param options.fs - filesystem; tests inject crashes through `crashAt`.
55
+ * @param options.crashAt - single crash point; production omits it.
56
+ * @param options.fetch - 文件 HTTP 传输;默认使用 Host fetch。
57
+ */
58
+ constructor(ctx, config = {}, options = {}) {
59
+ const spec = resolveWorkspaceBundlesSpec(config);
60
+ super(ctx, join(spec.journalRoot, 'local-host-replacement.json'), join(spec.journalRoot, 'local-host-identity.json'));
61
+ this.spec = spec;
62
+ this.fs = crashableFs(options.fs ?? createNodeFs(), options.crashAt);
63
+ this.download = options.fetch ?? fetch;
64
+ ctx.effect(() => async () => {
65
+ this.closed = true;
66
+ for (const timer of this.recycle.values())
67
+ clearTimeout(timer);
68
+ this.recycle.clear();
69
+ await Promise.all([...this.agentConfigSources.values()].map(source => source.dispose()));
70
+ await this.chain;
71
+ await Promise.all(this.contentDrains);
72
+ await Promise.all(this.recycleWork.values());
73
+ this.agentConfigSources.clear();
74
+ }, 'workspace-bundle: recycle timers');
75
+ }
76
+ /** @inheritdoc */
77
+ register(template) {
78
+ if (this.closed)
79
+ throw new WorkspaceBundleError('WORKSPACE_BUNDLE_NOT_REGISTERED', 'Workspace 内容服务已停止');
80
+ identityOf(template.bundleId, template.workspaceKey);
81
+ const key = keyOf(template);
82
+ this.templates.set(key, template);
83
+ const record = this.records.get(key);
84
+ if (record !== undefined)
85
+ this.records.set(key, { ...record, registered: true });
86
+ return () => {
87
+ if (this.templates.get(key) !== template)
88
+ return;
89
+ this.templates.delete(key);
90
+ const current = this.records.get(key);
91
+ if (current !== undefined)
92
+ this.records.set(key, { ...current, registered: false });
93
+ };
94
+ }
95
+ /** @inheritdoc */
96
+ ensure(identity) {
97
+ if (this.closed)
98
+ return Promise.reject(new WorkspaceBundleError('WORKSPACE_BUNDLE_NOT_REGISTERED', 'Workspace 内容服务已停止'));
99
+ identityOf(identity.bundleId, identity.workspaceKey);
100
+ return this.enqueue(() => this.ensureOne(identity));
101
+ }
102
+ /** @inheritdoc */
103
+ registerAgentConfigSource(source) {
104
+ if (this.closed)
105
+ throw new WorkspaceBundleError('WORKSPACE_BUNDLE_NOT_REGISTERED', 'Workspace 内容服务已停止');
106
+ const key = keyOf(source.identity);
107
+ const template = this.requireTemplate(source.identity);
108
+ if (this.agentConfigSources.has(key))
109
+ throw new WorkspaceBundleError('WORKSPACE_BUNDLE_TEMPLATE_INVALID', 'Agent 配置版本来源已注册');
110
+ const owner = new WorkspaceAgentConfigSourceOwner({ template, source }, this.spec, error => {
111
+ this.ctx.logger.warn('%s:%s', error.code, error.message);
112
+ }, this.download, () => this.enqueue(async () => {
113
+ if (this.closed || this.agentConfigSources.get(key) !== owner)
114
+ return;
115
+ const state = owner.state();
116
+ const record = this.records.get(key);
117
+ if (!state.blocked || state.pending === null || record?.workspaceId != null && (this.leases.get(record.workspaceId)?.size ?? 0) > 0)
118
+ return;
119
+ await this.ensureOne(source.identity);
120
+ }));
121
+ this.agentConfigSources.set(key, owner);
122
+ owner.start(() => this.enqueue(async () => {
123
+ await this.recoverOne(source.identity);
124
+ const record = this.records.get(key);
125
+ try {
126
+ await owner.recover(record?.templateDigest, record?.pluginVersion, record?.agentConfigSha256);
127
+ }
128
+ catch (error) {
129
+ // A refused committed version remains reportable, but cannot grant execution.
130
+ if (!(error instanceof AgentConfigError))
131
+ throw error;
132
+ }
133
+ }));
134
+ return async () => {
135
+ await owner.dispose();
136
+ if (this.agentConfigSources.get(key) === owner)
137
+ this.agentConfigSources.delete(key);
138
+ };
139
+ }
140
+ /** @inheritdoc */
141
+ agentConfig(identity) {
142
+ return this.agentConfigSources.get(keyOf(identity))?.state();
143
+ }
144
+ /** @inheritdoc */
145
+ async refreshAgentConfig(identity) {
146
+ const source = this.agentConfigSources.get(keyOf(identity));
147
+ if (source === undefined)
148
+ throw new WorkspaceBundleError('WORKSPACE_BUNDLE_NOT_REGISTERED', 'Agent 配置版本来源未注册');
149
+ await source.refresh();
150
+ }
151
+ /** @inheritdoc */
152
+ async acquire(workspaceId) {
153
+ if (this.closed)
154
+ throw new WorkspaceBundleError('WORKSPACE_BUNDLE_NOT_REGISTERED', 'Workspace 内容服务已停止');
155
+ if ([...this.records.values()].some(record => record.workspaceId === workspaceId && record.journal.phase === 'updating')) {
156
+ throw new WorkspaceBundleError('WORKSPACE_BUNDLE_NOT_REGISTERED', `workspace ${workspaceId} is updating`);
157
+ }
158
+ return this.enqueue(() => this.acquireOne(workspaceId));
159
+ }
160
+ /** @inheritdoc */
161
+ acquireAgentConfig(identity) {
162
+ if (this.closed)
163
+ return Promise.reject(new WorkspaceBundleError('WORKSPACE_BUNDLE_NOT_REGISTERED', 'Workspace content service is stopped'));
164
+ identityOf(identity.bundleId, identity.workspaceKey);
165
+ return this.enqueue(async () => {
166
+ if (this.closed)
167
+ throw new WorkspaceBundleError('WORKSPACE_BUNDLE_NOT_REGISTERED', 'Workspace content service is stopped');
168
+ const registered = this.requireTemplate(identity);
169
+ const source = this.agentConfigSources.get(keyOf(identity));
170
+ const revision = source?.registration.source.revision();
171
+ const workspace = await this.ensureOne(identity);
172
+ const record = this.records.get(keyOf(identity));
173
+ const template = source === undefined ? this.requireTemplate(identity)
174
+ : await source.recover(record.templateDigest, record.pluginVersion, record.agentConfigSha256);
175
+ await verifyTemplateTree(this.fs, template.sourceRoot, template.manifest);
176
+ if (this.closed)
177
+ throw new WorkspaceBundleError('WORKSPACE_BUNDLE_NOT_REGISTERED', 'Workspace content service is stopped');
178
+ if (this.requireTemplate(identity) !== registered || this.agentConfigSources.get(keyOf(identity)) !== source
179
+ || source?.registration.source.revision() !== revision) {
180
+ throw new AgentConfigError('AGENT_CONFIG_SCOPE_CHANGED');
181
+ }
182
+ if (template.manifest.digest !== record.templateDigest || template.manifest.pluginVersion !== record.pluginVersion) {
183
+ throw new AgentConfigError('AGENT_CONFIG_INVALID');
184
+ }
185
+ const version = { workspaceKind: identity.bundleId, workspaceId: workspace.id, version: record.pluginVersion,
186
+ sha256: template.agentConfigSha256 ?? null, hostAgentConfigApiVersion: HOST_AGENT_CONFIG_API_VERSION,
187
+ templateDigest: record.templateDigest, sourceRoot: template.sourceRoot };
188
+ return this.contentHandle(version, this.holdLease(workspace.id));
189
+ });
190
+ }
191
+ contentHandle(version, release) {
192
+ let references = 0;
193
+ const drained = Promise.withResolvers();
194
+ this.contentDrains.add(drained.promise);
195
+ const retain = () => {
196
+ references++;
197
+ let released = false;
198
+ return Object.freeze({ ...version,
199
+ retain: () => {
200
+ if (released || this.closed)
201
+ throw new WorkspaceBundleError('WORKSPACE_BUNDLE_NOT_REGISTERED', 'The content activity is closed');
202
+ return retain();
203
+ },
204
+ release: () => {
205
+ if (released)
206
+ return;
207
+ released = true;
208
+ if (--references === 0) {
209
+ release();
210
+ this.contentDrains.delete(drained.promise);
211
+ drained.resolve();
212
+ }
213
+ },
214
+ });
215
+ };
216
+ return retain();
217
+ }
218
+ async acquireOne(workspaceId) {
219
+ const record = [...this.records.values()].find(item => item.workspaceId === workspaceId);
220
+ if (record === undefined || !record.registered) {
221
+ throw new WorkspaceBundleError('WORKSPACE_BUNDLE_NOT_REGISTERED', `workspace ${workspaceId} is not an acquired bundle Workspace`);
222
+ }
223
+ if (record.journal.phase === 'updating') {
224
+ throw new WorkspaceBundleError('WORKSPACE_BUNDLE_NOT_REGISTERED', `workspace ${workspaceId} is updating`);
225
+ }
226
+ if ((this.leases.get(workspaceId)?.size ?? 0) === 0 && this.agentConfigSources.has(keyOf(record))) {
227
+ await this.ensureOne(record);
228
+ }
229
+ this.agentConfigSources.get(keyOf(record))?.assertExecutionAllowed();
230
+ return this.holdLease(workspaceId);
231
+ }
232
+ holdLease(workspaceId) {
233
+ const token = Symbol('workspace-bundle-lease');
234
+ let leases = this.leases.get(workspaceId);
235
+ if (leases === undefined) {
236
+ leases = new Set();
237
+ this.leases.set(workspaceId, leases);
238
+ }
239
+ leases.add(token);
240
+ return () => {
241
+ leases.delete(token);
242
+ if (leases.size === 0)
243
+ this.leases.delete(workspaceId);
244
+ const waiters = this.waiters.get(workspaceId);
245
+ if (waiters !== undefined)
246
+ for (const waiter of waiters)
247
+ waiter();
248
+ };
249
+ }
250
+ /** @inheritdoc */
251
+ record(identity) {
252
+ return this.records.get(keyOf(identity));
253
+ }
254
+ /** @inheritdoc */
255
+ pathOf(identity) {
256
+ identityOf(identity.bundleId, identity.workspaceKey);
257
+ return bundledDirectory(this.spec.materializeRoot, identity);
258
+ }
259
+ /**
260
+ * Replay an interrupted journal before serving the Workspace.
261
+ * @param identity - branded pair.
262
+ * @throws {WorkspaceBundleError} Before any rename or deletion if stored paths differ from this identity's controlled paths.
263
+ * @throws {AgentConfigError} After physical recovery when committed content fails compatibility or consumer validation.
264
+ */
265
+ async recover(identity) {
266
+ return this.enqueue(async () => {
267
+ const workspace = await this.recoverOne(identity);
268
+ const record = this.records.get(keyOf(identity));
269
+ await this.agentConfigSources.get(keyOf(identity))?.recover(record?.templateDigest, record?.pluginVersion, record?.agentConfigSha256);
270
+ return workspace;
271
+ });
272
+ }
273
+ enqueue(work) {
274
+ const run = this.chain.then(work, work);
275
+ this.chain = run.then(() => undefined, () => undefined);
276
+ return run;
277
+ }
278
+ async ensureOne(identity) {
279
+ let template = this.requireTemplate(identity);
280
+ const source = this.agentConfigSources.get(keyOf(identity));
281
+ const recovered = await this.recoverOne(identity);
282
+ const record = this.records.get(keyOf(identity));
283
+ if (source !== undefined) {
284
+ try {
285
+ template = await source.recover(record?.templateDigest, record?.pluginVersion, record?.agentConfigSha256);
286
+ }
287
+ catch (error) {
288
+ // An unusable committed tree stays on disk; only a validated candidate may replace it.
289
+ if (!(error instanceof AgentConfigError) || record?.workspaceId !== null && record?.workspaceId !== undefined
290
+ && (this.leases.get(record.workspaceId)?.size ?? 0) > 0)
291
+ throw error;
292
+ template = source.template();
293
+ }
294
+ }
295
+ if (source !== undefined && (record?.workspaceId === null || record?.workspaceId === undefined
296
+ || (this.leases.get(record.workspaceId)?.size ?? 0) === 0))
297
+ template = source.template();
298
+ if (recovered !== undefined && record !== undefined
299
+ && record.templateDigest === template.manifest.digest && record.pluginVersion === template.manifest.pluginVersion
300
+ && (record.agentConfigSha256 ?? null) === journalAgentConfigSha256(template, this.requireTemplate(identity)) && record.journal.phase === 'idle') {
301
+ return recovered;
302
+ }
303
+ try {
304
+ await source?.prepare(template);
305
+ const workspace = await this.replace(template, record, source === undefined ? undefined : () => source.assertApplicable(template));
306
+ await source?.committed(template);
307
+ return workspace;
308
+ }
309
+ catch (error) {
310
+ if (source === undefined)
311
+ throw error;
312
+ if (error instanceof AgentConfigError && error.code === 'AGENT_CONFIG_SCOPE_CHANGED') {
313
+ await this.rollbackAgentConfig(identity, record);
314
+ }
315
+ else
316
+ await this.recoverOne(identity);
317
+ const restored = this.records.get(keyOf(identity));
318
+ await source.recover(restored?.templateDigest, restored?.pluginVersion, restored?.agentConfigSha256);
319
+ source.failedApply(template, error);
320
+ if (error instanceof AgentConfigError && error.code === 'AGENT_CONFIG_SCOPE_CHANGED' && !this.closed) {
321
+ const workspace = restored === undefined || !await this.fs.exists(this.pathOf(identity))
322
+ ? undefined : await this.workspaceOf(restored);
323
+ return workspace ?? this.replace(this.requireTemplate(identity), restored);
324
+ }
325
+ this.ctx.logger.warn('Agent 配置应用失败,已尝试按 journal 恢复;当前版本未确认。');
326
+ throw error;
327
+ }
328
+ }
329
+ async rollbackAgentConfig(identity, previous) {
330
+ const current = this.records.get(keyOf(identity));
331
+ if (current === undefined) {
332
+ await this.fs.rm(this.stagingOf(identity), { recursive: true });
333
+ return;
334
+ }
335
+ if (current === previous && current.journal.phase === 'idle') {
336
+ await this.fs.rm(this.stagingOf(identity), { recursive: true });
337
+ return;
338
+ }
339
+ const builtin = this.requireTemplate(identity);
340
+ const base = previous ?? { ...current, pluginVersion: builtin.manifest.pluginVersion,
341
+ templateDigest: brandString('0'.repeat(64)), agentConfigSha256: null };
342
+ const rollback = { ...base, journal: { ...current.journal, phase: 'updating',
343
+ fromDigest: previous?.templateDigest ?? null, toDigest: current.templateDigest,
344
+ backupPath: await this.fs.exists(this.backupOf(identity)) ? this.backupOf(identity) : null,
345
+ stagingPath: this.stagingOf(identity), swapComplete: false } };
346
+ this.records.set(keyOf(identity), rollback);
347
+ await writeRecord(this.fs, this.recordFile(identity), rollback);
348
+ await this.restoreOld(identity, rollback);
349
+ }
350
+ requireTemplate(identity) {
351
+ const template = this.templates.get(keyOf(identity));
352
+ if (template === undefined) {
353
+ throw new WorkspaceBundleError('WORKSPACE_BUNDLE_NOT_REGISTERED', `${keyOf(identity)} is not registered`);
354
+ }
355
+ return template;
356
+ }
357
+ stagingOf(identity) {
358
+ return `${this.pathOf(identity)}.staging`;
359
+ }
360
+ backupOf(identity) {
361
+ return `${this.pathOf(identity)}.backup`;
362
+ }
363
+ recordFile(identity) {
364
+ return recordPath(this.spec.journalRoot, identity);
365
+ }
366
+ async loadStored(identity) {
367
+ const stored = await readRecord(this.fs, this.recordFile(identity));
368
+ if (stored !== undefined)
369
+ this.records.set(keyOf(identity), stored);
370
+ return stored;
371
+ }
372
+ async recoverOne(identity) {
373
+ const stored = this.records.get(keyOf(identity)) ?? await this.loadStored(identity);
374
+ if (stored === undefined)
375
+ return undefined;
376
+ await assertRecordPaths(this.fs, stored, this.pathOf(identity));
377
+ if (stored.journal.phase === 'idle')
378
+ return this.workspaceOf(stored);
379
+ return stored.journal.swapComplete ? this.finishNew(identity, stored) : this.restoreOld(identity, stored);
380
+ }
381
+ async workspaceOf(record) {
382
+ if (record.workspaceId === null)
383
+ return undefined;
384
+ return this.ctx.workspaceRegistry.get(record.workspaceId)
385
+ ?? await this.ctx.workspaceRegistry.create(record.path, undefined, originOf({
386
+ bundleId: record.bundleId, workspaceKey: record.workspaceKey,
387
+ }));
388
+ }
389
+ async restoreOld(identity, record) {
390
+ const root = this.pathOf(identity);
391
+ const backup = record.journal.backupPath ?? this.backupOf(identity);
392
+ const staging = record.journal.stagingPath ?? this.stagingOf(identity);
393
+ if (await this.fs.exists(backup)) {
394
+ if (await this.fs.exists(root))
395
+ await this.fs.rm(root, { recursive: true });
396
+ await this.fs.rename(backup, root);
397
+ }
398
+ else if (record.journal.fromDigest === null && await this.fs.exists(root))
399
+ await this.fs.rm(root, { recursive: true });
400
+ if (await this.fs.exists(staging))
401
+ await this.fs.rm(staging, { recursive: true });
402
+ const next = {
403
+ ...record,
404
+ journal: idleJournal(),
405
+ lastFailure: record.lastFailure,
406
+ };
407
+ await writeRecord(this.fs, this.recordFile(identity), next);
408
+ this.records.set(keyOf(identity), next);
409
+ return this.workspaceOf(next);
410
+ }
411
+ async finishNew(identity, record) {
412
+ let template = this.templates.get(keyOf(identity));
413
+ const root = this.pathOf(identity);
414
+ const toDigest = record.journal.toDigest;
415
+ try {
416
+ const source = this.agentConfigSources.get(keyOf(identity));
417
+ if (source !== undefined && toDigest !== null)
418
+ template = await source.recoverPhysical(toDigest, record.journal.pluginVersion ?? undefined, record.journal.agentConfigSha256);
419
+ if (template !== undefined && toDigest === template.manifest.digest) {
420
+ await verifyTemplateTree(this.fs, root, template.manifest);
421
+ return await this.commit(identity, record, template);
422
+ }
423
+ if (toDigest !== null && template === undefined) {
424
+ const cached = bundledDirectory(this.spec.templateCacheRoot, identity);
425
+ const manifest = await loadVerifiedManifest(this.fs, cached);
426
+ if (manifest.digest === toDigest) {
427
+ await verifyTemplateTree(this.fs, root, manifest);
428
+ const synthetic = {
429
+ bundleId: identity.bundleId, workspaceKey: identity.workspaceKey, title: 'Workspace',
430
+ sourceRoot: cached, manifest,
431
+ };
432
+ return await this.commit(identity, record, synthetic);
433
+ }
434
+ }
435
+ }
436
+ catch (error) {
437
+ // Fall through to restore the backup; the tree must be wholly old or wholly new.
438
+ }
439
+ return this.restoreOld(identity, record);
440
+ }
441
+ async replace(template, existing, assertApplicable) {
442
+ const identity = identityOf(template.bundleId, template.workspaceKey);
443
+ assertApplicable?.();
444
+ await loadVerifiedManifest(this.fs, template.sourceRoot);
445
+ const cache = bundledDirectory(this.spec.templateCacheRoot, identity);
446
+ await copyTree(this.fs, template.sourceRoot, cache, [...template.manifest.files, { path: 'manifest.json' }]);
447
+ const staging = this.stagingOf(identity);
448
+ await copyTree(this.fs, template.sourceRoot, staging, template.manifest.files);
449
+ assertApplicable?.();
450
+ this.fs.crash('after-staging');
451
+ const root = this.pathOf(identity);
452
+ const backup = this.backupOf(identity);
453
+ await this.prepareBackup(backup);
454
+ if (await this.fs.exists(backup))
455
+ await this.fs.rm(backup, { recursive: true });
456
+ const record = existing ?? {
457
+ version: 1,
458
+ bundleId: identity.bundleId,
459
+ workspaceKey: identity.workspaceKey,
460
+ workspaceId: null,
461
+ path: root,
462
+ pluginVersion: template.manifest.pluginVersion,
463
+ templateDigest: brandString('0'.repeat(64)),
464
+ registered: true,
465
+ journal: idleJournal(),
466
+ lastFailure: null,
467
+ };
468
+ const journal = {
469
+ phase: 'updating',
470
+ fromDigest: existing?.templateDigest ?? null,
471
+ toDigest: template.manifest.digest,
472
+ pluginVersion: template.manifest.pluginVersion,
473
+ stagingPath: staging,
474
+ backupPath: await this.fs.exists(root) ? backup : null,
475
+ swapComplete: false,
476
+ agentConfigSha256: journalAgentConfigSha256(template, this.requireTemplate(identity)),
477
+ };
478
+ const updating = await writeJournal(this.fs, this.recordFile(identity), { ...record, registered: true, path: root }, journal);
479
+ this.records.set(keyOf(identity), updating);
480
+ this.fs.crash('after-journal-updating');
481
+ if (updating.workspaceId !== null)
482
+ await this.ctx.serial('workspace-bundle/quiesce', updating.workspaceId);
483
+ await this.quiesce(updating.workspaceId);
484
+ assertApplicable?.();
485
+ this.fs.crash('after-quiesce');
486
+ await this.fs.mkdir(join(root, '..'));
487
+ if (await this.fs.exists(root)) {
488
+ if (await this.fs.exists(backup))
489
+ await this.fs.rm(backup, { recursive: true });
490
+ await this.fs.rename(root, backup);
491
+ }
492
+ this.fs.crash('after-rename-backup');
493
+ assertApplicable?.();
494
+ await this.fs.rename(staging, root);
495
+ const swapped = await writeJournal(this.fs, this.recordFile(identity), updating, { ...journal, swapComplete: true, stagingPath: null });
496
+ this.records.set(keyOf(identity), swapped);
497
+ this.fs.crash('after-rename-staging');
498
+ await verifyTemplateTree(this.fs, root, template.manifest);
499
+ this.fs.crash('after-verify');
500
+ assertApplicable?.();
501
+ const workspace = await this.commit(identity, swapped, template);
502
+ assertApplicable?.();
503
+ this.fs.crash('after-journal-commit');
504
+ this.scheduleRecycle(backup);
505
+ return workspace;
506
+ }
507
+ async commit(identity, record, template) {
508
+ const workspace = await this.ctx.workspaceRegistry.create(record.path, template.title, originOf(identity));
509
+ const next = committedRecord({ ...record, workspaceId: workspace.id, path: workspace.path, registered: true,
510
+ agentConfigSha256: journalAgentConfigSha256(template, this.templates.get(keyOf(identity))),
511
+ }, template.manifest.digest, template.manifest.pluginVersion);
512
+ await writeRecord(this.fs, this.recordFile(identity), next);
513
+ this.records.set(keyOf(identity), next);
514
+ return workspace;
515
+ }
516
+ async quiesce(workspaceId) {
517
+ if (workspaceId === null)
518
+ return;
519
+ const deadline = Date.now() + this.spec.quiesceTimeoutMs;
520
+ while ((this.leases.get(workspaceId)?.size ?? 0) > 0) {
521
+ if (Date.now() >= deadline) {
522
+ throw new WorkspaceBundleError('WORKSPACE_BUNDLE_QUIESCE_TIMEOUT', `workspace ${workspaceId} still has active leases`);
523
+ }
524
+ await new Promise(resolve => {
525
+ let waiters = this.waiters.get(workspaceId);
526
+ if (waiters === undefined) {
527
+ waiters = new Set();
528
+ this.waiters.set(workspaceId, waiters);
529
+ }
530
+ const timer = setTimeout(resolve, 25);
531
+ const waiter = () => {
532
+ clearTimeout(timer);
533
+ waiters.delete(waiter);
534
+ resolve();
535
+ };
536
+ waiters.add(waiter);
537
+ });
538
+ }
539
+ }
540
+ scheduleRecycle(backup) {
541
+ if (this.closed)
542
+ return;
543
+ const timer = setTimeout(() => {
544
+ this.recycle.delete(backup);
545
+ const work = this.fs.rm(backup, { recursive: true }).catch((error) => {
546
+ this.ctx.logger.warn('旧 Workspace 备份清理失败,请检查磁盘;当前版本保持可用。');
547
+ }).finally(() => { this.recycleWork.delete(backup); });
548
+ this.recycleWork.set(backup, work);
549
+ }, this.spec.recycleTimeoutMs);
550
+ this.recycle.set(backup, timer);
551
+ }
552
+ async prepareBackup(backup) {
553
+ const timer = this.recycle.get(backup);
554
+ if (timer !== undefined)
555
+ clearTimeout(timer);
556
+ this.recycle.delete(backup);
557
+ await this.recycleWork.get(backup);
558
+ }
559
+ }
560
+ export default GestaltWorkspaceBundles;
@@ -0,0 +1,23 @@
1
+ import type { Session } from '@deepseek-ai/dsh-session';
2
+ import type { WorkspaceAgentConfigVersion } from './agent-config-types.ts';
3
+ /** 一个 turn 使用的 Agent 配置版本;不含商家身份、地址或凭据。 */
4
+ export interface WorkspaceAgentConfigVersionEvent {
5
+ readonly workspaceKind: string;
6
+ readonly version: string;
7
+ readonly previousVersion: string | null;
8
+ readonly templateDigest: string;
9
+ readonly agentConfigSha256: string | null;
10
+ readonly hostAgentConfigApiVersion: string;
11
+ }
12
+ /**
13
+ * 在每个 Session 首次使用一个不同 Agent 配置版本的 turn 写入身份,重复 step 不重复记录。
14
+ * @param session - 即将请求模型的 Session。
15
+ * @param current - 当前租约固定的完整版本。
16
+ */
17
+ export declare function recordWorkspaceAgentConfigVersion(session: Session, current: WorkspaceAgentConfigVersion): void;
18
+ declare module '@deepseek-ai/dsh-session/types' {
19
+ interface SessionEventMap {
20
+ /** Session 开始使用一个不同的内置 Workspace Agent 配置版本;旧 reader 必须拒绝未知的必读事件。 */
21
+ 'workspace-bundle/agent-config-version': WorkspaceAgentConfigVersionEvent;
22
+ }
23
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * 在每个 Session 首次使用一个不同 Agent 配置版本的 turn 写入身份,重复 step 不重复记录。
3
+ * @param session - 即将请求模型的 Session。
4
+ * @param current - 当前租约固定的完整版本。
5
+ */
6
+ export function recordWorkspaceAgentConfigVersion(session, current) {
7
+ const previous = session.snapshotEvents().findLast(event => event.type === 'workspace-bundle/agent-config-version');
8
+ if (previous?.type === 'workspace-bundle/agent-config-version' && previous.data.version === current.version
9
+ && previous.data.agentConfigSha256 === current.sha256 && previous.data.templateDigest === current.templateDigest)
10
+ return;
11
+ session.append('workspace-bundle/agent-config-version', { workspaceKind: current.workspaceKind,
12
+ version: current.version, previousVersion: previous?.type === 'workspace-bundle/agent-config-version' ? previous.data.version : null,
13
+ templateDigest: current.templateDigest, agentConfigSha256: current.sha256, hostAgentConfigApiVersion: current.hostAgentConfigApiVersion });
14
+ }