@ricsam/r5d-worker 0.0.137 → 0.0.139
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 +359 -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,541 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { chmodSync, closeSync, constants, existsSync, fsyncSync, lstatSync, openSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { canonicalJson, negotiateProtocol, OwnershipFence, RuntimeHello, RuntimeId } from "@ricsam/r5d-api/runtime-protocol";
|
|
7
|
+
import { HostExecutorClient } from "../client.mjs";
|
|
8
|
+
import { AdapterConfig, AdapterGrant, ExecutorCommand, ExecutorError, EXECUTOR_CAPABILITY } from "../protocol.mjs";
|
|
9
|
+
import { privateDirectory, readPrivateJson } from "../storage.mjs";
|
|
10
|
+
import { AdapterManifest, bytesHash, canonicalDirectory, ImmutableAdapterArtifacts, outsideWorkspaces } from "./artifacts.mjs";
|
|
11
|
+
import { digest } from "./grants.mjs";
|
|
12
|
+
import { AdapterChild } from "./process.mjs";
|
|
13
|
+
import { AdapterRetirementCompletion } from "./retirement.mjs";
|
|
14
|
+
const ReleaseManagerConfig = z.object({
|
|
15
|
+
installationId: RuntimeId,
|
|
16
|
+
workerId: RuntimeId,
|
|
17
|
+
stateDir: z.string().startsWith("/"),
|
|
18
|
+
artifactsDir: z.string().startsWith("/"),
|
|
19
|
+
workspaceRoots: z.array(z.string().startsWith("/")).min(1),
|
|
20
|
+
runtimePath: z.string().startsWith("/"),
|
|
21
|
+
runtimeSha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
22
|
+
controller: AdapterConfig
|
|
23
|
+
}).strict();
|
|
24
|
+
function callDigest(command) {
|
|
25
|
+
const parsed = ExecutorCommand.parse(command);
|
|
26
|
+
if (parsed.method === "start") return digest({ ...parsed, operation: { ...parsed.operation, fence: null } });
|
|
27
|
+
if ("run" in parsed) return digest({ ...parsed, run: { ...parsed.run, fence: null } });
|
|
28
|
+
return digest(parsed);
|
|
29
|
+
}
|
|
30
|
+
function privateSqlitePath(path) {
|
|
31
|
+
if (!existsSync(path)) return;
|
|
32
|
+
const stat = lstatSync(path);
|
|
33
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.uid !== process.getuid?.() || stat.mode & 63)
|
|
34
|
+
throw new ExecutorError("insecure_release_state");
|
|
35
|
+
}
|
|
36
|
+
class WorkerAdapterReleases {
|
|
37
|
+
invocation = randomUUID();
|
|
38
|
+
config;
|
|
39
|
+
artifacts;
|
|
40
|
+
controller;
|
|
41
|
+
db;
|
|
42
|
+
lock;
|
|
43
|
+
ackKey;
|
|
44
|
+
children = /* @__PURE__ */ new Map();
|
|
45
|
+
deliveries = /* @__PURE__ */ new Map();
|
|
46
|
+
probes = 0;
|
|
47
|
+
retiring = /* @__PURE__ */ new Set();
|
|
48
|
+
serial = Promise.resolve();
|
|
49
|
+
closing = false;
|
|
50
|
+
closed;
|
|
51
|
+
constructor(input) {
|
|
52
|
+
this.config = ReleaseManagerConfig.parse(input);
|
|
53
|
+
const config = this.config;
|
|
54
|
+
if (config.controller.hello.installationId !== config.installationId || config.controller.hello.role !== "scheduler")
|
|
55
|
+
throw new ExecutorError("wrong_controller");
|
|
56
|
+
privateDirectory(config.stateDir);
|
|
57
|
+
outsideWorkspaces(config.stateDir, config.workspaceRoots);
|
|
58
|
+
this.verifyRuntime();
|
|
59
|
+
this.artifacts = new ImmutableAdapterArtifacts(config.artifactsDir, config.installationId, config.workerId, config.workspaceRoots);
|
|
60
|
+
this.controller = new HostExecutorClient(config.controller);
|
|
61
|
+
for (const file of ["manager.lock.sqlite", "releases.sqlite", "releases.sqlite-wal", "releases.sqlite-shm"])
|
|
62
|
+
privateSqlitePath(join(config.stateDir, file));
|
|
63
|
+
this.lock = new Database(join(config.stateDir, "manager.lock.sqlite"), { create: true });
|
|
64
|
+
chmodSync(join(config.stateDir, "manager.lock.sqlite"), 384);
|
|
65
|
+
try {
|
|
66
|
+
this.lock.exec("PRAGMA busy_timeout=0; BEGIN EXCLUSIVE; CREATE TABLE IF NOT EXISTS singleton (id INTEGER)");
|
|
67
|
+
} catch {
|
|
68
|
+
this.lock.close();
|
|
69
|
+
throw new ExecutorError("release_manager_already_running");
|
|
70
|
+
}
|
|
71
|
+
let opened;
|
|
72
|
+
try {
|
|
73
|
+
this.db = opened = new Database(join(config.stateDir, "releases.sqlite"), { create: true });
|
|
74
|
+
chmodSync(join(config.stateDir, "releases.sqlite"), 384);
|
|
75
|
+
this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;
|
|
76
|
+
CREATE TABLE IF NOT EXISTS identity (id INTEGER PRIMARY KEY CHECK(id=1), digest TEXT NOT NULL);
|
|
77
|
+
CREATE TABLE IF NOT EXISTS releases (id TEXT PRIMARY KEY, record TEXT NOT NULL);
|
|
78
|
+
CREATE TABLE IF NOT EXISTS calls (id TEXT PRIMARY KEY, principal TEXT NOT NULL, hash TEXT NOT NULL,
|
|
79
|
+
state TEXT NOT NULL, acknowledged INTEGER NOT NULL DEFAULT 0);
|
|
80
|
+
CREATE TABLE IF NOT EXISTS transitions (id INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT NOT NULL, body TEXT NOT NULL, state TEXT NOT NULL)`);
|
|
81
|
+
const binding = digest({
|
|
82
|
+
installationId: config.installationId,
|
|
83
|
+
workerId: config.workerId,
|
|
84
|
+
artifactsDir: config.artifactsDir,
|
|
85
|
+
socketPath: config.controller.socketPath,
|
|
86
|
+
controllerId: config.controller.principalId
|
|
87
|
+
});
|
|
88
|
+
const prior = this.db.query("SELECT digest FROM identity WHERE id=1").get();
|
|
89
|
+
if (prior && prior.digest !== binding) throw new ExecutorError("wrong_store_identity");
|
|
90
|
+
if (!prior) this.db.query("INSERT INTO identity VALUES (1,?)").run(binding);
|
|
91
|
+
this.db.exec("UPDATE calls SET state='unknown' WHERE state='pending'; UPDATE transitions SET state='unknown' WHERE state='pending'");
|
|
92
|
+
this.db.exec(`CREATE TABLE IF NOT EXISTS deliveries (id TEXT PRIMARY KEY, receipt TEXT NOT NULL);
|
|
93
|
+
CREATE TABLE IF NOT EXISTS rpc_identity (id INTEGER PRIMARY KEY CHECK(id=1), digest TEXT NOT NULL);
|
|
94
|
+
CREATE TABLE IF NOT EXISTS observation_sequence (id INTEGER PRIMARY KEY CHECK(id=1), seq INTEGER NOT NULL, ack_key TEXT NOT NULL)`);
|
|
95
|
+
this.db.query("INSERT OR IGNORE INTO observation_sequence VALUES(1,0,?)").run(randomBytes(32).toString("hex"));
|
|
96
|
+
this.ackKey = this.db.query("SELECT ack_key FROM observation_sequence WHERE id=1").get().ack_key;
|
|
97
|
+
for (const row of this.db.query("SELECT id,receipt FROM deliveries").all()) {
|
|
98
|
+
const receipt = JSON.parse(row.receipt);
|
|
99
|
+
if (receipt.state === "pending") this.saveDelivery({ ...receipt, state: "unknown", outcome: null });
|
|
100
|
+
}
|
|
101
|
+
privateDirectory(join(config.stateDir, "configs"));
|
|
102
|
+
} catch (error) {
|
|
103
|
+
opened?.close();
|
|
104
|
+
this.lock.close();
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
verifyRuntime() {
|
|
109
|
+
const config = this.config;
|
|
110
|
+
canonicalDirectory(config.runtimePath.slice(0, config.runtimePath.lastIndexOf("/")) || "/");
|
|
111
|
+
const runtime = lstatSync(config.runtimePath);
|
|
112
|
+
if (!runtime.isFile() || runtime.isSymbolicLink() || runtime.mode & 18 || bytesHash(readFileSync(config.runtimePath)) !== config.runtimeSha256)
|
|
113
|
+
throw new ExecutorError("unapproved_runtime");
|
|
114
|
+
}
|
|
115
|
+
exclusive(fn) {
|
|
116
|
+
const result = this.serial.then(() => {
|
|
117
|
+
if (this.closing) throw new ExecutorError("release_manager_closed");
|
|
118
|
+
return fn();
|
|
119
|
+
});
|
|
120
|
+
this.serial = result.catch(() => {
|
|
121
|
+
});
|
|
122
|
+
return result;
|
|
123
|
+
}
|
|
124
|
+
list() {
|
|
125
|
+
return this.db.query("SELECT record FROM releases ORDER BY id").all().map((row) => JSON.parse(row.record));
|
|
126
|
+
}
|
|
127
|
+
calls() {
|
|
128
|
+
return this.db.query("SELECT * FROM calls ORDER BY id").all();
|
|
129
|
+
}
|
|
130
|
+
transitions() {
|
|
131
|
+
return this.db.query("SELECT * FROM transitions ORDER BY id").all();
|
|
132
|
+
}
|
|
133
|
+
get(id) {
|
|
134
|
+
const record = this.list().find((record2) => record2.principalId === id);
|
|
135
|
+
if (!record) throw new ExecutorError("adapter_not_found");
|
|
136
|
+
return record;
|
|
137
|
+
}
|
|
138
|
+
put(record) {
|
|
139
|
+
this.db.query("INSERT INTO releases VALUES (?,?) ON CONFLICT(id) DO UPDATE SET record=excluded.record").run(record.principalId, canonicalJson(record));
|
|
140
|
+
}
|
|
141
|
+
configPath(id) {
|
|
142
|
+
return join(this.config.stateDir, "configs", `${digest(id)}.json`);
|
|
143
|
+
}
|
|
144
|
+
async keeper() {
|
|
145
|
+
const status = await this.controller.status();
|
|
146
|
+
if (status.hello.installationId !== this.config.installationId || status.workerId !== this.config.workerId)
|
|
147
|
+
throw new ExecutorError("wrong_resource");
|
|
148
|
+
return status;
|
|
149
|
+
}
|
|
150
|
+
/** Approval digest must come from the authenticated release ledger, not the source directory itself. */
|
|
151
|
+
stage(input) {
|
|
152
|
+
return this.exclusive(async () => {
|
|
153
|
+
const manifest = AdapterManifest.parse(input.manifest), grant = AdapterGrant.parse(input.grant), adapter = AdapterConfig.parse(input.adapter);
|
|
154
|
+
if (digest(manifest.hello) !== digest(grant.hello) || digest(adapter.hello) !== digest(grant.hello) || grant.artifactDigest !== input.approvalDigest || grant.installationId !== manifest.installationId || grant.workerId !== manifest.workerId || adapter.principalId !== grant.principal.id || adapter.socketPath !== this.config.controller.socketPath || bytesHash(Buffer.from(adapter.token)) !== grant.principal.tokenHash)
|
|
155
|
+
throw new ExecutorError("artifact_grant_mismatch");
|
|
156
|
+
const prior = this.list().find((row) => row.principalId === grant.principal.id);
|
|
157
|
+
if (prior && (digest(prior.grant) !== digest(grant) || prior.artifactDigest !== input.approvalDigest))
|
|
158
|
+
throw new ExecutorError("grant_identity_conflict");
|
|
159
|
+
if (prior?.phase === "retired") throw new ExecutorError("adapter_retired");
|
|
160
|
+
if (!prior && this.list().filter((row) => row.phase !== "retired").length >= 2) throw new ExecutorError("adapter_retention_blocked");
|
|
161
|
+
const keeper = await this.keeper();
|
|
162
|
+
negotiateProtocol(manifest.hello, keeper.hello, manifest.hello.capabilities);
|
|
163
|
+
this.artifacts.stage(input.source, manifest, input.approvalDigest);
|
|
164
|
+
const path = this.configPath(adapter.principalId);
|
|
165
|
+
if (existsSync(path)) {
|
|
166
|
+
if (digest(AdapterConfig.parse(readPrivateJson(path))) !== digest(adapter)) throw new ExecutorError("adapter_config_conflict");
|
|
167
|
+
} else {
|
|
168
|
+
const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 384);
|
|
169
|
+
try {
|
|
170
|
+
writeFileSync(fd, JSON.stringify(adapter));
|
|
171
|
+
fsyncSync(fd);
|
|
172
|
+
} finally {
|
|
173
|
+
closeSync(fd);
|
|
174
|
+
}
|
|
175
|
+
const directory = openSync(join(this.config.stateDir, "configs"), constants.O_RDONLY | constants.O_DIRECTORY);
|
|
176
|
+
try {
|
|
177
|
+
fsyncSync(directory);
|
|
178
|
+
} finally {
|
|
179
|
+
closeSync(directory);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const record = prior ?? {
|
|
183
|
+
principalId: adapter.principalId,
|
|
184
|
+
manifest,
|
|
185
|
+
artifactDigest: input.approvalDigest,
|
|
186
|
+
grant,
|
|
187
|
+
phase: "staged"
|
|
188
|
+
};
|
|
189
|
+
this.put(record);
|
|
190
|
+
await this.controller.registerAdapter(grant);
|
|
191
|
+
return record;
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
launch(id) {
|
|
195
|
+
return this.exclusive(async () => {
|
|
196
|
+
const record = this.get(id);
|
|
197
|
+
if (record.phase === "retired") throw new ExecutorError("adapter_retired");
|
|
198
|
+
await this.controller.registerAdapter(record.grant);
|
|
199
|
+
const directory = this.artifacts.verify(record.manifest, record.artifactDigest);
|
|
200
|
+
let child = this.children.get(id);
|
|
201
|
+
if (!child?.alive) {
|
|
202
|
+
this.verifyRuntime();
|
|
203
|
+
const adapter = AdapterConfig.parse(readPrivateJson(this.configPath(id)));
|
|
204
|
+
if (digest(adapter.hello) !== digest(record.grant.hello) || adapter.principalId !== id || adapter.socketPath !== this.config.controller.socketPath || bytesHash(Buffer.from(adapter.token)) !== record.grant.principal.tokenHash)
|
|
205
|
+
throw new ExecutorError("adapter_config_conflict");
|
|
206
|
+
child = new AdapterChild(
|
|
207
|
+
this.config.runtimePath,
|
|
208
|
+
join(directory, record.manifest.entrypoint),
|
|
209
|
+
this.configPath(id),
|
|
210
|
+
this.config.stateDir,
|
|
211
|
+
adapter.timeoutMs
|
|
212
|
+
);
|
|
213
|
+
this.children.set(id, child);
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
const status = await child.request({ method: "status" });
|
|
217
|
+
if (status.workerId !== this.config.workerId || status.degraded) throw new ExecutorError("candidate_not_ready");
|
|
218
|
+
negotiateProtocol(record.manifest.hello, status.hello, [EXECUTOR_CAPABILITY, ...record.manifest.hello.capabilities]);
|
|
219
|
+
if (record.phase === "staged") {
|
|
220
|
+
record.phase = "ready";
|
|
221
|
+
this.put(record);
|
|
222
|
+
}
|
|
223
|
+
return { pid: child.process.pid };
|
|
224
|
+
} catch (error) {
|
|
225
|
+
await child.close();
|
|
226
|
+
this.children.delete(id);
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
/** Fence/lease are the committed DB grant from the separately authenticated controller, not a locally invented epoch. */
|
|
232
|
+
promote(id, inputFence, validUntil) {
|
|
233
|
+
return this.exclusive(async () => {
|
|
234
|
+
const record = this.get(id), fence = OwnershipFence.parse(inputFence);
|
|
235
|
+
if (record.phase === "staged" || record.phase === "retired" || !this.children.get(id)?.alive)
|
|
236
|
+
throw new ExecutorError("candidate_not_ready");
|
|
237
|
+
if (fence.installationId !== this.config.installationId || fence.resourceId !== this.config.workerId || fence.resourceType !== "worker" || fence.ownerId !== record.grant.principal.ownerId)
|
|
238
|
+
throw new ExecutorError("wrong_resource");
|
|
239
|
+
this.artifacts.verify(record.manifest, record.artifactDigest);
|
|
240
|
+
const readiness = await this.children.get(id).request({ method: "status" });
|
|
241
|
+
if (readiness.workerId !== this.config.workerId || readiness.degraded) throw new ExecutorError("candidate_not_ready");
|
|
242
|
+
negotiateProtocol(record.manifest.hello, readiness.hello, record.manifest.hello.capabilities);
|
|
243
|
+
const transition = this.db.query("INSERT INTO transitions(kind,body,state) VALUES ('promote',?,'pending') RETURNING id").get(canonicalJson({ principalId: id, fence, validUntil }));
|
|
244
|
+
try {
|
|
245
|
+
await this.controller.setAuthority(fence, validUntil);
|
|
246
|
+
this.recordAuthority(id);
|
|
247
|
+
this.db.query("UPDATE transitions SET state='completed' WHERE id=?").run(transition.id);
|
|
248
|
+
} catch (error) {
|
|
249
|
+
this.db.query("UPDATE transitions SET state='unknown' WHERE id=?").run(transition.id);
|
|
250
|
+
throw error;
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
recordAuthority(id) {
|
|
255
|
+
this.db.transaction(() => {
|
|
256
|
+
for (const record of this.list()) {
|
|
257
|
+
if (record.principalId === id) {
|
|
258
|
+
record.phase = "active";
|
|
259
|
+
this.put(record);
|
|
260
|
+
} else if (record.phase === "active") {
|
|
261
|
+
record.phase = "retained";
|
|
262
|
+
this.put(record);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
})();
|
|
266
|
+
}
|
|
267
|
+
/** Recovery reads the keeper, never replays a promotion or kills a remembered PID. */
|
|
268
|
+
reconcile() {
|
|
269
|
+
return this.exclusive(async () => {
|
|
270
|
+
const status = await this.keeper(), owner = status.authority?.fence.ownerId;
|
|
271
|
+
const record = this.list().find((row) => row.grant.principal.ownerId === owner);
|
|
272
|
+
if (record && record.phase !== "retired") this.recordAuthority(record.principalId);
|
|
273
|
+
for (const row of this.list()) {
|
|
274
|
+
if (status.adapters.some((grant) => grant.principalId === row.principalId && grant.state === "retired")) {
|
|
275
|
+
row.phase = "retired";
|
|
276
|
+
this.put(row);
|
|
277
|
+
await this.children.get(row.principalId)?.close();
|
|
278
|
+
this.children.delete(row.principalId);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return status;
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
bindRpcPrincipals(principals) {
|
|
285
|
+
const hash = digest(principals);
|
|
286
|
+
const old = this.db.query("SELECT digest FROM rpc_identity WHERE id=1").get();
|
|
287
|
+
if (old && old.digest !== hash) throw new ExecutorError("rpc_identity_conflict");
|
|
288
|
+
if (!old) this.db.query("INSERT INTO rpc_identity VALUES(1,?)").run(hash);
|
|
289
|
+
}
|
|
290
|
+
/** Actual child request, independent of the delivery/admin queues; never substitutes direct executor status. */
|
|
291
|
+
async probe(id, hello) {
|
|
292
|
+
const record = this.get(id), child = this.children.get(id);
|
|
293
|
+
if (this.closing || this.retiring.has(id) || record.phase === "retired" || !child?.alive || digest(hello) !== digest(record.grant.hello))
|
|
294
|
+
throw new ExecutorError("candidate_not_ready");
|
|
295
|
+
if (this.probes >= 8) throw new ExecutorError("manager_probe_busy");
|
|
296
|
+
this.probes++;
|
|
297
|
+
try {
|
|
298
|
+
const status = await child.request({ method: "status" });
|
|
299
|
+
if (!child.alive || this.retiring.has(id) || this.children.get(id) !== child || status.workerId !== this.config.workerId || status.degraded)
|
|
300
|
+
throw new ExecutorError("candidate_not_ready");
|
|
301
|
+
negotiateProtocol(hello, status.hello, hello.capabilities);
|
|
302
|
+
return { ...status, adapterPid: child.process.pid };
|
|
303
|
+
} finally {
|
|
304
|
+
this.probes--;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
delivery(callId) {
|
|
308
|
+
const row = this.db.query("SELECT receipt FROM deliveries WHERE id=?").get(callId);
|
|
309
|
+
if (!row) return null;
|
|
310
|
+
const receipt = JSON.parse(row.receipt);
|
|
311
|
+
if (receipt.responseDigest !== digest({ callId: receipt.callId, commandDigest: receipt.commandDigest, state: receipt.state, outcome: receipt.outcome }))
|
|
312
|
+
throw new ExecutorError("delivery_corrupt");
|
|
313
|
+
return receipt;
|
|
314
|
+
}
|
|
315
|
+
ackToken(callId, responseDigest, requesterId, deliveryKind) {
|
|
316
|
+
return createHmac("sha256", this.ackKey).update(canonicalJson({ callId, responseDigest, requesterId, deliveryKind })).digest("hex");
|
|
317
|
+
}
|
|
318
|
+
saveDelivery(receipt) {
|
|
319
|
+
const value = {
|
|
320
|
+
...receipt,
|
|
321
|
+
responseDigest: digest({
|
|
322
|
+
callId: receipt.callId,
|
|
323
|
+
commandDigest: receipt.commandDigest,
|
|
324
|
+
state: receipt.state,
|
|
325
|
+
outcome: receipt.outcome
|
|
326
|
+
})
|
|
327
|
+
};
|
|
328
|
+
value.ackToken = this.ackToken(value.callId, value.responseDigest, value.requesterId, value.deliveryKind);
|
|
329
|
+
const serialized = canonicalJson(value);
|
|
330
|
+
if (Buffer.byteLength(serialized) > 1024 * 1024 - 4096) throw new ExecutorError("delivery_result_too_large");
|
|
331
|
+
this.db.query("INSERT INTO deliveries VALUES (?,?) ON CONFLICT(id) DO UPDATE SET receipt=excluded.receipt").run(value.callId, serialized);
|
|
332
|
+
return value;
|
|
333
|
+
}
|
|
334
|
+
/** Stable transport ID: persisted completed outcomes replay exactly; unknown never redispatches. */
|
|
335
|
+
deliver(callId, id, hello, requesterId, command) {
|
|
336
|
+
if (callId.startsWith("observation-")) return Promise.reject(new ExecutorError("reserved_delivery_id"));
|
|
337
|
+
if (command.method === "poll") return Promise.reject(new ExecutorError("observation_required"));
|
|
338
|
+
return this.dispatchDelivery(callId, id, hello, requesterId, command, "mutation");
|
|
339
|
+
}
|
|
340
|
+
observe(id, hello, requesterId, command) {
|
|
341
|
+
if (command.method !== "poll") return Promise.reject(new ExecutorError("forbidden"));
|
|
342
|
+
const seq = this.db.query("UPDATE observation_sequence SET seq=seq+1 WHERE id=1 AND seq<9007199254740990 RETURNING seq").get();
|
|
343
|
+
if (!seq) return Promise.reject(new ExecutorError("observation_sequence_exhausted"));
|
|
344
|
+
return this.dispatchDelivery(`observation-${seq.seq}`, id, hello, requesterId, command, "observation");
|
|
345
|
+
}
|
|
346
|
+
dispatchDelivery(callId, id, hello, requesterId, command, deliveryKind) {
|
|
347
|
+
const notAdmitted = (code) => new ExecutorError(code, code, command.method === "start");
|
|
348
|
+
if (this.closing) return Promise.reject(notAdmitted("release_manager_closed"));
|
|
349
|
+
RuntimeId.parse(callId);
|
|
350
|
+
RuntimeId.parse(requesterId);
|
|
351
|
+
const parsed = ExecutorCommand.parse(command);
|
|
352
|
+
if (!["start", "poll", "write", "resize", "cancel"].includes(parsed.method)) return Promise.reject(new ExecutorError("forbidden"));
|
|
353
|
+
const identity = parsed.method === "start" ? parsed.operation : "run" in parsed ? parsed.run : null;
|
|
354
|
+
if (!identity?.sessionId) return Promise.reject(new ExecutorError("forbidden"));
|
|
355
|
+
const hash = digest(parsed), prior = this.delivery(callId);
|
|
356
|
+
if (prior) {
|
|
357
|
+
if (prior.adapterId !== id || digest(this.get(id).grant.hello) !== digest(hello) || prior.commandDigest !== hash || prior.requesterId !== requesterId || prior.userId !== identity.userId || prior.sessionId !== identity.sessionId)
|
|
358
|
+
return Promise.reject(new ExecutorError("call_payload_mismatch"));
|
|
359
|
+
if (prior.state === "completed" && prior.outcome === null) return Promise.reject(new ExecutorError("delivery_payload_missing"));
|
|
360
|
+
return this.deliveries.get(callId) ?? Promise.resolve(prior);
|
|
361
|
+
}
|
|
362
|
+
const record = this.get(id), child = this.children.get(id);
|
|
363
|
+
if (this.retiring.has(id) || record.phase === "retired" || digest(record.grant.hello) !== digest(hello) || !child?.alive)
|
|
364
|
+
return Promise.reject(notAdmitted("candidate_not_ready"));
|
|
365
|
+
if (this.deliveries.size >= (parsed.method === "cancel" ? 32 : 28)) return Promise.reject(notAdmitted("manager_delivery_busy"));
|
|
366
|
+
const size = this.db.query(
|
|
367
|
+
"SELECT count(*) AS count, coalesce(sum(length(CAST(receipt AS BLOB))),0) AS bytes FROM deliveries WHERE json_extract(receipt,'$.deliveryKind')=?"
|
|
368
|
+
).get(deliveryKind);
|
|
369
|
+
const countLimit = deliveryKind === "observation" ? 256 : parsed.method === "start" ? 2048 : parsed.method === "cancel" ? 4096 : 3072;
|
|
370
|
+
const byteLimit = (deliveryKind === "observation" ? 8 : parsed.method === "start" ? 16 : parsed.method === "cancel" ? 32 : 24) * 1024 * 1024;
|
|
371
|
+
if (size.count >= countLimit || size.bytes + (this.deliveries.size + 1) * 1024 * 1024 > byteLimit)
|
|
372
|
+
return Promise.reject(notAdmitted("delivery_retention_full"));
|
|
373
|
+
const receipt = this.saveDelivery({
|
|
374
|
+
callId,
|
|
375
|
+
deliveryKind,
|
|
376
|
+
installationId: this.config.installationId,
|
|
377
|
+
workerId: this.config.workerId,
|
|
378
|
+
adapterId: id,
|
|
379
|
+
requesterId,
|
|
380
|
+
userId: identity.userId,
|
|
381
|
+
sessionId: identity.sessionId,
|
|
382
|
+
operationId: identity.operationId,
|
|
383
|
+
commandDigest: hash,
|
|
384
|
+
state: "pending",
|
|
385
|
+
outcome: null,
|
|
386
|
+
acknowledged: false
|
|
387
|
+
});
|
|
388
|
+
const task = (async () => {
|
|
389
|
+
try {
|
|
390
|
+
const result = await child.request(parsed);
|
|
391
|
+
if (result && typeof result === "object" && (result.state === "unknown" || result.receipt?.state === "unknown"))
|
|
392
|
+
return this.saveDelivery({ ...receipt, state: "unknown", outcome: null });
|
|
393
|
+
return this.saveDelivery({ ...receipt, state: "completed", outcome: { ok: true, result } });
|
|
394
|
+
} catch (error) {
|
|
395
|
+
const known = error instanceof ExecutorError && !error.code.endsWith("_unknown") && !["invalid_response", "delivery_result_too_large", "process_cleanup_uncertain"].includes(error.code);
|
|
396
|
+
const outcome = known ? { ok: false, error: { code: error.code, rejectedBeforeAdmission: error.rejectedBeforeAdmission } } : null;
|
|
397
|
+
return this.saveDelivery({ ...receipt, state: known ? "completed" : "unknown", outcome });
|
|
398
|
+
} finally {
|
|
399
|
+
this.deliveries.delete(callId);
|
|
400
|
+
}
|
|
401
|
+
})();
|
|
402
|
+
this.deliveries.set(callId, task);
|
|
403
|
+
return task;
|
|
404
|
+
}
|
|
405
|
+
acknowledgeDelivery(callId, responseDigest, ackToken, requesterId) {
|
|
406
|
+
const receipt = this.delivery(callId);
|
|
407
|
+
const kind = callId.startsWith("observation-") ? "observation" : "mutation";
|
|
408
|
+
const expected = this.ackToken(callId, responseDigest, requesterId, kind);
|
|
409
|
+
if (!/^[a-f0-9]{64}$/.test(ackToken) || !timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(ackToken, "hex")))
|
|
410
|
+
throw new ExecutorError("delivery_ack_mismatch");
|
|
411
|
+
if (!receipt) {
|
|
412
|
+
const match = /^observation-([1-9][0-9]*)$/.exec(callId), seq = Number(match?.[1]);
|
|
413
|
+
const highwater = this.db.query("SELECT seq FROM observation_sequence WHERE id=1").get().seq;
|
|
414
|
+
if (!match || !Number.isSafeInteger(seq) || seq > highwater) throw new ExecutorError("delivery_ack_mismatch");
|
|
415
|
+
} else {
|
|
416
|
+
if (receipt.requesterId !== requesterId || receipt.responseDigest !== responseDigest)
|
|
417
|
+
throw new ExecutorError("delivery_ack_mismatch");
|
|
418
|
+
if (receipt.state !== "completed" || receipt.outcome === null) throw new ExecutorError("delivery_reconciliation_required");
|
|
419
|
+
if (receipt.deliveryKind === "observation") this.db.query("DELETE FROM deliveries WHERE id=?").run(callId);
|
|
420
|
+
else this.saveDelivery({ ...receipt, acknowledged: true });
|
|
421
|
+
}
|
|
422
|
+
return { callId, responseDigest, acknowledged: true };
|
|
423
|
+
}
|
|
424
|
+
/** Admin-only attestation to independently durable reconciliation. Never replays or changes the unknown outcome. */
|
|
425
|
+
settleUnknownDelivery(callId, evidenceDigest) {
|
|
426
|
+
z.string().regex(/^[a-f0-9]{64}$/).parse(evidenceDigest);
|
|
427
|
+
const receipt = this.delivery(callId);
|
|
428
|
+
if (!receipt || receipt.state !== "unknown") throw new ExecutorError("delivery_not_unknown");
|
|
429
|
+
return this.db.transaction(() => {
|
|
430
|
+
this.db.query("INSERT INTO transitions(kind,body,state) VALUES ('settle-unknown',?,'completed')").run(canonicalJson({ callId, responseDigest: receipt.responseDigest, evidenceDigest }));
|
|
431
|
+
return this.saveDelivery({ ...receipt, acknowledged: true });
|
|
432
|
+
})();
|
|
433
|
+
}
|
|
434
|
+
deliveryDrain(id) {
|
|
435
|
+
const rows = this.db.query("SELECT receipt FROM deliveries").all();
|
|
436
|
+
const receipts = rows.map((row) => JSON.parse(row.receipt)).filter((receipt) => receipt.adapterId === id && !receipt.acknowledged);
|
|
437
|
+
return { unsettled: receipts.length, unknown: receipts.filter((receipt) => receipt.state === "unknown").length };
|
|
438
|
+
}
|
|
439
|
+
/** Stable callId journals delivery; reusing it never dispatches twice. Explicit retry uses reconcileCall. */
|
|
440
|
+
request(callId, id, command) {
|
|
441
|
+
return this.exclusive(() => this.dispatch(callId, id, command, false));
|
|
442
|
+
}
|
|
443
|
+
reconcileCall(callId, id, command) {
|
|
444
|
+
return this.exclusive(() => this.dispatch(callId, id, command, true));
|
|
445
|
+
}
|
|
446
|
+
async dispatch(callId, id, command, explicitRetry) {
|
|
447
|
+
RuntimeId.parse(callId);
|
|
448
|
+
if (!["start", "poll", "write", "resize", "cancel"].includes(command.method)) throw new ExecutorError("forbidden");
|
|
449
|
+
const hash = callDigest(command), prior = this.calls().find((call) => call.id === callId);
|
|
450
|
+
if (prior && prior.hash !== hash) throw new ExecutorError("call_payload_mismatch");
|
|
451
|
+
if (prior && !explicitRetry)
|
|
452
|
+
throw new ExecutorError(prior.state === "completed" ? "call_already_delivered" : "adapter_outcome_unknown");
|
|
453
|
+
if (!prior && explicitRetry) throw new ExecutorError("call_not_found");
|
|
454
|
+
if (!this.children.get(id)?.alive) throw new ExecutorError("candidate_not_ready");
|
|
455
|
+
if (!prior) this.db.query("INSERT INTO calls VALUES (?,?,?,'pending',0)").run(callId, id, hash);
|
|
456
|
+
try {
|
|
457
|
+
const result = await this.children.get(id).request(command);
|
|
458
|
+
this.db.query("UPDATE calls SET state='completed' WHERE id=?").run(callId);
|
|
459
|
+
return result;
|
|
460
|
+
} catch (error) {
|
|
461
|
+
this.db.query("UPDATE calls SET state='unknown' WHERE id=?").run(callId);
|
|
462
|
+
throw error;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
/** Called only after the controller's durable inbox/outbox has recorded the result OR explicit unknown reconciliation. */
|
|
466
|
+
acknowledge(callId) {
|
|
467
|
+
return this.exclusive(async () => {
|
|
468
|
+
const call = this.calls().find((row) => row.id === callId);
|
|
469
|
+
if (!call || call.state === "pending") throw new ExecutorError("call_not_settled");
|
|
470
|
+
this.db.query("UPDATE calls SET acknowledged=1 WHERE id=?").run(callId);
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
retire(id, retirementIntentDigest) {
|
|
474
|
+
return this.exclusive(async () => {
|
|
475
|
+
const record = this.get(id);
|
|
476
|
+
if (retirementIntentDigest !== void 0 && !/^[a-f0-9]{64}$/.test(retirementIntentDigest))
|
|
477
|
+
throw new ExecutorError("invalid_retirement_intent");
|
|
478
|
+
if (record.phase === "retired") {
|
|
479
|
+
const completion = record.retirementCompletion;
|
|
480
|
+
if (!completion || retirementIntentDigest && completion.retirementIntentDigest !== retirementIntentDigest)
|
|
481
|
+
throw new ExecutorError("adapter_retirement_unknown");
|
|
482
|
+
AdapterRetirementCompletion.parse(completion);
|
|
483
|
+
if (completion.installationId !== this.config.installationId || completion.workerId !== this.config.workerId || completion.adapterId !== id || completion.manifestDigest !== digest(record.manifest) || completion.grantDigest !== digest(record.grant))
|
|
484
|
+
throw new ExecutorError("adapter_retirement_unknown");
|
|
485
|
+
if (this.calls().some((call) => call.principal === id && !call.acknowledged) || this.deliveryDrain(id).unsettled)
|
|
486
|
+
throw new ExecutorError("adapter_drain_blocked");
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
const child = this.children.get(id);
|
|
490
|
+
if (this.calls().some((call) => call.principal === id && !call.acknowledged) || this.deliveryDrain(id).unsettled)
|
|
491
|
+
throw new ExecutorError("adapter_drain_blocked");
|
|
492
|
+
this.retiring.add(id);
|
|
493
|
+
try {
|
|
494
|
+
await this.controller.retireAdapter(id);
|
|
495
|
+
} catch (error) {
|
|
496
|
+
this.retiring.delete(id);
|
|
497
|
+
throw error;
|
|
498
|
+
}
|
|
499
|
+
record.phase = "retired";
|
|
500
|
+
this.put(record);
|
|
501
|
+
await child?.close();
|
|
502
|
+
this.children.delete(id);
|
|
503
|
+
if (child && child.process.pid && retirementIntentDigest && (child.process.exitCode !== null || child.process.signalCode !== null)) {
|
|
504
|
+
record.retirementCompletion = AdapterRetirementCompletion.parse({
|
|
505
|
+
format: 1,
|
|
506
|
+
installationId: this.config.installationId,
|
|
507
|
+
workerId: this.config.workerId,
|
|
508
|
+
adapterId: id,
|
|
509
|
+
manifestDigest: digest(record.manifest),
|
|
510
|
+
grantDigest: digest(record.grant),
|
|
511
|
+
retirementIntentDigest,
|
|
512
|
+
managerInvocation: this.invocation,
|
|
513
|
+
childPid: child.process.pid,
|
|
514
|
+
completionId: randomUUID()
|
|
515
|
+
});
|
|
516
|
+
this.put(record);
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
stopAdapter(id) {
|
|
521
|
+
return this.exclusive(async () => {
|
|
522
|
+
await this.children.get(id)?.close();
|
|
523
|
+
this.children.delete(id);
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
close() {
|
|
527
|
+
return this.closed ??= (async () => {
|
|
528
|
+
this.closing = true;
|
|
529
|
+
await this.serial;
|
|
530
|
+
await Promise.allSettled([...this.deliveries.values()]);
|
|
531
|
+
for (const child of this.children.values()) await child.close();
|
|
532
|
+
this.children.clear();
|
|
533
|
+
this.db.close();
|
|
534
|
+
this.lock.close();
|
|
535
|
+
})();
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
export {
|
|
539
|
+
ReleaseManagerConfig,
|
|
540
|
+
WorkerAdapterReleases
|
|
541
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { ExecutorError, isStartNonAdmissionCode, MAX_FRAME_BYTES } from "../protocol.mjs";
|
|
4
|
+
class AdapterChild {
|
|
5
|
+
constructor(runtimePath, entrypoint, configPath, home, timeoutMs = 1e4) {
|
|
6
|
+
this.timeoutMs = timeoutMs;
|
|
7
|
+
this.process = spawn(runtimePath, [entrypoint, configPath], {
|
|
8
|
+
cwd: home,
|
|
9
|
+
env: { PATH: "/usr/local/bin:/usr/bin:/bin", HOME: home, LANG: "C.UTF-8" },
|
|
10
|
+
stdio: "pipe"
|
|
11
|
+
});
|
|
12
|
+
let buffer = Buffer.alloc(0);
|
|
13
|
+
this.process.stdout.on("data", (chunk) => {
|
|
14
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
15
|
+
if (buffer.length > MAX_FRAME_BYTES) {
|
|
16
|
+
this.fail();
|
|
17
|
+
this.process.kill();
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
while (buffer.includes(10)) {
|
|
21
|
+
const end = buffer.indexOf(10), line = buffer.subarray(0, end);
|
|
22
|
+
buffer = buffer.subarray(end + 1);
|
|
23
|
+
try {
|
|
24
|
+
const message = JSON.parse(line.toString("utf8"));
|
|
25
|
+
const pending = this.pending.get(message.id);
|
|
26
|
+
if (!pending) continue;
|
|
27
|
+
this.pending.delete(message.id);
|
|
28
|
+
clearTimeout(pending.timer);
|
|
29
|
+
if (message.ok === true) pending.resolve(message.result);
|
|
30
|
+
else
|
|
31
|
+
pending.reject(
|
|
32
|
+
new ExecutorError(
|
|
33
|
+
message.error?.code ?? "adapter_outcome_unknown",
|
|
34
|
+
void 0,
|
|
35
|
+
message.ok === false && pending.method === "start" && isStartNonAdmissionCode(message.error?.code) && message.error?.rejectedBeforeAdmission === true
|
|
36
|
+
)
|
|
37
|
+
);
|
|
38
|
+
} catch {
|
|
39
|
+
this.fail();
|
|
40
|
+
this.process.kill();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
this.process.stderr.resume();
|
|
45
|
+
this.process.stdin.on("error", () => this.fail());
|
|
46
|
+
this.process.once("error", () => this.fail());
|
|
47
|
+
this.process.once("exit", () => this.fail());
|
|
48
|
+
}
|
|
49
|
+
timeoutMs;
|
|
50
|
+
process;
|
|
51
|
+
pending = /* @__PURE__ */ new Map();
|
|
52
|
+
gone = false;
|
|
53
|
+
fail() {
|
|
54
|
+
this.gone = true;
|
|
55
|
+
for (const call of this.pending.values()) {
|
|
56
|
+
clearTimeout(call.timer);
|
|
57
|
+
call.reject(new ExecutorError("adapter_outcome_unknown"));
|
|
58
|
+
}
|
|
59
|
+
this.pending.clear();
|
|
60
|
+
}
|
|
61
|
+
get alive() {
|
|
62
|
+
return !this.gone && this.process.exitCode === null && this.process.signalCode === null;
|
|
63
|
+
}
|
|
64
|
+
request(command) {
|
|
65
|
+
if (!this.alive) return Promise.reject(new ExecutorError("adapter_outcome_unknown"));
|
|
66
|
+
const id = randomUUID(), frame = JSON.stringify({ id, command }) + "\n";
|
|
67
|
+
if (Buffer.byteLength(frame) > MAX_FRAME_BYTES) return Promise.reject(new ExecutorError("request_too_large"));
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
const timer = setTimeout(() => {
|
|
70
|
+
this.pending.delete(id);
|
|
71
|
+
reject(new ExecutorError("adapter_outcome_unknown"));
|
|
72
|
+
}, this.timeoutMs);
|
|
73
|
+
this.pending.set(id, { resolve: (result) => resolve(result), reject, timer, method: command.method });
|
|
74
|
+
this.process.stdin.write(frame);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
async close() {
|
|
78
|
+
if (this.process.exitCode !== null || this.process.signalCode !== null) return;
|
|
79
|
+
const stopped = new Promise((resolve) => this.process.once("close", () => resolve()));
|
|
80
|
+
this.process.kill("SIGTERM");
|
|
81
|
+
const timer = setTimeout(() => this.process.kill("SIGKILL"), 2e3);
|
|
82
|
+
await stopped;
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
export {
|
|
87
|
+
AdapterChild
|
|
88
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { RuntimeId } from "@ricsam/r5d-api/runtime-protocol";
|
|
3
|
+
const Digest = z.string().regex(/^[a-f0-9]{64}$/);
|
|
4
|
+
const AdapterRetirementCompletion = z.object({
|
|
5
|
+
format: z.literal(1),
|
|
6
|
+
installationId: RuntimeId,
|
|
7
|
+
workerId: RuntimeId,
|
|
8
|
+
adapterId: RuntimeId,
|
|
9
|
+
manifestDigest: Digest,
|
|
10
|
+
grantDigest: Digest,
|
|
11
|
+
retirementIntentDigest: Digest,
|
|
12
|
+
managerInvocation: z.string().uuid(),
|
|
13
|
+
childPid: z.number().int().positive(),
|
|
14
|
+
completionId: z.string().uuid()
|
|
15
|
+
}).strict();
|
|
16
|
+
export {
|
|
17
|
+
AdapterRetirementCompletion
|
|
18
|
+
};
|