@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,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,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,77 @@
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 { 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 closed;
23
+ private readonly download;
24
+ /**
25
+ * @param ctx - Host context with the Workspace registry.
26
+ * @param config - raw row settings.
27
+ * @param options.fs - filesystem; tests inject crashes through `crashAt`.
28
+ * @param options.crashAt - single crash point; production omits it.
29
+ * @param options.fetch - 文件 HTTP 传输;默认使用 Host fetch。
30
+ */
31
+ constructor(ctx: Context, config?: Config, options?: {
32
+ fs?: WorkspaceBundleFs;
33
+ crashAt?: WorkspaceBundleCrashPoint;
34
+ fetch?: typeof fetch;
35
+ });
36
+ /** @inheritdoc */
37
+ register(template: WorkspaceTemplate): () => void;
38
+ /** @inheritdoc */
39
+ ensure(identity: WorkspaceBundleIdentity): Promise<Workspace>;
40
+ /** @inheritdoc */
41
+ registerAgentConfigSource(source: WorkspaceAgentConfigSource): () => Promise<void>;
42
+ /** @inheritdoc */
43
+ agentConfig(identity: WorkspaceBundleIdentity): WorkspaceAgentConfigState | undefined;
44
+ /** @inheritdoc */
45
+ refreshAgentConfig(identity: WorkspaceBundleIdentity): Promise<void>;
46
+ /** @inheritdoc */
47
+ acquire(workspaceId: WorkspaceId): Promise<() => void>;
48
+ private acquireOne;
49
+ /** @inheritdoc */
50
+ record(identity: WorkspaceBundleIdentity): WorkspaceBundleRecord | undefined;
51
+ /** @inheritdoc */
52
+ pathOf(identity: WorkspaceBundleIdentity): string;
53
+ /**
54
+ * Replay an interrupted journal before serving the Workspace.
55
+ * @param identity - branded pair.
56
+ * @throws {WorkspaceBundleError} Before any rename or deletion if stored paths differ from this identity's controlled paths.
57
+ */
58
+ recover(identity: WorkspaceBundleIdentity): Promise<Workspace | undefined>;
59
+ private enqueue;
60
+ private ensureOne;
61
+ private rollbackAgentConfig;
62
+ private requireTemplate;
63
+ private stagingOf;
64
+ private backupOf;
65
+ private recordFile;
66
+ private loadStored;
67
+ private recoverOne;
68
+ private workspaceOf;
69
+ private restoreOld;
70
+ private finishNew;
71
+ private replace;
72
+ private commit;
73
+ private quiesce;
74
+ private scheduleRecycle;
75
+ private prepareBackup;
76
+ }
77
+ export default GestaltWorkspaceBundles;