@gestaltrun/dsh-workspace-bundle 0.1.0 → 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.
- package/README.i18n.yaml +6 -6
- package/README.md +28 -8
- package/README.zh.md +28 -8
- package/lib/index.js +1737 -237
- package/lib/types/agent-config-contract/agent-configs.d.ts +621 -0
- package/lib/types/agent-config-contract/agent-configs.js +68 -0
- package/lib/types/agent-config-contract/cs-hosting.d.ts +1333 -0
- package/lib/types/agent-config-contract/cs-hosting.js +163 -0
- package/lib/types/agent-config-contract/errors.d.ts +224 -0
- package/lib/types/agent-config-contract/errors.js +202 -0
- package/lib/types/agent-config-contract/ids.d.ts +65 -0
- package/lib/types/agent-config-contract/ids.js +35 -0
- package/lib/types/agent-config-contract/route.d.ts +56 -0
- package/lib/types/agent-config-contract/route.js +1 -0
- package/lib/types/agent-config-contract/test-account-claim.d.ts +24 -0
- package/lib/types/agent-config-contract/test-account-claim.js +5 -0
- package/lib/types/agent-config-format.d.ts +5 -4
- package/lib/types/agent-config-format.js +7 -4
- package/lib/types/agent-config-observation.d.ts +64 -0
- package/lib/types/agent-config-observation.js +448 -0
- package/lib/types/agent-config-source.d.ts +31 -5
- package/lib/types/agent-config-source.js +175 -32
- package/lib/types/agent-config-types.d.ts +33 -0
- package/lib/types/bundled-agent-config.d.ts +29 -0
- package/lib/types/bundled-agent-config.js +47 -0
- package/lib/types/definition.d.ts +18 -4
- package/lib/types/definition.js +10 -2
- package/lib/types/index.d.ts +9 -1
- package/lib/types/index.js +5 -1
- package/lib/types/local-host-identity.d.ts +65 -0
- package/lib/types/local-host-identity.js +87 -0
- package/lib/types/local-host-replacement.d.ts +49 -0
- package/lib/types/local-host-replacement.js +112 -0
- package/lib/types/provider.d.ts +7 -1
- package/lib/types/provider.js +117 -19
- package/lib/types/types.d.ts +4 -1
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +17 -13
|
@@ -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
|
+
}
|
package/lib/types/provider.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Context } from '@deepseek-ai/cordis';
|
|
|
2
2
|
import type { Workspace, WorkspaceId } from '@deepseek-ai/dsh-workspace/types';
|
|
3
3
|
import { Config } from './config.ts';
|
|
4
4
|
import { WorkspaceBundles } from './definition.ts';
|
|
5
|
-
import type { WorkspaceAgentConfigSource, WorkspaceAgentConfigState } from './agent-config-types.ts';
|
|
5
|
+
import type { WorkspaceAgentConfigHandle, WorkspaceAgentConfigSource, WorkspaceAgentConfigState } from './agent-config-types.ts';
|
|
6
6
|
import type { WorkspaceBundleCrashPoint, WorkspaceBundleFs, WorkspaceBundleIdentity, WorkspaceBundleRecord, WorkspaceBundlesSpec, WorkspaceTemplate } from './types.ts';
|
|
7
7
|
/** Provider for packaged Workspace templates. */
|
|
8
8
|
export declare class GestaltWorkspaceBundles extends WorkspaceBundles {
|
|
@@ -19,6 +19,7 @@ export declare class GestaltWorkspaceBundles extends WorkspaceBundles {
|
|
|
19
19
|
private chain;
|
|
20
20
|
private readonly recycle;
|
|
21
21
|
private readonly recycleWork;
|
|
22
|
+
private readonly contentDrains;
|
|
22
23
|
private closed;
|
|
23
24
|
private readonly download;
|
|
24
25
|
/**
|
|
@@ -45,7 +46,11 @@ export declare class GestaltWorkspaceBundles extends WorkspaceBundles {
|
|
|
45
46
|
refreshAgentConfig(identity: WorkspaceBundleIdentity): Promise<void>;
|
|
46
47
|
/** @inheritdoc */
|
|
47
48
|
acquire(workspaceId: WorkspaceId): Promise<() => void>;
|
|
49
|
+
/** @inheritdoc */
|
|
50
|
+
acquireAgentConfig(identity: WorkspaceBundleIdentity): Promise<WorkspaceAgentConfigHandle>;
|
|
51
|
+
private contentHandle;
|
|
48
52
|
private acquireOne;
|
|
53
|
+
private holdLease;
|
|
49
54
|
/** @inheritdoc */
|
|
50
55
|
record(identity: WorkspaceBundleIdentity): WorkspaceBundleRecord | undefined;
|
|
51
56
|
/** @inheritdoc */
|
|
@@ -54,6 +59,7 @@ export declare class GestaltWorkspaceBundles extends WorkspaceBundles {
|
|
|
54
59
|
* Replay an interrupted journal before serving the Workspace.
|
|
55
60
|
* @param identity - branded pair.
|
|
56
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.
|
|
57
63
|
*/
|
|
58
64
|
recover(identity: WorkspaceBundleIdentity): Promise<Workspace | undefined>;
|
|
59
65
|
private enqueue;
|
package/lib/types/provider.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* hidden Workspace, and replace its tree as a whole with crash recovery.
|
|
4
4
|
*/
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
+
import { journalAgentConfigSha256 } from "./bundled-agent-config.js";
|
|
6
7
|
import { brandString } from '@deepseek-ai/dsh-brand';
|
|
7
8
|
import { Config, resolveWorkspaceBundlesSpec } from "./config.js";
|
|
8
9
|
import { WorkspaceBundles } from "./definition.js";
|
|
@@ -12,7 +13,7 @@ import { crashableFs, createNodeFs } from "./filesystem.js";
|
|
|
12
13
|
import { assertRecordPaths, committedRecord, idleJournal, readRecord, recordPath, writeJournal, writeRecord } from "./journal.js";
|
|
13
14
|
import { bundledDirectory, identityOf } from "./paths.js";
|
|
14
15
|
import { WorkspaceAgentConfigSourceOwner } from "./agent-config-source.js";
|
|
15
|
-
import { AgentConfigError } from "./agent-config-format.js";
|
|
16
|
+
import { AgentConfigError, HOST_AGENT_CONFIG_API_VERSION } from "./agent-config-format.js";
|
|
16
17
|
function keyOf(identity) {
|
|
17
18
|
return `${identity.bundleId}/${identity.workspaceKey}`;
|
|
18
19
|
}
|
|
@@ -44,6 +45,7 @@ export class GestaltWorkspaceBundles extends WorkspaceBundles {
|
|
|
44
45
|
chain = Promise.resolve();
|
|
45
46
|
recycle = new Map();
|
|
46
47
|
recycleWork = new Map();
|
|
48
|
+
contentDrains = new Set();
|
|
47
49
|
closed = false;
|
|
48
50
|
download;
|
|
49
51
|
/**
|
|
@@ -54,8 +56,9 @@ export class GestaltWorkspaceBundles extends WorkspaceBundles {
|
|
|
54
56
|
* @param options.fetch - 文件 HTTP 传输;默认使用 Host fetch。
|
|
55
57
|
*/
|
|
56
58
|
constructor(ctx, config = {}, options = {}) {
|
|
57
|
-
|
|
58
|
-
|
|
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;
|
|
59
62
|
this.fs = crashableFs(options.fs ?? createNodeFs(), options.crashAt);
|
|
60
63
|
this.download = options.fetch ?? fetch;
|
|
61
64
|
ctx.effect(() => async () => {
|
|
@@ -65,6 +68,7 @@ export class GestaltWorkspaceBundles extends WorkspaceBundles {
|
|
|
65
68
|
this.recycle.clear();
|
|
66
69
|
await Promise.all([...this.agentConfigSources.values()].map(source => source.dispose()));
|
|
67
70
|
await this.chain;
|
|
71
|
+
await Promise.all(this.contentDrains);
|
|
68
72
|
await Promise.all(this.recycleWork.values());
|
|
69
73
|
this.agentConfigSources.clear();
|
|
70
74
|
}, 'workspace-bundle: recycle timers');
|
|
@@ -105,9 +109,28 @@ export class GestaltWorkspaceBundles extends WorkspaceBundles {
|
|
|
105
109
|
throw new WorkspaceBundleError('WORKSPACE_BUNDLE_TEMPLATE_INVALID', 'Agent 配置版本来源已注册');
|
|
106
110
|
const owner = new WorkspaceAgentConfigSourceOwner({ template, source }, this.spec, error => {
|
|
107
111
|
this.ctx.logger.warn('%s:%s', error.code, error.message);
|
|
108
|
-
}, this.download)
|
|
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
|
+
}));
|
|
109
121
|
this.agentConfigSources.set(key, owner);
|
|
110
|
-
owner.start()
|
|
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
|
+
}));
|
|
111
134
|
return async () => {
|
|
112
135
|
await owner.dispose();
|
|
113
136
|
if (this.agentConfigSources.get(key) === owner)
|
|
@@ -134,6 +157,64 @@ export class GestaltWorkspaceBundles extends WorkspaceBundles {
|
|
|
134
157
|
}
|
|
135
158
|
return this.enqueue(() => this.acquireOne(workspaceId));
|
|
136
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
|
+
}
|
|
137
218
|
async acquireOne(workspaceId) {
|
|
138
219
|
const record = [...this.records.values()].find(item => item.workspaceId === workspaceId);
|
|
139
220
|
if (record === undefined || !record.registered) {
|
|
@@ -145,6 +226,10 @@ export class GestaltWorkspaceBundles extends WorkspaceBundles {
|
|
|
145
226
|
if ((this.leases.get(workspaceId)?.size ?? 0) === 0 && this.agentConfigSources.has(keyOf(record))) {
|
|
146
227
|
await this.ensureOne(record);
|
|
147
228
|
}
|
|
229
|
+
this.agentConfigSources.get(keyOf(record))?.assertExecutionAllowed();
|
|
230
|
+
return this.holdLease(workspaceId);
|
|
231
|
+
}
|
|
232
|
+
holdLease(workspaceId) {
|
|
148
233
|
const token = Symbol('workspace-bundle-lease');
|
|
149
234
|
let leases = this.leases.get(workspaceId);
|
|
150
235
|
if (leases === undefined) {
|
|
@@ -175,9 +260,15 @@ export class GestaltWorkspaceBundles extends WorkspaceBundles {
|
|
|
175
260
|
* Replay an interrupted journal before serving the Workspace.
|
|
176
261
|
* @param identity - branded pair.
|
|
177
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.
|
|
178
264
|
*/
|
|
179
265
|
async recover(identity) {
|
|
180
|
-
return this.enqueue(() =>
|
|
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
|
+
});
|
|
181
272
|
}
|
|
182
273
|
enqueue(work) {
|
|
183
274
|
const run = this.chain.then(work, work);
|
|
@@ -187,23 +278,30 @@ export class GestaltWorkspaceBundles extends WorkspaceBundles {
|
|
|
187
278
|
async ensureOne(identity) {
|
|
188
279
|
let template = this.requireTemplate(identity);
|
|
189
280
|
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
281
|
const recovered = await this.recoverOne(identity);
|
|
195
282
|
const record = this.records.get(keyOf(identity));
|
|
196
|
-
if (source !== undefined)
|
|
197
|
-
|
|
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
|
+
}
|
|
198
295
|
if (source !== undefined && (record?.workspaceId === null || record?.workspaceId === undefined
|
|
199
296
|
|| (this.leases.get(record.workspaceId)?.size ?? 0) === 0))
|
|
200
297
|
template = source.template();
|
|
201
298
|
if (recovered !== undefined && record !== undefined
|
|
202
299
|
&& record.templateDigest === template.manifest.digest && record.pluginVersion === template.manifest.pluginVersion
|
|
203
|
-
&& (record.agentConfigSha256 ?? null) === (template.
|
|
300
|
+
&& (record.agentConfigSha256 ?? null) === journalAgentConfigSha256(template, this.requireTemplate(identity)) && record.journal.phase === 'idle') {
|
|
204
301
|
return recovered;
|
|
205
302
|
}
|
|
206
303
|
try {
|
|
304
|
+
await source?.prepare(template);
|
|
207
305
|
const workspace = await this.replace(template, record, source === undefined ? undefined : () => source.assertApplicable(template));
|
|
208
306
|
await source?.committed(template);
|
|
209
307
|
return workspace;
|
|
@@ -317,10 +415,10 @@ export class GestaltWorkspaceBundles extends WorkspaceBundles {
|
|
|
317
415
|
try {
|
|
318
416
|
const source = this.agentConfigSources.get(keyOf(identity));
|
|
319
417
|
if (source !== undefined && toDigest !== null)
|
|
320
|
-
template = await source.
|
|
418
|
+
template = await source.recoverPhysical(toDigest, record.journal.pluginVersion ?? undefined, record.journal.agentConfigSha256);
|
|
321
419
|
if (template !== undefined && toDigest === template.manifest.digest) {
|
|
322
420
|
await verifyTemplateTree(this.fs, root, template.manifest);
|
|
323
|
-
return this.commit(identity, record, template);
|
|
421
|
+
return await this.commit(identity, record, template);
|
|
324
422
|
}
|
|
325
423
|
if (toDigest !== null && template === undefined) {
|
|
326
424
|
const cached = bundledDirectory(this.spec.templateCacheRoot, identity);
|
|
@@ -331,11 +429,11 @@ export class GestaltWorkspaceBundles extends WorkspaceBundles {
|
|
|
331
429
|
bundleId: identity.bundleId, workspaceKey: identity.workspaceKey, title: 'Workspace',
|
|
332
430
|
sourceRoot: cached, manifest,
|
|
333
431
|
};
|
|
334
|
-
return this.commit(identity, record, synthetic);
|
|
432
|
+
return await this.commit(identity, record, synthetic);
|
|
335
433
|
}
|
|
336
434
|
}
|
|
337
435
|
}
|
|
338
|
-
catch {
|
|
436
|
+
catch (error) {
|
|
339
437
|
// Fall through to restore the backup; the tree must be wholly old or wholly new.
|
|
340
438
|
}
|
|
341
439
|
return this.restoreOld(identity, record);
|
|
@@ -375,7 +473,7 @@ export class GestaltWorkspaceBundles extends WorkspaceBundles {
|
|
|
375
473
|
stagingPath: staging,
|
|
376
474
|
backupPath: await this.fs.exists(root) ? backup : null,
|
|
377
475
|
swapComplete: false,
|
|
378
|
-
|
|
476
|
+
agentConfigSha256: journalAgentConfigSha256(template, this.requireTemplate(identity)),
|
|
379
477
|
};
|
|
380
478
|
const updating = await writeJournal(this.fs, this.recordFile(identity), { ...record, registered: true, path: root }, journal);
|
|
381
479
|
this.records.set(keyOf(identity), updating);
|
|
@@ -409,7 +507,7 @@ export class GestaltWorkspaceBundles extends WorkspaceBundles {
|
|
|
409
507
|
async commit(identity, record, template) {
|
|
410
508
|
const workspace = await this.ctx.workspaceRegistry.create(record.path, template.title, originOf(identity));
|
|
411
509
|
const next = committedRecord({ ...record, workspaceId: workspace.id, path: workspace.path, registered: true,
|
|
412
|
-
|
|
510
|
+
agentConfigSha256: journalAgentConfigSha256(template, this.templates.get(keyOf(identity))),
|
|
413
511
|
}, template.manifest.digest, template.manifest.pluginVersion);
|
|
414
512
|
await writeRecord(this.fs, this.recordFile(identity), next);
|
|
415
513
|
this.records.set(keyOf(identity), next);
|
package/lib/types/types.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Client-safe types of the WorkspaceBundles seam; importing them loads no Host code. */
|
|
2
2
|
import type { Branded } from '@deepseek-ai/dsh-brand';
|
|
3
3
|
import type { WorkspaceBundleId, WorkspaceBundleKey, WorkspaceId, WorkspaceOrigin } from '@deepseek-ai/dsh-workspace/types';
|
|
4
|
+
import type { BundledAgentConfigReceipt } from './bundled-agent-config.ts';
|
|
4
5
|
export type { WorkspaceBundleId, WorkspaceBundleKey, WorkspaceId, WorkspaceOrigin };
|
|
5
6
|
/** SHA-256 hex digest of one template file. */
|
|
6
7
|
export type WorkspaceTemplateFileDigest = Branded<'WorkspaceTemplateFileDigest'>;
|
|
@@ -32,8 +33,10 @@ export interface WorkspaceTemplate {
|
|
|
32
33
|
readonly title: string;
|
|
33
34
|
readonly sourceRoot: string;
|
|
34
35
|
readonly manifest: WorkspaceTemplateManifest;
|
|
35
|
-
/**
|
|
36
|
+
/** Published gzip digest for downloaded or bundled content; absent for repository templates. */
|
|
36
37
|
readonly agentConfigSha256?: string;
|
|
38
|
+
/** Publication receipt adjacent to a shipped template, outside its file catalog. */
|
|
39
|
+
readonly agentConfigReceipt?: BundledAgentConfigReceipt;
|
|
37
40
|
}
|
|
38
41
|
/** Deployment settings the Provider resolves once at load. */
|
|
39
42
|
export interface WorkspaceBundlesSpec {
|
package/locale/en.json
CHANGED