@botiverse/k-carrier 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +24 -0
  3. package/README.md +62 -0
  4. package/core/src/artifact/README.md +1 -0
  5. package/core/src/artifact/collectStream.ts +49 -0
  6. package/core/src/artifact/download.ts +300 -0
  7. package/core/src/artifact/errors.ts +24 -0
  8. package/core/src/artifact/partialPath.ts +18 -0
  9. package/core/src/artifact/source.ts +59 -0
  10. package/core/src/artifact/staticManifestSource.ts +185 -0
  11. package/core/src/artifact/swap.ts +21 -0
  12. package/core/src/clock.ts +22 -0
  13. package/core/src/converge/lifecycle.ts +114 -0
  14. package/core/src/converge/predicates.ts +57 -0
  15. package/core/src/converge/report.ts +38 -0
  16. package/core/src/createUpgrader.ts +300 -0
  17. package/core/src/drive/README.md +1 -0
  18. package/core/src/index.ts +17 -0
  19. package/core/src/invariants.ts +243 -0
  20. package/core/src/lifecycle/hostAdapter.ts +77 -0
  21. package/core/src/liveness.ts +80 -0
  22. package/core/src/platform/README.md +1 -0
  23. package/core/src/platform/index.ts +11 -0
  24. package/core/src/platform/ops.ts +53 -0
  25. package/core/src/platform/posix.ts +99 -0
  26. package/core/src/platform/windows.ts +73 -0
  27. package/core/src/policy/README.md +1 -0
  28. package/core/src/progress.ts +54 -0
  29. package/core/src/provenance/journal.ts +216 -0
  30. package/core/src/status/report.ts +76 -0
  31. package/core/src/status/reportStore.ts +67 -0
  32. package/core/src/txn/effects.ts +40 -0
  33. package/core/src/txn/engine.ts +275 -0
  34. package/core/src/txn/fileEffects.ts +129 -0
  35. package/core/src/txn/hostCallBudget.ts +28 -0
  36. package/core/src/txn/lock.ts +124 -0
  37. package/core/src/txn/state.ts +77 -0
  38. package/core/src/txn/transitions.ts +49 -0
  39. package/core/src/upgrade/outcome.ts +53 -0
  40. package/core/src/upgrade/retire.ts +44 -0
  41. package/core/src/upgrader.ts +124 -0
  42. package/docs/design-v1.md +246 -0
  43. package/docs/harness-design.md +170 -0
  44. package/docs/integration.md +326 -0
  45. package/docs/prior-art.md +150 -0
  46. package/docs/test-plan.md +100 -0
  47. package/harness/README.md +20 -0
  48. package/harness/src/adapter/probeChecks.ts +93 -0
  49. package/harness/src/adapter/releaseKnob.ts +65 -0
  50. package/harness/src/adapter/serviceChecks.ts +260 -0
  51. package/harness/src/artifact/checks.ts +232 -0
  52. package/harness/src/artifact/downloadHoles.ts +300 -0
  53. package/harness/src/artifact/downloadHolesMutations.ts +170 -0
  54. package/harness/src/artifact/m1.ts +204 -0
  55. package/harness/src/artifact/m1Resume.ts +141 -0
  56. package/harness/src/artifact/m3.ts +296 -0
  57. package/harness/src/artifact/m3Hosts.ts +241 -0
  58. package/harness/src/artifact/m4.ts +201 -0
  59. package/harness/src/artifact/m5.ts +239 -0
  60. package/harness/src/artifact/m6.ts +274 -0
  61. package/harness/src/artifact/m6Drive.ts +296 -0
  62. package/harness/src/artifact/m6DriveMutations.ts +70 -0
  63. package/harness/src/artifact/m6Mutations.ts +136 -0
  64. package/harness/src/artifact/m6Status.ts +224 -0
  65. package/harness/src/artifact-factory/checks.ts +68 -0
  66. package/harness/src/artifact-factory/demo.ts +70 -0
  67. package/harness/src/artifact-factory/factory.ts +134 -0
  68. package/harness/src/artifact-factory/run.ts +107 -0
  69. package/harness/src/blackbox.ts +284 -0
  70. package/harness/src/cli.ts +281 -0
  71. package/harness/src/crash/enumerate.ts +66 -0
  72. package/harness/src/crash/runner.ts +162 -0
  73. package/harness/src/examples/checks.ts +286 -0
  74. package/harness/src/fake-host/checks.ts +188 -0
  75. package/harness/src/fake-host/daemon-entry.ts +39 -0
  76. package/harness/src/fake-host/daemon.ts +159 -0
  77. package/harness/src/fake-host/inproc.ts +256 -0
  78. package/harness/src/fake-server/manifest.ts +68 -0
  79. package/harness/src/fake-server/server.ts +205 -0
  80. package/harness/src/fake-server/store.ts +208 -0
  81. package/harness/src/fixtures/minimal-adapter.ts +108 -0
  82. package/harness/src/fixtures/service-adapter.ts +187 -0
  83. package/harness/src/index.ts +11 -0
  84. package/harness/src/receipt.ts +83 -0
  85. package/harness/src/runner.ts +268 -0
  86. package/harness/src/scenario/processScan.ts +55 -0
  87. package/harness/src/scenario/sandbox.ts +259 -0
  88. package/harness/src/scenario/virtualClock.ts +56 -0
  89. package/harness/src/selfverify/selfVerify.ts +136 -0
  90. package/harness/src/sim/checks.ts +46 -0
  91. package/harness/src/sim/corpus.ts +36 -0
  92. package/harness/src/sim/effectRuntime.ts +113 -0
  93. package/harness/src/sim/error.ts +14 -0
  94. package/harness/src/sim/prng.ts +29 -0
  95. package/harness/src/sim/record.ts +76 -0
  96. package/harness/src/sim/run.ts +148 -0
  97. package/harness/src/sim/scheduler.ts +72 -0
  98. package/harness/src/sim/world.ts +284 -0
  99. package/harness/src/target.ts +86 -0
  100. package/harness/src/targetCheck.ts +47 -0
  101. package/harness/src/teeth/artifact.ts +215 -0
  102. package/harness/src/teeth/artifactDownload.ts +136 -0
  103. package/harness/src/teeth/artifactFactory.ts +41 -0
  104. package/harness/src/teeth/artifactM5.ts +68 -0
  105. package/harness/src/teeth/artifactM6.ts +264 -0
  106. package/harness/src/teeth/blackbox.ts +22 -0
  107. package/harness/src/teeth/checks.ts +278 -0
  108. package/harness/src/teeth/examples.ts +57 -0
  109. package/harness/src/teeth/fakeHost.ts +120 -0
  110. package/harness/src/teeth/index.ts +16 -0
  111. package/harness/src/teeth/m0.ts +135 -0
  112. package/harness/src/teeth/registry.ts +267 -0
  113. package/harness/src/teeth/selfCheck.ts +45 -0
  114. package/harness/src/teeth/sim.ts +61 -0
  115. package/package.json +43 -0
@@ -0,0 +1,188 @@
1
+ /**
2
+ * fake-host acceptance checks — run bodies of the fake-host teeth
3
+ * (registered in teeth/fakeHost.ts), and the contract subset the
4
+ * `k-harness --adapter` mode reuses. Each throws on violation.
5
+ *
6
+ * Every check runs the NORMAL HostAdapter contract with faults OFF; a fault
7
+ * switch turned ON must turn the check red (the tooth "catches" the fault),
8
+ * and with the switch OFF the tooth is green — proving the tooth tests the
9
+ * fault, not the norm (harness-design §1.1).
10
+ *
11
+ * The ledger equivalence (incl. after rollback) is the decidable form of
12
+ * the managed profile's "session preservation": quiesce parks a
13
+ * deterministic counter+checksum ledger durably, resume must reproduce it
14
+ * byte-for-byte.
15
+ *
16
+ * Checks accept an injected host (adapter mode): the ledger checks need
17
+ * the workload driver (doWork/ledger/ledgerState); an adapter without one
18
+ * gets those checks marked na by the runner.
19
+ */
20
+ import assert from "node:assert/strict";
21
+ import * as path from "node:path";
22
+ import { type ToothContext } from "../teeth/registry.ts";
23
+ import { InprocFakeHost, type FakeHostFaults, type HostDriver, type InprocFakeHostOptions } from "./inproc.ts";
24
+ import { VirtualClock } from "../scenario/virtualClock.ts";
25
+
26
+ export interface ContractCheckOptions {
27
+ /** Fault switches for the default InprocFakeHost. Ignored when host is injected. */
28
+ faults?: FakeHostFaults;
29
+ /** Inject a host (adapter mode); default: fresh InprocFakeHost. */
30
+ host?: HostDriver;
31
+ }
32
+
33
+ function hostFor(ctx: ToothContext, opts: ContractCheckOptions): HostDriver {
34
+ if (opts.host) return opts.host;
35
+ const init: InprocFakeHostOptions = { stateDir: path.join(ctx.sandboxDir, "host") };
36
+ if (opts.faults !== undefined) init.faults = opts.faults;
37
+ return new InprocFakeHost(init);
38
+ }
39
+
40
+ /** Whether the host exposes the workload driver the ledger checks need. */
41
+ export function hasWorkloadDriver(host: HostDriver): boolean {
42
+ return (
43
+ typeof host.doWork === "function" &&
44
+ typeof host.ledger === "function" &&
45
+ typeof host.ledgerState === "function"
46
+ );
47
+ }
48
+
49
+ export async function checkLedgerEquivalence(
50
+ ctx: ToothContext,
51
+ opts: ContractCheckOptions = {},
52
+ ): Promise<void> {
53
+ const host = hostFor(ctx, opts);
54
+ if (!hasWorkloadDriver(host)) throw new Error("ledger-equivalence requires a workload driver");
55
+ await host.start("stable");
56
+ await host.doWork!(3);
57
+ await host.quiesce();
58
+ const parked = await host.ledger!();
59
+ // A parked workload must not mutate the session.
60
+ await assert.rejects(host.doWork!(1), /parked/, "quiesced workload must not mutate the session");
61
+ await host.resume();
62
+ assert.deepEqual(
63
+ await host.ledger!(),
64
+ parked,
65
+ "ledger must be byte-identical across quiesce↔resume",
66
+ );
67
+ // Session continues from the parked counter.
68
+ await host.doWork!(1);
69
+ assert.equal(
70
+ (await host.ledgerState!()).counter,
71
+ 4,
72
+ "resumed session must continue from the parked counter",
73
+ );
74
+ }
75
+
76
+ export async function checkLedgerEquivalenceAfterRollback(
77
+ ctx: ToothContext,
78
+ opts: ContractCheckOptions = {},
79
+ ): Promise<void> {
80
+ const host = hostFor(ctx, opts);
81
+ if (!hasWorkloadDriver(host)) {
82
+ throw new Error("ledger-equivalence-after-rollback requires a workload driver");
83
+ }
84
+ await host.start("stable");
85
+ await host.doWork!(2);
86
+ await host.quiesce();
87
+ const parked = await host.ledger!();
88
+ // Handover to the experiment slot, then roll back to stable.
89
+ await host.stop("stable");
90
+ await host.start("experiment");
91
+ const probe = await host.healthProbe();
92
+ assert.equal(probe.version, "2.0.0", "experiment slot must report its own version");
93
+ await host.stop("experiment");
94
+ await host.start("stable");
95
+ await host.resume();
96
+ assert.deepEqual(
97
+ await host.ledger!(),
98
+ parked,
99
+ "rolled-back resume must restore the parked ledger byte-for-byte",
100
+ );
101
+ await host.doWork!(1);
102
+ assert.equal(
103
+ (await host.ledgerState!()).counter,
104
+ 3,
105
+ "session must continue after rolled-back resume",
106
+ );
107
+ }
108
+
109
+ export async function checkQuiesceCompletes(
110
+ ctx: ToothContext,
111
+ opts: ContractCheckOptions = {},
112
+ ): Promise<void> {
113
+ const host = hostFor(ctx, opts);
114
+ await host.start("stable");
115
+ await host.doWork?.(1);
116
+ await host.quiesce(); // fail-on-quiesce throws here -> red
117
+ assert.equal(host.parked, true);
118
+ }
119
+
120
+ export async function checkStopCompletes(
121
+ ctx: ToothContext,
122
+ opts: ContractCheckOptions = {},
123
+ ): Promise<void> {
124
+ const injected = opts.host;
125
+ if (injected) {
126
+ // Adapter mode has no clock-window semantics: stop must simply settle.
127
+ await injected.start("stable");
128
+ await injected.stop("stable");
129
+ return;
130
+ }
131
+ const clock = new VirtualClock();
132
+ const init: InprocFakeHostOptions = {
133
+ stateDir: path.join(ctx.sandboxDir, "host"),
134
+ clock,
135
+ };
136
+ if (opts.faults !== undefined) init.faults = opts.faults;
137
+ const host = new InprocFakeHost(init);
138
+ await host.start("stable");
139
+ let done = false;
140
+ const p = host.stop("stable").then(() => {
141
+ done = true;
142
+ });
143
+ await Promise.resolve(); // let an immediate stop settle
144
+ clock.advance(1000); // the scenario window; a hanging stop stays pending
145
+ if (done) await p; // surface rejection only when it resolved
146
+ assert.equal(done, true, "stop must complete within the window");
147
+ }
148
+
149
+ export async function checkProbeVersionMatchesSlot(
150
+ ctx: ToothContext,
151
+ opts: ContractCheckOptions = {},
152
+ ): Promise<void> {
153
+ const host = hostFor(ctx, opts);
154
+ await host.start("stable");
155
+ const stableProbe = await host.healthProbe();
156
+ assert.equal(stableProbe.version, "1.0.0", "probe must report the stable slot's version");
157
+ await host.stop("stable");
158
+ await host.start("experiment");
159
+ const expProbe = await host.healthProbe();
160
+ assert.equal(expProbe.version, "2.0.0", "probe must report the experiment slot's version");
161
+ }
162
+
163
+ export async function checkProbeBindsCurrentIncarnation(
164
+ ctx: ToothContext,
165
+ opts: ContractCheckOptions = {},
166
+ ): Promise<void> {
167
+ const host = hostFor(ctx, opts);
168
+ await host.start("stable");
169
+ const first = await host.healthProbe();
170
+ await host.stop("stable");
171
+ await host.start("stable"); // new incarnation
172
+ const second = await host.healthProbe();
173
+ assert.notEqual(
174
+ second.startId,
175
+ first.startId,
176
+ "a new incarnation must have a fresh startId (anti-fake-green)",
177
+ );
178
+ assert.equal(second.startId, host.startId, "probe must report the CURRENT startId");
179
+ }
180
+
181
+ export async function checkStartCompletes(
182
+ ctx: ToothContext,
183
+ opts: ContractCheckOptions = {},
184
+ ): Promise<void> {
185
+ const host = hostFor(ctx, opts);
186
+ await host.start("stable"); // crash-during-start throws here -> red
187
+ assert.equal(host.running, "stable");
188
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Real fake-daemon process entry (harness-design §1.1).
3
+ *
4
+ * Spawned as an actual OS process so kill -9, dual-run detection and probe
5
+ * liveness are tested against process reality — mocked crashes test nothing.
6
+ * Writes a pid/startId file for out-of-band liveness inspection and answers
7
+ * probes over a tiny line protocol on stdin/stdout.
8
+ */
9
+ import { writeFileSync } from "node:fs";
10
+
11
+ const version = process.env["K_FAKE_VERSION"] ?? "0.0.0";
12
+ const slot = process.env["K_FAKE_SLOT"] ?? "stable";
13
+ const evidenceFile = process.env["K_FAKE_EVIDENCE_FILE"];
14
+ // startId must be unique per incarnation: pid alone is reusable by the OS.
15
+ const startId = `${process.pid}-${process.hrtime.bigint().toString(36)}`;
16
+
17
+ if (evidenceFile !== undefined) {
18
+ writeFileSync(evidenceFile, JSON.stringify({ version, slot, pid: process.pid, startId }));
19
+ }
20
+
21
+ process.stdout.write(`ready ${JSON.stringify({ version, slot, pid: process.pid, startId })}\n`);
22
+
23
+ process.stdin.on("data", (chunk: Buffer) => {
24
+ for (const line of chunk.toString("utf8").split("\n")) {
25
+ const cmd = line.trim();
26
+ if (cmd === "probe") {
27
+ process.stdout.write(`evidence ${JSON.stringify({ version, pid: process.pid, startId })}\n`);
28
+ } else if (cmd === "exit") {
29
+ process.exit(0);
30
+ } else if (cmd === "hang") {
31
+ // Simulates a host that never completes an operation; the harness must
32
+ // time out and kill rather than wait forever.
33
+ setInterval(() => {}, 1 << 30);
34
+ }
35
+ }
36
+ });
37
+
38
+ // Keep the process alive until told otherwise.
39
+ setInterval(() => {}, 1 << 30);
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Real-process fake host (harness-design §1.1, second form).
3
+ *
4
+ * Spawns daemon-entry.ts as a genuine OS process, so the properties that
5
+ * only exist in process reality are actually exercised:
6
+ * - kill -9 (SIGKILL) really terminates; recovery faces real leftovers
7
+ * - dual-run is detected by asking the OS, not by reading our own bookkeeping
8
+ * - probe liveness is bound to a real pid + per-incarnation startId
9
+ * - "verify dead" means the OS says gone, not that we sent a signal
10
+ */
11
+ import { spawn, type ChildProcess } from "node:child_process";
12
+ import path from "node:path";
13
+ import type { HostAdapter, ProcessEvidence, Slot } from "../../../core/src/lifecycle/hostAdapter.ts";
14
+
15
+ const ENTRY = path.join(import.meta.dirname, "daemon-entry.ts");
16
+
17
+ interface Incarnation {
18
+ child: ChildProcess;
19
+ slot: Slot;
20
+ version: string;
21
+ pid: number;
22
+ startId: string;
23
+ }
24
+
25
+ export interface DaemonHostOptions {
26
+ /** Version each slot's "binary" reports when started. */
27
+ slotVersions: Record<Slot, string | null>;
28
+ /** Milliseconds before a start/probe is considered hung. */
29
+ timeoutMs?: number;
30
+ /** Extra env for spawned incarnations (e.g. the sandbox marker). */
31
+ env?: Record<string, string>;
32
+ /** Extra trailing argv for spawned incarnations (e.g. `argvMarker()` —
33
+ * the command-line channel Windows' marker scan needs; daemon-entry
34
+ * ignores unknown argv). */
35
+ markerArgs?: string[];
36
+ }
37
+
38
+ /** Does the OS still know this pid? (Signal 0 = existence check, no delivery.) */
39
+ export function processAlive(pid: number): boolean {
40
+ try {
41
+ process.kill(pid, 0);
42
+ return true;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ export class DaemonFakeHost implements HostAdapter {
49
+ private current: Incarnation | null = null;
50
+ private readonly opts: DaemonHostOptions;
51
+ /** Every pid we ever started, for leak/dual-run auditing. */
52
+ readonly startedPids: number[] = [];
53
+
54
+ constructor(opts: DaemonHostOptions) {
55
+ this.opts = opts;
56
+ }
57
+
58
+ async quiesce(): Promise<void> {
59
+ // No workload ledger in the process tier; the in-proc host covers that.
60
+ }
61
+
62
+ async stop(_slot: Slot): Promise<void> {
63
+ const inc = this.current;
64
+ if (!inc) return;
65
+ this.current = null;
66
+ inc.child.kill("SIGKILL");
67
+ await this.waitUntilDead(inc.pid);
68
+ }
69
+
70
+ async start(slot: Slot): Promise<void> {
71
+ const version = this.opts.slotVersions[slot];
72
+ if (version === null) throw new Error(`slot ${slot} holds no version to start`);
73
+ const child = spawn(process.execPath, [ENTRY, ...(this.opts.markerArgs ?? [])], {
74
+ env: { ...process.env, ...this.opts.env, K_FAKE_VERSION: version, K_FAKE_SLOT: slot },
75
+ stdio: ["pipe", "pipe", "ignore"],
76
+ });
77
+ const ready = await this.readLine(child, "ready", this.opts.timeoutMs ?? 5000);
78
+ const info = JSON.parse(ready) as { pid: number; startId: string };
79
+ this.current = { child, slot, version, pid: info.pid, startId: info.startId };
80
+ this.startedPids.push(info.pid);
81
+ }
82
+
83
+ async healthProbe(): Promise<ProcessEvidence> {
84
+ const inc = this.current;
85
+ if (!inc) throw new Error("no live incarnation to probe");
86
+ inc.child.stdin?.write("probe\n");
87
+ const line = await this.readLine(inc.child, "evidence", this.opts.timeoutMs ?? 5000);
88
+ return JSON.parse(line) as ProcessEvidence;
89
+ }
90
+
91
+ async resume(): Promise<void> {}
92
+
93
+ /** SIGKILL the live incarnation without any orderly shutdown (crash sim). */
94
+ async crash(): Promise<number | null> {
95
+ const inc = this.current;
96
+ if (!inc) return null;
97
+ this.current = null;
98
+ inc.child.kill("SIGKILL");
99
+ await this.waitUntilDead(inc.pid);
100
+ return inc.pid;
101
+ }
102
+
103
+ /** Teardown contract: kill everything we started and PROVE it is gone. */
104
+ async teardownVerifyDead(): Promise<{ killed: number[]; survivors: number[] }> {
105
+ await this.crash();
106
+ const survivors: number[] = [];
107
+ for (const pid of this.startedPids) {
108
+ if (processAlive(pid)) {
109
+ try {
110
+ process.kill(pid, "SIGKILL");
111
+ } catch {
112
+ // already gone between the check and the signal
113
+ }
114
+ await this.waitUntilDead(pid, 2000).catch(() => survivors.push(pid));
115
+ }
116
+ }
117
+ return { killed: this.startedPids.filter((p) => !survivors.includes(p)), survivors };
118
+ }
119
+
120
+ /** Pids we started that the OS still reports alive (dual-run auditing). */
121
+ livePids(): number[] {
122
+ return this.startedPids.filter((pid) => processAlive(pid));
123
+ }
124
+
125
+ private async waitUntilDead(pid: number, timeoutMs = 5000): Promise<void> {
126
+ const deadline = Date.now() + timeoutMs;
127
+ while (processAlive(pid)) {
128
+ if (Date.now() > deadline) throw new Error(`pid ${pid} still alive after ${timeoutMs}ms`);
129
+ await new Promise((r) => {
130
+ setTimeout(r, 10);
131
+ });
132
+ }
133
+ }
134
+
135
+ private readLine(child: ChildProcess, prefix: string, timeoutMs: number): Promise<string> {
136
+ return new Promise((resolve, reject) => {
137
+ let buffer = "";
138
+ const timer = setTimeout(() => {
139
+ cleanup();
140
+ reject(new Error(`timed out waiting for "${prefix}" from fake daemon`));
141
+ }, timeoutMs);
142
+ const onData = (chunk: Buffer): void => {
143
+ buffer += chunk.toString("utf8");
144
+ for (const line of buffer.split("\n")) {
145
+ if (line.startsWith(`${prefix} `)) {
146
+ cleanup();
147
+ resolve(line.slice(prefix.length + 1));
148
+ return;
149
+ }
150
+ }
151
+ };
152
+ const cleanup = (): void => {
153
+ clearTimeout(timer);
154
+ child.stdout?.off("data", onData);
155
+ };
156
+ child.stdout?.on("data", onData);
157
+ });
158
+ }
159
+ }
@@ -0,0 +1,256 @@
1
+ /**
2
+ * inproc fake-host (harness-design §1.1 first half) — an IN-PROCESS
3
+ * HostAdapter implementation with per-method fault-injection switches and
4
+ * a deterministic virtual-load ledger.
5
+ *
6
+ * Virtual load ledger: the fake host simulates a hosted "session" as a
7
+ * deterministic state — a counter + rolling sha256 checksum — persisted
8
+ * to `<stateDir>/ledger.bin`. quiesce() durably parks it (fsync), resume()
9
+ * restores it; `quiesce↔resume` equivalence is the byte-for-byte ledger
10
+ * comparison, including resume after rollback. This is the decidable form
11
+ * of the managed profile's "session preservation" claim.
12
+ *
13
+ * Fault switches (per method, harness-design §1.1): fail-on-quiesce /
14
+ * hang-on-stop / wrong-version-probe / stale-startId-probe /
15
+ * crash-during-start. Teeth run the NORMAL contract with switches off
16
+ * (green) and must go RED with a switch on — proving the tooth tests the
17
+ * fault, not the norm.
18
+ *
19
+ * Time: the host touches time ONLY through the injected Clock (core's
20
+ * clock seam); hang-on-stop is a clock-scheduled far-future resolution so
21
+ * a scenario VirtualClock can detect it without real sleeps.
22
+ */
23
+ import { promises as fs } from "node:fs";
24
+ import * as path from "node:path";
25
+ import { createHash } from "node:crypto";
26
+ import { type Clock, systemClock } from "../../../core/src/clock.ts";
27
+ import type { HostAdapter, ProcessEvidence, Slot } from "../../../core/src/lifecycle/hostAdapter.ts";
28
+
29
+ export interface FakeHostFaults {
30
+ /** quiesce() throws. */
31
+ failOnQuiesce?: boolean;
32
+ /** stop() never completes (clock-scheduled far-future). */
33
+ hangOnStop?: boolean;
34
+ /** healthProbe() reports a version different from the running slot's. */
35
+ wrongVersionProbe?: boolean;
36
+ /** healthProbe() reports a PREVIOUS incarnation's startId (fake-green). */
37
+ staleStartIdProbe?: boolean;
38
+ /** start() throws. */
39
+ crashDuringStart?: boolean;
40
+ }
41
+
42
+ export interface InprocFakeHostOptions {
43
+ /** Where the virtual-load ledger lives (inside the scenario sandbox). */
44
+ stateDir: string;
45
+ /** Clock seam; default real system clock. Scenarios inject VirtualClock. */
46
+ clock?: Clock;
47
+ faults?: FakeHostFaults;
48
+ /** Version of the binaries in each slot. */
49
+ versions?: { stable: string; experiment: string };
50
+ }
51
+
52
+ export const LEDGER_FILE = "ledger.bin";
53
+ const LEDGER_PREFIX = "k-ledger-v1";
54
+ const COUNTER_BYTES = 8;
55
+ const CHECKSUM_BYTES = 32;
56
+ const HANG_HORIZON_MS = 1e12;
57
+
58
+ export interface LedgerState {
59
+ counter: number;
60
+ checksum: Uint8Array;
61
+ }
62
+
63
+ /**
64
+ * The harness-side driving surface a fake host (or adopter adapter, in
65
+ * --adapter mode) can expose beyond the plain HostAdapter contract: the
66
+ * lifecycle getters the contract checks assert on, and the workload
67
+ * driver the ledger-equivalence checks need. An adopter adapter that
68
+ * cannot drive a deterministic workload simply omits doWork/ledger and
69
+ * the ledger checks are marked na.
70
+ */
71
+ export interface HostDriver extends HostAdapter {
72
+ /** Slot currently running, or null. */
73
+ readonly running: Slot | null;
74
+ /** Whether the workload is currently parked (quiesced). */
75
+ readonly parked: boolean;
76
+ /** StartId of the current incarnation (evidence binding key). */
77
+ readonly startId: string | null;
78
+ /** Simulate n units of hosted session activity (deterministic). */
79
+ doWork?(n: number): Promise<void>;
80
+ /** Current ledger bytes (durability view). */
81
+ ledger?(): Promise<Uint8Array>;
82
+ /** Parsed ledger state. */
83
+ ledgerState?(): Promise<LedgerState>;
84
+ }
85
+
86
+ export class InprocFakeHost implements HostDriver {
87
+ private readonly stateDir: string;
88
+ private readonly clock: Clock;
89
+ private readonly faults: FakeHostFaults;
90
+ private readonly versionBySlot: { stable: string; experiment: string };
91
+
92
+ private counter = 0;
93
+ private checksum: Uint8Array;
94
+ private runningSlot: Slot | null = null;
95
+ private quiesced = false;
96
+ private incarnation = 0;
97
+ private startIds: string[] = [];
98
+ private parkedLedger: Uint8Array | null = null;
99
+
100
+ constructor(opts: InprocFakeHostOptions) {
101
+ this.stateDir = opts.stateDir;
102
+ this.clock = opts.clock ?? systemClock;
103
+ this.faults = opts.faults ?? {};
104
+ this.versionBySlot = opts.versions ?? { stable: "1.0.0", experiment: "2.0.0" };
105
+ this.checksum = createHash("sha256").update(LEDGER_PREFIX).digest();
106
+ }
107
+
108
+ /** Slot currently running, or null. */
109
+ get running(): Slot | null {
110
+ return this.runningSlot;
111
+ }
112
+
113
+ /** Whether the workload is currently parked (quiesced). */
114
+ get parked(): boolean {
115
+ return this.quiesced;
116
+ }
117
+
118
+ /** StartId of the current incarnation (evidence binding key). */
119
+ get startId(): string | null {
120
+ const current = this.startIds.at(-1);
121
+ return current ?? null;
122
+ }
123
+
124
+ // -------------------------------------------------------------------------
125
+ // HostAdapter contract
126
+ // -------------------------------------------------------------------------
127
+
128
+ async quiesce(): Promise<void> {
129
+ if (this.faults.failOnQuiesce) throw new Error("fail-on-quiesce");
130
+ if (this.quiesced) return; // idempotent
131
+ if (!this.runningSlot) throw new Error("quiesce: no running slot");
132
+ this.quiesced = true;
133
+ // Durable park: flush the ledger (fsync) and keep the parked bytes.
134
+ await this.writeLedger();
135
+ this.parkedLedger = await this.readLedger();
136
+ }
137
+
138
+ async stop(slot: Slot): Promise<void> {
139
+ if (this.faults.hangOnStop) {
140
+ await new Promise<void>((resolve) => {
141
+ this.clock.after(HANG_HORIZON_MS, resolve);
142
+ });
143
+ return;
144
+ }
145
+ if (this.runningSlot !== slot) throw new Error(`stop: ${slot} is not the running slot`);
146
+ this.runningSlot = null;
147
+ }
148
+
149
+ async start(slot: Slot): Promise<void> {
150
+ if (this.faults.crashDuringStart) throw new Error("crash-during-start");
151
+ if (this.runningSlot !== null) throw new Error(`start: ${this.runningSlot} already running`);
152
+ this.runningSlot = slot;
153
+ this.incarnation += 1;
154
+ this.startIds.push(`inc:${this.incarnation}:${this.clock.nowMs()}`);
155
+ }
156
+
157
+ async healthProbe(): Promise<ProcessEvidence> {
158
+ const runningSlot = this.runningSlot;
159
+ if (!runningSlot) throw new Error("probe: no running slot");
160
+ const currentStartId = this.startIds.at(-1);
161
+ if (!currentStartId) throw new Error("probe: no startId for running incarnation");
162
+ const version = this.faults.wrongVersionProbe
163
+ ? "9.9.9"
164
+ : this.versionBySlot[runningSlot];
165
+ let startId = currentStartId;
166
+ if (this.faults.staleStartIdProbe) {
167
+ const previous = this.startIds.at(-2);
168
+ startId = previous ?? "stale-000";
169
+ }
170
+ return { version, pid: process.pid, startId };
171
+ }
172
+
173
+ async resume(): Promise<void> {
174
+ if (!this.quiesced) throw new Error("resume: not quiesced");
175
+ const parked = this.parkedLedger;
176
+ if (!parked) throw new Error("resume: no parked ledger");
177
+ const state = this.parseLedger(parked);
178
+ this.counter = state.counter;
179
+ this.checksum = state.checksum;
180
+ this.quiesced = false;
181
+ // Rewriting from the restored state must reproduce the parked bytes.
182
+ await this.writeLedger();
183
+ }
184
+
185
+ // -------------------------------------------------------------------------
186
+ // Harness-side workload driver
187
+ // -------------------------------------------------------------------------
188
+
189
+ /**
190
+ * Simulate `n` units of hosted session activity: counter increments and
191
+ * the checksum chains deterministically. Persisted to the ledger file.
192
+ * Refuses while parked — a quiesced workload must not mutate the session.
193
+ */
194
+ async doWork(n: number): Promise<void> {
195
+ if (this.quiesced) throw new Error("workload is parked (quiesced)");
196
+ if (!this.runningSlot) throw new Error("doWork: no running slot");
197
+ for (let i = 0; i < n; i++) {
198
+ this.counter += 1;
199
+ this.checksum = createHash("sha256").update(be64(this.counter)).update(this.checksum).digest();
200
+ }
201
+ await this.writeLedger();
202
+ }
203
+
204
+ /** Current ledger file bytes (what a durability check would read). */
205
+ async ledger(): Promise<Uint8Array> {
206
+ return this.readLedger();
207
+ }
208
+
209
+ /** Parsed ledger state (counter + checksum). */
210
+ async ledgerState(): Promise<LedgerState> {
211
+ return this.parseLedger(await this.readLedger());
212
+ }
213
+
214
+ // -------------------------------------------------------------------------
215
+ // Ledger persistence
216
+ // -------------------------------------------------------------------------
217
+
218
+ private ledgerBytes(): Uint8Array {
219
+ const out = new Uint8Array(COUNTER_BYTES + CHECKSUM_BYTES);
220
+ new DataView(out.buffer).setBigUint64(0, BigInt(this.counter), false);
221
+ out.set(this.checksum, COUNTER_BYTES);
222
+ return out;
223
+ }
224
+
225
+ private parseLedger(bytes: Uint8Array): LedgerState {
226
+ if (bytes.length !== COUNTER_BYTES + CHECKSUM_BYTES) {
227
+ throw new Error(`ledger corrupted: ${bytes.length} bytes`);
228
+ }
229
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
230
+ const counter = Number(view.getBigUint64(0, false));
231
+ const checksum = bytes.slice(COUNTER_BYTES);
232
+ return { counter, checksum };
233
+ }
234
+
235
+ private async writeLedger(): Promise<void> {
236
+ await fs.mkdir(this.stateDir, { recursive: true });
237
+ const fh = await fs.open(path.join(this.stateDir, LEDGER_FILE), "w");
238
+ try {
239
+ await fh.writeFile(this.ledgerBytes());
240
+ await fh.sync(); // durable park at quiesce
241
+ } finally {
242
+ await fh.close();
243
+ }
244
+ }
245
+
246
+ private async readLedger(): Promise<Uint8Array> {
247
+ const raw = await fs.readFile(path.join(this.stateDir, LEDGER_FILE));
248
+ return new Uint8Array(raw);
249
+ }
250
+ }
251
+
252
+ function be64(n: number): Uint8Array {
253
+ const out = new Uint8Array(8);
254
+ new DataView(out.buffer).setBigUint64(0, BigInt(n), false);
255
+ return out;
256
+ }