@ricsam/r5d-worker 0.0.137 → 0.0.138
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.md +4 -94
- package/dist/cjs/package.json +1 -1
- package/dist/mjs/main.mjs +46 -7543
- package/dist/mjs/package.json +1 -1
- package/dist/mjs/personal/action-scheduler.mjs +31 -0
- package/dist/mjs/personal/client.mjs +215 -0
- package/dist/mjs/personal/runtime.mjs +328 -0
- package/dist/mjs/personal/tcp.mjs +217 -0
- package/dist/mjs/runtime/adapter.mjs +42 -0
- package/dist/mjs/runtime/client.mjs +95 -0
- package/dist/mjs/runtime/daemon.mjs +131 -0
- package/dist/mjs/runtime/executor.mjs +436 -0
- package/dist/mjs/runtime/index.mjs +15 -0
- package/dist/mjs/runtime/protocol.mjs +139 -0
- package/dist/mjs/runtime/pty-host.mjs +156 -0
- package/dist/mjs/runtime/pty.mjs +127 -0
- package/dist/mjs/runtime/releases/artifacts.mjs +201 -0
- package/dist/mjs/runtime/releases/grants.mjs +93 -0
- package/dist/mjs/runtime/releases/main.mjs +101 -0
- package/dist/mjs/runtime/releases/manager.mjs +541 -0
- package/dist/mjs/runtime/releases/process.mjs +88 -0
- package/dist/mjs/runtime/releases/retirement.mjs +18 -0
- package/dist/mjs/runtime/releases/rpc-client.mjs +184 -0
- package/dist/mjs/runtime/releases/rpc-main.mjs +213 -0
- package/dist/mjs/runtime/releases/rpc-protocol.mjs +103 -0
- package/dist/mjs/runtime/storage.mjs +156 -0
- package/dist/mjs/runtime/workspace/artifacts.mjs +121 -0
- package/dist/mjs/runtime/workspace/authority.mjs +1355 -0
- package/dist/mjs/runtime/workspace/contracts.mjs +60 -0
- package/dist/mjs/runtime/workspace/file-write.mjs +23 -0
- package/dist/mjs/runtime/workspace/files.mjs +308 -0
- package/dist/mjs/runtime/workspace/http-body.mjs +45 -0
- package/dist/mjs/runtime/workspace/storage-client.mjs +59 -0
- package/dist/mjs/runtime/workspace/storage-wire.mjs +53 -0
- package/dist/types/main.d.ts +0 -1167
- package/dist/types/personal/action-scheduler.d.ts +23 -0
- package/dist/types/personal/client.d.ts +14 -0
- package/dist/types/personal/runtime.d.ts +121 -0
- package/dist/types/personal/tcp.d.ts +14 -0
- package/dist/types/runtime/client.d.ts +3 -1
- package/dist/types/runtime/executor.d.ts +6 -1
- package/dist/types/runtime/protocol.d.ts +176 -4
- package/dist/types/runtime/pty.d.ts +1 -0
- package/dist/types/runtime/releases/artifacts.d.ts +51 -0
- package/dist/types/runtime/releases/grants.d.ts +16 -0
- package/dist/types/runtime/releases/manager.d.ts +145 -0
- package/dist/types/runtime/releases/process.d.ts +14 -0
- package/dist/types/runtime/releases/retirement.d.ts +15 -0
- package/dist/types/runtime/releases/rpc-client.d.ts +52 -0
- package/dist/types/runtime/releases/rpc-main.d.ts +10 -0
- package/dist/types/runtime/releases/rpc-protocol.d.ts +1139 -0
- package/dist/types/runtime/workspace/artifacts.d.ts +30 -0
- package/dist/types/runtime/workspace/authority.d.ts +338 -0
- package/dist/types/runtime/workspace/contracts.d.ts +86 -0
- package/dist/types/runtime/workspace/file-write.d.ts +14 -0
- package/dist/types/runtime/workspace/files.d.ts +21 -0
- package/dist/types/runtime/workspace/http-body.d.ts +9 -0
- package/dist/types/runtime/workspace/storage-client.d.ts +23 -0
- package/dist/types/runtime/workspace/storage-wire.d.ts +138 -0
- package/package.json +8 -6
- package/dist/cjs/atomic-rename.cjs +0 -303
- package/dist/cjs/browser-upload.cjs +0 -73
- package/dist/cjs/cli-update.cjs +0 -108
- package/dist/cjs/command-launcher.cjs +0 -423
- package/dist/cjs/control-command-policy.cjs +0 -177
- package/dist/cjs/git-blob-hash.cjs +0 -41
- package/dist/cjs/git-identity.cjs +0 -39
- package/dist/cjs/git-process-environment.cjs +0 -133
- package/dist/cjs/inline-output.cjs +0 -62
- package/dist/cjs/main.cjs +0 -7517
- package/dist/cjs/managed-paths.cjs +0 -215
- package/dist/cjs/plan-lint.cjs +0 -165
- package/dist/cjs/plan-parser.cjs +0 -127
- package/dist/cjs/plan-paths.cjs +0 -53
- package/dist/cjs/port-forward-client.cjs +0 -206
- package/dist/cjs/process-tree.cjs +0 -117
- package/dist/cjs/project-checkout-garbage.cjs +0 -219
- package/dist/cjs/project-mirror-fetch-policy.cjs +0 -82
- package/dist/cjs/project-mirror-refs-token.cjs +0 -85
- package/dist/cjs/project-workspace-state.cjs +0 -972
- package/dist/cjs/project-worktrees.cjs +0 -1713
- package/dist/cjs/pty-output-coalescer.cjs +0 -87
- package/dist/cjs/recovery-journal-protocol.cjs +0 -56
- package/dist/cjs/recovery-journal-runtime.cjs +0 -133
- package/dist/cjs/recovery-journal-thread.cjs +0 -9
- package/dist/cjs/recovery-journal.cjs +0 -742
- package/dist/cjs/recovery-output-window.cjs +0 -77
- package/dist/cjs/recovery-protocol.cjs +0 -31
- package/dist/cjs/recovery-store.cjs +0 -252
- package/dist/cjs/registry-auth.cjs +0 -310
- package/dist/cjs/repository-transition-policy.cjs +0 -49
- package/dist/cjs/runtime-version.cjs +0 -88
- package/dist/cjs/session-file-mutations.cjs +0 -61
- package/dist/cjs/supervisor.cjs +0 -147
- package/dist/cjs/three-way-merge.cjs +0 -346
- package/dist/cjs/working-tree-mirror.cjs +0 -1872
- package/dist/cjs/workspace-automatic-sync-policy.cjs +0 -79
- package/dist/cjs/workspace-branch-incarnation-policy.cjs +0 -37
- package/dist/cjs/workspace-command-sync-policy.cjs +0 -112
- package/dist/cjs/workspace-command-targets.cjs +0 -63
- package/dist/cjs/workspace-filesystem-executor-thread.cjs +0 -36
- package/dist/cjs/workspace-filesystem-executor.cjs +0 -327
- package/dist/cjs/workspace-filesystem-job-types.cjs +0 -144
- package/dist/cjs/workspace-filesystem-jobs.cjs +0 -59
- package/dist/cjs/workspace-git-sync.cjs +0 -3595
- package/dist/cjs/workspace-hydration-ledger.cjs +0 -83
- package/dist/cjs/workspace-hydration-merge.cjs +0 -448
- package/dist/cjs/workspace-hydration-recovery-state.cjs +0 -53
- package/dist/cjs/workspace-incident-state.cjs +0 -37
- package/dist/cjs/workspace-merge-projection.cjs +0 -628
- package/dist/cjs/workspace-mount-boundary.cjs +0 -71
- package/dist/cjs/workspace-mount-hold-fence.cjs +0 -120
- package/dist/cjs/workspace-mutation-gate.cjs +0 -127
- package/dist/cjs/workspace-path-move.cjs +0 -195
- package/dist/cjs/workspace-preserve-only-policy.cjs +0 -36
- package/dist/cjs/workspace-project-config-policy.cjs +0 -53
- package/dist/cjs/workspace-projection-ledger.cjs +0 -81
- package/dist/cjs/workspace-publication-evidence.cjs +0 -46
- package/dist/cjs/workspace-sync-coalescer.cjs +0 -58
- package/dist/mjs/atomic-rename.mjs +0 -261
- package/dist/mjs/browser-upload.mjs +0 -42
- package/dist/mjs/command-launcher.mjs +0 -393
- package/dist/mjs/control-command-policy.mjs +0 -146
- package/dist/mjs/git-blob-hash.mjs +0 -16
- package/dist/mjs/git-identity.mjs +0 -15
- package/dist/mjs/git-process-environment.mjs +0 -105
- package/dist/mjs/inline-output.mjs +0 -37
- package/dist/mjs/managed-paths.mjs +0 -174
- package/dist/mjs/plan-lint.mjs +0 -129
- package/dist/mjs/plan-parser.mjs +0 -94
- package/dist/mjs/plan-paths.mjs +0 -25
- package/dist/mjs/port-forward-client.mjs +0 -172
- package/dist/mjs/project-checkout-garbage.mjs +0 -178
- package/dist/mjs/project-mirror-fetch-policy.mjs +0 -56
- package/dist/mjs/project-mirror-refs-token.mjs +0 -53
- package/dist/mjs/project-workspace-state.mjs +0 -937
- package/dist/mjs/project-worktrees.mjs +0 -1657
- package/dist/mjs/pty-output-coalescer.mjs +0 -61
- package/dist/mjs/recovery-journal-protocol.mjs +0 -30
- package/dist/mjs/recovery-journal-runtime.mjs +0 -112
- package/dist/mjs/recovery-journal-thread.mjs +0 -8
- package/dist/mjs/recovery-journal.mjs +0 -694
- package/dist/mjs/recovery-output-window.mjs +0 -53
- package/dist/mjs/recovery-protocol.mjs +0 -6
- package/dist/mjs/recovery-store.mjs +0 -217
- package/dist/mjs/registry-auth.mjs +0 -269
- package/dist/mjs/repository-transition-policy.mjs +0 -22
- package/dist/mjs/runtime-version.mjs +0 -54
- package/dist/mjs/session-file-mutations.mjs +0 -37
- package/dist/mjs/supervisor.mjs +0 -114
- package/dist/mjs/three-way-merge.mjs +0 -318
- package/dist/mjs/working-tree-mirror.mjs +0 -1817
- package/dist/mjs/workspace-automatic-sync-policy.mjs +0 -48
- package/dist/mjs/workspace-branch-incarnation-policy.mjs +0 -13
- package/dist/mjs/workspace-command-sync-policy.mjs +0 -84
- package/dist/mjs/workspace-command-targets.mjs +0 -37
- package/dist/mjs/workspace-filesystem-executor-thread.mjs +0 -38
- package/dist/mjs/workspace-filesystem-executor.mjs +0 -287
- package/dist/mjs/workspace-filesystem-job-types.mjs +0 -116
- package/dist/mjs/workspace-filesystem-jobs.mjs +0 -55
- package/dist/mjs/workspace-git-sync.mjs +0 -3553
- package/dist/mjs/workspace-hydration-ledger.mjs +0 -58
- package/dist/mjs/workspace-hydration-merge.mjs +0 -414
- package/dist/mjs/workspace-hydration-recovery-state.mjs +0 -29
- package/dist/mjs/workspace-incident-state.mjs +0 -12
- package/dist/mjs/workspace-merge-projection.mjs +0 -594
- package/dist/mjs/workspace-mount-boundary.mjs +0 -37
- package/dist/mjs/workspace-mount-hold-fence.mjs +0 -95
- package/dist/mjs/workspace-mutation-gate.mjs +0 -103
- package/dist/mjs/workspace-path-move.mjs +0 -160
- package/dist/mjs/workspace-preserve-only-policy.mjs +0 -11
- package/dist/mjs/workspace-project-config-policy.mjs +0 -27
- package/dist/mjs/workspace-projection-ledger.mjs +0 -57
- package/dist/mjs/workspace-publication-evidence.mjs +0 -21
- package/dist/mjs/workspace-sync-coalescer.mjs +0 -34
- package/dist/types/atomic-rename.d.ts +0 -78
- package/dist/types/browser-upload.d.ts +0 -7
- package/dist/types/command-launcher.d.ts +0 -104
- package/dist/types/control-command-policy.d.ts +0 -53
- package/dist/types/git-blob-hash.d.ts +0 -10
- package/dist/types/git-identity.d.ts +0 -9
- package/dist/types/git-process-environment.d.ts +0 -9
- package/dist/types/inline-output.d.ts +0 -3
- package/dist/types/managed-paths.d.ts +0 -19
- package/dist/types/plan-lint.d.ts +0 -16
- package/dist/types/plan-parser.d.ts +0 -38
- package/dist/types/plan-paths.d.ts +0 -36
- package/dist/types/port-forward-client.d.ts +0 -8
- package/dist/types/project-checkout-garbage.d.ts +0 -86
- package/dist/types/project-mirror-fetch-policy.d.ts +0 -84
- package/dist/types/project-mirror-refs-token.d.ts +0 -53
- package/dist/types/project-workspace-state.d.ts +0 -188
- package/dist/types/project-worktrees.d.ts +0 -397
- package/dist/types/pty-output-coalescer.d.ts +0 -20
- package/dist/types/recovery-journal-protocol.d.ts +0 -35
- package/dist/types/recovery-journal-runtime.d.ts +0 -12
- package/dist/types/recovery-journal-stall-fixture.d.ts +0 -1
- package/dist/types/recovery-journal-thread.d.ts +0 -1
- package/dist/types/recovery-journal.d.ts +0 -248
- package/dist/types/recovery-output-window.d.ts +0 -19
- package/dist/types/recovery-protocol.d.ts +0 -59
- package/dist/types/recovery-store.d.ts +0 -52
- package/dist/types/registry-auth.d.ts +0 -47
- package/dist/types/repository-transition-policy.d.ts +0 -26
- package/dist/types/runtime-version.d.ts +0 -1
- package/dist/types/session-file-mutations.d.ts +0 -22
- package/dist/types/supervisor-daemonized-fixture.d.ts +0 -1
- package/dist/types/supervisor-launcher-cleanup-fixture.d.ts +0 -1
- package/dist/types/supervisor-signal-fixture.d.ts +0 -1
- package/dist/types/supervisor.d.ts +0 -23
- package/dist/types/three-way-merge.d.ts +0 -77
- package/dist/types/working-tree-mirror.d.ts +0 -424
- package/dist/types/workspace-automatic-sync-policy.d.ts +0 -57
- package/dist/types/workspace-branch-incarnation-policy.d.ts +0 -14
- package/dist/types/workspace-command-sync-policy.d.ts +0 -57
- package/dist/types/workspace-command-targets.d.ts +0 -37
- package/dist/types/workspace-filesystem-executor-thread.d.ts +0 -1
- package/dist/types/workspace-filesystem-executor.d.ts +0 -123
- package/dist/types/workspace-filesystem-job-types.d.ts +0 -302
- package/dist/types/workspace-filesystem-jobs.d.ts +0 -7
- package/dist/types/workspace-git-sync.d.ts +0 -514
- package/dist/types/workspace-hydration-ledger.d.ts +0 -76
- package/dist/types/workspace-hydration-merge.d.ts +0 -103
- package/dist/types/workspace-hydration-recovery-state.d.ts +0 -10
- package/dist/types/workspace-incident-state.d.ts +0 -19
- package/dist/types/workspace-merge-projection.d.ts +0 -69
- package/dist/types/workspace-mount-boundary.d.ts +0 -10
- package/dist/types/workspace-mount-hold-fence.d.ts +0 -42
- package/dist/types/workspace-mutation-gate.d.ts +0 -42
- package/dist/types/workspace-path-move.d.ts +0 -21
- package/dist/types/workspace-preserve-only-policy.d.ts +0 -15
- package/dist/types/workspace-project-config-policy.d.ts +0 -48
- package/dist/types/workspace-projection-ledger.d.ts +0 -59
- package/dist/types/workspace-publication-evidence.d.ts +0 -17
- package/dist/types/workspace-sync-coalescer.d.ts +0 -28
- /package/dist/types/{credential-authority-lock-fixture.d.ts → runtime/releases/main.d.ts} +0 -0
|
@@ -0,0 +1,1355 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
+
import { canonicalJson, OperationEnvelope, operationPayloadHash } from "@ricsam/r5d-api/runtime-protocol";
|
|
6
|
+
import { STORAGE_LIMITS, GitOid, BranchName, safeTreePath } from "./storage-wire.mjs";
|
|
7
|
+
import { ExecutorError, isDefiniteStartRejection, ShellPayload } from "../protocol.mjs";
|
|
8
|
+
import {
|
|
9
|
+
ApprovedWorkbench,
|
|
10
|
+
WorkspaceConfig,
|
|
11
|
+
WorkspaceError,
|
|
12
|
+
WorkspacePublicationNotAdmitted,
|
|
13
|
+
DEFAULT_WORKSPACE_IGNORE
|
|
14
|
+
} from "./contracts.mjs";
|
|
15
|
+
import { SessionArtifactStore, SessionArtifactChunk, RESERVED_ARTIFACT_ENV } from "./artifacts.mjs";
|
|
16
|
+
import { WorkspaceFileWrite, WorkspaceFileWriteLookup } from "./file-write.mjs";
|
|
17
|
+
import { WorkspaceStorageClient } from "./storage-client.mjs";
|
|
18
|
+
import {
|
|
19
|
+
durableJson,
|
|
20
|
+
git,
|
|
21
|
+
noSymlinkAncestors,
|
|
22
|
+
privateRoot,
|
|
23
|
+
readRegular,
|
|
24
|
+
selectedTree,
|
|
25
|
+
sha256,
|
|
26
|
+
snapshotTree,
|
|
27
|
+
sourceBytes,
|
|
28
|
+
verifyIgnore
|
|
29
|
+
} from "./files.mjs";
|
|
30
|
+
class WorkspaceAuthority {
|
|
31
|
+
constructor(options) {
|
|
32
|
+
this.options = options;
|
|
33
|
+
this.config = WorkspaceConfig.parse(options.config);
|
|
34
|
+
}
|
|
35
|
+
options;
|
|
36
|
+
config;
|
|
37
|
+
benches = /* @__PURE__ */ new Map();
|
|
38
|
+
queues = /* @__PURE__ */ new Map();
|
|
39
|
+
lockId = randomUUID();
|
|
40
|
+
closing = false;
|
|
41
|
+
closed = false;
|
|
42
|
+
closeTask;
|
|
43
|
+
actions = /* @__PURE__ */ new Set();
|
|
44
|
+
pending = 0;
|
|
45
|
+
static async open(options) {
|
|
46
|
+
const authority = new WorkspaceAuthority(options);
|
|
47
|
+
const { config } = authority;
|
|
48
|
+
await privateRoot(config.root, config.installationId);
|
|
49
|
+
const contents = await fs.readdir(config.root);
|
|
50
|
+
const binding = { installationId: config.installationId, workbenches: config.workbenches, ...config.dynamicWorkbenches ? { dynamicWorkbenches: true } : {} };
|
|
51
|
+
if (Boolean(config.dynamicWorkbenches) !== Boolean(options.resolveWorkbench)) throw new WorkspaceError("invalid_config", "Dynamic workspace requires catalog authorization");
|
|
52
|
+
if (new Set(config.workbenches.map((b) => b.id)).size !== config.workbenches.length || new Set(config.workbenches.map((b) => `${b.userId}:${b.sessionId}`)).size !== config.workbenches.length)
|
|
53
|
+
throw new WorkspaceError("invalid_config", "Duplicate workbench or session route");
|
|
54
|
+
for (const b of config.workbenches) {
|
|
55
|
+
if (b.cwd !== path.join(config.root, "workbenches", b.id))
|
|
56
|
+
throw new WorkspaceError("unsafe_root", "Approved cwd must equal root/workbenches/id; arbitrary and legacy roots are forbidden");
|
|
57
|
+
}
|
|
58
|
+
if (contents.length) {
|
|
59
|
+
const existing = JSON.parse((await readRegular(path.join(config.root, "installation.json"), 128 * 1024)).toString());
|
|
60
|
+
if (canonicalJson(existing) !== canonicalJson(binding))
|
|
61
|
+
throw new WorkspaceError("wrong_binding", "Existing root binding cannot be changed");
|
|
62
|
+
}
|
|
63
|
+
await fs.mkdir(path.join(config.root, ".sync-lock"), { mode: 448 }).catch(() => {
|
|
64
|
+
throw new WorkspaceError(
|
|
65
|
+
"authority_busy",
|
|
66
|
+
"Sync lock exists; stop old authority/executor and review preserved state before maintenance"
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
await durableJson(path.join(config.root, ".sync-lock", "owner.json"), { lockId: authority.lockId });
|
|
70
|
+
if (!contents.length) {
|
|
71
|
+
await durableJson(path.join(config.root, "installation.json"), binding);
|
|
72
|
+
await fs.mkdir(path.join(config.root, "workbenches"), { mode: 448 });
|
|
73
|
+
await fs.mkdir(path.join(config.root, "state"), { mode: 448 });
|
|
74
|
+
for (const b of config.workbenches) {
|
|
75
|
+
await fs.mkdir(b.cwd, { mode: 448 });
|
|
76
|
+
const directory = path.join(config.root, "state", b.id);
|
|
77
|
+
await fs.mkdir(directory, { mode: 448 });
|
|
78
|
+
await fs.mkdir(path.join(directory, "repo"), { mode: 448 });
|
|
79
|
+
await git(path.join(directory, "repo"), ["init", "--bare", "--template=", "."]);
|
|
80
|
+
await durableJson(path.join(directory, "state.json"), { initialized: false, head: null, blocked: null, runs: {} });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
for (const b of config.workbenches) {
|
|
84
|
+
await noSymlinkAncestors(b.cwd);
|
|
85
|
+
const directory = path.join(config.root, "state", b.id);
|
|
86
|
+
const state = JSON.parse((await readRegular(path.join(directory, "state.json"), 1024 * 1024)).toString());
|
|
87
|
+
if (typeof state.initialized !== "boolean" || !state.runs || !(state.head === null || GitOid.safeParse(state.head).success))
|
|
88
|
+
throw new WorkspaceError("invalid_state", "Invalid durable workspace state; maintenance required");
|
|
89
|
+
authority.benches.set(b.id, { config: b, state, directory, repo: path.join(directory, "repo") });
|
|
90
|
+
}
|
|
91
|
+
if (config.dynamicWorkbenches) {
|
|
92
|
+
for (const id of await fs.readdir(path.join(config.root, "state"))) {
|
|
93
|
+
if (authority.benches.has(id)) continue;
|
|
94
|
+
const directory = path.join(config.root, "state", id);
|
|
95
|
+
await noSymlinkAncestors(directory);
|
|
96
|
+
const b = ApprovedWorkbench.parse(JSON.parse((await readRegular(path.join(directory, "workbench.json"), 128 * 1024)).toString()));
|
|
97
|
+
if (b.id !== id || b.cwd !== path.join(config.root, "workbenches", id)) throw new WorkspaceError("wrong_binding", "Invalid durable dynamic workbench");
|
|
98
|
+
await noSymlinkAncestors(b.cwd);
|
|
99
|
+
const state = JSON.parse((await readRegular(path.join(directory, "state.json"), 1024 * 1024)).toString());
|
|
100
|
+
if (typeof state.initialized !== "boolean" || !state.runs || !(state.head === null || GitOid.safeParse(state.head).success)) throw new WorkspaceError("invalid_state", "Invalid dynamic workspace state");
|
|
101
|
+
authority.benches.set(id, { config: b, state, directory, repo: path.join(directory, "repo") });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return authority;
|
|
105
|
+
}
|
|
106
|
+
registrations = /* @__PURE__ */ new Map();
|
|
107
|
+
async bench(identity, visited = /* @__PURE__ */ new Set()) {
|
|
108
|
+
if (visited.has(identity.sessionId) || visited.size >= 16) throw new WorkspaceError("wrong_binding", "Shared workspace cycle or depth exceeded");
|
|
109
|
+
const fixed = this.config.workbenches.find((b) => b.userId === identity.userId && b.sessionId === identity.sessionId);
|
|
110
|
+
const approved = fixed ?? await this.options.resolveWorkbench?.(identity);
|
|
111
|
+
if (!approved) throw new WorkspaceError("forbidden", "No approved workbench for authenticated user/session");
|
|
112
|
+
const config = ApprovedWorkbench.parse(approved);
|
|
113
|
+
if (config.userId !== identity.userId || config.sessionId !== identity.sessionId || config.cwd !== path.join(this.config.root, "workbenches", config.id))
|
|
114
|
+
throw new WorkspaceError("unsafe_root", "Catalog returned an invalid workspace identity");
|
|
115
|
+
if (config.sharedSessionId) {
|
|
116
|
+
const parent = await this.bench({ userId: identity.userId, sessionId: config.sharedSessionId }, /* @__PURE__ */ new Set([...visited, identity.sessionId]));
|
|
117
|
+
if (parent.config.userId !== config.userId || parent.config.id !== config.id || parent.config.repositoryId !== config.repositoryId || parent.config.branch !== config.branch || parent.config.cwd !== config.cwd) throw new WorkspaceError("wrong_binding", "Shared workspace does not match its authorized parent");
|
|
118
|
+
return parent;
|
|
119
|
+
}
|
|
120
|
+
const existing = this.benches.get(config.id);
|
|
121
|
+
if (existing) {
|
|
122
|
+
if (canonicalJson(existing.config) !== canonicalJson(config)) throw new WorkspaceError("wrong_binding", "Workbench identity is immutable");
|
|
123
|
+
return existing;
|
|
124
|
+
}
|
|
125
|
+
let task = this.registrations.get(config.id);
|
|
126
|
+
if (!task) {
|
|
127
|
+
task = (async () => {
|
|
128
|
+
await this.owned();
|
|
129
|
+
const directory = path.join(this.config.root, "state", config.id);
|
|
130
|
+
const manifest = path.join(directory, "workbench.json");
|
|
131
|
+
const stored = await fs.readFile(manifest, "utf8").catch((error) => {
|
|
132
|
+
if (error.code === "ENOENT") return null;
|
|
133
|
+
throw error;
|
|
134
|
+
});
|
|
135
|
+
if (stored) {
|
|
136
|
+
if (canonicalJson(JSON.parse(stored)) !== canonicalJson(config)) throw new WorkspaceError("wrong_binding", "Persisted workbench identity changed");
|
|
137
|
+
} else {
|
|
138
|
+
await fs.mkdir(directory, { mode: 448 });
|
|
139
|
+
await durableJson(manifest, config);
|
|
140
|
+
await fs.mkdir(config.cwd, { mode: 448 });
|
|
141
|
+
await fs.mkdir(path.join(directory, "repo"), { mode: 448 });
|
|
142
|
+
await git(path.join(directory, "repo"), ["init", "--bare", "--template=", "."]);
|
|
143
|
+
await durableJson(path.join(directory, "state.json"), { initialized: false, head: null, blocked: null, runs: {} });
|
|
144
|
+
}
|
|
145
|
+
await noSymlinkAncestors(config.cwd);
|
|
146
|
+
const state = JSON.parse((await readRegular(path.join(directory, "state.json"), 1024 * 1024)).toString());
|
|
147
|
+
if (typeof state.initialized !== "boolean" || !state.runs || !(state.head === null || GitOid.safeParse(state.head).success)) throw new WorkspaceError("invalid_state", "Invalid workspace state");
|
|
148
|
+
const bench = { config, state, directory, repo: path.join(directory, "repo") };
|
|
149
|
+
this.benches.set(config.id, bench);
|
|
150
|
+
return bench;
|
|
151
|
+
})();
|
|
152
|
+
this.registrations.set(config.id, task);
|
|
153
|
+
void task.finally(() => this.registrations.delete(config.id)).catch(() => {
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
return task;
|
|
157
|
+
}
|
|
158
|
+
hydrations = /* @__PURE__ */ new Map();
|
|
159
|
+
async ensureHydrated(identity) {
|
|
160
|
+
const b = await this.bench(identity);
|
|
161
|
+
if (b.state.initialized) return;
|
|
162
|
+
let task = this.hydrations.get(b.config.id);
|
|
163
|
+
if (!task) {
|
|
164
|
+
task = this.hydrate(identity).then(() => {
|
|
165
|
+
});
|
|
166
|
+
this.hydrations.set(b.config.id, task);
|
|
167
|
+
void task.finally(() => this.hydrations.delete(b.config.id)).catch(() => {
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
await task;
|
|
171
|
+
}
|
|
172
|
+
async readFile(identity, file) {
|
|
173
|
+
const b = await this.bench(identity);
|
|
174
|
+
if (!file || file.length > 4096 || file.includes("\\") || file.split("/").some((part) => !part || part === "." || part === ".." || part === ".git"))
|
|
175
|
+
throw new WorkspaceError("unsafe_path", "Invalid file path");
|
|
176
|
+
const target = path.join(b.config.cwd, file);
|
|
177
|
+
await noSymlinkAncestors(target);
|
|
178
|
+
const bytes = await readRegular(target, 1024 * 1024);
|
|
179
|
+
return { path: file, content: bytes.toString("utf8"), bytes: bytes.length };
|
|
180
|
+
}
|
|
181
|
+
async processes(identity) {
|
|
182
|
+
const b = await this.bench(identity), route = await this.route(b);
|
|
183
|
+
const result = [];
|
|
184
|
+
for (const [operationId, metadata] of Object.entries(b.state.runs)) {
|
|
185
|
+
if (metadata.state === "rejected_capacity" || (metadata.sessionId ?? b.config.sessionId) !== identity.sessionId) continue;
|
|
186
|
+
const poll = await this.poll(identity, { userId: identity.userId, sessionId: identity.sessionId, operationId, fence: route.workerFence }, 0, 0, 1);
|
|
187
|
+
result.push({ ...metadata, ...poll.receipt, workerLabel: route.workerFence.resourceId });
|
|
188
|
+
}
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
191
|
+
async fileHistoryDates(identity, file) {
|
|
192
|
+
safeTreePath(file);
|
|
193
|
+
const b = await this.bench(identity);
|
|
194
|
+
return this.serial(b, async () => {
|
|
195
|
+
if (!b.state.initialized || !b.state.head) throw new WorkspaceError("not_initialized", "Initialize workspace first");
|
|
196
|
+
const target = path.join(b.config.cwd, file);
|
|
197
|
+
await noSymlinkAncestors(target);
|
|
198
|
+
const stat = await fs.stat(target);
|
|
199
|
+
if (!stat.isFile() || stat.nlink !== 1) throw new WorkspaceError("unsafe_file", "Expected a single-link regular file");
|
|
200
|
+
const dates = (await git(b.repo, ["log", "--max-count=10000", "--format=%cI", b.state.head, "--", file])).toString().trim().split("\n").filter(Boolean);
|
|
201
|
+
if (dates.length >= 1e4) throw new WorkspaceError("too_large", "File history exceeds date inspection budget");
|
|
202
|
+
if (dates.some((value) => !Number.isFinite(Date.parse(value)))) throw new WorkspaceError("invalid_state", "Invalid file history timestamp");
|
|
203
|
+
if (dates.length) return { createdAt: new Date(dates.at(-1)).toISOString(), updatedAt: new Date(dates[0]).toISOString() };
|
|
204
|
+
return { createdAt: stat.birthtimeMs > 0 ? stat.birthtime.toISOString() : null, updatedAt: stat.mtime.toISOString() };
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
/** Bounded file browsing through the same catalog-authorized workbench as tools. */
|
|
208
|
+
async inspectFiles(identity, command) {
|
|
209
|
+
const b = await this.bench(identity);
|
|
210
|
+
const relative = command.path.replace(/^\/+/, "").replace(/\/+$/, "");
|
|
211
|
+
if (relative) safeTreePath(relative);
|
|
212
|
+
if (!relative && command.method === "raw") throw new WorkspaceError("unsafe_path", "A file path is required");
|
|
213
|
+
const target = path.join(b.config.cwd, relative);
|
|
214
|
+
await noSymlinkAncestors(target).catch((error) => {
|
|
215
|
+
if (error.code === "ENOENT" || error.code === "ENOTDIR") throw new WorkspaceError("not_found", "File or directory not found");
|
|
216
|
+
throw error;
|
|
217
|
+
});
|
|
218
|
+
const stat = await fs.stat(target);
|
|
219
|
+
if (command.method === "raw") {
|
|
220
|
+
const bytes = await readRegular(target, 8 * 1024 * 1024);
|
|
221
|
+
return { path: `/${relative}`, base64: bytes.toString("base64"), size: bytes.length, modifiedAt: stat.mtime.toISOString() };
|
|
222
|
+
}
|
|
223
|
+
if (!stat.isDirectory()) throw new WorkspaceError("not_found", "Directory not found");
|
|
224
|
+
const names = await fs.readdir(target, { withFileTypes: true });
|
|
225
|
+
if (names.length > STORAGE_LIMITS.treeEntries) throw new WorkspaceError("too_large", "Directory exceeds entry budget");
|
|
226
|
+
const entries = await Promise.all(names.filter((entry) => entry.name !== ".git" && !entry.isSymbolicLink() && (entry.isFile() || entry.isDirectory())).map(async (entry) => {
|
|
227
|
+
const nested = await fs.stat(path.join(target, entry.name));
|
|
228
|
+
return {
|
|
229
|
+
name: entry.name,
|
|
230
|
+
path: `/${path.posix.join(relative, entry.name)}`,
|
|
231
|
+
type: entry.isDirectory() ? "directory" : "file",
|
|
232
|
+
size: entry.isDirectory() ? null : nested.size,
|
|
233
|
+
modifiedAt: nested.mtime.toISOString()
|
|
234
|
+
};
|
|
235
|
+
}));
|
|
236
|
+
return { directory: `/${relative}`, entries };
|
|
237
|
+
}
|
|
238
|
+
async inspectGit(identity, command) {
|
|
239
|
+
const b = await this.bench(identity);
|
|
240
|
+
return this.serial(b, async () => {
|
|
241
|
+
if (!b.state.initialized || !b.state.head) throw new WorkspaceError("not_initialized", "Initialize workspace first");
|
|
242
|
+
const head = b.state.head;
|
|
243
|
+
const base = command.base ? GitOid.parse(command.base) : head;
|
|
244
|
+
const treeHash = await snapshotTree(b.repo, b.config.cwd, head);
|
|
245
|
+
if (command.method === "history") {
|
|
246
|
+
const history = (await git(b.repo, ["log", "-100", "--format=%H%x00%P%x00%s%x00%an%x00%ae%x00%aI%x00%cI", head])).toString();
|
|
247
|
+
return { head, treeHash, commits: history.trimEnd().split("\n").filter(Boolean).map((line) => {
|
|
248
|
+
const [hash, parents, subject, authorName, authorEmail, authoredAt, committedAt] = line.split("\0");
|
|
249
|
+
return { hash, parents: parents?.split(" ").filter(Boolean) ?? [], subject, authorName, authorEmail, authoredAt, committedAt };
|
|
250
|
+
}) };
|
|
251
|
+
}
|
|
252
|
+
if (command.method === "fileDiff") {
|
|
253
|
+
safeTreePath(command.path);
|
|
254
|
+
const side = async (tree) => {
|
|
255
|
+
const listing = (await git(b.repo, ["ls-tree", "-z", tree, "--", command.path])).toString().replace(/\0$/, "");
|
|
256
|
+
if (!listing) return { kind: "missing", objectType: null, mode: null, byteSize: null, content: null };
|
|
257
|
+
const [mode, objectType, oid] = listing.split(/[\s\t]/);
|
|
258
|
+
if (objectType !== "blob") return { kind: "unsupported", objectType, mode, byteSize: null, content: null };
|
|
259
|
+
const size = Number((await git(b.repo, ["cat-file", "-s", oid])).toString());
|
|
260
|
+
if (size > 1024 * 1024) return { kind: "oversized", objectType, mode, byteSize: size, content: null };
|
|
261
|
+
const bytes = await git(b.repo, ["cat-file", "blob", oid]);
|
|
262
|
+
const binary = bytes.includes(0);
|
|
263
|
+
return { kind: binary ? "binary" : "text", objectType, mode, byteSize: size, content: binary ? null : bytes.toString("utf8") };
|
|
264
|
+
};
|
|
265
|
+
return { head, treeHash, original: await side(base), modified: await side(treeHash) };
|
|
266
|
+
}
|
|
267
|
+
const statuses = (await git(b.repo, ["diff", "--no-renames", "--name-status", "-z", base, treeHash])).toString().split("\0");
|
|
268
|
+
const changes = [];
|
|
269
|
+
for (let index = 0; index + 1 < statuses.length; index += 2) {
|
|
270
|
+
const status = { A: "added", M: "modified", D: "deleted", T: "type_changed", U: "unmerged" }[statuses[index]] ?? "unknown";
|
|
271
|
+
changes.push({ path: statuses[index + 1], status, additions: null, deletions: null, binary: false });
|
|
272
|
+
}
|
|
273
|
+
const nums = (await git(b.repo, ["diff", "--no-renames", "--numstat", "-z", base, treeHash])).toString().split("\0");
|
|
274
|
+
for (const line of nums.filter(Boolean)) {
|
|
275
|
+
const [added, removed, ...name] = line.split(" ");
|
|
276
|
+
const file = changes.find((item) => item.path === name.join(" "));
|
|
277
|
+
if (file) Object.assign(file, { additions: added === "-" ? null : Number(added), deletions: removed === "-" ? null : Number(removed), binary: added === "-" });
|
|
278
|
+
}
|
|
279
|
+
const patch = command.method === "diff" ? (await git(b.repo, ["diff", "--no-ext-diff", "--no-textconv", "--no-renames", base, treeHash])).toString() : void 0;
|
|
280
|
+
return { head, treeHash, files: changes, ...patch === void 0 ? {} : { patch } };
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
save(b) {
|
|
284
|
+
return durableJson(path.join(b.directory, "state.json"), b.state);
|
|
285
|
+
}
|
|
286
|
+
async owned(admitted = false) {
|
|
287
|
+
if (this.closing && !admitted) throw new WorkspaceError("authority_closed", "Workspace authority is closing");
|
|
288
|
+
const owner = JSON.parse((await readRegular(path.join(this.config.root, ".sync-lock", "owner.json"), 1024)).toString());
|
|
289
|
+
if (owner.lockId !== this.lockId) throw new WorkspaceError("authority_lost", "Workspace authority lock changed");
|
|
290
|
+
}
|
|
291
|
+
serial(b, fn, admitted = false) {
|
|
292
|
+
if (this.closing && !admitted) return Promise.reject(new WorkspaceError("authority_closed", "Workspace authority is closing"));
|
|
293
|
+
if (this.pending >= 32) return Promise.reject(new WorkspaceError("busy", "Workspace request budget reached"));
|
|
294
|
+
this.pending++;
|
|
295
|
+
const task = (this.queues.get(b.config.id) ?? Promise.resolve()).catch(() => {
|
|
296
|
+
}).then(async () => {
|
|
297
|
+
await this.owned(admitted);
|
|
298
|
+
return fn();
|
|
299
|
+
});
|
|
300
|
+
this.queues.set(b.config.id, task);
|
|
301
|
+
void task.finally(() => {
|
|
302
|
+
this.pending--;
|
|
303
|
+
if (this.queues.get(b.config.id) === task) this.queues.delete(b.config.id);
|
|
304
|
+
}).catch(() => {
|
|
305
|
+
});
|
|
306
|
+
return task;
|
|
307
|
+
}
|
|
308
|
+
async route(b, identity = b.config) {
|
|
309
|
+
const route = await this.options.executor(identity);
|
|
310
|
+
if (route.workerFence.installationId !== this.config.installationId)
|
|
311
|
+
throw new WorkspaceError("wrong_installation", "Executor route is for another installation");
|
|
312
|
+
return route;
|
|
313
|
+
}
|
|
314
|
+
async storage(b) {
|
|
315
|
+
const storage = await this.options.storage(b.config);
|
|
316
|
+
if (storage.installationId !== this.config.installationId)
|
|
317
|
+
throw new WorkspaceError("wrong_installation", "Storage client is for another installation");
|
|
318
|
+
return storage;
|
|
319
|
+
}
|
|
320
|
+
async refresh(b) {
|
|
321
|
+
if (!Object.values(b.state.runs).some((r) => !["completed", "cancelled", "rejected_capacity"].includes(r.state))) return;
|
|
322
|
+
for (const [operationId, run] of Object.entries(b.state.runs)) {
|
|
323
|
+
if (["completed", "cancelled", "rejected_capacity"].includes(run.state)) continue;
|
|
324
|
+
try {
|
|
325
|
+
const route = await this.route(b, { userId: b.config.userId, sessionId: run.sessionId ?? b.config.sessionId });
|
|
326
|
+
const result = await route.client.poll(
|
|
327
|
+
{ operationId, userId: b.config.userId, sessionId: run.sessionId ?? b.config.sessionId, fence: route.workerFence },
|
|
328
|
+
0,
|
|
329
|
+
0,
|
|
330
|
+
1
|
|
331
|
+
);
|
|
332
|
+
run.state = result.receipt.state;
|
|
333
|
+
} catch {
|
|
334
|
+
run.state = "unknown";
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
await this.save(b);
|
|
338
|
+
}
|
|
339
|
+
async idle(b, allowBlocked = false) {
|
|
340
|
+
await this.refresh(b);
|
|
341
|
+
if (Object.values(b.state.runs).some((r) => r.mutating && !["completed", "cancelled", "rejected_capacity"].includes(r.state)))
|
|
342
|
+
throw new WorkspaceError(
|
|
343
|
+
"workbench_busy",
|
|
344
|
+
"A mutating command is active/unknown; poll original executor run to terminal before syncing"
|
|
345
|
+
);
|
|
346
|
+
if (b.state.blocked && !allowBlocked) throw new WorkspaceError(b.state.blocked.code, b.state.blocked.message);
|
|
347
|
+
}
|
|
348
|
+
async status(identity) {
|
|
349
|
+
const b = await this.bench(identity);
|
|
350
|
+
return this.serial(b, async () => {
|
|
351
|
+
await this.refresh(b);
|
|
352
|
+
let files = null;
|
|
353
|
+
if (b.state.initialized) {
|
|
354
|
+
try {
|
|
355
|
+
const output = await git(b.config.cwd, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"]);
|
|
356
|
+
const names = output.toString("utf8").split("\0").filter(Boolean);
|
|
357
|
+
if (names.length <= 1e4) files = [...new Set(names)].sort();
|
|
358
|
+
} catch {
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return { workbench: b.config, ...structuredClone(b.state), files };
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
async cleanRefreshBase(b, expectedBase) {
|
|
365
|
+
if (!b.state.initialized || b.state.head !== expectedBase)
|
|
366
|
+
throw new WorkspaceError("conflict", "Workbench base changed; inspect before refreshing");
|
|
367
|
+
const expectedTree = (await git(b.repo, ["rev-parse", `${expectedBase}^{tree}`])).toString().trim();
|
|
368
|
+
if (await snapshotTree(b.repo, b.config.cwd, expectedBase) !== expectedTree)
|
|
369
|
+
throw new WorkspaceError("dirty_workbench", "Source differs from its canonical base; preserve and publish or reconcile edits first");
|
|
370
|
+
const metadata = path.join(b.config.cwd, ".git");
|
|
371
|
+
const ref = `refs/heads/${b.config.branch}`;
|
|
372
|
+
const indexBytes = await readRegular(path.join(metadata, "index"), 8 * 1024 * 1024);
|
|
373
|
+
const localHead = (await readRegular(path.join(metadata, ref), 1024)).toString().trim();
|
|
374
|
+
const retained = b.state.publishedMetadata;
|
|
375
|
+
const unchangedPublishedMetadata = retained && retained.head === localHead && retained.indexHash === sha256(indexBytes);
|
|
376
|
+
if ((await readRegular(path.join(metadata, "HEAD"), 1024)).toString() !== `ref: ${ref}
|
|
377
|
+
` || localHead !== expectedBase && !unchangedPublishedMetadata)
|
|
378
|
+
throw new WorkspaceError("dirty_workbench", "Local branch changed; preserve and reconcile it before refreshing");
|
|
379
|
+
const index = path.join(b.repo, "refresh-inspection-index");
|
|
380
|
+
await fs.writeFile(index, indexBytes, { mode: 384 });
|
|
381
|
+
if (!unchangedPublishedMetadata && (await git(b.repo, ["write-tree"], void 0, index)).toString().trim() !== expectedTree)
|
|
382
|
+
throw new WorkspaceError("dirty_workbench", "Staged changes are retained; resolve them before refreshing");
|
|
383
|
+
}
|
|
384
|
+
/** No-argument hydration is initial-only. Explicit expectedBase permits a clean,
|
|
385
|
+
* idle workbench refresh; all replaced source/index/ref bytes are retained. */
|
|
386
|
+
async hydrate(identity, expectedBase) {
|
|
387
|
+
const b = await this.bench(identity);
|
|
388
|
+
return this.serial(b, () => this.hydrateIdle(b, expectedBase));
|
|
389
|
+
}
|
|
390
|
+
async hydrateIdle(b, expectedBase) {
|
|
391
|
+
if (b.state.blocked?.code === "account_initialization_unknown") {
|
|
392
|
+
await this.idle(b, true);
|
|
393
|
+
await this.assertFreshAccountWorkbench(b, expectedBase);
|
|
394
|
+
const operationId = `account-initialize-${sha256(b.config.userId)}`;
|
|
395
|
+
if (b.state.blocked.operationId !== operationId) throw new WorkspaceError("wrong_binding", "Account initialization receipt identity changed");
|
|
396
|
+
const storage2 = await this.storage(b);
|
|
397
|
+
const receipt = await storage2.read({ method: "operation.get", lookupId: operationId });
|
|
398
|
+
if (receipt.state !== "completed" || receipt.result?.repositoryId !== b.config.repositoryId || !GitOid.safeParse(receipt.result?.head).success)
|
|
399
|
+
throw new WorkspaceError("account_initialization_unknown", "Original account initialization is not proven complete; preserve its receipt");
|
|
400
|
+
b.state.blocked = null;
|
|
401
|
+
await this.save(b);
|
|
402
|
+
}
|
|
403
|
+
await this.idle(b);
|
|
404
|
+
if (expectedBase !== void 0) {
|
|
405
|
+
GitOid.parse(expectedBase);
|
|
406
|
+
await this.cleanRefreshBase(b, expectedBase);
|
|
407
|
+
} else if (b.state.initialized || (await fs.readdir(b.config.cwd)).length)
|
|
408
|
+
throw new WorkspaceError(
|
|
409
|
+
"nonempty_workbench",
|
|
410
|
+
"Hydration requires initially empty approved workbench; preserve/import existing work explicitly"
|
|
411
|
+
);
|
|
412
|
+
const storage = await this.storage(b);
|
|
413
|
+
const readHead = () => storage.read({ method: "repository.get", repositoryId: b.config.repositoryId, branch: b.config.branch });
|
|
414
|
+
let canonical;
|
|
415
|
+
const account = b.config.repositoryId === `user-workspace-${sha256(b.config.userId).slice(0, 40)}` && b.config.branch === "main";
|
|
416
|
+
try {
|
|
417
|
+
canonical = await readHead();
|
|
418
|
+
} catch (error) {
|
|
419
|
+
if (!(error instanceof WorkspaceError) || error.code !== "not_found" || !account) throw error;
|
|
420
|
+
canonical = { head: null };
|
|
421
|
+
}
|
|
422
|
+
if (account && canonical.head === null) {
|
|
423
|
+
await this.assertFreshAccountWorkbench(b, expectedBase);
|
|
424
|
+
const operationId = `account-initialize-${sha256(b.config.userId)}`;
|
|
425
|
+
b.state.blocked = { code: "account_initialization_unknown", operationId, message: "Inspect the original account initialization receipt; do not replace or reset the workbench" };
|
|
426
|
+
await this.save(b);
|
|
427
|
+
await this.initializeAccountCanonical(b, storage);
|
|
428
|
+
b.state.blocked = null;
|
|
429
|
+
await this.save(b);
|
|
430
|
+
canonical = await readHead();
|
|
431
|
+
}
|
|
432
|
+
const { head } = canonical;
|
|
433
|
+
if (!head) throw new WorkspaceError("empty_repository", "Seed README explicitly for an empty canonical branch");
|
|
434
|
+
GitOid.parse(head);
|
|
435
|
+
if (expectedBase === head) return { head };
|
|
436
|
+
b.state.blocked = {
|
|
437
|
+
code: expectedBase === void 0 ? "hydration_incomplete" : "refresh_incomplete",
|
|
438
|
+
message: "Source synchronization interrupted; preserve workbench and inspect staged tree/retained originals before maintenance"
|
|
439
|
+
};
|
|
440
|
+
await this.save(b);
|
|
441
|
+
const chunks = [];
|
|
442
|
+
let offset = 0, snapshot = null, size = -1, hash = "";
|
|
443
|
+
do {
|
|
444
|
+
const chunk = await storage.read({
|
|
445
|
+
method: "repository.bundle",
|
|
446
|
+
repositoryId: b.config.repositoryId,
|
|
447
|
+
snapshot,
|
|
448
|
+
offset,
|
|
449
|
+
limit: STORAGE_LIMITS.chunkBytes
|
|
450
|
+
});
|
|
451
|
+
const bytes = Buffer.from(chunk.data, "base64");
|
|
452
|
+
if (chunk.data !== bytes.toString("base64") || bytes.length > STORAGE_LIMITS.chunkBytes || chunk.offset !== offset || chunk.nextOffset !== offset + bytes.length || !bytes.length || chunk.size > STORAGE_LIMITS.blobBytes || chunk.nextOffset > chunk.size || snapshot && (snapshot !== chunk.snapshot || size !== chunk.size || hash !== chunk.sha256))
|
|
453
|
+
throw new WorkspaceError("invalid_bundle", "Chunk identity/size/offset changed; no checkout performed");
|
|
454
|
+
snapshot = chunk.snapshot;
|
|
455
|
+
size = chunk.size;
|
|
456
|
+
hash = chunk.sha256;
|
|
457
|
+
offset = chunk.nextOffset;
|
|
458
|
+
chunks.push(bytes);
|
|
459
|
+
} while (offset < size);
|
|
460
|
+
const bundle = Buffer.concat(chunks);
|
|
461
|
+
if (sha256(bundle) !== hash) throw new WorkspaceError("invalid_bundle", "Bundle SHA256 mismatch");
|
|
462
|
+
const file = path.join(b.directory, `${randomUUID()}.bundle`);
|
|
463
|
+
await fs.writeFile(file, bundle, { mode: 384, flag: "wx" });
|
|
464
|
+
await git(b.repo, ["bundle", "unbundle", file]);
|
|
465
|
+
const entries = await selectedTree(b.repo, head);
|
|
466
|
+
const staged = path.join(b.directory, `hydrate-${randomUUID()}`);
|
|
467
|
+
await fs.mkdir(staged, { mode: 448 });
|
|
468
|
+
let total = 0;
|
|
469
|
+
for (const entry of entries) {
|
|
470
|
+
const bytes = await git(b.repo, ["cat-file", "blob", entry.oid]);
|
|
471
|
+
sourceBytes(bytes, entry.file);
|
|
472
|
+
if ((total += bytes.length) > 128 * 1024 * 1024) throw new WorkspaceError("too_large", "Hydrated tree exceeds limit");
|
|
473
|
+
const target = path.join(staged, entry.file);
|
|
474
|
+
await fs.mkdir(path.dirname(target), { recursive: true, mode: 448 });
|
|
475
|
+
await fs.writeFile(target, bytes, { mode: entry.mode === "100755" ? 448 : 384, flag: "wx" });
|
|
476
|
+
}
|
|
477
|
+
await verifyIgnore(b.repo, staged);
|
|
478
|
+
const latest = await storage.read({
|
|
479
|
+
method: "repository.get",
|
|
480
|
+
repositoryId: b.config.repositoryId,
|
|
481
|
+
branch: b.config.branch
|
|
482
|
+
});
|
|
483
|
+
if (latest.head !== head)
|
|
484
|
+
throw new WorkspaceError("conflict", "Canonical head changed during hydration; staged tree retained for review");
|
|
485
|
+
if (expectedBase === void 0) {
|
|
486
|
+
if ((await fs.readdir(b.config.cwd)).length)
|
|
487
|
+
throw new WorkspaceError("nonempty_workbench", "Workbench changed during hydration; no overwrite");
|
|
488
|
+
for (const name of await fs.readdir(staged)) await fs.rename(path.join(staged, name), path.join(b.config.cwd, name));
|
|
489
|
+
await this.installGitPolicy(b, head);
|
|
490
|
+
} else {
|
|
491
|
+
await this.cleanRefreshBase(b, expectedBase);
|
|
492
|
+
const original = await selectedTree(b.repo, expectedBase);
|
|
493
|
+
const originalPaths = new Set(original.map((entry) => entry.file));
|
|
494
|
+
for (const entry of entries) {
|
|
495
|
+
const target = path.join(b.config.cwd, entry.file);
|
|
496
|
+
const existing = await fs.lstat(target).catch((error) => {
|
|
497
|
+
if (error.code === "ENOENT") return null;
|
|
498
|
+
throw error;
|
|
499
|
+
});
|
|
500
|
+
if (existing && (!originalPaths.has(entry.file) || !existing.isFile() || existing.isSymbolicLink()))
|
|
501
|
+
throw new WorkspaceError("refresh_collision", "Canonical source would replace retained local content");
|
|
502
|
+
let parent = path.dirname(target);
|
|
503
|
+
while (parent !== b.config.cwd) {
|
|
504
|
+
const current = await fs.lstat(parent).catch((error) => {
|
|
505
|
+
if (error.code === "ENOENT") return null;
|
|
506
|
+
throw error;
|
|
507
|
+
});
|
|
508
|
+
if (current && (!current.isDirectory() || current.isSymbolicLink()))
|
|
509
|
+
throw new WorkspaceError("refresh_collision", "Canonical source has a conflicting local ancestor");
|
|
510
|
+
parent = path.dirname(parent);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
const retained = path.join(b.directory, `refresh-retained-${randomUUID()}`);
|
|
514
|
+
await fs.mkdir(retained, { mode: 448 });
|
|
515
|
+
const changedDirectories = /* @__PURE__ */ new Set([b.directory, retained]);
|
|
516
|
+
const changed = (directory, root) => {
|
|
517
|
+
while (true) {
|
|
518
|
+
changedDirectories.add(directory);
|
|
519
|
+
if (directory === root) break;
|
|
520
|
+
directory = path.dirname(directory);
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
const persistFile = async (filename) => {
|
|
524
|
+
const file2 = await fs.open(filename, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
525
|
+
try {
|
|
526
|
+
await file2.sync();
|
|
527
|
+
} finally {
|
|
528
|
+
await file2.close();
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
await durableJson(path.join(retained, "recovery.json"), {
|
|
532
|
+
previousHead: expectedBase,
|
|
533
|
+
head,
|
|
534
|
+
paths: original.map((entry) => entry.file)
|
|
535
|
+
});
|
|
536
|
+
const metadata = path.join(b.config.cwd, ".git");
|
|
537
|
+
const ref = `refs/heads/${b.config.branch}`;
|
|
538
|
+
for (const name of ["index", "HEAD", ref]) {
|
|
539
|
+
const destination = path.join(retained, "git", name);
|
|
540
|
+
await fs.mkdir(path.dirname(destination), { recursive: true, mode: 448 });
|
|
541
|
+
await this.replaceFile(destination, await readRegular(path.join(metadata, name), 8 * 1024 * 1024));
|
|
542
|
+
changed(path.dirname(destination), retained);
|
|
543
|
+
}
|
|
544
|
+
for (const entry of original) {
|
|
545
|
+
const destination = path.join(retained, "source", entry.file);
|
|
546
|
+
await fs.mkdir(path.dirname(destination), { recursive: true, mode: 448 });
|
|
547
|
+
await persistFile(path.join(b.config.cwd, entry.file));
|
|
548
|
+
await fs.rename(path.join(b.config.cwd, entry.file), destination);
|
|
549
|
+
changed(path.dirname(destination), retained);
|
|
550
|
+
changed(path.dirname(path.join(b.config.cwd, entry.file)), b.config.cwd);
|
|
551
|
+
}
|
|
552
|
+
for (const entry of entries) {
|
|
553
|
+
const destination = path.join(b.config.cwd, entry.file);
|
|
554
|
+
await fs.mkdir(path.dirname(destination), { recursive: true, mode: 448 });
|
|
555
|
+
await persistFile(path.join(staged, entry.file));
|
|
556
|
+
await fs.rename(path.join(staged, entry.file), destination);
|
|
557
|
+
changed(path.dirname(destination), b.config.cwd);
|
|
558
|
+
changed(path.dirname(path.join(staged, entry.file)), staged);
|
|
559
|
+
}
|
|
560
|
+
const copyObjects = async (relative = "") => {
|
|
561
|
+
const source = path.join(b.repo, "objects", relative), destination = path.join(metadata, "objects", relative);
|
|
562
|
+
await fs.mkdir(destination, { recursive: true, mode: 448 });
|
|
563
|
+
await noSymlinkAncestors(destination);
|
|
564
|
+
changed(destination, b.config.cwd);
|
|
565
|
+
for (const entry of await fs.readdir(source, { withFileTypes: true })) {
|
|
566
|
+
const nested = path.join(relative, entry.name);
|
|
567
|
+
if (entry.isDirectory()) await copyObjects(nested);
|
|
568
|
+
else if (entry.isFile()) {
|
|
569
|
+
const bytes = await readRegular(path.join(source, entry.name));
|
|
570
|
+
const target = path.join(destination, entry.name);
|
|
571
|
+
const old = await readRegular(target).catch((error) => {
|
|
572
|
+
if (error.code === "ENOENT") return null;
|
|
573
|
+
throw error;
|
|
574
|
+
});
|
|
575
|
+
if (old && !old.equals(bytes))
|
|
576
|
+
throw new WorkspaceError("unsafe_git", "Existing Git object differs; preserve both for review");
|
|
577
|
+
if (!old) {
|
|
578
|
+
await fs.copyFile(path.join(source, entry.name), target, constants.COPYFILE_EXCL);
|
|
579
|
+
await persistFile(target);
|
|
580
|
+
}
|
|
581
|
+
} else throw new WorkspaceError("unsafe_git", "Authority Git object is not a regular file");
|
|
582
|
+
}
|
|
583
|
+
};
|
|
584
|
+
await copyObjects();
|
|
585
|
+
await git(b.repo, ["read-tree", head]);
|
|
586
|
+
await this.replaceFile(path.join(metadata, "index"), await readRegular(path.join(b.repo, "index"), 8 * 1024 * 1024));
|
|
587
|
+
await this.replaceFile(path.join(metadata, ref), Buffer.from(`${head}
|
|
588
|
+
`));
|
|
589
|
+
for (const directory of [...changedDirectories].sort((a, b2) => b2.length - a.length)) {
|
|
590
|
+
const handle = await fs.open(directory, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
591
|
+
try {
|
|
592
|
+
await handle.sync();
|
|
593
|
+
} finally {
|
|
594
|
+
await handle.close();
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
if (expectedBase !== void 0) {
|
|
599
|
+
const latest2 = await storage.read({
|
|
600
|
+
method: "repository.get",
|
|
601
|
+
repositoryId: b.config.repositoryId,
|
|
602
|
+
branch: b.config.branch
|
|
603
|
+
});
|
|
604
|
+
if (latest2.head !== head)
|
|
605
|
+
throw new WorkspaceError("conflict", "Canonical changed during refresh; both source trees retained for review");
|
|
606
|
+
}
|
|
607
|
+
b.state.initialized = true;
|
|
608
|
+
b.state.head = head;
|
|
609
|
+
delete b.state.publishedMetadata;
|
|
610
|
+
b.state.blocked = null;
|
|
611
|
+
await this.save(b);
|
|
612
|
+
return { head };
|
|
613
|
+
}
|
|
614
|
+
async assertFreshAccountWorkbench(b, expectedBase) {
|
|
615
|
+
if (expectedBase !== void 0 || b.state.initialized || b.state.head !== null || Object.keys(b.state.runs).length || b.config.repositoryId !== `user-workspace-${sha256(b.config.userId).slice(0, 40)}` || b.config.branch !== "main" || (await fs.readdir(b.config.cwd)).length)
|
|
616
|
+
throw new WorkspaceError("nonempty_workbench", "Account initialization requires its original empty uninitialized workbench; preserve existing files and history");
|
|
617
|
+
}
|
|
618
|
+
async initializeAccountCanonical(b, storage) {
|
|
619
|
+
const operationId = `account-initialize-${sha256(b.config.userId)}`;
|
|
620
|
+
let receipt;
|
|
621
|
+
try {
|
|
622
|
+
receipt = await storage.mutate({ method: "repository.initializeAccount", operationId, repositoryId: b.config.repositoryId });
|
|
623
|
+
} catch (error) {
|
|
624
|
+
if (error instanceof WorkspaceError && error.rejectedBeforeAdmission && b.state.blocked?.code === "account_initialization_unknown" && b.state.blocked.operationId === operationId) {
|
|
625
|
+
b.state.blocked = null;
|
|
626
|
+
await this.save(b);
|
|
627
|
+
}
|
|
628
|
+
throw error;
|
|
629
|
+
}
|
|
630
|
+
if (receipt.repositoryId !== b.config.repositoryId || !GitOid.safeParse(receipt.head).success)
|
|
631
|
+
throw new WorkspaceError("invalid_response", "Account initialization receipt mismatch; preserve the original operation");
|
|
632
|
+
return receipt;
|
|
633
|
+
}
|
|
634
|
+
async replaceFile(destination, bytes) {
|
|
635
|
+
const temporary = `${destination}.${randomUUID()}.next`;
|
|
636
|
+
const file = await fs.open(temporary, "wx", 384);
|
|
637
|
+
try {
|
|
638
|
+
await file.writeFile(bytes);
|
|
639
|
+
await file.sync();
|
|
640
|
+
} finally {
|
|
641
|
+
await file.close();
|
|
642
|
+
}
|
|
643
|
+
await fs.rename(temporary, destination);
|
|
644
|
+
const directory = await fs.open(path.dirname(destination), "r");
|
|
645
|
+
try {
|
|
646
|
+
await directory.sync();
|
|
647
|
+
} finally {
|
|
648
|
+
await directory.close();
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
/** Explicit trusted import boundary for the first isolated README seed, not legacy tree import. */
|
|
652
|
+
async seedReadme(identity, readme) {
|
|
653
|
+
const b = await this.bench(identity);
|
|
654
|
+
return this.serial(b, async () => {
|
|
655
|
+
await this.idle(b);
|
|
656
|
+
if (Buffer.byteLength(readme) > 1024 * 1024) throw new WorkspaceError("too_large", "README seed exceeds limit");
|
|
657
|
+
sourceBytes(Buffer.from(readme));
|
|
658
|
+
if (b.state.initialized || (await fs.readdir(b.config.cwd)).length)
|
|
659
|
+
throw new WorkspaceError("nonempty_workbench", "Seed only an initially empty approved workbench");
|
|
660
|
+
const storage = await this.storage(b);
|
|
661
|
+
const operationId = `ws-${randomUUID()}`;
|
|
662
|
+
b.state.blocked = {
|
|
663
|
+
code: "seed_unknown",
|
|
664
|
+
operationId,
|
|
665
|
+
message: "Inspect original provision/seed receipt; do not automatically rerun import"
|
|
666
|
+
};
|
|
667
|
+
await this.save(b);
|
|
668
|
+
await storage.mutate({ method: "repository.provision", operationId, repositoryId: b.config.repositoryId });
|
|
669
|
+
const existing = await storage.read({
|
|
670
|
+
method: "repository.get",
|
|
671
|
+
repositoryId: b.config.repositoryId,
|
|
672
|
+
branch: b.config.branch
|
|
673
|
+
});
|
|
674
|
+
if (existing.head) throw new WorkspaceError("conflict", "Canonical branch already has a head; hydrate instead");
|
|
675
|
+
await fs.writeFile(path.join(b.config.cwd, ".gitignore"), DEFAULT_WORKSPACE_IGNORE, { mode: 384, flag: "wx" });
|
|
676
|
+
await fs.writeFile(path.join(b.config.cwd, "README.md"), readme, { mode: 384, flag: "wx" });
|
|
677
|
+
b.state.initialized = true;
|
|
678
|
+
b.state.blocked = null;
|
|
679
|
+
await this.save(b);
|
|
680
|
+
const result = await this.publishIdle(b);
|
|
681
|
+
await this.installGitPolicy(b, result.head);
|
|
682
|
+
return result;
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
async installGitPolicy(b, head) {
|
|
686
|
+
delete b.state.publishedMetadata;
|
|
687
|
+
const destination = path.join(b.config.cwd, ".git");
|
|
688
|
+
await git(b.repo, ["read-tree", head]);
|
|
689
|
+
await fs.mkdir(destination, { mode: 448 });
|
|
690
|
+
await fs.cp(b.repo, destination, { recursive: true, errorOnExist: true });
|
|
691
|
+
const remote = `r5d-canonical://${this.config.installationId}/${b.config.userId}/${b.config.repositoryId}`;
|
|
692
|
+
await fs.writeFile(
|
|
693
|
+
path.join(destination, "config"),
|
|
694
|
+
`[core]
|
|
695
|
+
repositoryformatversion = 0
|
|
696
|
+
bare = false
|
|
697
|
+
hooksPath = /dev/null
|
|
698
|
+
[protocol]
|
|
699
|
+
allow = never
|
|
700
|
+
[remote "canonical"]
|
|
701
|
+
url = ${remote}
|
|
702
|
+
pushurl = ${remote}
|
|
703
|
+
`,
|
|
704
|
+
{ mode: 384 }
|
|
705
|
+
);
|
|
706
|
+
await fs.writeFile(path.join(destination, "HEAD"), `ref: refs/heads/${b.config.branch}
|
|
707
|
+
`, { mode: 384 });
|
|
708
|
+
const ref = path.join(destination, "refs", "heads", b.config.branch);
|
|
709
|
+
await fs.mkdir(path.dirname(ref), { recursive: true, mode: 448 });
|
|
710
|
+
await fs.writeFile(ref, `${head}
|
|
711
|
+
`, { mode: 384 });
|
|
712
|
+
}
|
|
713
|
+
/** Scoped receipt lookup never repeats a file effect; completion can clear its own crash marker. */
|
|
714
|
+
async fileWriteResult(identity, input) {
|
|
715
|
+
WorkspaceFileWriteLookup.parse({ method: "fileWriteResult", ...input });
|
|
716
|
+
const b = await this.bench(identity);
|
|
717
|
+
return this.serial(b, async () => {
|
|
718
|
+
const bytes = await readRegular(path.join(b.directory, `product-${sha256(input.id)}.json`), 1024 * 1024).catch((error) => {
|
|
719
|
+
if (error.code === "ENOENT") return null;
|
|
720
|
+
throw error;
|
|
721
|
+
});
|
|
722
|
+
if (!bytes) return { state: "absent" };
|
|
723
|
+
const receipt = JSON.parse(bytes.toString());
|
|
724
|
+
if (receipt.state !== "completed") throw new WorkspaceError("product_action_unknown", "The original file mutation outcome is not proven");
|
|
725
|
+
if (receipt.result?.method !== "fileWrite" || receipt.result.userId !== identity.userId || receipt.result.sessionId !== identity.sessionId)
|
|
726
|
+
throw new WorkspaceError("operation_payload_mismatch", "This action belongs to another operation or conversation");
|
|
727
|
+
if (b.state.blocked?.code === "file_write_unknown" && b.state.blocked.operationId === input.id) {
|
|
728
|
+
b.state.blocked = null;
|
|
729
|
+
await this.save(b);
|
|
730
|
+
}
|
|
731
|
+
return { state: "completed", result: receipt.result };
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
async fileWrite(identity, raw) {
|
|
735
|
+
const input = WorkspaceFileWrite.parse(raw), bytes = Buffer.from(input.base64, "base64");
|
|
736
|
+
if (bytes.toString("base64") !== input.base64 || bytes.length > 768 * 1024)
|
|
737
|
+
throw new WorkspaceError("invalid_input", "Expected canonical base64 for at most 768 KiB", true);
|
|
738
|
+
const b = await this.bench(identity), target = path.join(b.config.cwd, input.path);
|
|
739
|
+
const fingerprint = { ...input, userId: identity.userId, sessionId: identity.sessionId };
|
|
740
|
+
let mode = 420;
|
|
741
|
+
const check = async () => {
|
|
742
|
+
await this.idle(b);
|
|
743
|
+
if (!b.state.initialized) throw new WorkspaceError("uninitialized", "Hydrate this workspace before writing files");
|
|
744
|
+
let current = b.config.cwd;
|
|
745
|
+
for (const part of input.path.split("/").slice(0, -1)) {
|
|
746
|
+
current = path.join(current, part);
|
|
747
|
+
const stat = await fs.lstat(current).catch((error) => {
|
|
748
|
+
if (error.code === "ENOENT") return null;
|
|
749
|
+
throw error;
|
|
750
|
+
});
|
|
751
|
+
if (stat && (!stat.isDirectory() || stat.isSymbolicLink())) throw new WorkspaceError("unsafe_path", "File parent is not a real directory");
|
|
752
|
+
}
|
|
753
|
+
const previous = await readRegular(target, 8 * 1024 * 1024).catch((error) => {
|
|
754
|
+
if (error.code === "ENOENT") return null;
|
|
755
|
+
throw error;
|
|
756
|
+
});
|
|
757
|
+
if ((previous === null ? null : sha256(previous)) !== input.expectedSha256)
|
|
758
|
+
throw new WorkspaceError("file_changed", "File changed since it was read; read it again before editing");
|
|
759
|
+
if (previous !== null) mode = (await fs.lstat(target)).mode & 511;
|
|
760
|
+
};
|
|
761
|
+
return this.serial(b, async () => {
|
|
762
|
+
const result = await this.productAction(b, input.id, fingerprint, async () => {
|
|
763
|
+
b.state.blocked = { code: "file_write_unknown", operationId: input.id, message: "Inspect the original file write receipt before changing this workbench" };
|
|
764
|
+
await this.save(b);
|
|
765
|
+
await fs.mkdir(path.dirname(target), { recursive: true, mode: 493 });
|
|
766
|
+
await noSymlinkAncestors(path.dirname(target));
|
|
767
|
+
const temporary = path.join(path.dirname(target), `.r5d-file-${randomUUID()}`);
|
|
768
|
+
const file = await fs.open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, mode);
|
|
769
|
+
try {
|
|
770
|
+
await file.writeFile(bytes);
|
|
771
|
+
await file.sync();
|
|
772
|
+
} finally {
|
|
773
|
+
await file.close();
|
|
774
|
+
}
|
|
775
|
+
const current = await readRegular(target, 8 * 1024 * 1024).catch((error) => {
|
|
776
|
+
if (error.code === "ENOENT") return null;
|
|
777
|
+
throw error;
|
|
778
|
+
});
|
|
779
|
+
if ((current === null ? null : sha256(current)) !== input.expectedSha256)
|
|
780
|
+
throw new WorkspaceError("product_action_unknown", "External file change raced an admitted write; preserve the original receipt");
|
|
781
|
+
await fs.rename(temporary, target);
|
|
782
|
+
const directory = await fs.open(path.dirname(target), constants.O_RDONLY);
|
|
783
|
+
try {
|
|
784
|
+
await directory.sync();
|
|
785
|
+
} finally {
|
|
786
|
+
await directory.close();
|
|
787
|
+
}
|
|
788
|
+
return {
|
|
789
|
+
method: "fileWrite",
|
|
790
|
+
id: input.id,
|
|
791
|
+
userId: identity.userId,
|
|
792
|
+
sessionId: identity.sessionId,
|
|
793
|
+
path: input.path,
|
|
794
|
+
sha256: sha256(bytes),
|
|
795
|
+
gitBlobHash: createHash("sha1").update(`blob ${bytes.length}\0`).update(bytes).digest("hex"),
|
|
796
|
+
size: bytes.length,
|
|
797
|
+
modifiedAt: (await fs.stat(target)).mtime.toISOString(),
|
|
798
|
+
requestHash: sha256(canonicalJson(fingerprint)),
|
|
799
|
+
...input.intentHash ? { intentHash: input.intentHash } : {}
|
|
800
|
+
};
|
|
801
|
+
}, check);
|
|
802
|
+
if (b.state.blocked?.code === "file_write_unknown" && b.state.blocked.operationId === input.id) {
|
|
803
|
+
b.state.blocked = null;
|
|
804
|
+
await this.save(b);
|
|
805
|
+
}
|
|
806
|
+
return result;
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
async productAction(b, id, input, action, prepare) {
|
|
810
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,127}$/.test(id)) throw new WorkspaceError("invalid_input", "Invalid product action ID");
|
|
811
|
+
const hash = sha256(canonicalJson(input));
|
|
812
|
+
const file = path.join(b.directory, `product-${sha256(id)}.json`);
|
|
813
|
+
const previous = await readRegular(file, 1024 * 1024).catch((error) => {
|
|
814
|
+
if (error.code === "ENOENT") return null;
|
|
815
|
+
throw error;
|
|
816
|
+
});
|
|
817
|
+
if (previous) {
|
|
818
|
+
const receipt = JSON.parse(previous.toString());
|
|
819
|
+
if (receipt.hash !== hash) throw new WorkspaceError("operation_payload_mismatch", "Action ID already bound to different input");
|
|
820
|
+
if (receipt.state === "completed") return receipt.result;
|
|
821
|
+
throw new WorkspaceError("product_action_unknown", "Inspect the original product action; its outcome is not yet proven");
|
|
822
|
+
}
|
|
823
|
+
try {
|
|
824
|
+
await prepare?.();
|
|
825
|
+
} catch (error) {
|
|
826
|
+
if (error instanceof WorkspaceError) throw new WorkspaceError(error.code, error.message, true);
|
|
827
|
+
throw error;
|
|
828
|
+
}
|
|
829
|
+
await durableJson(file, { id, hash, state: "pending" });
|
|
830
|
+
const result = await action();
|
|
831
|
+
await durableJson(file, { id, hash, state: "completed", result });
|
|
832
|
+
return result;
|
|
833
|
+
}
|
|
834
|
+
validateGithubRemote(remote) {
|
|
835
|
+
const url = new URL(remote.url);
|
|
836
|
+
if (url.protocol !== "https:" || url.hostname !== "github.com" || url.port || url.username || url.password || url.search || url.hash || !/^\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\.git)?$/.test(url.pathname) || !remote.token || remote.token.length > 8192)
|
|
837
|
+
throw new WorkspaceError("invalid_remote", "Expected an authorized GitHub repository");
|
|
838
|
+
BranchName.parse(remote.branch);
|
|
839
|
+
}
|
|
840
|
+
/** Import only a selected exact GitHub tree into canonical storage; never execute its Git configuration. */
|
|
841
|
+
async importRepository(identity, input) {
|
|
842
|
+
const b = await this.bench(identity);
|
|
843
|
+
this.validateGithubRemote({ ...input, branch: input.defaultBranch });
|
|
844
|
+
return this.serial(b, () => this.productAction(b, input.id, { method: "import", url: input.url, branch: input.defaultBranch }, async () => {
|
|
845
|
+
await git(b.repo, ["-c", "protocol.https.allow=always", "fetch", "--no-tags", "--no-recurse-submodules", "--", input.url, `refs/heads/${input.defaultBranch}`], void 0, void 0, { token: input.token });
|
|
846
|
+
const head = GitOid.parse((await git(b.repo, ["rev-parse", "FETCH_HEAD"])).toString().trim());
|
|
847
|
+
return this.importCommit(b, head, input.id);
|
|
848
|
+
}, async () => {
|
|
849
|
+
await this.idle(b);
|
|
850
|
+
if (b.state.initialized || (await fs.readdir(b.config.cwd)).length) throw new WorkspaceError("nonempty_workbench", "Import requires a new empty workbench");
|
|
851
|
+
}));
|
|
852
|
+
}
|
|
853
|
+
async cloneBranch(identity, sourceIdentity, input) {
|
|
854
|
+
if (identity.userId !== sourceIdentity.userId || identity.sessionId === sourceIdentity.sessionId) throw new WorkspaceError("forbidden", "Invalid branch source");
|
|
855
|
+
const target = await this.bench(identity);
|
|
856
|
+
const fingerprint = { method: "clone", sourceSessionId: sourceIdentity.sessionId, workingTree: input.workingTree };
|
|
857
|
+
const completed = await this.serial(target, async () => {
|
|
858
|
+
const bytes = await readRegular(path.join(target.directory, `product-${sha256(input.id)}.json`), 1024 * 1024).catch((error) => {
|
|
859
|
+
if (error.code === "ENOENT") return null;
|
|
860
|
+
throw error;
|
|
861
|
+
});
|
|
862
|
+
if (!bytes) return null;
|
|
863
|
+
const receipt = JSON.parse(bytes.toString());
|
|
864
|
+
if (receipt.hash !== sha256(canonicalJson(fingerprint))) throw new WorkspaceError("operation_payload_mismatch", "Branch action identity changed");
|
|
865
|
+
if (receipt.state !== "completed") throw new WorkspaceError("product_action_unknown", "Inspect original branch operation");
|
|
866
|
+
return receipt.result;
|
|
867
|
+
});
|
|
868
|
+
if (completed) return completed;
|
|
869
|
+
const source = await this.bench(sourceIdentity);
|
|
870
|
+
if (source.config.repositoryId !== target.config.repositoryId || source.config.branch === target.config.branch) throw new WorkspaceError("forbidden", "Branch source must belong to the same project");
|
|
871
|
+
await this.ensureHydrated(sourceIdentity);
|
|
872
|
+
const prepared = await this.serial(source, async () => {
|
|
873
|
+
await this.idle(source);
|
|
874
|
+
let head = source.state.head;
|
|
875
|
+
if (input.workingTree === "carry") {
|
|
876
|
+
const tree = await snapshotTree(source.repo, source.config.cwd, head);
|
|
877
|
+
if (tree !== (await git(source.repo, ["rev-parse", `${head}^{tree}`])).toString().trim()) head = (await git(source.repo, ["commit-tree", tree, "-p", head], "Branch working tree\n")).toString().trim();
|
|
878
|
+
}
|
|
879
|
+
const ref = `refs/r5d/product-${sha256(input.id)}`;
|
|
880
|
+
await git(source.repo, ["update-ref", ref, head]);
|
|
881
|
+
const file = path.join(source.directory, `branch-${randomUUID()}.bundle`);
|
|
882
|
+
await git(source.repo, ["bundle", "create", file, ref]);
|
|
883
|
+
return { head, bytes: await readRegular(file, STORAGE_LIMITS.blobBytes) };
|
|
884
|
+
});
|
|
885
|
+
return this.serial(target, () => this.productAction(target, input.id, fingerprint, async () => {
|
|
886
|
+
const file = path.join(target.directory, `clone-${randomUUID()}.bundle`);
|
|
887
|
+
await fs.writeFile(file, prepared.bytes, { mode: 384, flag: "wx" });
|
|
888
|
+
await git(target.repo, ["bundle", "unbundle", file]);
|
|
889
|
+
return this.importCommit(target, prepared.head, input.id);
|
|
890
|
+
}, async () => {
|
|
891
|
+
await this.idle(target);
|
|
892
|
+
if (target.state.initialized || (await fs.readdir(target.config.cwd)).length) throw new WorkspaceError("nonempty_workbench", "Branch already has a workbench");
|
|
893
|
+
}));
|
|
894
|
+
}
|
|
895
|
+
async importCommit(b, head, id) {
|
|
896
|
+
const entries = await selectedTree(b.repo, head);
|
|
897
|
+
const staged = path.join(b.directory, `import-${randomUUID()}`);
|
|
898
|
+
await fs.mkdir(staged, { mode: 448 });
|
|
899
|
+
let total = 0;
|
|
900
|
+
for (const entry of entries) {
|
|
901
|
+
const bytes2 = await git(b.repo, ["cat-file", "blob", entry.oid]);
|
|
902
|
+
sourceBytes(bytes2, entry.file);
|
|
903
|
+
if ((total += bytes2.length) > STORAGE_LIMITS.blobBytes) throw new WorkspaceError("too_large", "Imported tree exceeds limit");
|
|
904
|
+
const destination = path.join(staged, entry.file);
|
|
905
|
+
await fs.mkdir(path.dirname(destination), { recursive: true, mode: 448 });
|
|
906
|
+
await fs.writeFile(destination, bytes2, { mode: entry.mode === "100755" ? 448 : 384, flag: "wx" });
|
|
907
|
+
}
|
|
908
|
+
const ignore = path.join(staged, ".gitignore");
|
|
909
|
+
try {
|
|
910
|
+
await verifyIgnore(b.repo, staged);
|
|
911
|
+
} catch (error) {
|
|
912
|
+
if (!(error instanceof WorkspaceError) && error.code !== "ENOENT") throw error;
|
|
913
|
+
const oldIgnore = await fs.readFile(ignore, "utf8").catch((error2) => {
|
|
914
|
+
if (error2.code === "ENOENT") return "";
|
|
915
|
+
throw error2;
|
|
916
|
+
});
|
|
917
|
+
await fs.writeFile(ignore, `${oldIgnore}
|
|
918
|
+
${DEFAULT_WORKSPACE_IGNORE}`, { mode: 384 });
|
|
919
|
+
await verifyIgnore(b.repo, staged);
|
|
920
|
+
}
|
|
921
|
+
const tree = await snapshotTree(b.repo, staged, head);
|
|
922
|
+
if (tree !== (await git(b.repo, ["rev-parse", `${head}^{tree}`])).toString().trim()) head = (await git(b.repo, ["commit-tree", tree, "-p", head], "Configure workspace exclusions\n")).toString().trim();
|
|
923
|
+
const storage = await this.storage(b), operationId = `product-${sha256(id).slice(0, 48)}`;
|
|
924
|
+
let existing;
|
|
925
|
+
try {
|
|
926
|
+
existing = await storage.read({ method: "repository.get", repositoryId: b.config.repositoryId, branch: b.config.branch });
|
|
927
|
+
} catch (error) {
|
|
928
|
+
if (!(error instanceof WorkspaceError) || error.code !== "not_found") throw error;
|
|
929
|
+
await storage.mutate({ method: "repository.provision", operationId: `${operationId}-provision`, repositoryId: b.config.repositoryId });
|
|
930
|
+
existing = { head: null };
|
|
931
|
+
}
|
|
932
|
+
if (existing.head) throw new WorkspaceError("conflict", "Branch already exists in canonical storage");
|
|
933
|
+
await git(b.repo, ["update-ref", `refs/heads/${b.config.branch}`, head]);
|
|
934
|
+
const file = path.join(b.directory, `publish-${randomUUID()}.bundle`);
|
|
935
|
+
await git(b.repo, ["bundle", "create", file, `refs/heads/${b.config.branch}`]);
|
|
936
|
+
const bytes = await readRegular(file, STORAGE_LIMITS.blobBytes), blobId = operationId;
|
|
937
|
+
b.state.blocked = { code: "publication_unknown", operationId, commit: head, message: "Inspect the original import publication receipt" };
|
|
938
|
+
await this.save(b);
|
|
939
|
+
for (let offset = 0; offset < bytes.length; offset += STORAGE_LIMITS.chunkBytes) {
|
|
940
|
+
const end = Math.min(bytes.length, offset + STORAGE_LIMITS.chunkBytes);
|
|
941
|
+
await storage.mutate({ method: "blob.append", operationId: `${operationId}-chunk-${offset}`, blobId, kind: "blob", offset, data: bytes.subarray(offset, end).toString("base64"), seal: end === bytes.length });
|
|
942
|
+
}
|
|
943
|
+
const result = await storage.mutate({ method: "repository.publish", operationId, repositoryId: b.config.repositoryId, branch: b.config.branch, expectedHead: null, commit: head, bundleId: blobId });
|
|
944
|
+
if (result.head !== head) throw new WorkspaceError("invalid_receipt", "Import receipt does not match selected commit");
|
|
945
|
+
for (const name of await fs.readdir(staged)) await fs.rename(path.join(staged, name), path.join(b.config.cwd, name));
|
|
946
|
+
await this.installGitPolicy(b, head);
|
|
947
|
+
b.state.initialized = true;
|
|
948
|
+
b.state.head = head;
|
|
949
|
+
b.state.blocked = null;
|
|
950
|
+
await this.save(b);
|
|
951
|
+
return { head, workspaceHead: head };
|
|
952
|
+
}
|
|
953
|
+
async commit(identity, input) {
|
|
954
|
+
const b = await this.bench(identity);
|
|
955
|
+
if (!input.message.trim() || input.message.length > 1e4) throw new WorkspaceError("invalid_input", "Commit message is required");
|
|
956
|
+
return this.serial(b, () => this.productAction(b, input.id, { method: "commit", expectedHead: input.expectedHead, message: input.message }, async () => {
|
|
957
|
+
const result = await this.publishIdle(b, input.message);
|
|
958
|
+
const metadata = path.join(b.config.cwd, ".git");
|
|
959
|
+
await noSymlinkAncestors(metadata);
|
|
960
|
+
const retained = path.join(b.directory, `commit-retained-${randomUUID()}`);
|
|
961
|
+
await fs.mkdir(retained, { mode: 448 });
|
|
962
|
+
await fs.rename(metadata, path.join(retained, "git"));
|
|
963
|
+
await this.installGitPolicy(b, result.head);
|
|
964
|
+
return { ...result, treeHash: (await git(b.repo, ["rev-parse", `${result.head}^{tree}`])).toString().trim() };
|
|
965
|
+
}, async () => {
|
|
966
|
+
await this.idle(b);
|
|
967
|
+
if (input.expectedHead !== void 0 && input.expectedHead !== b.state.head) throw new WorkspaceError("conflict", "Workbench head changed");
|
|
968
|
+
}));
|
|
969
|
+
}
|
|
970
|
+
async push(identity, input) {
|
|
971
|
+
const b = await this.bench(identity);
|
|
972
|
+
this.validateGithubRemote(input.remote);
|
|
973
|
+
return this.serial(b, () => this.productAction(b, input.id, { method: "push", expectedHead: input.expectedHead, url: input.remote.url, branch: input.remote.branch, expectedRemoteHead: input.remote.expectedHead }, async () => {
|
|
974
|
+
if (input.remote.expectedHead) {
|
|
975
|
+
GitOid.parse(input.remote.expectedHead);
|
|
976
|
+
await git(b.repo, ["merge-base", "--is-ancestor", input.remote.expectedHead, b.state.head]);
|
|
977
|
+
}
|
|
978
|
+
await git(b.repo, ["-c", "protocol.https.allow=always", "push", "--porcelain", ...input.remote.expectedHead !== void 0 ? [`--force-with-lease=refs/heads/${input.remote.branch}:${input.remote.expectedHead ?? ""}`] : [], "--", input.remote.url, `${b.state.head}:refs/heads/${input.remote.branch}`], void 0, void 0, { token: input.remote.token });
|
|
979
|
+
return { head: b.state.head, pushed: true };
|
|
980
|
+
}, async () => {
|
|
981
|
+
await this.idle(b);
|
|
982
|
+
if (!b.state.head || input.expectedHead !== void 0 && input.expectedHead !== b.state.head) throw new WorkspaceError("conflict", "Workbench head changed");
|
|
983
|
+
}));
|
|
984
|
+
}
|
|
985
|
+
async archiveBranch(identity, input) {
|
|
986
|
+
const b = await this.bench(identity);
|
|
987
|
+
return this.serial(b, () => this.productAction(b, input.id, { method: "archive" }, async () => {
|
|
988
|
+
const storage = await this.storage(b);
|
|
989
|
+
const current = await storage.read({ method: "repository.get", repositoryId: b.config.repositoryId, branch: b.config.branch });
|
|
990
|
+
const result = await storage.mutate({ method: "repository.archive", operationId: `archive-${sha256(input.id).slice(0, 48)}`, repositoryId: b.config.repositoryId, branch: b.config.branch, expectedHead: current.head });
|
|
991
|
+
b.state.blocked = { code: "archived", message: "This branch incarnation is archived; its workbench and source are retained" };
|
|
992
|
+
await this.save(b);
|
|
993
|
+
return { removed: true, receipt: result };
|
|
994
|
+
}, () => this.idle(b)));
|
|
995
|
+
}
|
|
996
|
+
/** An explicit user reset retains the complete old tree/index before hydration. */
|
|
997
|
+
async reset(identity, input) {
|
|
998
|
+
const b = await this.bench(identity);
|
|
999
|
+
return this.serial(b, () => this.productAction(b, input.id, { method: "reset", expectedHead: input.expectedHead }, async () => {
|
|
1000
|
+
await this.idle(b, true);
|
|
1001
|
+
if (input.expectedHead !== void 0 && input.expectedHead !== b.state.head) throw new WorkspaceError("conflict", "Workbench base changed before reset");
|
|
1002
|
+
if (b.state.blocked?.code === "archived") throw new WorkspaceError("archived", "A deleted branch cannot be reset");
|
|
1003
|
+
const storage = await this.storage(b);
|
|
1004
|
+
if (b.state.blocked?.operationId) {
|
|
1005
|
+
const receipt = await storage.read({ method: "operation.get", lookupId: b.state.blocked.operationId });
|
|
1006
|
+
if (receipt.state !== "completed" || b.state.blocked.commit && receipt.result?.head !== b.state.blocked.commit) throw new WorkspaceError("publication_unknown", "Reconcile the original publication before resetting");
|
|
1007
|
+
}
|
|
1008
|
+
const canonical = await storage.read({ method: "repository.get", repositoryId: b.config.repositoryId, branch: b.config.branch });
|
|
1009
|
+
if (!canonical.head) throw new WorkspaceError("empty_repository", "No canonical source exists to restore");
|
|
1010
|
+
const retained = path.join(b.directory, `reset-retained-${randomUUID()}`);
|
|
1011
|
+
await fs.mkdir(retained, { mode: 448 });
|
|
1012
|
+
await durableJson(path.join(retained, "recovery.json"), { workbench: b.config, previousState: b.state, resetId: input.id, targetHead: canonical.head });
|
|
1013
|
+
b.state.blocked = { code: "reset_incomplete", message: "Reset source retained; complete hydration after inspection" };
|
|
1014
|
+
await this.save(b);
|
|
1015
|
+
await noSymlinkAncestors(b.config.cwd);
|
|
1016
|
+
await fs.rename(b.config.cwd, path.join(retained, "source"));
|
|
1017
|
+
await fs.mkdir(b.config.cwd, { mode: 448 });
|
|
1018
|
+
b.state.initialized = false;
|
|
1019
|
+
b.state.head = null;
|
|
1020
|
+
b.state.blocked = null;
|
|
1021
|
+
await this.save(b);
|
|
1022
|
+
const result = await this.hydrateIdle(b);
|
|
1023
|
+
return { ...result, reset: true, retained: true };
|
|
1024
|
+
}));
|
|
1025
|
+
}
|
|
1026
|
+
async publish(identity) {
|
|
1027
|
+
const b = await this.bench(identity);
|
|
1028
|
+
return this.serial(b, async () => {
|
|
1029
|
+
try {
|
|
1030
|
+
await this.idle(b);
|
|
1031
|
+
} catch (error) {
|
|
1032
|
+
if (!(error instanceof WorkspaceError) || error.code !== "workbench_busy") throw error;
|
|
1033
|
+
throw new WorkspacePublicationNotAdmitted("workbench_busy", error.message);
|
|
1034
|
+
}
|
|
1035
|
+
return this.publishIdle(b);
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
async publishIdle(b, message = "Destination workspace snapshot") {
|
|
1039
|
+
if (!b.state.initialized) throw new WorkspaceError("not_initialized", "Hydrate or explicitly seed first");
|
|
1040
|
+
const storage = await this.storage(b);
|
|
1041
|
+
const latest = await storage.read({
|
|
1042
|
+
method: "repository.get",
|
|
1043
|
+
repositoryId: b.config.repositoryId,
|
|
1044
|
+
branch: b.config.branch
|
|
1045
|
+
});
|
|
1046
|
+
if (latest.head !== b.state.head) {
|
|
1047
|
+
b.state.blocked = {
|
|
1048
|
+
code: "conflict",
|
|
1049
|
+
message: `Canonical head differs from workbench base ${b.state.head ?? "empty"}; dirty files/index retained. Review and reconcile manually.`
|
|
1050
|
+
};
|
|
1051
|
+
await this.save(b);
|
|
1052
|
+
throw new WorkspaceError("conflict", b.state.blocked.message);
|
|
1053
|
+
}
|
|
1054
|
+
const tree = await snapshotTree(b.repo, b.config.cwd, b.state.head);
|
|
1055
|
+
if (b.state.head && tree === (await git(b.repo, ["rev-parse", `${b.state.head}^{tree}`])).toString().trim())
|
|
1056
|
+
return { head: b.state.head, unchanged: true };
|
|
1057
|
+
const commit = (await git(b.repo, ["commit-tree", tree, ...b.state.head ? ["-p", b.state.head] : []], `${message}
|
|
1058
|
+
`)).toString().trim();
|
|
1059
|
+
await git(b.repo, ["update-ref", `refs/heads/${b.config.branch}`, commit]);
|
|
1060
|
+
const bundleFile = path.join(b.directory, `${randomUUID()}.bundle`);
|
|
1061
|
+
await git(b.repo, ["bundle", "create", bundleFile, `refs/heads/${b.config.branch}`]);
|
|
1062
|
+
const bytes = await readRegular(bundleFile, STORAGE_LIMITS.blobBytes);
|
|
1063
|
+
const operationId = `ws-${randomUUID()}`, blobId = `ws-${randomUUID()}`;
|
|
1064
|
+
try {
|
|
1065
|
+
const metadata = path.join(b.config.cwd, ".git"), ref = `refs/heads/${b.config.branch}`;
|
|
1066
|
+
const localHead = (await readRegular(path.join(metadata, ref), 1024)).toString().trim();
|
|
1067
|
+
const index = await readRegular(path.join(metadata, "index"), 8 * 1024 * 1024);
|
|
1068
|
+
if ((await readRegular(path.join(metadata, "HEAD"), 1024)).toString() !== `ref: ${ref}
|
|
1069
|
+
`) throw new Error("modified_head");
|
|
1070
|
+
const previous = b.state.publishedMetadata;
|
|
1071
|
+
if (!previous || previous.head !== localHead || previous.indexHash !== sha256(index)) {
|
|
1072
|
+
if (!b.state.head || localHead !== b.state.head) throw new Error("modified_ref");
|
|
1073
|
+
const inspect = path.join(b.repo, "publication-inspection-index");
|
|
1074
|
+
await fs.writeFile(inspect, index, { mode: 384 });
|
|
1075
|
+
if ((await git(b.repo, ["write-tree"], void 0, inspect)).toString().trim() !== (await git(b.repo, ["rev-parse", `${localHead}^{tree}`])).toString().trim()) throw new Error("staged_changes");
|
|
1076
|
+
b.state.publishedMetadata = { head: localHead, indexHash: sha256(index) };
|
|
1077
|
+
}
|
|
1078
|
+
} catch {
|
|
1079
|
+
delete b.state.publishedMetadata;
|
|
1080
|
+
}
|
|
1081
|
+
b.state.blocked = {
|
|
1082
|
+
code: "publication_unknown",
|
|
1083
|
+
operationId,
|
|
1084
|
+
commit,
|
|
1085
|
+
message: "Publication attempt is in progress/unknown. Inspect original storage operation; NEVER retry under a new ID. Workbench and prepared commit retained."
|
|
1086
|
+
};
|
|
1087
|
+
await this.save(b);
|
|
1088
|
+
for (let offset = 0; offset < bytes.length; offset += STORAGE_LIMITS.chunkBytes) {
|
|
1089
|
+
const end = Math.min(bytes.length, offset + STORAGE_LIMITS.chunkBytes);
|
|
1090
|
+
await storage.mutate({
|
|
1091
|
+
method: "blob.append",
|
|
1092
|
+
operationId: `${operationId}-chunk-${offset}`,
|
|
1093
|
+
blobId,
|
|
1094
|
+
kind: "blob",
|
|
1095
|
+
offset,
|
|
1096
|
+
data: bytes.subarray(offset, end).toString("base64"),
|
|
1097
|
+
seal: end === bytes.length
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
const result = await storage.mutate({
|
|
1101
|
+
method: "repository.publish",
|
|
1102
|
+
operationId,
|
|
1103
|
+
repositoryId: b.config.repositoryId,
|
|
1104
|
+
branch: b.config.branch,
|
|
1105
|
+
expectedHead: b.state.head,
|
|
1106
|
+
commit,
|
|
1107
|
+
bundleId: blobId
|
|
1108
|
+
});
|
|
1109
|
+
if (result.head !== commit)
|
|
1110
|
+
throw new WorkspaceError("invalid_response", "Publication receipt head mismatch; inspect original operation");
|
|
1111
|
+
b.state.head = commit;
|
|
1112
|
+
b.state.blocked = null;
|
|
1113
|
+
await this.save(b);
|
|
1114
|
+
return { head: commit };
|
|
1115
|
+
}
|
|
1116
|
+
/** Read-only resolution of a lost publication ACK; no mutation retry, rebase, reset, or file overwrite. */
|
|
1117
|
+
async reconcilePublication(identity) {
|
|
1118
|
+
const b = await this.bench(identity);
|
|
1119
|
+
return this.serial(b, async () => {
|
|
1120
|
+
const blocked = b.state.blocked;
|
|
1121
|
+
if (blocked?.code !== "publication_unknown" || !blocked.operationId || !blocked.commit)
|
|
1122
|
+
throw new WorkspaceError("not_reconcilable", "Only a verified completed publication receipt can resolve automatically");
|
|
1123
|
+
const storage = await this.storage(b);
|
|
1124
|
+
const receipt = await storage.read({
|
|
1125
|
+
method: "operation.get",
|
|
1126
|
+
lookupId: blocked.operationId
|
|
1127
|
+
});
|
|
1128
|
+
if (receipt.state !== "completed" || receipt.result?.head !== blocked.commit)
|
|
1129
|
+
throw new WorkspaceError("publication_unknown", "Original publication not proven completed; preserve state for operator review");
|
|
1130
|
+
b.state.head = blocked.commit;
|
|
1131
|
+
b.state.blocked = null;
|
|
1132
|
+
await this.save(b);
|
|
1133
|
+
return { head: b.state.head };
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
/** Resolve mutable account settings once, before the first terminal admission.
|
|
1137
|
+
* A lost ACK must recover the same command and credential generations even if
|
|
1138
|
+
* settings change before redelivery or the workspace service restarts. */
|
|
1139
|
+
async startWithCredentials(identity, input, resolve) {
|
|
1140
|
+
const operation = OperationEnvelope.parse(JSON.parse(canonicalJson(input)));
|
|
1141
|
+
const payload = ShellPayload.parse(operation.payload);
|
|
1142
|
+
if (payload.credentials.length || operation.kind !== "host.shell" || operation.installationId !== this.config.installationId || operation.userId !== identity.userId || operation.sessionId !== identity.sessionId)
|
|
1143
|
+
throw new WorkspaceError("forbidden", "Credential binding requires an authenticated shell identity");
|
|
1144
|
+
operation.payload = payload;
|
|
1145
|
+
const requestHash = await operationPayloadHash(operation);
|
|
1146
|
+
const b = await this.bench(identity);
|
|
1147
|
+
if (payload.cwd !== b.config.cwd) throw new WorkspaceError("forbidden_cwd", "Shell cwd must be exact approved workbench");
|
|
1148
|
+
const credentials = await this.serial(b, async () => {
|
|
1149
|
+
const file = path.join(b.directory, `terminal-credentials-${sha256(operation.operationId)}.json`);
|
|
1150
|
+
const previous = await readRegular(file, 128e3).catch((error) => {
|
|
1151
|
+
if (error.code === "ENOENT") return null;
|
|
1152
|
+
throw error;
|
|
1153
|
+
});
|
|
1154
|
+
if (previous) {
|
|
1155
|
+
const binding = JSON.parse(previous.toString());
|
|
1156
|
+
if (binding.requestHash !== requestHash) throw new WorkspaceError("operation_payload_mismatch", "Terminal ID is bound to another command or size");
|
|
1157
|
+
return ShellPayload.shape.credentials.parse(binding.credentials);
|
|
1158
|
+
}
|
|
1159
|
+
if (b.state.runs[operation.operationId]) throw new WorkspaceError("credential_binding_missing", "Preserve the original terminal receipt for review");
|
|
1160
|
+
const selected = ShellPayload.shape.credentials.parse(await resolve());
|
|
1161
|
+
await durableJson(file, { requestHash, credentials: selected });
|
|
1162
|
+
return selected;
|
|
1163
|
+
});
|
|
1164
|
+
return this.start({ userId: identity.userId, sessionId: identity.sessionId }, { ...operation, payload: { ...payload, credentials } });
|
|
1165
|
+
}
|
|
1166
|
+
async materializeArtifact(identity, input) {
|
|
1167
|
+
const b = await this.bench(identity);
|
|
1168
|
+
return this.serial(b, () => new SessionArtifactStore(this.config.root).materialize(identity, input));
|
|
1169
|
+
}
|
|
1170
|
+
/** Default arbitrary shell is mutating. Exact trusted config approval is the sole exemption. */
|
|
1171
|
+
async start(identity, input) {
|
|
1172
|
+
const b = await this.bench(identity);
|
|
1173
|
+
const operation = OperationEnvelope.parse(JSON.parse(canonicalJson(input)));
|
|
1174
|
+
return this.serial(b, async () => {
|
|
1175
|
+
if (!b.state.initialized || b.state.blocked)
|
|
1176
|
+
throw new WorkspaceError("workbench_blocked", b.state.blocked?.message ?? "Initialize workbench first");
|
|
1177
|
+
if (operation.installationId !== this.config.installationId || operation.userId !== identity.userId || operation.sessionId !== identity.sessionId || operation.kind !== "host.shell")
|
|
1178
|
+
throw new WorkspaceError("forbidden", "Shell identity does not match approved workbench");
|
|
1179
|
+
const payload = ShellPayload.parse(operation.payload);
|
|
1180
|
+
if (RESERVED_ARTIFACT_ENV.some((name) => name in payload.env)) throw new WorkspaceError("reserved_environment", "R5D_ROOT and R5D_SESSION_ID are set by the workspace authority");
|
|
1181
|
+
const requestedEnv = payload.env, prior = b.state.runs[operation.operationId];
|
|
1182
|
+
if (!prior || prior.artifactEnvironment) {
|
|
1183
|
+
payload.env = { ...payload.env, ...await new SessionArtifactStore(this.config.root).environment(identity) };
|
|
1184
|
+
operation.payload = payload;
|
|
1185
|
+
}
|
|
1186
|
+
if (payload.cwd !== b.config.cwd) throw new WorkspaceError("forbidden_cwd", "Shell cwd must be exact approved workbench");
|
|
1187
|
+
let route = await this.route(b, identity);
|
|
1188
|
+
if (operation.fence.installationId !== this.config.installationId || operation.fence.resourceType !== route.workerFence.resourceType || operation.fence.resourceId !== route.workerFence.resourceId)
|
|
1189
|
+
throw new WorkspaceError("wrong_resource", "Wrong worker resource");
|
|
1190
|
+
operation.fence = route.workerFence;
|
|
1191
|
+
const hash = await operationPayloadHash(operation);
|
|
1192
|
+
const previous = b.state.runs[operation.operationId];
|
|
1193
|
+
if (previous) {
|
|
1194
|
+
if (previous.payloadHash !== hash)
|
|
1195
|
+
throw new WorkspaceError("operation_payload_mismatch", "Run ID is already bound to another command");
|
|
1196
|
+
if (previous.state === "rejected_capacity")
|
|
1197
|
+
throw new ExecutorError(
|
|
1198
|
+
"capacity_busy",
|
|
1199
|
+
"Original attempt definitively rejected before admission; choose a new operation ID",
|
|
1200
|
+
true
|
|
1201
|
+
);
|
|
1202
|
+
const receipt = (await route.client.poll({ operationId: operation.operationId, userId: identity.userId, sessionId: identity.sessionId, fence: route.workerFence }, 0, 0, 1)).receipt;
|
|
1203
|
+
previous.state = receipt.state;
|
|
1204
|
+
await this.save(b);
|
|
1205
|
+
return receipt;
|
|
1206
|
+
}
|
|
1207
|
+
try {
|
|
1208
|
+
await this.idle(b);
|
|
1209
|
+
} catch (error) {
|
|
1210
|
+
if (!(error instanceof WorkspaceError) || error.code !== "workbench_busy") throw error;
|
|
1211
|
+
b.state.runs[operation.operationId] = { artifactEnvironment: true, sessionId: identity.sessionId, payloadHash: hash, mutating: false, state: "rejected_capacity" };
|
|
1212
|
+
await this.save(b);
|
|
1213
|
+
throw new ExecutorError(
|
|
1214
|
+
"capacity_busy",
|
|
1215
|
+
"Workspace is busy; poll the original command until terminal before choosing new work",
|
|
1216
|
+
true
|
|
1217
|
+
);
|
|
1218
|
+
}
|
|
1219
|
+
const nonmutating = Object.keys(requestedEnv).length === 0 && payload.credentials.length === 0 && b.config.nonmutatingArgv.some((argv) => canonicalJson(argv) === canonicalJson(payload.argv));
|
|
1220
|
+
b.state.runs[operation.operationId] = { artifactEnvironment: true, sessionId: identity.sessionId, payloadHash: hash, mutating: !nonmutating, state: "unknown", argv: payload.argv, cwd: payload.cwd, startedAt: (/* @__PURE__ */ new Date()).toISOString(), updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
1221
|
+
await this.save(b);
|
|
1222
|
+
for (let attempt = 0; ; attempt++) {
|
|
1223
|
+
try {
|
|
1224
|
+
const receipt = await route.client.start(operation);
|
|
1225
|
+
b.state.runs[operation.operationId].state = receipt.state;
|
|
1226
|
+
await this.save(b);
|
|
1227
|
+
return receipt;
|
|
1228
|
+
} catch (error) {
|
|
1229
|
+
if (!isDefiniteStartRejection(error)) throw error;
|
|
1230
|
+
if (error.code === "capacity_busy") {
|
|
1231
|
+
b.state.runs[operation.operationId].state = "rejected_capacity";
|
|
1232
|
+
await this.save(b);
|
|
1233
|
+
throw error;
|
|
1234
|
+
}
|
|
1235
|
+
if (attempt === 2) {
|
|
1236
|
+
delete b.state.runs[operation.operationId];
|
|
1237
|
+
await this.save(b);
|
|
1238
|
+
throw error;
|
|
1239
|
+
}
|
|
1240
|
+
route = await this.route(b, identity);
|
|
1241
|
+
operation.fence = route.workerFence;
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
async runRoute(identity, run) {
|
|
1247
|
+
const b = await this.bench(identity);
|
|
1248
|
+
if (run.userId !== identity.userId || run.sessionId !== identity.sessionId || !b.state.runs[run.operationId] || (b.state.runs[run.operationId].sessionId ?? b.config.sessionId) !== identity.sessionId)
|
|
1249
|
+
throw new WorkspaceError("forbidden", "Run does not belong to this workbench");
|
|
1250
|
+
if (b.state.runs[run.operationId].state === "rejected_capacity")
|
|
1251
|
+
throw new WorkspaceError("run_not_admitted", "This operation was definitively rejected; no executor run exists");
|
|
1252
|
+
await this.owned();
|
|
1253
|
+
const route = await this.route(b, identity);
|
|
1254
|
+
if (run.fence.installationId !== this.config.installationId || run.fence.resourceType !== route.workerFence.resourceType || run.fence.resourceId !== route.workerFence.resourceId)
|
|
1255
|
+
throw new WorkspaceError("wrong_resource", "Wrong worker resource");
|
|
1256
|
+
return { b, route };
|
|
1257
|
+
}
|
|
1258
|
+
action(fn) {
|
|
1259
|
+
if (this.closing) return Promise.reject(new WorkspaceError("authority_closed", "Workspace authority is closing"));
|
|
1260
|
+
if (this.actions.size >= 32) return Promise.reject(new WorkspaceError("busy", "Workspace action budget reached"));
|
|
1261
|
+
const task = Promise.resolve().then(fn);
|
|
1262
|
+
this.actions.add(task);
|
|
1263
|
+
void task.finally(() => this.actions.delete(task)).catch(() => {
|
|
1264
|
+
});
|
|
1265
|
+
return task;
|
|
1266
|
+
}
|
|
1267
|
+
async poll(identity, run, stdoutOffset = 0, stderrOffset = 0, maxBytes = 32768) {
|
|
1268
|
+
return this.action(async () => {
|
|
1269
|
+
const { b, route } = await this.runRoute(identity, run);
|
|
1270
|
+
const result = await route.client.poll({ ...run, fence: route.workerFence }, stdoutOffset, stderrOffset, maxBytes);
|
|
1271
|
+
await this.serial(
|
|
1272
|
+
b,
|
|
1273
|
+
async () => {
|
|
1274
|
+
b.state.runs[run.operationId].state = result.receipt.state;
|
|
1275
|
+
b.state.runs[run.operationId].updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1276
|
+
if (["completed", "cancelled"].includes(result.receipt.state)) b.state.runs[run.operationId].completedAt ??= (/* @__PURE__ */ new Date()).toISOString();
|
|
1277
|
+
await this.save(b);
|
|
1278
|
+
},
|
|
1279
|
+
true
|
|
1280
|
+
);
|
|
1281
|
+
return result;
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
async actionOutcome(b, actionId, invoke) {
|
|
1285
|
+
try {
|
|
1286
|
+
const result = await invoke();
|
|
1287
|
+
if (result.state !== "completed") throw new WorkspaceError("action_unknown", "Executor action receipt is unsettled");
|
|
1288
|
+
return result;
|
|
1289
|
+
} catch (error) {
|
|
1290
|
+
await this.serial(
|
|
1291
|
+
b,
|
|
1292
|
+
async () => {
|
|
1293
|
+
b.state.blocked ??= {
|
|
1294
|
+
code: "action_unknown",
|
|
1295
|
+
operationId: actionId,
|
|
1296
|
+
message: "Inspect original executor action receipt before maintenance; never replay input/cancellation automatically"
|
|
1297
|
+
};
|
|
1298
|
+
await this.save(b);
|
|
1299
|
+
},
|
|
1300
|
+
true
|
|
1301
|
+
);
|
|
1302
|
+
throw error;
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
async write(identity, run, actionId, data, eof = false) {
|
|
1306
|
+
return this.action(async () => {
|
|
1307
|
+
const { b, route } = await this.runRoute(identity, run);
|
|
1308
|
+
return this.actionOutcome(b, actionId, () => route.client.write({ ...run, fence: route.workerFence }, actionId, data, eof));
|
|
1309
|
+
});
|
|
1310
|
+
}
|
|
1311
|
+
async resize(identity, run, actionId, cols, rows) {
|
|
1312
|
+
return this.action(async () => {
|
|
1313
|
+
const { b, route } = await this.runRoute(identity, run);
|
|
1314
|
+
if (!route.client.resize) throw new WorkspaceError("pty_unavailable", "Executor does not advertise terminal resizing");
|
|
1315
|
+
return this.actionOutcome(b, actionId, () => route.client.resize({ ...run, fence: route.workerFence }, actionId, cols, rows));
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
async cancel(identity, run, actionId) {
|
|
1319
|
+
return this.action(async () => {
|
|
1320
|
+
const { b, route } = await this.runRoute(identity, run);
|
|
1321
|
+
return this.actionOutcome(b, actionId, () => route.client.cancel({ ...run, fence: route.workerFence }, actionId));
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
close() {
|
|
1325
|
+
if (this.closed) return Promise.resolve();
|
|
1326
|
+
if (this.closeTask) return this.closeTask;
|
|
1327
|
+
this.closing = true;
|
|
1328
|
+
const task = this.finishClose();
|
|
1329
|
+
this.closeTask = task;
|
|
1330
|
+
void task.finally(() => {
|
|
1331
|
+
if (!this.closed) this.closeTask = void 0;
|
|
1332
|
+
}).catch(() => {
|
|
1333
|
+
});
|
|
1334
|
+
return task;
|
|
1335
|
+
}
|
|
1336
|
+
async finishClose() {
|
|
1337
|
+
await Promise.allSettled([...this.actions]);
|
|
1338
|
+
await Promise.allSettled([...this.queues.values()]);
|
|
1339
|
+
for (const b of this.benches.values()) await this.refresh(b);
|
|
1340
|
+
if ([...this.benches.values()].some(
|
|
1341
|
+
(b) => b.state.blocked || Object.values(b.state.runs).some((r) => !["completed", "cancelled", "rejected_capacity"].includes(r.state))
|
|
1342
|
+
))
|
|
1343
|
+
throw new WorkspaceError(
|
|
1344
|
+
"maintenance_required",
|
|
1345
|
+
"Active/unknown runs or sync intents remain; authority lock retained. Drain and review before restart"
|
|
1346
|
+
);
|
|
1347
|
+
const owner = JSON.parse((await readRegular(path.join(this.config.root, ".sync-lock", "owner.json"), 1024)).toString());
|
|
1348
|
+
if (owner.lockId !== this.lockId) throw new WorkspaceError("authority_lost", "Never release a successor's lock");
|
|
1349
|
+
await fs.rm(path.join(this.config.root, ".sync-lock"), { recursive: true });
|
|
1350
|
+
this.closed = true;
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
export {
|
|
1354
|
+
WorkspaceAuthority
|
|
1355
|
+
};
|