@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,107 @@
1
+ /**
2
+ * Guarded command execution — run a binary/command as a real child
3
+ * process, capturing stdout/stderr and exit code, with a hard timeout
4
+ * kill (a hang-on-quiesce artifact must never hang the harness).
5
+ *
6
+ * This is the process-management primitive the harness needs across the
7
+ * board: the factory teeth run bare artifacts, the black-box --bin mode
8
+ * runs the binary's declared commands with args + env. The fake-host
9
+ * daemon (harness/src/fake-host/) will later standardize the full
10
+ * spawn/probe/pgrep conventions on top of it.
11
+ */
12
+ import { spawn } from "node:child_process";
13
+ import { closeSync, openSync, readSync } from "node:fs";
14
+ import * as path from "node:path";
15
+
16
+ export interface RunResult {
17
+ stdout: string;
18
+ stderr: string;
19
+ /** Exit code, or null if the process was killed by timeout / failed to spawn. */
20
+ code: number | null;
21
+ timedOut: boolean;
22
+ }
23
+
24
+ export interface RunOptions {
25
+ /** Extra env vars (merged over process.env). */
26
+ env?: Record<string, string>;
27
+ /** Hard timeout; the child is SIGKILLed past it. Default 5000ms. */
28
+ timeoutMs?: number;
29
+ }
30
+
31
+ /** PATH with the running node's bin dir first (shebang `env node` works). */
32
+ function nodeFirstPath(): string {
33
+ const nodeBin = path.dirname(process.execPath);
34
+ return `${nodeBin}${path.delimiter}${process.env.PATH ?? ""}`;
35
+ }
36
+
37
+ /**
38
+ * How to spawn an artifact on THIS platform.
39
+ *
40
+ * The factory's demo artifact is a single node script whose first line is a
41
+ * `#!/usr/bin/env node` shebang — one file, one sha256, swappable bytes. On
42
+ * POSIX the kernel honors the shebang, so the artifact is spawned directly
43
+ * (which also keeps the exec-bit reality exercised). Windows has no shebang:
44
+ * spawning the file raises EFTYPE. Routing through the running node binary
45
+ * keeps the ARTIFACT BYTES identical on every platform — only the execution
46
+ * convention forks, never the artifact model.
47
+ *
48
+ * The sniff is content-based (leading `#!` + "node" on the shebang line), not
49
+ * extension-based, because the artifact deliberately has no extension. A real
50
+ * adopter binary on Windows is a PE `.exe` — no `#!` — and spawns directly.
51
+ * An unreadable file falls through to a direct spawn so missing-target
52
+ * failure semantics stay exactly what the caller would have seen.
53
+ */
54
+ export function commandForArtifact(filePath: string, args: string[]): { cmd: string; args: string[] } {
55
+ if (process.platform !== "win32") return { cmd: filePath, args };
56
+ try {
57
+ const fd = openSync(filePath, "r");
58
+ try {
59
+ const head = Buffer.alloc(64);
60
+ const n = readSync(fd, head, 0, head.length, 0);
61
+ const firstLine = head.subarray(0, n).toString("utf8").split("\n")[0] ?? "";
62
+ if (firstLine.startsWith("#!") && firstLine.includes("node")) {
63
+ return { cmd: process.execPath, args: [filePath, ...args] };
64
+ }
65
+ } finally {
66
+ closeSync(fd);
67
+ }
68
+ } catch {
69
+ // unreadable/missing: spawn directly and let the caller see the real error
70
+ }
71
+ return { cmd: filePath, args };
72
+ }
73
+
74
+ export function runCommand(cmd: string, args: string[], opts: RunOptions = {}): Promise<RunResult> {
75
+ const routed = commandForArtifact(cmd, args);
76
+ const child = spawn(routed.cmd, routed.args, {
77
+ env: { ...process.env, PATH: nodeFirstPath(), ...opts.env },
78
+ });
79
+ let stdout = "";
80
+ let stderr = "";
81
+ let timedOut = false;
82
+ child.stdout.on("data", (d: Buffer) => {
83
+ stdout += String(d);
84
+ });
85
+ child.stderr.on("data", (d: Buffer) => {
86
+ stderr += String(d);
87
+ });
88
+ return new Promise<RunResult>((resolve) => {
89
+ const timer = setTimeout(() => {
90
+ timedOut = true;
91
+ child.kill("SIGKILL");
92
+ }, opts.timeoutMs ?? 5000);
93
+ child.on("close", (code) => {
94
+ clearTimeout(timer);
95
+ resolve({ stdout, stderr, code, timedOut });
96
+ });
97
+ child.on("error", () => {
98
+ clearTimeout(timer);
99
+ resolve({ stdout, stderr, code: null, timedOut });
100
+ });
101
+ });
102
+ }
103
+
104
+ /** Run a bare artifact with no args (factory teeth convenience). */
105
+ export function runArtifact(artifactPath: string, timeoutMs = 5000): Promise<RunResult> {
106
+ return runCommand(artifactPath, [], { timeoutMs });
107
+ }
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Black-box `--bin` mode (harness-design §1.75/§1.76).
3
+ *
4
+ * Given a REAL binary (`k-harness --bin ./mytool`), the harness:
5
+ * 1. loads the binary's `k.target.ts` command declarations (REQUIRED —
6
+ * missing = typed FAIL, the harness never guesses commands);
7
+ * 2. starts a fake-server and publishes a target release via
8
+ * artifact-factory (the fixture loop);
9
+ * 3. drives the binary through its declared commands and asserts from
10
+ * OUTSIDE: exit codes, on-disk bytes, next-run version.
11
+ *
12
+ * Contract checks are typed FAILs (CONTRACT_* / BLACKBOX_* codes) — they
13
+ * are mechanical acceptance, NOT tooth-review material (§1.76 ③: commands
14
+ * that don't run / status output that violates the schema fail directly).
15
+ *
16
+ * Config surface: the binary reads its releaseBase from the K_RELEASE_BASE
17
+ * env var (the demo's contract; real apps wire their own config).
18
+ */
19
+ import { promises as fs } from "node:fs";
20
+ import * as path from "node:path";
21
+ import { type Profile } from "./teeth/registry.ts";
22
+ import { createSandbox } from "./scenario/sandbox.ts";
23
+ import { buildReceipt, type CheckResult, type Receipt } from "./receipt.ts";
24
+ import { FakeServer } from "./fake-server/server.ts";
25
+ import { ArtifactFactory } from "./artifact-factory/factory.ts";
26
+ import { runCommand } from "./artifact-factory/run.ts";
27
+ import { sha256Hex } from "./fake-server/manifest.ts";
28
+ import { loadTarget, type BlackBoxTarget } from "./target.ts";
29
+
30
+ export { loadTarget, type BlackBoxTarget, TARGET_FILE } from "./target.ts";
31
+
32
+ export const DEFAULT_TARGET_VERSION = "2.0.0";
33
+ export const RELEASE_BASE_ENV = "K_RELEASE_BASE";
34
+
35
+ /** Run a declared command; returns the raw result (contract checks decide). */
36
+ async function runDeclared(binPath: string, args: string[], env: Record<string, string>) {
37
+ return runCommand(binPath, args, { env });
38
+ }
39
+
40
+ /** sha256 of the binary's own file (the "on-disk" external assertion). */
41
+ async function fileSha256(binPath: string): Promise<string> {
42
+ return sha256Hex(new Uint8Array(await fs.readFile(binPath)));
43
+ }
44
+
45
+ /**
46
+ * §1.76 ① status output schema: {ProcessEvidence, TxnState,
47
+ * ConvergenceReport} — the three existing core types, no new schema.
48
+ * Returns null when valid, else a human-readable reason.
49
+ */
50
+ export function validateStatusOutput(text: string): string | null {
51
+ let obj: unknown;
52
+ try {
53
+ obj = JSON.parse(text);
54
+ } catch {
55
+ return "status output is not valid JSON";
56
+ }
57
+ if (typeof obj !== "object" || obj === null) return "status output must be a JSON object";
58
+ const o = obj as Record<string, unknown>;
59
+
60
+ const evidence = o.ProcessEvidence as Record<string, unknown> | undefined;
61
+ if (!evidence) return "missing ProcessEvidence";
62
+ if (typeof evidence.version !== "string") return "ProcessEvidence.version must be a string";
63
+ if (typeof evidence.pid !== "number") return "ProcessEvidence.pid must be a number";
64
+ if (typeof evidence.startId !== "string") return "ProcessEvidence.startId must be a string";
65
+
66
+ const txn = o.TxnState as Record<string, unknown> | undefined;
67
+ if (!txn) return "missing TxnState";
68
+ const PHASES = [
69
+ "idle",
70
+ "staged",
71
+ "handing-over",
72
+ "running-experiment",
73
+ "readback",
74
+ "promoted",
75
+ "rolled-back",
76
+ ];
77
+ if (typeof txn.phase !== "string" || !PHASES.includes(txn.phase)) {
78
+ return `TxnState.phase must be one of ${PHASES.join("/")}`;
79
+ }
80
+ if (typeof txn.stableVersion !== "string") return "TxnState.stableVersion must be a string";
81
+ if (txn.experimentVersion !== null && typeof txn.experimentVersion !== "string") {
82
+ return "TxnState.experimentVersion must be a string or null";
83
+ }
84
+ if (txn.rollbackReason !== null && typeof txn.rollbackReason !== "string") {
85
+ return "TxnState.rollbackReason must be a string or null";
86
+ }
87
+
88
+ const converge = o.ConvergenceReport as Record<string, unknown> | undefined;
89
+ if (!converge) return "missing ConvergenceReport";
90
+ for (const key of ["binaryAtTarget", "hostLifecycleConverged"] as const) {
91
+ const pred = converge[key] as Record<string, unknown> | null | undefined;
92
+ // null is a legal, meaningful value for hostLifecycleConverged: the app
93
+ // declared no OS-lifecycle surface, so nothing was observed. Distinct from
94
+ // a missing field, which means the status shape itself is wrong.
95
+ if (pred === null && key === "hostLifecycleConverged") continue;
96
+ if (!pred) return `ConvergenceReport.${key} missing`;
97
+ if (typeof pred.passed !== "boolean") return `${key}.passed must be a boolean`;
98
+ if (typeof pred.source !== "string") return `${key}.source must be a string`;
99
+ if (typeof pred.observedAtMs !== "number") return `${key}.observedAtMs must be a number`;
100
+ if (typeof pred.detail !== "object" || pred.detail === null) {
101
+ return `${key}.detail must be an object`;
102
+ }
103
+ }
104
+ return null;
105
+ }
106
+
107
+ export interface BinModeOptions {
108
+ binPath: string;
109
+ profile: Profile;
110
+ targetVersion?: string;
111
+ /** Explicit --target <path>; defaults to <binDir>/k.target.ts. */
112
+ targetPath?: string;
113
+ /** Injectable server for tests that want a pre-seeded release store. */
114
+ server?: FakeServer;
115
+ }
116
+
117
+ /** The three contract checks of bin mode, run sequentially in one sandbox. */
118
+ export async function runBinMode(opts: BinModeOptions): Promise<Receipt> {
119
+ const startedAtMs = Date.now();
120
+ const binPath = path.resolve(opts.binPath);
121
+ const binDir = path.dirname(binPath);
122
+ const targetVersion = opts.targetVersion ?? DEFAULT_TARGET_VERSION;
123
+ const checks: CheckResult[] = [];
124
+
125
+ const sb = await createSandbox({ prefix: "k-harness-bin" });
126
+ const server = opts.server ?? new FakeServer({ storeDir: path.join(sb.dir, "store") });
127
+ const startedServer = opts.server === undefined;
128
+ try {
129
+ // Binary-present guard: a missing binary is a typed FAIL, not a crash.
130
+ let binStat;
131
+ try {
132
+ binStat = await fs.stat(binPath);
133
+ } catch {
134
+ checks.push({
135
+ id: "contract.binary-present",
136
+ status: "fail",
137
+ error: "CONTRACT_BIN_MISSING: binary does not exist",
138
+ durationMs: 0,
139
+ });
140
+ return buildReceipt({
141
+ mode: "bin",
142
+ profile: opts.profile,
143
+ target: binPath,
144
+ checks,
145
+ startedAtMs,
146
+ durationMs: Date.now() - startedAtMs,
147
+ });
148
+ }
149
+ checks.push({ id: "contract.binary-present", status: "pass", error: null, durationMs: 0 });
150
+ // Windows executability is by extension, not file mode (no exec bits);
151
+ // the mode check is POSIX-only.
152
+ if (!(binStat.mode & 0o111) && process.platform !== "win32") {
153
+ checks.push({
154
+ id: "contract.binary-executable",
155
+ status: "fail",
156
+ error: "CONTRACT_BIN_NOT_EXECUTABLE: binary is not executable",
157
+ durationMs: 0,
158
+ });
159
+ return buildReceipt({
160
+ mode: "bin",
161
+ profile: opts.profile,
162
+ target: binPath,
163
+ checks,
164
+ startedAtMs,
165
+ durationMs: Date.now() - startedAtMs,
166
+ });
167
+ }
168
+ checks.push({ id: "contract.binary-executable", status: "pass", error: null, durationMs: 0 });
169
+
170
+ if (startedServer) await server.start();
171
+
172
+ // Target declaration check — REQUIRED, no guessing: a missing or
173
+ // invalid k.target.ts is a typed FAIL that stops the run.
174
+ const tTarget = Date.now();
175
+ let target: BlackBoxTarget;
176
+ try {
177
+ const loadOpts: { binDir: string; explicitPath?: string } = { binDir };
178
+ if (opts.targetPath !== undefined) loadOpts.explicitPath = opts.targetPath;
179
+ target = (await loadTarget(loadOpts)).target;
180
+ checks.push(passCheck("contract.target-declarations", tTarget));
181
+ } catch (err) {
182
+ const msg = (err as Error).message;
183
+ const code = msg.startsWith("BLACKBOX_TARGET_REQUIRED")
184
+ ? "BLACKBOX_TARGET_REQUIRED"
185
+ : "BLACKBOX_TARGET_INVALID";
186
+ checks.push(failCheck("contract.target-declarations", code, msg, tTarget));
187
+ return buildReceipt({
188
+ mode: "bin",
189
+ profile: opts.profile,
190
+ target: binPath,
191
+ checks,
192
+ startedAtMs,
193
+ durationMs: Date.now() - startedAtMs,
194
+ });
195
+ }
196
+
197
+ // Publish the target release (artifact-factory closes the loop).
198
+ const factory = new ArtifactFactory({ cacheDir: path.join(sb.dir, "cache") });
199
+ await factory.makeRelease({
200
+ version: targetVersion,
201
+ behavior: "ok",
202
+ store: server.store,
203
+ });
204
+
205
+ const env = { ...target.env, [RELEASE_BASE_ENV]: server.url };
206
+
207
+ // Contract 1: the declared version command runs and prints a version.
208
+ {
209
+ const t0 = Date.now();
210
+ const r = await runDeclared(binPath, target.version, env);
211
+ if (r.timedOut) {
212
+ checks.push(failCheck("contract.version-command", "CONTRACT_CMD_TIMEOUT", "version command timed out", t0));
213
+ } else if (r.code !== 0) {
214
+ checks.push(failCheck("contract.version-command", "CONTRACT_CMD_EXIT", `version command exited ${r.code}: ${r.stderr.trim()}`, t0));
215
+ } else if (r.stdout.trim() === "") {
216
+ checks.push(failCheck("contract.version-command", "CONTRACT_CMD_EMPTY_OUTPUT", "version command printed nothing", t0));
217
+ } else {
218
+ checks.push(passCheck("contract.version-command", t0));
219
+ }
220
+ }
221
+
222
+ // Contract 2: self upgrade — on-disk bytes change, next run is the target.
223
+ {
224
+ const t0 = Date.now();
225
+ const before = await fileSha256(binPath);
226
+ const r = await runDeclared(binPath, target.selfUpgrade, env);
227
+ const after = await fileSha256(binPath);
228
+ if (r.timedOut) {
229
+ checks.push(failCheck("contract.self-upgrade", "CONTRACT_CMD_TIMEOUT", "self upgrade timed out", t0));
230
+ } else if (r.code !== 0) {
231
+ checks.push(failCheck("contract.self-upgrade", "CONTRACT_CMD_EXIT", `self upgrade exited ${r.code}: ${r.stderr.trim()}`, t0));
232
+ } else if (after === before) {
233
+ checks.push(failCheck("contract.self-upgrade", "CONTRACT_UPGRADE_SELF_UNCHANGED", "binary bytes unchanged after upgrade", t0));
234
+ } else {
235
+ const v = await runDeclared(binPath, target.version, env);
236
+ if (v.timedOut || v.code !== 0) {
237
+ checks.push(failCheck("contract.self-upgrade", "CONTRACT_NEXT_RUN_VERSION", `next-run version command failed (exit ${v.code})`, t0));
238
+ } else if (v.stdout.trim() === targetVersion) {
239
+ checks.push(passCheck("contract.self-upgrade", t0));
240
+ } else {
241
+ checks.push(failCheck("contract.self-upgrade", "CONTRACT_NEXT_RUN_VERSION", `next run reports ${JSON.stringify(v.stdout.trim())}, expected ${targetVersion}`, t0));
242
+ }
243
+ }
244
+ }
245
+
246
+ // Contract 3: status schema — only when the binary declares status.
247
+ if (target.status) {
248
+ const t0 = Date.now();
249
+ const r = await runDeclared(binPath, target.status, env);
250
+ const schemaError = r.timedOut
251
+ ? "status command timed out"
252
+ : r.code === 0
253
+ ? validateStatusOutput(r.stdout)
254
+ : `status exited ${r.code}`;
255
+ if (schemaError) {
256
+ checks.push(failCheck("contract.status-schema", "CONTRACT_STATUS_SCHEMA", schemaError, t0));
257
+ } else {
258
+ checks.push(passCheck("contract.status-schema", t0));
259
+ }
260
+ } else {
261
+ checks.push({ id: "contract.status-schema", status: "na", error: null, durationMs: 0 });
262
+ }
263
+ } finally {
264
+ if (startedServer) await server.stop();
265
+ await sb.teardown();
266
+ }
267
+
268
+ return buildReceipt({
269
+ mode: "bin",
270
+ profile: opts.profile,
271
+ target: binPath,
272
+ checks,
273
+ startedAtMs,
274
+ durationMs: Date.now() - startedAtMs,
275
+ });
276
+ }
277
+
278
+ function passCheck(id: string, startedAtMs: number): CheckResult {
279
+ return { id, status: "pass", error: null, durationMs: Date.now() - startedAtMs };
280
+ }
281
+
282
+ function failCheck(id: string, code: string, message: string, startedAtMs: number): CheckResult {
283
+ return { id, status: "fail", error: `${code}: ${message}`, durationMs: Date.now() - startedAtMs };
284
+ }
@@ -0,0 +1,281 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * k-harness — the harness CLI (harness-design §1.75/§1.76).
4
+ *
5
+ * k-harness --list [--profile <p>] what cases exist, and where to read one
6
+ * k-harness --profile <swap|service> run the tier-filtered teeth
7
+ * k-harness --bin ./mytool [--profile swap] black-box: drive the real
8
+ * binary through its declared
9
+ * commands (k.target.ts REQUIRED)
10
+ * k-harness --adapter <path> [--profile service] run the adopter contract subset
11
+ * against an external adapter
12
+ * k-harness sim [--seed N | --seeds N --start-seed N] seeded deterministic simulation
13
+ * k-harness --json machine-readable receipt
14
+ * k-harness --target-version <v> version served by --bin mode
15
+ * k-harness --target <path> explicit target file for --bin
16
+ * k-harness --help
17
+ *
18
+ * Exit code 0 iff the receipt result is "pass". Receipt: structured, human
19
+ * + CI readable (harness-design §1.3).
20
+ */
21
+ import { runProfile, runAdapter } from "./runner.ts";
22
+ import { allTeeth, teethFor, ALL_CAPABILITIES } from "./teeth/registry.ts";
23
+ import "./teeth/index.ts"; // registers all teeth (side effect)
24
+ import { runBinMode, type BinModeOptions } from "./blackbox.ts";
25
+ import { printReceipt } from "./receipt.ts";
26
+ import type { Profile } from "./teeth/registry.ts";
27
+ import { SMOKE_SEEDS, sequentialSeeds } from "./sim/corpus.ts";
28
+ import { runSimulationBatch, type SimulationBatch } from "./sim/run.ts";
29
+ import { recordFailures } from "./sim/record.ts";
30
+
31
+ const USAGE = `k-harness — K acceptance harness
32
+
33
+ Usage:
34
+ k-harness --list [--profile <swap|service>] [--json]
35
+ k-harness --profile <swap|service> [--json]
36
+ k-harness --bin <path-to-binary> [--profile swap] [--target-version <v>] [--target <path>] [--json]
37
+ k-harness --adapter <path-to-module> [--profile service] [--json]
38
+ k-harness sim [--seed <uint32> | --seeds <count> [--start-seed <uint32>]] [--record-failures <path>] [--json]
39
+ k-harness --help
40
+
41
+ Options:
42
+ --list list the acceptance cases (teeth) and where each is defined
43
+ --profile <p> tier-filtered tooth set (swap | service)
44
+ --bin <path> black-box mode: drive a real binary (k.target.ts REQUIRED)
45
+ --adapter <path> adapter mode: contract subset against an external adapter
46
+ (module default export: (stateDir) => HostDriver)
47
+ sim deterministic simulator; no seed flags = fixed PR smoke corpus
48
+ --seed <n> replay one exact uint32 seed (decimal or 0x...)
49
+ --seeds <n> run n sequential seeds (nightly/extended mode)
50
+ --start-seed <n> first seed for --seeds (default 1)
51
+ --record-failures <path>
52
+ atomically merge failing seeds here (default .k-harness/sim-failures.json)
53
+ --target <path> explicit target file for --bin (default <bin-dir>/k.target.ts)
54
+ --target-version <v> version the --bin fake-server serves (default 2.0.0)
55
+ --json print the receipt as JSON (human lines always printed)
56
+ --help this message
57
+ `;
58
+
59
+ function parseArgs(argv: string[]): {
60
+ list: boolean;
61
+ profile: Profile | null;
62
+ binPath: string | null;
63
+ adapterPath: string | null;
64
+ targetVersion: string | null;
65
+ targetPath: string | null;
66
+ json: boolean;
67
+ sim: boolean;
68
+ seed: number | null;
69
+ seedCount: number | null;
70
+ startSeed: number | null;
71
+ failurePath: string;
72
+ } {
73
+ const out = {
74
+ list: false,
75
+ profile: null as Profile | null,
76
+ binPath: null as string | null,
77
+ adapterPath: null as string | null,
78
+ targetVersion: null as string | null,
79
+ targetPath: null as string | null,
80
+ json: false,
81
+ sim: false,
82
+ seed: null as number | null,
83
+ seedCount: null as number | null,
84
+ startSeed: null as number | null,
85
+ failurePath: ".k-harness/sim-failures.json",
86
+ };
87
+ for (let i = 0; i < argv.length; i++) {
88
+ const arg = argv[i];
89
+ const next = (): string => {
90
+ const v = argv[i + 1];
91
+ if (v === undefined) throw new Error(`flag ${arg} requires a value`);
92
+ i += 1;
93
+ return v;
94
+ };
95
+ switch (arg) {
96
+ case "--profile": {
97
+ const v = next();
98
+ if (v !== "swap" && v !== "service" && v !== "service") {
99
+ throw new Error(`--profile must be swap|service, got ${v}`);
100
+ }
101
+ out.profile = v;
102
+ break;
103
+ }
104
+ case "--bin":
105
+ out.binPath = next();
106
+ break;
107
+ case "--adapter":
108
+ out.adapterPath = next();
109
+ break;
110
+ case "sim":
111
+ out.sim = true;
112
+ break;
113
+ case "--seed":
114
+ out.seed = parseUint32(next(), "--seed");
115
+ break;
116
+ case "--seeds":
117
+ out.seedCount = parsePositiveInteger(next(), "--seeds");
118
+ break;
119
+ case "--start-seed":
120
+ out.startSeed = parseUint32(next(), "--start-seed");
121
+ break;
122
+ case "--record-failures":
123
+ out.failurePath = next();
124
+ break;
125
+ case "--target-version":
126
+ out.targetVersion = next();
127
+ break;
128
+ case "--target":
129
+ out.targetPath = next();
130
+ break;
131
+ case "--list":
132
+ out.list = true;
133
+ break;
134
+ case "--json":
135
+ out.json = true;
136
+ break;
137
+ case "--help":
138
+ return out;
139
+ default:
140
+ throw new Error(`unknown flag ${arg}`);
141
+ }
142
+ }
143
+ return out;
144
+ }
145
+
146
+ function parseUint32(raw: string, flag: string): number {
147
+ const value = /^0x[0-9a-f]+$/i.test(raw) ? Number.parseInt(raw.slice(2), 16) : Number(raw);
148
+ if (!Number.isSafeInteger(value) || value < 0 || value > 0xffffffff) {
149
+ throw new Error(`${flag} must be a uint32, got ${raw}`);
150
+ }
151
+ return value >>> 0;
152
+ }
153
+
154
+ function parsePositiveInteger(raw: string, flag: string): number {
155
+ const value = Number(raw);
156
+ if (!Number.isSafeInteger(value) || value <= 0) {
157
+ throw new Error(`${flag} must be a positive integer, got ${raw}`);
158
+ }
159
+ return value;
160
+ }
161
+
162
+ function printSimulation(batch: SimulationBatch, json: boolean): void {
163
+ if (json) {
164
+ console.log(JSON.stringify(batch, null, 2));
165
+ return;
166
+ }
167
+ console.log(`k-harness sim (${batch.summary.total} seeds)`);
168
+ for (const result of batch.results.filter((item) => item.status === "fail")) {
169
+ console.log(` ✖ seed ${result.seed} — ${result.failure}`);
170
+ console.log(` replay: ${result.replay}`);
171
+ }
172
+ console.log(
173
+ `faults: delay=${batch.coverage.delay} crash-before=${batch.coverage["crash-before"]} crash-after=${batch.coverage["crash-after"]} fail-before=${batch.coverage["fail-before"]} partial-write=${batch.coverage["partial-write"]} reorder-volatile=${batch.coverage["reorder-volatile"]}`,
174
+ );
175
+ console.log(`result: ${batch.result} — ${batch.summary.pass} pass, ${batch.summary.fail} fail`);
176
+ }
177
+
178
+ /**
179
+ * Print the case inventory. Answers "which acceptance cases exist and where
180
+ * do I read one" -- previously only answerable by grepping, because a tooth
181
+ * is deliberately split across three files (registration / body / self-check)
182
+ * and none of them is a list.
183
+ *
184
+ * `where` is the registration site, captured at registration rather than
185
+ * declared, so it cannot drift from the code it points at.
186
+ */
187
+ function listTeeth(profile: Profile | null, json: boolean): void {
188
+ const teeth = profile ? teethFor(profile, ALL_CAPABILITIES) : allTeeth();
189
+ if (json) {
190
+ console.log(JSON.stringify(
191
+ teeth.map((t) => ({
192
+ id: t.id,
193
+ profiles: t.profiles,
194
+ layers: t.layers,
195
+ kind: t.kind.kind,
196
+ mustRed: t.mustRed.map((m) => m.mutate),
197
+ where: t.registeredAt ?? null,
198
+ })),
199
+ null,
200
+ 2,
201
+ ));
202
+ return;
203
+ }
204
+ const scope = profile ? `profile ${profile}` : "all profiles";
205
+ console.log(`k-harness: ${teeth.length} acceptance cases (${scope})\n`);
206
+ for (const t of teeth) {
207
+ console.log(`${t.id}`);
208
+ console.log(` ${t.kind.kind} · layers ${t.layers.join(",")} · profiles ${t.profiles.join(",")}`);
209
+ console.log(` defined at ${t.registeredAt ?? "(unknown)"}`);
210
+ // The must-red list is the honest part: it is what this case claims it
211
+ // would catch. A case with a vague one is a case that proves little.
212
+ for (const m of t.mustRed) console.log(` goes red if: ${m.mutate}`);
213
+ console.log("");
214
+ }
215
+ }
216
+
217
+ async function main(): Promise<number> {
218
+ const raw = process.argv.slice(2);
219
+ if (raw.includes("--help")) {
220
+ console.log(USAGE);
221
+ return 0;
222
+ }
223
+ const args = parseArgs(raw);
224
+ if (args.list) {
225
+ listTeeth(args.profile, args.json);
226
+ return 0;
227
+ }
228
+ if (args.sim) {
229
+ if (args.binPath || args.adapterPath || args.profile !== null) {
230
+ throw new Error("sim is mutually exclusive with --profile, --bin, and --adapter");
231
+ }
232
+ if (args.seed !== null && args.seedCount !== null) {
233
+ throw new Error("--seed and --seeds are mutually exclusive");
234
+ }
235
+ if (args.startSeed !== null && args.seedCount === null) {
236
+ throw new Error("--start-seed requires --seeds");
237
+ }
238
+ let seeds: number[];
239
+ if (args.seed === null) {
240
+ seeds =
241
+ args.seedCount === null
242
+ ? [...SMOKE_SEEDS]
243
+ : sequentialSeeds(args.startSeed ?? 1, args.seedCount);
244
+ } else {
245
+ seeds = [args.seed];
246
+ }
247
+ const batch = await runSimulationBatch(seeds);
248
+ if (batch.result === "fail") {
249
+ await recordFailures(args.failurePath, batch.results);
250
+ }
251
+ printSimulation(batch, args.json);
252
+ return batch.result === "pass" ? 0 : 1;
253
+ }
254
+ if (args.binPath && args.adapterPath) throw new Error("--bin and --adapter are mutually exclusive");
255
+ if (!args.binPath && !args.adapterPath && !args.profile) throw new Error("need one of --list, --profile, --bin, --adapter, sim");
256
+
257
+ const profile: Profile = args.profile ?? (args.binPath ? "swap" : "service");
258
+
259
+ let receipt;
260
+ if (args.binPath) {
261
+ const binOpts: BinModeOptions = { binPath: args.binPath, profile };
262
+ if (args.targetVersion !== null) binOpts.targetVersion = args.targetVersion;
263
+ if (args.targetPath !== null) binOpts.targetPath = args.targetPath;
264
+ receipt = await runBinMode(binOpts);
265
+ } else if (args.adapterPath) {
266
+ receipt = await runAdapter(profile, args.adapterPath);
267
+ } else {
268
+ receipt = await runProfile(profile);
269
+ }
270
+ printReceipt(receipt, args.json);
271
+ return receipt.result === "pass" ? 0 : 1;
272
+ }
273
+
274
+ try {
275
+ const code = await main();
276
+ process.exitCode = code;
277
+ } catch (err) {
278
+ console.error(`k-harness: ${(err as Error).message}`);
279
+ console.error(USAGE);
280
+ process.exitCode = 2;
281
+ }