@botiverse/k-carrier 0.1.8 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +5 -2
- package/README.md +72 -26
- package/core/src/artifact/download.ts +23 -1
- package/core/src/artifact/gzip.ts +26 -0
- package/core/src/artifact/source.ts +2 -0
- package/core/src/{createUpgrader.ts → createRunner.ts} +13 -15
- package/core/src/index.ts +11 -3
- package/core/src/launcher/launch.ts +15 -0
- package/core/src/launcher/supervise.ts +170 -0
- package/core/src/lifecycle/commandHost.ts +111 -0
- package/core/src/lifecycle/hostAdapter.ts +28 -16
- package/core/src/operation.ts +47 -25
- package/core/src/operationLifecycle.ts +2 -7
- package/core/src/platform/ops.ts +7 -0
- package/core/src/platform/posix.ts +26 -6
- package/core/src/platform/windows.ts +8 -2
- package/core/src/protocol/runner.ts +81 -0
- package/core/src/provenance/journal.ts +1 -1
- package/core/src/runner/cli.ts +27 -0
- package/core/src/runner/execute.ts +68 -0
- package/core/src/txn/engine.ts +41 -85
- package/core/src/txn/fileEffects.ts +15 -1
- package/core/src/txn/hostCallBudget.ts +4 -1
- package/core/src/txn/hostCallUncertain.ts +2 -0
- package/core/src/txn/lock.ts +81 -37
- package/core/src/txn/state.ts +1 -1
- package/core/src/upgrade/drive.ts +31 -2
- package/core/src/upgrade/outcome.ts +1 -1
- package/core/src/upgrade/recover.ts +22 -1
- package/core/src/upgrade/retire.ts +1 -1
- package/core/src/upgrader.ts +4 -9
- package/docs/design.md +173 -0
- package/docs/guide.md +196 -0
- package/docs/harness-design.md +75 -170
- package/docs/integration.md +221 -364
- package/docs/prior-art/design-influences.md +26 -0
- package/docs/prior-art/external-runner-research.md +49 -0
- package/docs/reference.md +209 -0
- package/docs/test-plan.md +89 -92
- package/harness/src/adapter/releaseKnob.ts +1 -1
- package/harness/src/adapter/serviceChecks.ts +5 -5
- package/harness/src/artifact/m1.ts +8 -8
- package/harness/src/artifact/m1Resume.ts +2 -2
- package/harness/src/artifact/m3.ts +25 -104
- package/harness/src/artifact/m3Hosts.ts +9 -61
- package/harness/src/artifact/m4.ts +3 -3
- package/harness/src/artifact/m5.ts +5 -5
- package/harness/src/artifact/m6.ts +6 -6
- package/harness/src/artifact/m6Status.ts +1 -1
- package/harness/src/examples/checks.ts +10 -13
- package/harness/src/fixtures/cliToolSource.ts +166 -0
- package/harness/src/fixtures/externalCrashAdapter.ts +19 -0
- package/harness/src/fixtures/managedHost.ts +100 -0
- package/harness/src/fixtures/serviceSource.ts +181 -0
- package/harness/src/fixtures/supervisedAdapter.ts +57 -0
- package/harness/src/scenario/processScan.ts +3 -1
- package/harness/src/scenario/sandbox.ts +2 -2
- package/harness/src/teeth/artifact.ts +3 -3
- package/harness/src/teeth/examples.ts +1 -1
- package/package.json +5 -3
- package/docs/design-v1.md +0 -246
- package/docs/prior-art.md +0 -150
|
@@ -10,22 +10,31 @@
|
|
|
10
10
|
* equivalent — including when resume() happens on the ROLLED-BACK slot.
|
|
11
11
|
* - healthProbe(): evidence must be bound to one live process (same-PID /
|
|
12
12
|
* startId), never assembled from files or caches. A probe that cannot
|
|
13
|
-
* prove which process answered is not a probe.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* never "the successor is running": only healthProbe() can say that.
|
|
13
|
+
* prove which process answered is not a probe. K probes once before
|
|
14
|
+
* handover and refuses readback evidence that repeats that startId.
|
|
15
|
+
* - start(): may return before readiness. The external controller asks
|
|
16
|
+
* the service manager to start the selected artifact; healthProbe()
|
|
17
|
+
* then proves that the requested incarnation is actually running.
|
|
19
18
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* experiment version from a different incarnation), never by a flag saying the
|
|
25
|
-
* restart was planned. A crash could set that flag just as easily.
|
|
19
|
+
* K runs outside the resident process and service unit, so stopping the
|
|
20
|
+
* application does not kill the transaction owner. A runner crash before
|
|
21
|
+
* durable promote intent restores stable; after that intent, recovery
|
|
22
|
+
* replays the commit. A live candidate alone never authorizes promotion.
|
|
26
23
|
*/
|
|
27
24
|
export interface HostAdapter {
|
|
28
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* Drain or fence effects left by an earlier worker/controller before replay.
|
|
27
|
+
* Called under K's transaction lock. Throw if isolation cannot be proved.
|
|
28
|
+
* Required for adapters whose effects can outlive their worker; purely
|
|
29
|
+
* in-process adapters without surviving effects may omit it.
|
|
30
|
+
*/
|
|
31
|
+
fence?(): Promise<void>;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Park all hosted workloads durably. Idempotent. A stateless service
|
|
35
|
+
* acknowledges and returns; the obligation covers only workloads the
|
|
36
|
+
* product promises to preserve across an upgrade.
|
|
37
|
+
*/
|
|
29
38
|
quiesce(): Promise<void>;
|
|
30
39
|
|
|
31
40
|
/** Stop the resident service process tree for the given slot. */
|
|
@@ -40,7 +49,10 @@ export interface HostAdapter {
|
|
|
40
49
|
*/
|
|
41
50
|
healthProbe(): Promise<ProcessEvidence>;
|
|
42
51
|
|
|
43
|
-
/**
|
|
52
|
+
/**
|
|
53
|
+
* Resume workloads parked by quiesce(). Must also work after rollback,
|
|
54
|
+
* i.e. on the previous version. Trivial when quiesce() is trivial.
|
|
55
|
+
*/
|
|
44
56
|
resume(): Promise<void>;
|
|
45
57
|
}
|
|
46
58
|
|
|
@@ -60,8 +72,8 @@ export interface HostAdapter {
|
|
|
60
72
|
* reconstructed after a failure, it was never disturbed.
|
|
61
73
|
*
|
|
62
74
|
* ⚠️ `stable` here is a POSITION, not the name of a release channel. If your
|
|
63
|
-
* product also has a channel called "stable"
|
|
64
|
-
*
|
|
75
|
+
* product also has a channel called "stable", they are unrelated: a channel
|
|
76
|
+
* says which stream you follow, a slot says which copy on disk.
|
|
65
77
|
*/
|
|
66
78
|
export type Slot = "stable" | "experiment";
|
|
67
79
|
|
package/core/src/operation.ts
CHANGED
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
* Hosts may project this record into their own UI or transport, but they do
|
|
5
5
|
* not maintain a second upgrade state machine. The operation receipt is the
|
|
6
6
|
* single durable answer to: what is running, which version was requested,
|
|
7
|
-
* what stable version can be restored, and
|
|
8
|
-
*
|
|
7
|
+
* what stable version can be restored, and the outcome. Transport delivery
|
|
8
|
+
* tracking belongs to the caller and cannot block another transaction.
|
|
9
9
|
*/
|
|
10
10
|
import { promises as fs } from "node:fs";
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
11
12
|
import * as path from "node:path";
|
|
12
13
|
import { platformOpsFor } from "./platform/index.ts";
|
|
13
14
|
import type { ProvenanceIdentity } from "./upgrader.ts";
|
|
@@ -75,8 +76,6 @@ export interface OperationRecord {
|
|
|
75
76
|
reason: string | null;
|
|
76
77
|
provenance: ProvenanceIdentity | null;
|
|
77
78
|
metadata: Record<string, string>;
|
|
78
|
-
/** Host transport receipt, not a second transaction outcome. */
|
|
79
|
-
acknowledgedAtMs: number | null;
|
|
80
79
|
}
|
|
81
80
|
|
|
82
81
|
export type OperationRead =
|
|
@@ -97,7 +96,7 @@ function validIdentity(value: unknown): value is ProvenanceIdentity {
|
|
|
97
96
|
);
|
|
98
97
|
}
|
|
99
98
|
|
|
100
|
-
function parseOperation(text: string): OperationRecord {
|
|
99
|
+
export function parseOperation(text: string): OperationRecord {
|
|
101
100
|
const parsed = JSON.parse(text) as Partial<OperationRecord>;
|
|
102
101
|
if (
|
|
103
102
|
parsed.formatVersion !== OPERATION_FORMAT_VERSION
|
|
@@ -122,7 +121,6 @@ function parseOperation(text: string): OperationRecord {
|
|
|
122
121
|
|| parsed.metadata === null
|
|
123
122
|
|| Array.isArray(parsed.metadata)
|
|
124
123
|
|| Object.values(parsed.metadata).some((value) => typeof value !== "string")
|
|
125
|
-
|| !(parsed.acknowledgedAtMs === null || typeof parsed.acknowledgedAtMs === "number")
|
|
126
124
|
) {
|
|
127
125
|
throw new Error("operation record has an invalid shape");
|
|
128
126
|
}
|
|
@@ -160,23 +158,47 @@ export async function loadOperation(stateDir: string): Promise<OperationRead> {
|
|
|
160
158
|
}
|
|
161
159
|
}
|
|
162
160
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
161
|
+
/** A completed request is replayed from its durable receipt, never executed twice. */
|
|
162
|
+
export class OperationReplay extends Error {
|
|
163
|
+
readonly operation: OperationRecord;
|
|
164
|
+
constructor(operation: OperationRecord) {
|
|
165
|
+
super(`OPERATION_REPLAY: ${operation.id}`);
|
|
166
|
+
this.operation = operation;
|
|
167
|
+
this.name = "OperationReplay";
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function archivePath(stateDir: string, id: string): string {
|
|
172
|
+
return path.join(stateDir, "receipts", `${createHash("sha256").update(id).digest("hex")}.json`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Durable history is independent of transport acknowledgement. Called under K's lock. */
|
|
176
|
+
export async function archiveOperation(stateDir: string, operation: OperationRecord): Promise<void> {
|
|
177
|
+
if (operation.outcome === null) throw new Error("cannot archive an active operation");
|
|
178
|
+
const target = archivePath(stateDir, operation.id);
|
|
179
|
+
const existing = await loadArchivedOperation(stateDir, operation.id);
|
|
180
|
+
if (existing.kind === "unreadable") throw new Error(existing.reason);
|
|
181
|
+
if (existing.kind === "observed") {
|
|
182
|
+
if (existing.operation.targetVersion !== operation.targetVersion || existing.operation.outcome !== operation.outcome) {
|
|
183
|
+
throw new Error("OPERATION_ARCHIVE_CONFLICT");
|
|
184
|
+
}
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
188
|
+
const tmp = `${target}.tmp`;
|
|
189
|
+
const handle = await fs.open(tmp, "w", 0o600);
|
|
190
|
+
try { await handle.writeFile(JSON.stringify(operation)); await handle.sync(); }
|
|
191
|
+
finally { await handle.close(); }
|
|
192
|
+
await platformOpsFor().renamePath(tmp, target);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function loadArchivedOperation(stateDir: string, id: string): Promise<OperationRead> {
|
|
196
|
+
try {
|
|
197
|
+
const operation = parseOperation(await fs.readFile(archivePath(stateDir, id), "utf8"));
|
|
198
|
+
if (operation.id !== id || operation.outcome === null) throw new Error("invalid archived receipt identity/outcome");
|
|
199
|
+
return { kind: "observed", operation };
|
|
200
|
+
} catch (error) {
|
|
201
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { kind: "genesis" };
|
|
202
|
+
return { kind: "unreadable", reason: "cannot read archived K receipt" };
|
|
203
|
+
}
|
|
182
204
|
}
|
|
@@ -2,7 +2,7 @@ import type { Clock } from "./clock.ts";
|
|
|
2
2
|
import { phaseAtRest, type TxnState } from "./txn/state.ts";
|
|
3
3
|
import type { ProvenanceIdentity } from "./upgrader.ts";
|
|
4
4
|
import {
|
|
5
|
-
|
|
5
|
+
archiveOperation,
|
|
6
6
|
loadOperation,
|
|
7
7
|
persistOperation,
|
|
8
8
|
type OperationDescriptor,
|
|
@@ -27,7 +27,6 @@ export interface OperationLifecycle {
|
|
|
27
27
|
settleRecovery(): Promise<void>;
|
|
28
28
|
reset(): void;
|
|
29
29
|
read(): Promise<OperationRead>;
|
|
30
|
-
acknowledge(operationId: string): Promise<"acknowledged" | "not-terminal" | "not-found" | "changed">;
|
|
31
30
|
}
|
|
32
31
|
|
|
33
32
|
export function createOperationLifecycle(
|
|
@@ -58,9 +57,7 @@ export function createOperationLifecycle(
|
|
|
58
57
|
if (existing.operation.outcome === null) {
|
|
59
58
|
throw new Error(`OPERATION_IN_PROGRESS: ${existing.operation.id}`);
|
|
60
59
|
}
|
|
61
|
-
|
|
62
|
-
throw new Error(`OPERATION_RECEIPT_PENDING: ${existing.operation.id}`);
|
|
63
|
-
}
|
|
60
|
+
await archiveOperation(stateDir, existing.operation);
|
|
64
61
|
}
|
|
65
62
|
if (existing.kind === "observed" && existing.operation.id === descriptor.id) {
|
|
66
63
|
record = existing.operation;
|
|
@@ -79,7 +76,6 @@ export function createOperationLifecycle(
|
|
|
79
76
|
reason: null,
|
|
80
77
|
provenance: descriptor.provenance ?? provenance,
|
|
81
78
|
metadata: { ...descriptor.metadata },
|
|
82
|
-
acknowledgedAtMs: null,
|
|
83
79
|
};
|
|
84
80
|
await persistOperation(stateDir, record);
|
|
85
81
|
},
|
|
@@ -108,6 +104,5 @@ export function createOperationLifecycle(
|
|
|
108
104
|
|
|
109
105
|
read: () => loadOperation(stateDir),
|
|
110
106
|
|
|
111
|
-
acknowledge: (operationId) => acknowledgeOperation(stateDir, operationId, clock.nowMs()),
|
|
112
107
|
};
|
|
113
108
|
}
|
package/core/src/platform/ops.ts
CHANGED
|
@@ -36,6 +36,13 @@ export interface PlatformOps {
|
|
|
36
36
|
* RUNNING image; this one is plain state.
|
|
37
37
|
*/
|
|
38
38
|
renamePath(from: string, to: string): Promise<void>;
|
|
39
|
+
/**
|
|
40
|
+
* Make directory entries durable. A rename or file creation is only
|
|
41
|
+
* crash-safe once the PARENT directory is fsync'd; `fh.sync()` on the file
|
|
42
|
+
* alone leaves the new name in the page cache. No-op where the OS does not
|
|
43
|
+
* support fsync on a directory handle (Windows).
|
|
44
|
+
*/
|
|
45
|
+
syncDirectory(dir: string): Promise<void>;
|
|
39
46
|
/** Identifier used to select a manifest target, e.g. "linux-x64". */
|
|
40
47
|
platformKey(): string;
|
|
41
48
|
}
|
|
@@ -3,9 +3,16 @@
|
|
|
3
3
|
* The only place in core allowed to name rename/signals.
|
|
4
4
|
*/
|
|
5
5
|
import { execFileSync } from "node:child_process";
|
|
6
|
-
import { promises as fs } from "node:fs";
|
|
6
|
+
import { promises as fs, readFileSync } from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
7
8
|
import type { PlatformOps } from "./ops.ts";
|
|
8
9
|
|
|
10
|
+
/** fsync a directory so entries created/renamed inside it survive power loss. */
|
|
11
|
+
async function syncDirectory(dir: string): Promise<void> {
|
|
12
|
+
const fh = await fs.open(dir, "r");
|
|
13
|
+
try { await fh.sync(); } finally { await fh.close(); }
|
|
14
|
+
}
|
|
15
|
+
|
|
9
16
|
async function atomicReplace(filePath: string, data: Uint8Array): Promise<void> {
|
|
10
17
|
const tmpPath = `${filePath}.tmp`;
|
|
11
18
|
try {
|
|
@@ -28,6 +35,7 @@ async function atomicReplace(filePath: string, data: Uint8Array): Promise<void>
|
|
|
28
35
|
}
|
|
29
36
|
// Atomic on POSIX: a reader sees either the old file or the whole new one.
|
|
30
37
|
await fs.rename(tmpPath, filePath);
|
|
38
|
+
await syncDirectory(path.dirname(filePath)); // the new name itself must be durable
|
|
31
39
|
} catch (err) {
|
|
32
40
|
await fs.unlink(tmpPath).catch(() => {});
|
|
33
41
|
throw err;
|
|
@@ -77,19 +85,31 @@ export function platformKeyFor(
|
|
|
77
85
|
export const posixOps: PlatformOps = {
|
|
78
86
|
swapExecutable: atomicReplace,
|
|
79
87
|
isProcessAlive(pid) {
|
|
80
|
-
try {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
88
|
+
try { process.kill(pid, 0); }
|
|
89
|
+
catch (error) { return (error as NodeJS.ErrnoException).code !== "ESRCH"; }
|
|
90
|
+
if (process.platform === "linux") {
|
|
91
|
+
try {
|
|
92
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
93
|
+
// A zombie may stay in a container's process table, but cannot execute.
|
|
94
|
+
if (stat.slice(stat.lastIndexOf(")") + 2).startsWith("Z")) return false;
|
|
95
|
+
} catch (error) {
|
|
96
|
+
// Access restrictions are uncertainty, never proof of death.
|
|
97
|
+
return (error as NodeJS.ErrnoException).code !== "ENOENT";
|
|
98
|
+
}
|
|
85
99
|
}
|
|
100
|
+
return true;
|
|
86
101
|
},
|
|
87
102
|
killProcess(pid) {
|
|
88
103
|
process.kill(pid, "SIGKILL");
|
|
89
104
|
},
|
|
90
105
|
async renamePath(from, to) {
|
|
91
106
|
await fs.rename(from, to);
|
|
107
|
+
const target = path.dirname(to);
|
|
108
|
+
await syncDirectory(target);
|
|
109
|
+
const source = path.dirname(from);
|
|
110
|
+
if (source !== target) await syncDirectory(source);
|
|
92
111
|
},
|
|
112
|
+
syncDirectory,
|
|
93
113
|
async makeExecutable(filePath) {
|
|
94
114
|
await fs.chmod(filePath, 0o755);
|
|
95
115
|
},
|
|
@@ -51,8 +51,8 @@ export const windowsOps: PlatformOps = {
|
|
|
51
51
|
try {
|
|
52
52
|
process.kill(pid, 0);
|
|
53
53
|
return true;
|
|
54
|
-
} catch {
|
|
55
|
-
return
|
|
54
|
+
} catch (error) {
|
|
55
|
+
return (error as NodeJS.ErrnoException).code !== "ESRCH";
|
|
56
56
|
}
|
|
57
57
|
},
|
|
58
58
|
killProcess(pid) {
|
|
@@ -63,6 +63,12 @@ export const windowsOps: PlatformOps = {
|
|
|
63
63
|
// Plain state moves DO work on Windows (unlike replacing a running .exe).
|
|
64
64
|
await fs.rename(from, to);
|
|
65
65
|
},
|
|
66
|
+
async syncDirectory() {
|
|
67
|
+
// Node does not provide a portable directory flush here. This no-op
|
|
68
|
+
// supplies no power-loss durability guarantee; NTFS journaling alone
|
|
69
|
+
// is not an acknowledgement that these particular writes reached disk.
|
|
70
|
+
await Promise.resolve();
|
|
71
|
+
},
|
|
66
72
|
async makeExecutable() {
|
|
67
73
|
// No executable bit on Windows; nothing to do (a real no-op).
|
|
68
74
|
await Promise.resolve();
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { parseOperation, type OperationRead } from "../operation.ts";
|
|
2
|
+
|
|
3
|
+
/** Wire version is independent of the helper and application release versions. */
|
|
4
|
+
export const RUNNER_PROTOCOL_VERSION = 1;
|
|
5
|
+
export type RunnerRequest =
|
|
6
|
+
| { protocolVersion: 1; action: "upgrade"; id: string; targetVersion: string; consented: boolean }
|
|
7
|
+
| { protocolVersion: 1; action: "recover"; expected?: { id: string; targetVersion: string } }
|
|
8
|
+
| { protocolVersion: 1; action: "status" }
|
|
9
|
+
;
|
|
10
|
+
|
|
11
|
+
export interface RunnerResponse {
|
|
12
|
+
protocolVersion: 1;
|
|
13
|
+
action: RunnerRequest["action"];
|
|
14
|
+
/** 0 = completed command, 1 = failure/unknown, 2 = hold, 3 = recovery needed. */
|
|
15
|
+
exitCode: 0 | 1 | 2 | 3;
|
|
16
|
+
result: string;
|
|
17
|
+
/** Verbatim K receipt; an exception never manufactures a rollback receipt. */
|
|
18
|
+
operation: OperationRead;
|
|
19
|
+
error: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function objectValue(value: unknown): Record<string, unknown> {
|
|
23
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
24
|
+
throw new Error("RUNNER_PROTOCOL_INVALID: expected an object");
|
|
25
|
+
}
|
|
26
|
+
return value as Record<string, unknown>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function textField(value: unknown, field: string): string {
|
|
30
|
+
if (typeof value !== "string" || value.trim() !== value || value.length === 0 || value.length > 256) {
|
|
31
|
+
throw new Error(`RUNNER_PROTOCOL_INVALID: invalid ${field}`);
|
|
32
|
+
}
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Reject unknown versions and fields before loading an adapter or touching disk. */
|
|
37
|
+
export function parseRunnerRequest(input: unknown): RunnerRequest {
|
|
38
|
+
const value = objectValue(input);
|
|
39
|
+
if (value.protocolVersion !== RUNNER_PROTOCOL_VERSION) throw new Error("RUNNER_PROTOCOL_UNSUPPORTED");
|
|
40
|
+
const keys = value.action === "upgrade" ? ["protocolVersion", "action", "id", "targetVersion", "consented"] : value.action === "recover" ? ["protocolVersion", "action", "expected"] : ["protocolVersion", "action"];
|
|
41
|
+
if (Object.keys(value).some((key) => !keys.includes(key))) throw new Error("RUNNER_PROTOCOL_INVALID: unknown field");
|
|
42
|
+
switch (value.action) {
|
|
43
|
+
case "upgrade":
|
|
44
|
+
if (typeof value.consented !== "boolean") throw new Error("RUNNER_PROTOCOL_INVALID: consented must be boolean");
|
|
45
|
+
return { protocolVersion: 1, action: "upgrade", id: textField(value.id, "id"),
|
|
46
|
+
targetVersion: textField(value.targetVersion, "targetVersion"), consented: value.consented };
|
|
47
|
+
case "recover": {
|
|
48
|
+
if (value.expected === undefined) return { protocolVersion: 1, action: "recover" };
|
|
49
|
+
const expected = objectValue(value.expected);
|
|
50
|
+
if (Object.keys(expected).some((key) => !["id", "targetVersion"].includes(key))) throw new Error("RUNNER_PROTOCOL_INVALID: unknown expected field");
|
|
51
|
+
return { protocolVersion: 1, action: "recover", expected: {
|
|
52
|
+
id: textField(expected.id, "id"), targetVersion: textField(expected.targetVersion, "targetVersion"),
|
|
53
|
+
} };
|
|
54
|
+
}
|
|
55
|
+
case "status":
|
|
56
|
+
return { protocolVersion: 1, action: value.action };
|
|
57
|
+
default: throw new Error("RUNNER_PROTOCOL_INVALID: unknown action");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The supervisor accepts only a complete, typed runner response. */
|
|
62
|
+
export function parseRunnerResponse(input: unknown): RunnerResponse {
|
|
63
|
+
const value = objectValue(input);
|
|
64
|
+
if (value.protocolVersion !== 1 || !["upgrade", "recover", "status"].includes(String(value.action)) ||
|
|
65
|
+
![0, 1, 2, 3].includes(Number(value.exitCode)) || typeof value.exitCode !== "number" ||
|
|
66
|
+
typeof value.result !== "string" || !(value.error === null || typeof value.error === "string")) {
|
|
67
|
+
throw new Error("RUNNER_RESPONSE_INVALID");
|
|
68
|
+
}
|
|
69
|
+
const read = objectValue(value.operation);
|
|
70
|
+
let operation: OperationRead;
|
|
71
|
+
switch (read.kind) {
|
|
72
|
+
case "genesis": operation = { kind: "genesis" }; break;
|
|
73
|
+
case "unreadable":
|
|
74
|
+
if (typeof read.reason !== "string") throw new Error("RUNNER_RESPONSE_INVALID");
|
|
75
|
+
operation = { kind: "unreadable", reason: read.reason }; break;
|
|
76
|
+
case "observed": operation = { kind: "observed", operation: parseOperation(JSON.stringify(read.operation)) }; break;
|
|
77
|
+
default: throw new Error("RUNNER_RESPONSE_INVALID");
|
|
78
|
+
}
|
|
79
|
+
return { protocolVersion: 1, action: value.action as RunnerRequest["action"],
|
|
80
|
+
exitCode: value.exitCode as RunnerResponse["exitCode"], result: value.result, error: value.error, operation };
|
|
81
|
+
}
|
|
@@ -174,7 +174,7 @@ export interface ProvenanceSummary {
|
|
|
174
174
|
}
|
|
175
175
|
|
|
176
176
|
/**
|
|
177
|
-
* Record one reconcile in the journal (used by
|
|
177
|
+
* Record one reconcile in the journal (used by createRunner before the
|
|
178
178
|
* transaction). The VERSION comes from the release the reconcile drove —
|
|
179
179
|
* the journal records what was attempted, not the caller's claim. Identity
|
|
180
180
|
* defaults to the local operator.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Readable, Writable } from "node:stream";
|
|
2
|
+
import { parseRunnerRequest } from "../protocol/runner.ts";
|
|
3
|
+
import { executeRequest, type RunnerUpgrader } from "./execute.ts";
|
|
4
|
+
|
|
5
|
+
/** Build-time adapter factory; request JSON can never name code to import. */
|
|
6
|
+
export async function serveRunner(
|
|
7
|
+
create: () => Promise<RunnerUpgrader> | RunnerUpgrader,
|
|
8
|
+
input: Readable = process.stdin, output: Writable = process.stdout,
|
|
9
|
+
): Promise<number> {
|
|
10
|
+
try {
|
|
11
|
+
let text = "";
|
|
12
|
+
for await (const chunk of input) {
|
|
13
|
+
text += String(chunk);
|
|
14
|
+
if (text.length > 16_384) throw new Error("RUNNER_REQUEST_TOO_LARGE");
|
|
15
|
+
}
|
|
16
|
+
const request = parseRunnerRequest(JSON.parse(text));
|
|
17
|
+
const response = await executeRequest(await create(), request);
|
|
18
|
+
await new Promise<void>((resolve, reject) => {
|
|
19
|
+
output.write(`${JSON.stringify(response)}\n`, (error) => error ? reject(error) : resolve());
|
|
20
|
+
});
|
|
21
|
+
return response.exitCode;
|
|
22
|
+
} catch (error) {
|
|
23
|
+
output.write(`${JSON.stringify({ protocolVersion: 1, result: "failed", exitCode: 1,
|
|
24
|
+
error: error instanceof Error ? error.message : "runner failed" })}\n`);
|
|
25
|
+
return 1;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Upgrader } from "../upgrader.ts";
|
|
2
|
+
import { OperationReplay, type OperationRead } from "../operation.ts";
|
|
3
|
+
import { systemClock, type Clock } from "../clock.ts";
|
|
4
|
+
import { parseRunnerRequest, type RunnerRequest, type RunnerResponse } from "../protocol/runner.ts";
|
|
5
|
+
|
|
6
|
+
export type RunnerUpgrader = Pick<Upgrader, "upgradeTo" | "recover" | "operation">;
|
|
7
|
+
|
|
8
|
+
async function readOperation(upgrader: RunnerUpgrader): Promise<OperationRead> {
|
|
9
|
+
try { return await upgrader.operation(); }
|
|
10
|
+
catch { return { kind: "unreadable", reason: "cannot read K operation" }; }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function receiptCode(read: OperationRead): RunnerResponse["exitCode"] {
|
|
14
|
+
if (read.kind !== "observed") return read.kind === "genesis" ? 0 : 1;
|
|
15
|
+
switch (read.operation.outcome) {
|
|
16
|
+
case "promoted": case "up-to-date": return 0;
|
|
17
|
+
case "held": return 2;
|
|
18
|
+
case null: return 3;
|
|
19
|
+
default: return 1;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Runs in the disposable helper, never in the resident application's process. */
|
|
24
|
+
export async function executeRequest(
|
|
25
|
+
upgrader: RunnerUpgrader, input: RunnerRequest, clock: Clock = systemClock,
|
|
26
|
+
): Promise<RunnerResponse> {
|
|
27
|
+
const request = parseRunnerRequest(input);
|
|
28
|
+
let result: string;
|
|
29
|
+
let exitCode: RunnerResponse["exitCode"] = 0;
|
|
30
|
+
try {
|
|
31
|
+
switch (request.action) {
|
|
32
|
+
case "status": result = "observed"; break;
|
|
33
|
+
case "recover": await upgrader.recover(request.expected); result = "recovered"; break;
|
|
34
|
+
case "upgrade": {
|
|
35
|
+
const outcome = await upgrader.upgradeTo(request.targetVersion, {
|
|
36
|
+
consented: request.consented,
|
|
37
|
+
operation: { id: request.id, startedAtMs: clock.nowMs(),
|
|
38
|
+
provenance: { who: "local-operator", carrier: "external-runner" } },
|
|
39
|
+
});
|
|
40
|
+
result = outcome.result;
|
|
41
|
+
exitCode = outcome.result === "held" ? 2 : outcome.result === "rolled-back" ? 1 : 0;
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const operation = await readOperation(upgrader);
|
|
46
|
+
if (request.action === "upgrade" && exitCode === 0 &&
|
|
47
|
+
(operation.kind !== "observed" || operation.operation.id !== request.id ||
|
|
48
|
+
operation.operation.targetVersion !== request.targetVersion)) {
|
|
49
|
+
throw new Error("RUNNER_RECEIPT_MISMATCH");
|
|
50
|
+
}
|
|
51
|
+
if (request.action !== "status" && exitCode === 0) {
|
|
52
|
+
exitCode = receiptCode(operation);
|
|
53
|
+
}
|
|
54
|
+
if (operation.kind === "unreadable") exitCode = 1;
|
|
55
|
+
return { protocolVersion: 1, action: request.action, result, exitCode, operation, error: null };
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (error instanceof OperationReplay) {
|
|
58
|
+
const operation: OperationRead = { kind: "observed", operation: error.operation };
|
|
59
|
+
return { protocolVersion: 1, action: request.action, result: "replayed", operation,
|
|
60
|
+
exitCode: receiptCode(operation), error: null };
|
|
61
|
+
}
|
|
62
|
+
const operation = await readOperation(upgrader);
|
|
63
|
+
const recovering = operation.kind === "observed" && operation.operation.outcome === null;
|
|
64
|
+
return { protocolVersion: 1, action: request.action, result: recovering ? "recovery-required" : "failed",
|
|
65
|
+
exitCode: recovering ? 3 : 1, operation,
|
|
66
|
+
error: error instanceof Error ? error.message : "executor failed" };
|
|
67
|
+
}
|
|
68
|
+
}
|