@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.
@@ -0,0 +1,462 @@
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 { brandString } from '@deepseek-ai/dsh-brand';
7
+ import { Config, resolveWorkspaceBundlesSpec } from "./config.js";
8
+ import { WorkspaceBundles } from "./definition.js";
9
+ import { loadVerifiedManifest, verifyTemplateTree } from "./digest.js";
10
+ import { WorkspaceBundleError } from "./errors.js";
11
+ import { crashableFs, createNodeFs } from "./filesystem.js";
12
+ import { assertRecordPaths, committedRecord, idleJournal, readRecord, recordPath, writeJournal, writeRecord } from "./journal.js";
13
+ import { bundledDirectory, identityOf } from "./paths.js";
14
+ import { WorkspaceAgentConfigSourceOwner } from "./agent-config-source.js";
15
+ import { AgentConfigError } from "./agent-config-format.js";
16
+ function keyOf(identity) {
17
+ return `${identity.bundleId}/${identity.workspaceKey}`;
18
+ }
19
+ function originOf(identity) {
20
+ return { kind: 'bundle', bundleId: identity.bundleId, workspaceKey: identity.workspaceKey };
21
+ }
22
+ async function copyTree(fs, from, to, files) {
23
+ await fs.rm(to, { recursive: true });
24
+ await fs.mkdir(to);
25
+ for (const file of files) {
26
+ const relative = file.path.split('/');
27
+ const destination = join(to, ...relative);
28
+ await fs.mkdir(join(destination, '..'));
29
+ await fs.copyFile(join(from, ...relative), destination);
30
+ }
31
+ }
32
+ /** Provider for packaged Workspace templates. */
33
+ export class GestaltWorkspaceBundles extends WorkspaceBundles {
34
+ static inject = ['workspaceRegistry'];
35
+ static Config = Config;
36
+ /** Deployment settings resolved at load. */
37
+ spec;
38
+ fs;
39
+ templates = new Map();
40
+ agentConfigSources = new Map();
41
+ records = new Map();
42
+ leases = new Map();
43
+ waiters = new Map();
44
+ chain = Promise.resolve();
45
+ recycle = new Map();
46
+ recycleWork = new Map();
47
+ closed = false;
48
+ download;
49
+ /**
50
+ * @param ctx - Host context with the Workspace registry.
51
+ * @param config - raw row settings.
52
+ * @param options.fs - filesystem; tests inject crashes through `crashAt`.
53
+ * @param options.crashAt - single crash point; production omits it.
54
+ * @param options.fetch - 文件 HTTP 传输;默认使用 Host fetch。
55
+ */
56
+ constructor(ctx, config = {}, options = {}) {
57
+ super(ctx);
58
+ this.spec = resolveWorkspaceBundlesSpec(config);
59
+ this.fs = crashableFs(options.fs ?? createNodeFs(), options.crashAt);
60
+ this.download = options.fetch ?? fetch;
61
+ ctx.effect(() => async () => {
62
+ this.closed = true;
63
+ for (const timer of this.recycle.values())
64
+ clearTimeout(timer);
65
+ this.recycle.clear();
66
+ await Promise.all([...this.agentConfigSources.values()].map(source => source.dispose()));
67
+ await this.chain;
68
+ await Promise.all(this.recycleWork.values());
69
+ this.agentConfigSources.clear();
70
+ }, 'workspace-bundle: recycle timers');
71
+ }
72
+ /** @inheritdoc */
73
+ register(template) {
74
+ if (this.closed)
75
+ throw new WorkspaceBundleError('WORKSPACE_BUNDLE_NOT_REGISTERED', 'Workspace 内容服务已停止');
76
+ identityOf(template.bundleId, template.workspaceKey);
77
+ const key = keyOf(template);
78
+ this.templates.set(key, template);
79
+ const record = this.records.get(key);
80
+ if (record !== undefined)
81
+ this.records.set(key, { ...record, registered: true });
82
+ return () => {
83
+ if (this.templates.get(key) !== template)
84
+ return;
85
+ this.templates.delete(key);
86
+ const current = this.records.get(key);
87
+ if (current !== undefined)
88
+ this.records.set(key, { ...current, registered: false });
89
+ };
90
+ }
91
+ /** @inheritdoc */
92
+ ensure(identity) {
93
+ if (this.closed)
94
+ return Promise.reject(new WorkspaceBundleError('WORKSPACE_BUNDLE_NOT_REGISTERED', 'Workspace 内容服务已停止'));
95
+ identityOf(identity.bundleId, identity.workspaceKey);
96
+ return this.enqueue(() => this.ensureOne(identity));
97
+ }
98
+ /** @inheritdoc */
99
+ registerAgentConfigSource(source) {
100
+ if (this.closed)
101
+ throw new WorkspaceBundleError('WORKSPACE_BUNDLE_NOT_REGISTERED', 'Workspace 内容服务已停止');
102
+ const key = keyOf(source.identity);
103
+ const template = this.requireTemplate(source.identity);
104
+ if (this.agentConfigSources.has(key))
105
+ throw new WorkspaceBundleError('WORKSPACE_BUNDLE_TEMPLATE_INVALID', 'Agent 配置版本来源已注册');
106
+ const owner = new WorkspaceAgentConfigSourceOwner({ template, source }, this.spec, error => {
107
+ this.ctx.logger.warn('%s:%s', error.code, error.message);
108
+ }, this.download);
109
+ this.agentConfigSources.set(key, owner);
110
+ owner.start();
111
+ return async () => {
112
+ await owner.dispose();
113
+ if (this.agentConfigSources.get(key) === owner)
114
+ this.agentConfigSources.delete(key);
115
+ };
116
+ }
117
+ /** @inheritdoc */
118
+ agentConfig(identity) {
119
+ return this.agentConfigSources.get(keyOf(identity))?.state();
120
+ }
121
+ /** @inheritdoc */
122
+ async refreshAgentConfig(identity) {
123
+ const source = this.agentConfigSources.get(keyOf(identity));
124
+ if (source === undefined)
125
+ throw new WorkspaceBundleError('WORKSPACE_BUNDLE_NOT_REGISTERED', 'Agent 配置版本来源未注册');
126
+ await source.refresh();
127
+ }
128
+ /** @inheritdoc */
129
+ async acquire(workspaceId) {
130
+ if (this.closed)
131
+ throw new WorkspaceBundleError('WORKSPACE_BUNDLE_NOT_REGISTERED', 'Workspace 内容服务已停止');
132
+ if ([...this.records.values()].some(record => record.workspaceId === workspaceId && record.journal.phase === 'updating')) {
133
+ throw new WorkspaceBundleError('WORKSPACE_BUNDLE_NOT_REGISTERED', `workspace ${workspaceId} is updating`);
134
+ }
135
+ return this.enqueue(() => this.acquireOne(workspaceId));
136
+ }
137
+ async acquireOne(workspaceId) {
138
+ const record = [...this.records.values()].find(item => item.workspaceId === workspaceId);
139
+ if (record === undefined || !record.registered) {
140
+ throw new WorkspaceBundleError('WORKSPACE_BUNDLE_NOT_REGISTERED', `workspace ${workspaceId} is not an acquired bundle Workspace`);
141
+ }
142
+ if (record.journal.phase === 'updating') {
143
+ throw new WorkspaceBundleError('WORKSPACE_BUNDLE_NOT_REGISTERED', `workspace ${workspaceId} is updating`);
144
+ }
145
+ if ((this.leases.get(workspaceId)?.size ?? 0) === 0 && this.agentConfigSources.has(keyOf(record))) {
146
+ await this.ensureOne(record);
147
+ }
148
+ const token = Symbol('workspace-bundle-lease');
149
+ let leases = this.leases.get(workspaceId);
150
+ if (leases === undefined) {
151
+ leases = new Set();
152
+ this.leases.set(workspaceId, leases);
153
+ }
154
+ leases.add(token);
155
+ return () => {
156
+ leases.delete(token);
157
+ if (leases.size === 0)
158
+ this.leases.delete(workspaceId);
159
+ const waiters = this.waiters.get(workspaceId);
160
+ if (waiters !== undefined)
161
+ for (const waiter of waiters)
162
+ waiter();
163
+ };
164
+ }
165
+ /** @inheritdoc */
166
+ record(identity) {
167
+ return this.records.get(keyOf(identity));
168
+ }
169
+ /** @inheritdoc */
170
+ pathOf(identity) {
171
+ identityOf(identity.bundleId, identity.workspaceKey);
172
+ return bundledDirectory(this.spec.materializeRoot, identity);
173
+ }
174
+ /**
175
+ * Replay an interrupted journal before serving the Workspace.
176
+ * @param identity - branded pair.
177
+ * @throws {WorkspaceBundleError} Before any rename or deletion if stored paths differ from this identity's controlled paths.
178
+ */
179
+ async recover(identity) {
180
+ return this.enqueue(() => this.recoverOne(identity));
181
+ }
182
+ enqueue(work) {
183
+ const run = this.chain.then(work, work);
184
+ this.chain = run.then(() => undefined, () => undefined);
185
+ return run;
186
+ }
187
+ async ensureOne(identity) {
188
+ let template = this.requireTemplate(identity);
189
+ const source = this.agentConfigSources.get(keyOf(identity));
190
+ const stored = this.records.get(keyOf(identity)) ?? await this.loadStored(identity);
191
+ if (source !== undefined) {
192
+ template = await source.recover(stored?.templateDigest, stored?.pluginVersion, stored?.agentConfigSha256);
193
+ }
194
+ const recovered = await this.recoverOne(identity);
195
+ const record = this.records.get(keyOf(identity));
196
+ if (source !== undefined)
197
+ template = await source.recover(record?.templateDigest, record?.pluginVersion, record?.agentConfigSha256);
198
+ if (source !== undefined && (record?.workspaceId === null || record?.workspaceId === undefined
199
+ || (this.leases.get(record.workspaceId)?.size ?? 0) === 0))
200
+ template = source.template();
201
+ if (recovered !== undefined && record !== undefined
202
+ && record.templateDigest === template.manifest.digest && record.pluginVersion === template.manifest.pluginVersion
203
+ && (record.agentConfigSha256 ?? null) === (template.agentConfigSha256 ?? null) && record.journal.phase === 'idle') {
204
+ return recovered;
205
+ }
206
+ try {
207
+ const workspace = await this.replace(template, record, source === undefined ? undefined : () => source.assertApplicable(template));
208
+ await source?.committed(template);
209
+ return workspace;
210
+ }
211
+ catch (error) {
212
+ if (source === undefined)
213
+ throw error;
214
+ if (error instanceof AgentConfigError && error.code === 'AGENT_CONFIG_SCOPE_CHANGED') {
215
+ await this.rollbackAgentConfig(identity, record);
216
+ }
217
+ else
218
+ await this.recoverOne(identity);
219
+ const restored = this.records.get(keyOf(identity));
220
+ await source.recover(restored?.templateDigest, restored?.pluginVersion, restored?.agentConfigSha256);
221
+ source.failedApply(template, error);
222
+ if (error instanceof AgentConfigError && error.code === 'AGENT_CONFIG_SCOPE_CHANGED' && !this.closed) {
223
+ const workspace = restored === undefined || !await this.fs.exists(this.pathOf(identity))
224
+ ? undefined : await this.workspaceOf(restored);
225
+ return workspace ?? this.replace(this.requireTemplate(identity), restored);
226
+ }
227
+ this.ctx.logger.warn('Agent 配置应用失败,已尝试按 journal 恢复;当前版本未确认。');
228
+ throw error;
229
+ }
230
+ }
231
+ async rollbackAgentConfig(identity, previous) {
232
+ const current = this.records.get(keyOf(identity));
233
+ if (current === undefined) {
234
+ await this.fs.rm(this.stagingOf(identity), { recursive: true });
235
+ return;
236
+ }
237
+ if (current === previous && current.journal.phase === 'idle') {
238
+ await this.fs.rm(this.stagingOf(identity), { recursive: true });
239
+ return;
240
+ }
241
+ const builtin = this.requireTemplate(identity);
242
+ const base = previous ?? { ...current, pluginVersion: builtin.manifest.pluginVersion,
243
+ templateDigest: brandString('0'.repeat(64)), agentConfigSha256: null };
244
+ const rollback = { ...base, journal: { ...current.journal, phase: 'updating',
245
+ fromDigest: previous?.templateDigest ?? null, toDigest: current.templateDigest,
246
+ backupPath: await this.fs.exists(this.backupOf(identity)) ? this.backupOf(identity) : null,
247
+ stagingPath: this.stagingOf(identity), swapComplete: false } };
248
+ this.records.set(keyOf(identity), rollback);
249
+ await writeRecord(this.fs, this.recordFile(identity), rollback);
250
+ await this.restoreOld(identity, rollback);
251
+ }
252
+ requireTemplate(identity) {
253
+ const template = this.templates.get(keyOf(identity));
254
+ if (template === undefined) {
255
+ throw new WorkspaceBundleError('WORKSPACE_BUNDLE_NOT_REGISTERED', `${keyOf(identity)} is not registered`);
256
+ }
257
+ return template;
258
+ }
259
+ stagingOf(identity) {
260
+ return `${this.pathOf(identity)}.staging`;
261
+ }
262
+ backupOf(identity) {
263
+ return `${this.pathOf(identity)}.backup`;
264
+ }
265
+ recordFile(identity) {
266
+ return recordPath(this.spec.journalRoot, identity);
267
+ }
268
+ async loadStored(identity) {
269
+ const stored = await readRecord(this.fs, this.recordFile(identity));
270
+ if (stored !== undefined)
271
+ this.records.set(keyOf(identity), stored);
272
+ return stored;
273
+ }
274
+ async recoverOne(identity) {
275
+ const stored = this.records.get(keyOf(identity)) ?? await this.loadStored(identity);
276
+ if (stored === undefined)
277
+ return undefined;
278
+ await assertRecordPaths(this.fs, stored, this.pathOf(identity));
279
+ if (stored.journal.phase === 'idle')
280
+ return this.workspaceOf(stored);
281
+ return stored.journal.swapComplete ? this.finishNew(identity, stored) : this.restoreOld(identity, stored);
282
+ }
283
+ async workspaceOf(record) {
284
+ if (record.workspaceId === null)
285
+ return undefined;
286
+ return this.ctx.workspaceRegistry.get(record.workspaceId)
287
+ ?? await this.ctx.workspaceRegistry.create(record.path, undefined, originOf({
288
+ bundleId: record.bundleId, workspaceKey: record.workspaceKey,
289
+ }));
290
+ }
291
+ async restoreOld(identity, record) {
292
+ const root = this.pathOf(identity);
293
+ const backup = record.journal.backupPath ?? this.backupOf(identity);
294
+ const staging = record.journal.stagingPath ?? this.stagingOf(identity);
295
+ if (await this.fs.exists(backup)) {
296
+ if (await this.fs.exists(root))
297
+ await this.fs.rm(root, { recursive: true });
298
+ await this.fs.rename(backup, root);
299
+ }
300
+ else if (record.journal.fromDigest === null && await this.fs.exists(root))
301
+ await this.fs.rm(root, { recursive: true });
302
+ if (await this.fs.exists(staging))
303
+ await this.fs.rm(staging, { recursive: true });
304
+ const next = {
305
+ ...record,
306
+ journal: idleJournal(),
307
+ lastFailure: record.lastFailure,
308
+ };
309
+ await writeRecord(this.fs, this.recordFile(identity), next);
310
+ this.records.set(keyOf(identity), next);
311
+ return this.workspaceOf(next);
312
+ }
313
+ async finishNew(identity, record) {
314
+ let template = this.templates.get(keyOf(identity));
315
+ const root = this.pathOf(identity);
316
+ const toDigest = record.journal.toDigest;
317
+ try {
318
+ const source = this.agentConfigSources.get(keyOf(identity));
319
+ if (source !== undefined && toDigest !== null)
320
+ template = await source.recover(toDigest, record.journal.pluginVersion ?? undefined, record.journal.agentConfigSha256);
321
+ if (template !== undefined && toDigest === template.manifest.digest) {
322
+ await verifyTemplateTree(this.fs, root, template.manifest);
323
+ return this.commit(identity, record, template);
324
+ }
325
+ if (toDigest !== null && template === undefined) {
326
+ const cached = bundledDirectory(this.spec.templateCacheRoot, identity);
327
+ const manifest = await loadVerifiedManifest(this.fs, cached);
328
+ if (manifest.digest === toDigest) {
329
+ await verifyTemplateTree(this.fs, root, manifest);
330
+ const synthetic = {
331
+ bundleId: identity.bundleId, workspaceKey: identity.workspaceKey, title: 'Workspace',
332
+ sourceRoot: cached, manifest,
333
+ };
334
+ return this.commit(identity, record, synthetic);
335
+ }
336
+ }
337
+ }
338
+ catch {
339
+ // Fall through to restore the backup; the tree must be wholly old or wholly new.
340
+ }
341
+ return this.restoreOld(identity, record);
342
+ }
343
+ async replace(template, existing, assertApplicable) {
344
+ const identity = identityOf(template.bundleId, template.workspaceKey);
345
+ assertApplicable?.();
346
+ await loadVerifiedManifest(this.fs, template.sourceRoot);
347
+ const cache = bundledDirectory(this.spec.templateCacheRoot, identity);
348
+ await copyTree(this.fs, template.sourceRoot, cache, [...template.manifest.files, { path: 'manifest.json' }]);
349
+ const staging = this.stagingOf(identity);
350
+ await copyTree(this.fs, template.sourceRoot, staging, template.manifest.files);
351
+ assertApplicable?.();
352
+ this.fs.crash('after-staging');
353
+ const root = this.pathOf(identity);
354
+ const backup = this.backupOf(identity);
355
+ await this.prepareBackup(backup);
356
+ if (await this.fs.exists(backup))
357
+ await this.fs.rm(backup, { recursive: true });
358
+ const record = existing ?? {
359
+ version: 1,
360
+ bundleId: identity.bundleId,
361
+ workspaceKey: identity.workspaceKey,
362
+ workspaceId: null,
363
+ path: root,
364
+ pluginVersion: template.manifest.pluginVersion,
365
+ templateDigest: brandString('0'.repeat(64)),
366
+ registered: true,
367
+ journal: idleJournal(),
368
+ lastFailure: null,
369
+ };
370
+ const journal = {
371
+ phase: 'updating',
372
+ fromDigest: existing?.templateDigest ?? null,
373
+ toDigest: template.manifest.digest,
374
+ pluginVersion: template.manifest.pluginVersion,
375
+ stagingPath: staging,
376
+ backupPath: await this.fs.exists(root) ? backup : null,
377
+ swapComplete: false,
378
+ ...(template.agentConfigSha256 === undefined ? {} : { agentConfigSha256: template.agentConfigSha256 }),
379
+ };
380
+ const updating = await writeJournal(this.fs, this.recordFile(identity), { ...record, registered: true, path: root }, journal);
381
+ this.records.set(keyOf(identity), updating);
382
+ this.fs.crash('after-journal-updating');
383
+ if (updating.workspaceId !== null)
384
+ await this.ctx.serial('workspace-bundle/quiesce', updating.workspaceId);
385
+ await this.quiesce(updating.workspaceId);
386
+ assertApplicable?.();
387
+ this.fs.crash('after-quiesce');
388
+ await this.fs.mkdir(join(root, '..'));
389
+ if (await this.fs.exists(root)) {
390
+ if (await this.fs.exists(backup))
391
+ await this.fs.rm(backup, { recursive: true });
392
+ await this.fs.rename(root, backup);
393
+ }
394
+ this.fs.crash('after-rename-backup');
395
+ assertApplicable?.();
396
+ await this.fs.rename(staging, root);
397
+ const swapped = await writeJournal(this.fs, this.recordFile(identity), updating, { ...journal, swapComplete: true, stagingPath: null });
398
+ this.records.set(keyOf(identity), swapped);
399
+ this.fs.crash('after-rename-staging');
400
+ await verifyTemplateTree(this.fs, root, template.manifest);
401
+ this.fs.crash('after-verify');
402
+ assertApplicable?.();
403
+ const workspace = await this.commit(identity, swapped, template);
404
+ assertApplicable?.();
405
+ this.fs.crash('after-journal-commit');
406
+ this.scheduleRecycle(backup);
407
+ return workspace;
408
+ }
409
+ async commit(identity, record, template) {
410
+ const workspace = await this.ctx.workspaceRegistry.create(record.path, template.title, originOf(identity));
411
+ const next = committedRecord({ ...record, workspaceId: workspace.id, path: workspace.path, registered: true,
412
+ ...(this.agentConfigSources.has(keyOf(identity)) ? { agentConfigSha256: template.agentConfigSha256 ?? null } : {}),
413
+ }, template.manifest.digest, template.manifest.pluginVersion);
414
+ await writeRecord(this.fs, this.recordFile(identity), next);
415
+ this.records.set(keyOf(identity), next);
416
+ return workspace;
417
+ }
418
+ async quiesce(workspaceId) {
419
+ if (workspaceId === null)
420
+ return;
421
+ const deadline = Date.now() + this.spec.quiesceTimeoutMs;
422
+ while ((this.leases.get(workspaceId)?.size ?? 0) > 0) {
423
+ if (Date.now() >= deadline) {
424
+ throw new WorkspaceBundleError('WORKSPACE_BUNDLE_QUIESCE_TIMEOUT', `workspace ${workspaceId} still has active leases`);
425
+ }
426
+ await new Promise(resolve => {
427
+ let waiters = this.waiters.get(workspaceId);
428
+ if (waiters === undefined) {
429
+ waiters = new Set();
430
+ this.waiters.set(workspaceId, waiters);
431
+ }
432
+ const timer = setTimeout(resolve, 25);
433
+ const waiter = () => {
434
+ clearTimeout(timer);
435
+ waiters.delete(waiter);
436
+ resolve();
437
+ };
438
+ waiters.add(waiter);
439
+ });
440
+ }
441
+ }
442
+ scheduleRecycle(backup) {
443
+ if (this.closed)
444
+ return;
445
+ const timer = setTimeout(() => {
446
+ this.recycle.delete(backup);
447
+ const work = this.fs.rm(backup, { recursive: true }).catch((error) => {
448
+ this.ctx.logger.warn('旧 Workspace 备份清理失败,请检查磁盘;当前版本保持可用。');
449
+ }).finally(() => { this.recycleWork.delete(backup); });
450
+ this.recycleWork.set(backup, work);
451
+ }, this.spec.recycleTimeoutMs);
452
+ this.recycle.set(backup, timer);
453
+ }
454
+ async prepareBackup(backup) {
455
+ const timer = this.recycle.get(backup);
456
+ if (timer !== undefined)
457
+ clearTimeout(timer);
458
+ this.recycle.delete(backup);
459
+ await this.recycleWork.get(backup);
460
+ }
461
+ }
462
+ 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
+ }
@@ -0,0 +1,123 @@
1
+ /** Client-safe types of the WorkspaceBundles seam; importing them loads no Host code. */
2
+ import type { Branded } from '@deepseek-ai/dsh-brand';
3
+ import type { WorkspaceBundleId, WorkspaceBundleKey, WorkspaceId, WorkspaceOrigin } from '@deepseek-ai/dsh-workspace/types';
4
+ export type { WorkspaceBundleId, WorkspaceBundleKey, WorkspaceId, WorkspaceOrigin };
5
+ /** SHA-256 hex digest of one template file. */
6
+ export type WorkspaceTemplateFileDigest = Branded<'WorkspaceTemplateFileDigest'>;
7
+ /** SHA-256 hex digest of the ordered path+file-digest catalog. */
8
+ export type WorkspaceTemplateDigest = Branded<'WorkspaceTemplateDigest'>;
9
+ /** One file listed in a template manifest. */
10
+ export interface WorkspaceTemplateFile {
11
+ readonly path: string;
12
+ readonly bytes: number;
13
+ readonly digest: WorkspaceTemplateFileDigest;
14
+ }
15
+ /** Repository and commit of the packaged customer-service instruction files. */
16
+ export interface WorkspaceTemplateSource {
17
+ readonly repository: string;
18
+ readonly commit: string;
19
+ }
20
+ /** Verified catalog of a packaged Workspace template. */
21
+ export interface WorkspaceTemplateManifest {
22
+ readonly formatVersion: 1;
23
+ readonly pluginVersion: string;
24
+ readonly source?: WorkspaceTemplateSource;
25
+ readonly files: readonly WorkspaceTemplateFile[];
26
+ readonly digest: WorkspaceTemplateDigest;
27
+ }
28
+ /** A registered template the Host can materialize. */
29
+ export interface WorkspaceTemplate {
30
+ readonly bundleId: WorkspaceBundleId;
31
+ readonly workspaceKey: WorkspaceBundleKey;
32
+ readonly title: string;
33
+ readonly sourceRoot: string;
34
+ readonly manifest: WorkspaceTemplateManifest;
35
+ /** 已下载包的整体摘要;内置模板不携带此字段。 */
36
+ readonly agentConfigSha256?: string;
37
+ }
38
+ /** Deployment settings the Provider resolves once at load. */
39
+ export interface WorkspaceBundlesSpec {
40
+ /** Parent of each `{bundleId}/{workspaceKey}` Workspace root. */
41
+ readonly materializeRoot: string;
42
+ /** Parent of the immutable template copies used when the package source is gone. */
43
+ readonly templateCacheRoot: string;
44
+ /** Parent of per-Workspace journal files. */
45
+ readonly journalRoot: string;
46
+ /** How long upgrade waits for acquired leases to drain, in milliseconds. */
47
+ readonly quiesceTimeoutMs: number;
48
+ /** How long a successful upgrade waits before deleting the backup, in milliseconds. */
49
+ readonly recycleTimeoutMs: number;
50
+ /** 内容分配查询间隔,单位毫秒。 */
51
+ readonly agentConfigPollIntervalMs: number;
52
+ /** 每次查询和下载共享的超时,单位毫秒。 */
53
+ readonly agentConfigRequestTimeoutMs: number;
54
+ /** 压缩Agent 配置字节上限。 */
55
+ readonly agentConfigMaxDownloadBytes: number;
56
+ /** 解压 JSON 和文件总字节上限。 */
57
+ readonly agentConfigMaxExpandedBytes: number;
58
+ /** 仅允许显式配置的本地测试文件服务使用 HTTP。 */
59
+ readonly allowLoopbackAgentConfigHttp: boolean;
60
+ }
61
+ /** Named configuration failure. */
62
+ export type WorkspaceBundlesConfigErrorCode = 'WORKSPACE_BUNDLES_MATERIALIZE_ROOT_MISSING' | 'WORKSPACE_BUNDLES_TEMPLATE_CACHE_ROOT_MISSING' | 'WORKSPACE_BUNDLES_JOURNAL_ROOT_MISSING' | 'WORKSPACE_BUNDLES_PATH_INVALID' | 'WORKSPACE_BUNDLES_TIMEOUT_INVALID';
63
+ /** Named runtime failure of template access, materialize, or upgrade. */
64
+ export type WorkspaceBundleErrorCode = 'WORKSPACE_BUNDLE_NOT_REGISTERED' | 'WORKSPACE_BUNDLE_TEMPLATE_INVALID' | 'WORKSPACE_BUNDLE_PATH_TRAVERSAL' | 'WORKSPACE_BUNDLE_SYMLINK_REJECTED' | 'WORKSPACE_BUNDLE_MISSING_ASSET' | 'WORKSPACE_BUNDLE_DIGEST_MISMATCH' | 'WORKSPACE_BUNDLE_QUIESCE_TIMEOUT' | 'WORKSPACE_BUNDLE_CRASH_INJECTED' | 'WORKSPACE_BUNDLE_IDENTITY_INVALID';
65
+ /** Observable upgrade barrier: consumers must not read a half-replaced tree. */
66
+ export type WorkspaceBundleBarrier = 'open' | 'updating';
67
+ /** Durable upgrade journal. `updating` means consumers are barred. */
68
+ export interface WorkspaceBundleJournal {
69
+ readonly phase: 'idle' | 'updating';
70
+ readonly fromDigest: WorkspaceTemplateDigest | null;
71
+ readonly toDigest: WorkspaceTemplateDigest | null;
72
+ readonly pluginVersion: string | null;
73
+ readonly stagingPath: string | null;
74
+ readonly backupPath: string | null;
75
+ readonly swapComplete: boolean;
76
+ /** 新Agent 配置身份;旧 journal 没有此字段。 */
77
+ readonly agentConfigSha256?: string | null;
78
+ }
79
+ /** Durable product record for one bundled Workspace. */
80
+ export interface WorkspaceBundleRecord {
81
+ readonly version: 1;
82
+ readonly bundleId: WorkspaceBundleId;
83
+ readonly workspaceKey: WorkspaceBundleKey;
84
+ readonly workspaceId: WorkspaceId | null;
85
+ readonly path: string;
86
+ readonly pluginVersion: string;
87
+ readonly templateDigest: WorkspaceTemplateDigest;
88
+ /** 已提交Agent 配置身份;旧 record 缺失时表示内置模板。 */
89
+ readonly agentConfigSha256?: string | null;
90
+ readonly registered: boolean;
91
+ readonly journal: WorkspaceBundleJournal;
92
+ readonly lastFailure: {
93
+ readonly code: WorkspaceBundleErrorCode;
94
+ readonly message: string;
95
+ readonly at: string;
96
+ } | null;
97
+ }
98
+ /** Points where tests inject a crash; production never sets them. */
99
+ export type WorkspaceBundleCrashPoint = 'after-staging' | 'after-journal-updating' | 'after-quiesce' | 'after-rename-backup' | 'after-rename-staging' | 'after-verify' | 'after-journal-commit';
100
+ /** Filesystem operations the materializer uses so tests can inject crashes. */
101
+ export interface WorkspaceBundleFs {
102
+ mkdir(path: string): Promise<void>;
103
+ writeFile(path: string, data: Uint8Array | string): Promise<void>;
104
+ readFile(path: string): Promise<Buffer>;
105
+ rename(from: string, to: string): Promise<void>;
106
+ rm(path: string, options?: {
107
+ recursive?: boolean;
108
+ }): Promise<void>;
109
+ lstat(path: string): Promise<{
110
+ isFile(): boolean;
111
+ isDirectory(): boolean;
112
+ isSymbolicLink(): boolean;
113
+ }>;
114
+ readdir(path: string): Promise<readonly string[]>;
115
+ realpath(path: string): Promise<string>;
116
+ copyFile(from: string, to: string): Promise<void>;
117
+ exists(path: string): Promise<boolean>;
118
+ }
119
+ /** Identity of one bundled Workspace. */
120
+ export interface WorkspaceBundleIdentity {
121
+ readonly bundleId: WorkspaceBundleId;
122
+ readonly workspaceKey: WorkspaceBundleKey;
123
+ }
@@ -0,0 +1 @@
1
+ export {};
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Workspace templates",
4
+ "description": "Register, materialize, and wholly replace packaged Workspace templates"
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "工作区模板",
4
+ "description": "内置工作区模板的注册、物化与整体升级"
5
+ }
6
+ }