@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,109 @@
1
+ /** Durable upgrade journal: staging → updating → swap → verify → commit, with crash recovery. */
2
+ import { basename, dirname, join } from 'node:path';
3
+ import { bundledDirectory } from "./paths.js";
4
+ import { WorkspaceBundleError } from "./errors.js";
5
+ const IDLE = {
6
+ phase: 'idle',
7
+ fromDigest: null,
8
+ toDigest: null,
9
+ pluginVersion: null,
10
+ stagingPath: null,
11
+ backupPath: null,
12
+ swapComplete: false,
13
+ };
14
+ /** @returns a journal with no in-flight replacement. */
15
+ export function idleJournal() {
16
+ return IDLE;
17
+ }
18
+ /**
19
+ * @param parent - journal root from Spec.
20
+ * @param identity - bundled Workspace.
21
+ */
22
+ export function journalPath(parent, identity) {
23
+ return join(bundledDirectory(parent, identity), 'journal.json');
24
+ }
25
+ /**
26
+ * @param parent - journal root from Spec.
27
+ * @param identity - bundled Workspace.
28
+ */
29
+ export function recordPath(parent, identity) {
30
+ return join(bundledDirectory(parent, identity), 'record.json');
31
+ }
32
+ async function writeJson(fs, path, value) {
33
+ const tmp = `${path}.tmp`;
34
+ await fs.writeFile(tmp, `${JSON.stringify(value)}\n`);
35
+ await fs.rename(tmp, path);
36
+ }
37
+ /**
38
+ * Persist the product record.
39
+ * @param fs - filesystem.
40
+ * @param path - record.json path.
41
+ * @param record - value.
42
+ */
43
+ export async function writeRecord(fs, path, record) {
44
+ await writeJson(fs, path, record);
45
+ }
46
+ /**
47
+ * Read a product record when present.
48
+ * @param fs - filesystem.
49
+ * @param path - record.json path.
50
+ */
51
+ export async function readRecord(fs, path) {
52
+ if (!await fs.exists(path))
53
+ return undefined;
54
+ const value = JSON.parse((await fs.readFile(path)).toString('utf8'));
55
+ if (value.version !== 1)
56
+ throw new Error(`workspace-bundle: unsupported record version ${String(value.version)}`);
57
+ return value;
58
+ }
59
+ async function canonicalPath(fs, path) {
60
+ try {
61
+ return await fs.realpath(path);
62
+ }
63
+ catch (error) {
64
+ if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT' || dirname(path) === path)
65
+ throw error;
66
+ // A replacement can leave root or staging absent; existing ancestors still resolve directory aliases.
67
+ return join(await canonicalPath(fs, dirname(path)), basename(path));
68
+ }
69
+ }
70
+ /**
71
+ * Reject stored paths whose canonical locations differ from the identity's controlled directories.
72
+ * @param fs - filesystem used by recovery, including realpath resolution.
73
+ * @param record - stored product record.
74
+ * @param root - materialize directory derived from the requested identity and current Spec.
75
+ * @returns fulfills when every stored path has the same canonical location as its controlled directory.
76
+ * @throws {WorkspaceBundleError} Before recovery mutates files when a stored path differs.
77
+ */
78
+ export async function assertRecordPaths(fs, record, root) {
79
+ for (const [stored, controlled] of [
80
+ [record.path, root],
81
+ [record.journal.stagingPath, `${root}.staging`],
82
+ [record.journal.backupPath, `${root}.backup`],
83
+ ]) {
84
+ if (stored !== null && await canonicalPath(fs, stored) !== await canonicalPath(fs, controlled)) {
85
+ throw new WorkspaceBundleError('WORKSPACE_BUNDLE_PATH_TRAVERSAL', `stored Workspace paths must match the canonical controlled directory ${controlled}`);
86
+ }
87
+ }
88
+ }
89
+ /**
90
+ * Persist the journal as part of the product record.
91
+ * @param fs - filesystem.
92
+ * @param path - record.json path.
93
+ * @param record - current record.
94
+ * @param journal - next journal.
95
+ */
96
+ export async function writeJournal(fs, path, record, journal) {
97
+ const next = { ...record, journal };
98
+ await writeRecord(fs, path, next);
99
+ return next;
100
+ }
101
+ /**
102
+ * Mark a replacement as committed: idle journal, new digest and plugin version.
103
+ * @param record - current record.
104
+ * @param digest - verified tree digest.
105
+ * @param pluginVersion - packaged plugin version.
106
+ */
107
+ export function committedRecord(record, digest, pluginVersion) {
108
+ return { ...record, pluginVersion, templateDigest: digest, journal: idleJournal(), lastFailure: null };
109
+ }
@@ -0,0 +1,65 @@
1
+ import type { MerchantHostIdentity } from '@gestaltrun/dsh-merchant-account';
2
+ import type { WorkspaceAgentConfigHostId } from './agent-config-observation.ts';
3
+ /** Server and member whose local modules report under one identity; the server keeps one current local Host per member and module. */
4
+ export type LocalHostIdentityScope = Pick<MerchantHostIdentity, 'serverOrigin' | 'userId'>;
5
+ /** Identity read from its owning module when a queued publish commits. */
6
+ export interface LocalHostIdentityValue {
7
+ readonly hostId: WorkspaceAgentConfigHostId;
8
+ /** The server confirmed this identity against the installation key; a confirmed identity is never replaced. */
9
+ readonly confirmed: boolean;
10
+ }
11
+ /** Shared identity exposed by the WorkspaceBundles owner. */
12
+ export interface LocalHostIdentity {
13
+ /** @param scope - signed-in server and member. @returns the persisted identity, or undefined before its owner published one. */
14
+ get(scope: LocalHostIdentityScope): WorkspaceAgentConfigHostId | undefined;
15
+ /**
16
+ * Persist the owner's identity after earlier publishes; readers observe it only after the write succeeds.
17
+ * A different identity replaces only an unconfirmed one, so readers never return to an identity the server already replaced.
18
+ * @param scope - member the identity belongs to.
19
+ * @param read - owner's identity for that scope, read when this publish commits; undefined skips the write, for an owner disposed meanwhile.
20
+ * @returns whether readers now observe a different identity.
21
+ */
22
+ publish(scope: LocalHostIdentityScope, read: () => LocalHostIdentityValue | undefined): Promise<boolean>;
23
+ }
24
+ /** Load stops instead of letting modules report under their own identities, which the server may already have replaced. */
25
+ export declare class LocalHostIdentityLoadError extends Error {
26
+ /** @param path - state file that could not be read or parsed. @param cause - underlying read or validation failure. */
27
+ constructor(path: string, cause: unknown);
28
+ }
29
+ /** The previous identity remains visible; the owner reports this failure and keeps its own reporting. */
30
+ export declare class LocalHostIdentityWriteError extends Error {
31
+ /** @param cause - underlying private filesystem failure. */
32
+ constructor(cause: unknown);
33
+ }
34
+ /** Loads synchronously so the first module report already reads the persisted identity. */
35
+ export declare class LocalHostIdentityState implements LocalHostIdentity {
36
+ private readonly path;
37
+ private state;
38
+ private writes;
39
+ /** @param path - owner-only state file; null creates an explicit in-memory owner. An unreadable or invalid file fails load instead of exposing no identity. */
40
+ constructor(path: string | null);
41
+ /** @inheritdoc */
42
+ get(scope: LocalHostIdentityScope): WorkspaceAgentConfigHostId | undefined;
43
+ /** @inheritdoc */
44
+ publish(scope: LocalHostIdentityScope, read: () => LocalHostIdentityValue | undefined): Promise<boolean>;
45
+ private save;
46
+ }
47
+ /** Merchant session and WorkspaceBundles members read by {@link sharedLocalHostId}; a Host context satisfies it. */
48
+ export interface LocalHostIdentitySources {
49
+ readonly merchantAccount: {
50
+ captureIdentity(): {
51
+ readonly identity: LocalHostIdentityScope;
52
+ } | null;
53
+ };
54
+ readonly workspaceBundles: {
55
+ readonly localHostIdentity: LocalHostIdentity;
56
+ };
57
+ }
58
+ /**
59
+ * Identity getter for a local module reporter: the shared identity of the signed-in member, otherwise the module's own identity.
60
+ * Pass it as `hostId` and keep the fallback fixed; once an identity is shared, the reporter never returns to the fallback.
61
+ * @param ctx - context with the merchant session and WorkspaceBundles.
62
+ * @param fallback - module identity used before any local module published a shared one.
63
+ * @returns getter read before each registration.
64
+ */
65
+ export declare function sharedLocalHostId(ctx: LocalHostIdentitySources, fallback: WorkspaceAgentConfigHostId): () => WorkspaceAgentConfigHostId;
@@ -0,0 +1,87 @@
1
+ /** One durable local Host identity per server and member, reused by every local module observation. */
2
+ import { createHash } from 'node:crypto';
3
+ import { readFileSync } from 'node:fs';
4
+ import { brandString } from '@deepseek-ai/dsh-brand';
5
+ import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
6
+ import { z } from 'zod';
7
+ const State = z.strictObject({ version: z.literal(1), scopes: z.record(z.string().regex(/^[a-f0-9]{64}$/u), z.strictObject({ hostId: z.uuid(), confirmed: z.boolean() })) });
8
+ function keyOf(scope) {
9
+ return createHash('sha256').update(JSON.stringify([scope.serverOrigin, scope.userId])).digest('hex');
10
+ }
11
+ /** Load stops instead of letting modules report under their own identities, which the server may already have replaced. */
12
+ export class LocalHostIdentityLoadError extends Error {
13
+ /** @param path - state file that could not be read or parsed. @param cause - underlying read or validation failure. */
14
+ constructor(path, cause) {
15
+ super(`The shared local Host identity file ${path} cannot be read or is invalid. Restore it from a backup or fix its permissions; deleting it makes local modules report under their own identities and may require reactivating local hosting.`, { cause });
16
+ }
17
+ }
18
+ /** The previous identity remains visible; the owner reports this failure and keeps its own reporting. */
19
+ export class LocalHostIdentityWriteError extends Error {
20
+ /** @param cause - underlying private filesystem failure. */
21
+ constructor(cause) { super('LOCAL_HOST_IDENTITY_WRITE_FAILED', { cause }); }
22
+ }
23
+ /** Loads synchronously so the first module report already reads the persisted identity. */
24
+ export class LocalHostIdentityState {
25
+ path;
26
+ state = { version: 1, scopes: {} };
27
+ writes = Promise.resolve();
28
+ /** @param path - owner-only state file; null creates an explicit in-memory owner. An unreadable or invalid file fails load instead of exposing no identity. */
29
+ constructor(path) {
30
+ this.path = path;
31
+ if (path === null)
32
+ return;
33
+ try {
34
+ this.state = State.parse(JSON.parse(readFileSync(path, 'utf8')));
35
+ }
36
+ catch (error) {
37
+ if (error.code === 'ENOENT')
38
+ return;
39
+ throw new LocalHostIdentityLoadError(path, error);
40
+ }
41
+ }
42
+ /** @inheritdoc */
43
+ get(scope) {
44
+ const entry = this.state.scopes[keyOf(scope)];
45
+ return entry === undefined ? undefined : brandString(entry.hostId);
46
+ }
47
+ /** @inheritdoc */
48
+ publish(scope, read) {
49
+ const work = this.writes.then(async () => {
50
+ const key = keyOf(scope), previous = this.state.scopes[key], next = read();
51
+ if (next === undefined)
52
+ return false;
53
+ if (previous !== undefined && (previous.hostId === next.hostId ? previous.confirmed || !next.confirmed : previous.confirmed))
54
+ return false;
55
+ const state = { ...this.state, scopes: { ...this.state.scopes, [key]: { hostId: next.hostId, confirmed: next.confirmed } } };
56
+ await this.save(state);
57
+ this.state = state;
58
+ return previous?.hostId !== next.hostId;
59
+ });
60
+ // Each returned publish reports its own failure; later publishes must still run.
61
+ this.writes = work.then(() => { }, (error) => { void error; });
62
+ return work;
63
+ }
64
+ async save(state) {
65
+ if (this.path === null)
66
+ return;
67
+ try {
68
+ await writeFileAtomic(this.path, JSON.stringify(state) + '\n', { mode: 0o600, dirMode: 0o700 });
69
+ }
70
+ catch (error) {
71
+ throw new LocalHostIdentityWriteError(error);
72
+ }
73
+ }
74
+ }
75
+ /**
76
+ * Identity getter for a local module reporter: the shared identity of the signed-in member, otherwise the module's own identity.
77
+ * Pass it as `hostId` and keep the fallback fixed; once an identity is shared, the reporter never returns to the fallback.
78
+ * @param ctx - context with the merchant session and WorkspaceBundles.
79
+ * @param fallback - module identity used before any local module published a shared one.
80
+ * @returns getter read before each registration.
81
+ */
82
+ export function sharedLocalHostId(ctx, fallback) {
83
+ return () => {
84
+ const scope = ctx.merchantAccount.captureIdentity()?.identity;
85
+ return (scope === undefined ? undefined : ctx.workspaceBundles.localHostIdentity.get(scope)) ?? fallback;
86
+ };
87
+ }
@@ -0,0 +1,49 @@
1
+ import type { AgentConfigModule } from './agent-config-contract/agent-configs.ts';
2
+ import type { MerchantHostIdentity } from '@gestaltrun/dsh-merchant-account';
3
+ /** Server, member and selected shop whose local modules share one activation decision. */
4
+ export type LocalHostMember = Pick<MerchantHostIdentity, 'serverOrigin' | 'userId' | 'currentTenantId'>;
5
+ /** Shared member fence exposed by the WorkspaceBundles owner. */
6
+ export interface LocalHostReplacement {
7
+ /** @param member - current member. @returns whether explicit activation is required. */
8
+ isReplaced(member: LocalHostMember): boolean;
9
+ /** @param listener - reacts to changes. @returns its disposer. */
10
+ subscribe(listener: () => void): () => void;
11
+ /** Stop immediately in memory, then persist; a rejection keeps the stop and permits the next call to retry. @param member - rejected member. @returns completion of the durable write. */
12
+ markReplaced(member: LocalHostMember): Promise<void>;
13
+ /** @param member - member whose two modules have acknowledged activation. @returns completion of the durable clear; failure keeps reception stopped. */
14
+ activate(member: LocalHostMember): Promise<void>;
15
+ /** @param member - current member. @param module - legacy source. @returns whether its state was imported. */
16
+ migrated(member: LocalHostMember, module: AgentConfigModule): boolean;
17
+ /** @param member - current member. @param module - legacy source. @param replaced - previous stop flag. @returns completion before any legacy marker may be removed. */
18
+ importLegacy(member: LocalHostMember, module: AgentConfigModule, replaced: boolean): Promise<void>;
19
+ }
20
+ /** The memory fence remains active; callers retain fallback markers and report this failure. */
21
+ export declare class LocalHostReplacementWriteError extends Error {
22
+ /** @param cause - underlying private filesystem failure. */
23
+ constructor(cause: unknown);
24
+ }
25
+ /** Shared by module reporters, lease supervision and checkpoints within one Host. */
26
+ export declare class LocalHostReplacementState implements LocalHostReplacement {
27
+ private readonly path;
28
+ private state;
29
+ private readonly listeners;
30
+ private persisted;
31
+ private writes;
32
+ private readonly rejections;
33
+ /** @param path - owner-only state file; null creates an explicit in-memory owner. */
34
+ constructor(path: string | null);
35
+ /** @param member - current authenticated member. @returns whether explicit activation is required. */
36
+ isReplaced(member: LocalHostMember): boolean;
37
+ /** @param listener - reacts to a changed fence. @returns its disposer. */
38
+ subscribe(listener: () => void): () => void;
39
+ /** @param member - rejected member. Stops consumers before persistence starts. @returns durable completion; failure permits a later retry. */
40
+ markReplaced(member: LocalHostMember): Promise<void>;
41
+ /** @param member - member whose two module registrations have both been acknowledged. @returns durable completion, unless a newer rejection requires another activation. */
42
+ activate(member: LocalHostMember): Promise<void>;
43
+ /** @param member - current member. @param module - module whose legacy files were inspected. @returns whether migration is complete. */
44
+ migrated(member: LocalHostMember, module: AgentConfigModule): boolean;
45
+ /** @param member - current member. @param module - legacy source. @param replaced - whether any prior installation was stopped. @returns durable completion before the caller removes fallback flags. */
46
+ importLegacy(member: LocalHostMember, module: AgentConfigModule, replaced: boolean): Promise<void>;
47
+ private enqueue;
48
+ private save;
49
+ }
@@ -0,0 +1,112 @@
1
+ /** One durable replacement fence per authenticated shop member, independent of installation and module. */
2
+ import { createHash } from 'node:crypto';
3
+ import { readFileSync } from 'node:fs';
4
+ import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
5
+ import { z } from 'zod';
6
+ const State = z.strictObject({ version: z.literal(1), replaced: z.array(z.string().regex(/^[a-f0-9]{64}$/u)), migrated: z.array(z.string().regex(/^[a-f0-9]{64}\/(cs-hosting|resource-intake)$/u)) });
7
+ function keyOf(member) {
8
+ return createHash('sha256').update(JSON.stringify([member.serverOrigin, member.userId, member.currentTenantId])).digest('hex');
9
+ }
10
+ /** The memory fence remains active; callers retain fallback markers and report this failure. */
11
+ export class LocalHostReplacementWriteError extends Error {
12
+ /** @param cause - underlying private filesystem failure. */
13
+ constructor(cause) { super('LOCAL_HOST_REPLACEMENT_WRITE_FAILED', { cause }); }
14
+ }
15
+ /** Shared by module reporters, lease supervision and checkpoints within one Host. */
16
+ export class LocalHostReplacementState {
17
+ path;
18
+ state = { version: 1, replaced: [], migrated: [] };
19
+ listeners = new Set();
20
+ persisted = this.state;
21
+ writes = Promise.resolve();
22
+ rejections = new Map();
23
+ /** @param path - owner-only state file; null creates an explicit in-memory owner. */
24
+ constructor(path) {
25
+ this.path = path;
26
+ if (path === null)
27
+ return;
28
+ try {
29
+ this.state = State.parse(JSON.parse(readFileSync(path, 'utf8')));
30
+ }
31
+ catch (error) {
32
+ if (error.code !== 'ENOENT')
33
+ throw error;
34
+ }
35
+ this.persisted = this.state;
36
+ }
37
+ /** @param member - current authenticated member. @returns whether explicit activation is required. */
38
+ isReplaced(member) { return this.state.replaced.includes(keyOf(member)); }
39
+ /** @param listener - reacts to a changed fence. @returns its disposer. */
40
+ subscribe(listener) { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }
41
+ /** @param member - rejected member. Stops consumers before persistence starts. @returns durable completion; failure permits a later retry. */
42
+ markReplaced(member) {
43
+ const key = keyOf(member);
44
+ this.rejections.set(key, (this.rejections.get(key) ?? 0) + 1);
45
+ if (!this.state.replaced.includes(key)) {
46
+ this.state = { ...this.state, replaced: [...this.state.replaced, key] };
47
+ for (const listener of this.listeners)
48
+ listener();
49
+ }
50
+ if (this.persisted === this.state)
51
+ return Promise.resolve();
52
+ return this.enqueue(async () => {
53
+ const state = this.state;
54
+ await this.save(state);
55
+ this.persisted = state;
56
+ });
57
+ }
58
+ /** @param member - member whose two module registrations have both been acknowledged. @returns durable completion, unless a newer rejection requires another activation. */
59
+ activate(member) {
60
+ const key = keyOf(member), rejection = this.rejections.get(key);
61
+ if (!this.state.replaced.includes(key))
62
+ return Promise.resolve();
63
+ this.persisted = undefined;
64
+ return this.enqueue(async () => {
65
+ const previous = this.state;
66
+ if (!previous.replaced.includes(key))
67
+ return;
68
+ if (this.rejections.get(key) !== rejection)
69
+ throw new Error('LOCAL_HOST_REPLACED');
70
+ const next = { ...previous, replaced: previous.replaced.filter(value => value !== key) };
71
+ await this.save(next);
72
+ if (this.state !== previous || this.rejections.get(key) !== rejection) {
73
+ await this.save(this.state);
74
+ throw new Error('LOCAL_HOST_REPLACED');
75
+ }
76
+ this.persisted = this.state = next;
77
+ for (const listener of this.listeners)
78
+ listener();
79
+ });
80
+ }
81
+ /** @param member - current member. @param module - module whose legacy files were inspected. @returns whether migration is complete. */
82
+ migrated(member, module) { return this.state.migrated.includes(`${keyOf(member)}/${module}`); }
83
+ /** @param member - current member. @param module - legacy source. @param replaced - whether any prior installation was stopped. @returns durable completion before the caller removes fallback flags. */
84
+ async importLegacy(member, module, replaced) {
85
+ if (replaced)
86
+ await this.markReplaced(member);
87
+ if (this.migrated(member, module))
88
+ return;
89
+ this.state = { ...this.state, migrated: [...this.state.migrated, `${keyOf(member)}/${module}`] };
90
+ await this.enqueue(async () => {
91
+ const state = this.state;
92
+ await this.save(state);
93
+ this.persisted = state;
94
+ });
95
+ }
96
+ enqueue(write) {
97
+ const work = this.writes.then(write);
98
+ // Each returned operation reports its own failure; later writes must still run.
99
+ this.writes = work.catch((error) => { void error; });
100
+ return work;
101
+ }
102
+ async save(state) {
103
+ if (this.path === null)
104
+ return;
105
+ try {
106
+ await writeFileAtomic(this.path, JSON.stringify(state) + '\n', { mode: 0o600, dirMode: 0o700 });
107
+ }
108
+ catch (error) {
109
+ throw new LocalHostReplacementWriteError(error);
110
+ }
111
+ }
112
+ }
@@ -0,0 +1,26 @@
1
+ import type { WorkspaceBundleId, WorkspaceBundleKey } from '@deepseek-ai/dsh-workspace/types';
2
+ import type { WorkspaceBundleIdentity } from './types.ts';
3
+ /**
4
+ * Encode one branded identity as a single path segment.
5
+ * @param value - bundle id or workspace key.
6
+ * @param field - diagnostic name.
7
+ * @returns the same spelling when it is a controlled segment.
8
+ * @throws {WorkspaceBundleError} When the value could traverse or collide with `.` / `..`.
9
+ */
10
+ export declare function encodeBundleSegment(value: string, field: 'bundleId' | 'workspaceKey'): string;
11
+ /**
12
+ * Join a parent with `{bundleId}/{workspaceKey}`.
13
+ * @param parent - absolute parent already validated by Config.
14
+ * @param identity - branded pair.
15
+ * @returns the joined path using the host separator.
16
+ */
17
+ export declare function bundledDirectory(parent: string, identity: WorkspaceBundleIdentity): string;
18
+ /**
19
+ * Reject a template-relative path that leaves the root, is absolute, or uses a reserved segment.
20
+ * @param relativePath - POSIX relative path from the manifest.
21
+ * @returns the same spelling.
22
+ * @throws {WorkspaceBundleError} On traversal, absolute paths, empty segments, or backslashes.
23
+ */
24
+ export declare function assertTemplateRelativePath(relativePath: string): string;
25
+ /** @param bundleId - branded bundle identity. @param workspaceKey - branded workspace key. */
26
+ export declare function identityOf(bundleId: WorkspaceBundleId, workspaceKey: WorkspaceBundleKey): WorkspaceBundleIdentity;
@@ -0,0 +1,48 @@
1
+ /** Controlled encoding of branded Workspace bundle identities into Home path segments. */
2
+ import { join, posix, sep } from 'node:path';
3
+ import { WorkspaceBundleError } from "./errors.js";
4
+ const SEGMENT = /^[a-z0-9][a-z0-9._-]{0,63}$/u;
5
+ /**
6
+ * Encode one branded identity as a single path segment.
7
+ * @param value - bundle id or workspace key.
8
+ * @param field - diagnostic name.
9
+ * @returns the same spelling when it is a controlled segment.
10
+ * @throws {WorkspaceBundleError} When the value could traverse or collide with `.` / `..`.
11
+ */
12
+ export function encodeBundleSegment(value, field) {
13
+ if (!SEGMENT.test(value) || value === '.' || value === '..' || value.includes(sep) || value.includes(posix.sep) || value.includes('\0')) {
14
+ throw new WorkspaceBundleError('WORKSPACE_BUNDLE_IDENTITY_INVALID', `${field} ${JSON.stringify(value)} is not a controlled path segment`);
15
+ }
16
+ return value;
17
+ }
18
+ /**
19
+ * Join a parent with `{bundleId}/{workspaceKey}`.
20
+ * @param parent - absolute parent already validated by Config.
21
+ * @param identity - branded pair.
22
+ * @returns the joined path using the host separator.
23
+ */
24
+ export function bundledDirectory(parent, identity) {
25
+ return join(parent, encodeBundleSegment(identity.bundleId, 'bundleId'), encodeBundleSegment(identity.workspaceKey, 'workspaceKey'));
26
+ }
27
+ /**
28
+ * Reject a template-relative path that leaves the root, is absolute, or uses a reserved segment.
29
+ * @param relativePath - POSIX relative path from the manifest.
30
+ * @returns the same spelling.
31
+ * @throws {WorkspaceBundleError} On traversal, absolute paths, empty segments, or backslashes.
32
+ */
33
+ export function assertTemplateRelativePath(relativePath) {
34
+ if (relativePath === '' || relativePath.startsWith('/') || relativePath.includes('\\') || relativePath.includes('\0')) {
35
+ throw new WorkspaceBundleError('WORKSPACE_BUNDLE_PATH_TRAVERSAL', `template path ${JSON.stringify(relativePath)} is not a relative POSIX path`);
36
+ }
37
+ const parts = relativePath.split('/');
38
+ if (parts.some(part => part === '' || part === '.' || part === '..')) {
39
+ throw new WorkspaceBundleError('WORKSPACE_BUNDLE_PATH_TRAVERSAL', `template path ${JSON.stringify(relativePath)} contains a reserved segment`);
40
+ }
41
+ return relativePath;
42
+ }
43
+ /** @param bundleId - branded bundle identity. @param workspaceKey - branded workspace key. */
44
+ export function identityOf(bundleId, workspaceKey) {
45
+ encodeBundleSegment(bundleId, 'bundleId');
46
+ encodeBundleSegment(workspaceKey, 'workspaceKey');
47
+ return { bundleId, workspaceKey };
48
+ }
@@ -0,0 +1,83 @@
1
+ import { Context } from '@deepseek-ai/cordis';
2
+ import type { Workspace, WorkspaceId } from '@deepseek-ai/dsh-workspace/types';
3
+ import { Config } from './config.ts';
4
+ import { WorkspaceBundles } from './definition.ts';
5
+ import type { WorkspaceAgentConfigHandle, WorkspaceAgentConfigSource, WorkspaceAgentConfigState } from './agent-config-types.ts';
6
+ import type { WorkspaceBundleCrashPoint, WorkspaceBundleFs, WorkspaceBundleIdentity, WorkspaceBundleRecord, WorkspaceBundlesSpec, WorkspaceTemplate } from './types.ts';
7
+ /** Provider for packaged Workspace templates. */
8
+ export declare class GestaltWorkspaceBundles extends WorkspaceBundles {
9
+ static inject: string[];
10
+ static Config: import("@deepseek-ai/schemastery").default<Config, import("./config.ts").ResolvedConfig>;
11
+ /** Deployment settings resolved at load. */
12
+ readonly spec: WorkspaceBundlesSpec;
13
+ private readonly fs;
14
+ private readonly templates;
15
+ private readonly agentConfigSources;
16
+ private readonly records;
17
+ private readonly leases;
18
+ private readonly waiters;
19
+ private chain;
20
+ private readonly recycle;
21
+ private readonly recycleWork;
22
+ private readonly contentDrains;
23
+ private closed;
24
+ private readonly download;
25
+ /**
26
+ * @param ctx - Host context with the Workspace registry.
27
+ * @param config - raw row settings.
28
+ * @param options.fs - filesystem; tests inject crashes through `crashAt`.
29
+ * @param options.crashAt - single crash point; production omits it.
30
+ * @param options.fetch - 文件 HTTP 传输;默认使用 Host fetch。
31
+ */
32
+ constructor(ctx: Context, config?: Config, options?: {
33
+ fs?: WorkspaceBundleFs;
34
+ crashAt?: WorkspaceBundleCrashPoint;
35
+ fetch?: typeof fetch;
36
+ });
37
+ /** @inheritdoc */
38
+ register(template: WorkspaceTemplate): () => void;
39
+ /** @inheritdoc */
40
+ ensure(identity: WorkspaceBundleIdentity): Promise<Workspace>;
41
+ /** @inheritdoc */
42
+ registerAgentConfigSource(source: WorkspaceAgentConfigSource): () => Promise<void>;
43
+ /** @inheritdoc */
44
+ agentConfig(identity: WorkspaceBundleIdentity): WorkspaceAgentConfigState | undefined;
45
+ /** @inheritdoc */
46
+ refreshAgentConfig(identity: WorkspaceBundleIdentity): Promise<void>;
47
+ /** @inheritdoc */
48
+ acquire(workspaceId: WorkspaceId): Promise<() => void>;
49
+ /** @inheritdoc */
50
+ acquireAgentConfig(identity: WorkspaceBundleIdentity): Promise<WorkspaceAgentConfigHandle>;
51
+ private contentHandle;
52
+ private acquireOne;
53
+ private holdLease;
54
+ /** @inheritdoc */
55
+ record(identity: WorkspaceBundleIdentity): WorkspaceBundleRecord | undefined;
56
+ /** @inheritdoc */
57
+ pathOf(identity: WorkspaceBundleIdentity): string;
58
+ /**
59
+ * Replay an interrupted journal before serving the Workspace.
60
+ * @param identity - branded pair.
61
+ * @throws {WorkspaceBundleError} Before any rename or deletion if stored paths differ from this identity's controlled paths.
62
+ * @throws {AgentConfigError} After physical recovery when committed content fails compatibility or consumer validation.
63
+ */
64
+ recover(identity: WorkspaceBundleIdentity): Promise<Workspace | undefined>;
65
+ private enqueue;
66
+ private ensureOne;
67
+ private rollbackAgentConfig;
68
+ private requireTemplate;
69
+ private stagingOf;
70
+ private backupOf;
71
+ private recordFile;
72
+ private loadStored;
73
+ private recoverOne;
74
+ private workspaceOf;
75
+ private restoreOld;
76
+ private finishNew;
77
+ private replace;
78
+ private commit;
79
+ private quiesce;
80
+ private scheduleRecycle;
81
+ private prepareBackup;
82
+ }
83
+ export default GestaltWorkspaceBundles;