@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,187 @@
1
+ /**
2
+ * Service-tier adopter adapter fixture — a STANDALONE implementation of
3
+ * the five HostAdapter responsibilities with REAL process semantics, used
4
+ * to exercise `k-harness --adapter` against the service-profile teeth
5
+ * (upgrade / rollback / lifecycle-converged).
6
+ *
7
+ * It is intentionally NOT the demo's host: a real adopter brings its own
8
+ * host semantics. This one models the computer-shaped host:
9
+ * - quiesce()/resume(): no-op (computer does not preserve sessions);
10
+ * - stop(): SIGKILL the running successor child and VERIFY it is gone
11
+ * (signal-sent ≠ dead);
12
+ * - start(): spawn the slot artifact as a real child process and wait
13
+ * for its ready line — the EVIDENCE. Only healthProbe() can say the
14
+ * successor is running;
15
+ * - healthProbe(): ask the LIVE process (the child's own ready/evidence
16
+ * line protocol) — never reads files, never computes its own version;
17
+ * - lifecycle surface: start() registers the OS auto-start entry
18
+ * (the app's SSOT) which the ReadbackSurface reads back.
19
+ *
20
+ * Default export contract: `(stateDir: string) => HostDriver`; the
21
+ * lifecycle surfaces come from the same factory's `lifecycleSurfaces`.
22
+ */
23
+ import { spawn, type ChildProcess } from "node:child_process";
24
+ import { promises as fs } from "node:fs";
25
+ import * as path from "node:path";
26
+ import type { HostAdapter, ProcessEvidence, Slot } from "../../../core/src/lifecycle/hostAdapter.ts";
27
+ import type { ReadbackSurface } from "../../../core/src/converge/predicates.ts";
28
+ import type { HostDriver, LedgerState } from "../fake-host/inproc.ts";
29
+ import { slotArtifactPath } from "../../../core/src/txn/fileEffects.ts";
30
+
31
+ interface Successor {
32
+ child: ChildProcess;
33
+ version: string;
34
+ pid: number;
35
+ startId: string;
36
+ }
37
+
38
+ function readLine(child: ChildProcess, prefix: string, timeoutMs: number): Promise<string> {
39
+ return new Promise((resolve, reject) => {
40
+ let buffer = "";
41
+ const timer = setTimeout(() => {
42
+ cleanup();
43
+ reject(new Error(`timed out waiting for "${prefix}" from successor`));
44
+ }, timeoutMs);
45
+ const onData = (chunk: Buffer): void => {
46
+ buffer += chunk.toString("utf8");
47
+ // Only COMPLETE lines (newline-terminated) are protocol messages.
48
+ for (;;) {
49
+ const nl = buffer.indexOf("\n");
50
+ if (nl === -1) break;
51
+ const line = buffer.slice(0, nl);
52
+ buffer = buffer.slice(nl + 1);
53
+ if (line.startsWith(`${prefix} `)) {
54
+ cleanup();
55
+ resolve(line.slice(prefix.length + 1));
56
+ return;
57
+ }
58
+ }
59
+ };
60
+ const cleanup = (): void => {
61
+ clearTimeout(timer);
62
+ child.stdout?.off("data", onData);
63
+ };
64
+ child.stdout?.on("data", onData);
65
+ });
66
+ }
67
+
68
+ async function tryReady(child: ChildProcess, timeoutMs: number): Promise<Successor | null> {
69
+ try {
70
+ const line = await readLine(child, "ready", timeoutMs);
71
+ const info = JSON.parse(line) as { version: string; pid: number; startId: string };
72
+ return { child, ...info };
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ export function createServiceAdapter(stateDir: string): HostDriver & {
79
+ lifecycleSurfaces(): ReadbackSurface[];
80
+ } {
81
+ let successor: Successor | null = null;
82
+ let runningSlot: Slot | null = null;
83
+
84
+ const autostartPath = path.join(stateDir, "lifecycle", "autostart.json");
85
+ const writeAutostart = async (slot: Slot): Promise<void> => {
86
+ await fs.mkdir(path.dirname(autostartPath), { recursive: true });
87
+ await fs.writeFile(
88
+ autostartPath,
89
+ JSON.stringify({ enabled: true, target: slotArtifactPath(stateDir, slot) }),
90
+ );
91
+ };
92
+
93
+ const host: HostAdapter = {
94
+ async quiesce() {
95
+ // computer does not preserve sessions: nothing to park
96
+ },
97
+ async stop() {
98
+ runningSlot = null;
99
+ const cur = successor;
100
+ successor = null;
101
+ if (cur) {
102
+ try {
103
+ process.kill(cur.pid, "SIGKILL");
104
+ } catch {
105
+ // already gone
106
+ }
107
+ const deadline = Date.now() + 5000;
108
+ while (processAlive(cur.pid)) {
109
+ if (Date.now() > deadline) throw new Error(`pid ${cur.pid} still alive`);
110
+ await new Promise((r) => {
111
+ setTimeout(r, 10);
112
+ });
113
+ }
114
+ }
115
+ },
116
+ async start(slot: Slot) {
117
+ runningSlot = slot;
118
+ const artifact = slotArtifactPath(stateDir, slot);
119
+ const child = spawn(process.execPath, [artifact], {
120
+ env: { ...process.env, K_STATE_DIR: stateDir },
121
+ stdio: ["pipe", "pipe", "ignore"],
122
+ });
123
+ const info = await tryReady(child, 5000);
124
+ successor = info;
125
+ if (info !== null) await writeAutostart(slot);
126
+ },
127
+ async healthProbe(): Promise<ProcessEvidence> {
128
+ const cur = successor;
129
+ if (!cur || cur.child.exitCode !== null) throw new Error("no live successor to probe");
130
+ cur.child.stdin?.write("probe\n");
131
+ const line = await readLine(cur.child, "evidence", 5000);
132
+ return JSON.parse(line) as ProcessEvidence;
133
+ },
134
+ async resume() {
135
+ // nothing parked in quiesce
136
+ },
137
+ };
138
+
139
+ return {
140
+ ...host,
141
+ get running(): Slot | null {
142
+ return runningSlot;
143
+ },
144
+ get parked(): boolean {
145
+ return false;
146
+ },
147
+ get startId(): string | null {
148
+ return successor?.startId ?? null;
149
+ },
150
+ async ledger(): Promise<Uint8Array> {
151
+ throw new Error("service adapter has no workload ledger");
152
+ },
153
+ async ledgerState(): Promise<LedgerState> {
154
+ throw new Error("service adapter has no workload ledger");
155
+ },
156
+ lifecycleSurfaces(): ReadbackSurface[] {
157
+ return [
158
+ {
159
+ id: "adapter.autostart",
160
+ read: async () => {
161
+ try {
162
+ const raw = await fs.readFile(autostartPath, "utf8");
163
+ const parsed = JSON.parse(raw) as { enabled: boolean; target: string };
164
+ return {
165
+ value: parsed.enabled ? parsed.target : "",
166
+ source: "adapter.autostart",
167
+ };
168
+ } catch {
169
+ return { value: "", source: "adapter.autostart" };
170
+ }
171
+ },
172
+ },
173
+ ];
174
+ },
175
+ };
176
+ }
177
+
178
+ function processAlive(pid: number): boolean {
179
+ try {
180
+ process.kill(pid, 0);
181
+ return true;
182
+ } catch {
183
+ return false;
184
+ }
185
+ }
186
+
187
+ export default createServiceAdapter;
@@ -0,0 +1,11 @@
1
+ // Public API of @botiverse/k-carrier/harness — the acceptance harness.
2
+ //
3
+ // This barrel is the single supported entry point for the harness; deep
4
+ // imports into ./harness/src/** are internal and not part of the public API.
5
+ // The harness is also runnable as the `k-harness` bin.
6
+
7
+ // The black-box target contract an adopter ships (k.target.ts) and its loader.
8
+ export * from "./target.ts";
9
+
10
+ // The in-process fake host used to exercise an adapter deterministically.
11
+ export * from "./fake-host/inproc.ts";
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Structured receipt — the k-harness output contract (harness-design §1.3:
3
+ * "跑完输出结构化 receipt(给 CI 和人两用)"). Human-readable lines on
4
+ * stdout, full JSON on request (`--json`); exit code = 0 iff result pass.
5
+ */
6
+ import type { Profile } from "./teeth/registry.ts";
7
+
8
+ export type CheckStatus = "pass" | "fail" | "na";
9
+
10
+ export interface CheckResult {
11
+ id: string;
12
+ status: CheckStatus;
13
+ /** Typed failure (`CODE: message`) or null on pass/na. */
14
+ error: string | null;
15
+ durationMs: number;
16
+ }
17
+
18
+ export type Mode = "profile" | "bin" | "adapter";
19
+
20
+ export interface Receipt {
21
+ mode: Mode;
22
+ profile: Profile;
23
+ /** The binary/adapter under test, or null for profile mode. */
24
+ target: string | null;
25
+ startedAtMs: number;
26
+ durationMs: number;
27
+ checks: CheckResult[];
28
+ summary: { pass: number; fail: number; na: number; total: number };
29
+ result: "pass" | "fail";
30
+ }
31
+
32
+ export function buildReceipt(opts: {
33
+ mode: Mode;
34
+ profile: Profile;
35
+ target: string | null;
36
+ checks: CheckResult[];
37
+ startedAtMs?: number;
38
+ durationMs?: number;
39
+ }): Receipt {
40
+ const pass = opts.checks.filter((c) => c.status === "pass").length;
41
+ const fail = opts.checks.filter((c) => c.status === "fail").length;
42
+ const na = opts.checks.filter((c) => c.status === "na").length;
43
+ // Fail-closed on BOTH shapes of nothing:
44
+ // - an empty check list ("0 checks" ≠ "all passed"), and
45
+ // - a list where nothing actually executed. A receipt of all-`na` used to
46
+ // report pass, which is the empty suite wearing a non-empty list: the
47
+ // count looks healthy and not one thing was verified.
48
+ // A green therefore requires at least one check that genuinely passed.
49
+ const result: "pass" | "fail" =
50
+ fail > 0 || opts.checks.length === 0 || pass === 0 ? "fail" : "pass";
51
+ return {
52
+ mode: opts.mode,
53
+ profile: opts.profile,
54
+ target: opts.target,
55
+ startedAtMs: opts.startedAtMs ?? Date.now(),
56
+ durationMs: opts.durationMs ?? 0,
57
+ checks: opts.checks,
58
+ summary: { pass, fail, na, total: opts.checks.length },
59
+ result,
60
+ };
61
+ }
62
+
63
+ const MARK: Record<CheckStatus, string> = { pass: "✔", fail: "✖", na: "–" };
64
+
65
+ /** Human-readable lines (also on --json, so the receipt is never silent). */
66
+ export function printReceiptLines(r: Receipt, out: (line: string) => void): void {
67
+ out(`k-harness ${r.mode} (profile ${r.profile}${r.target ? `, target ${r.target}` : ""})`);
68
+ for (const c of r.checks) {
69
+ const detail = c.status === "fail" ? ` — ${c.error}` : "";
70
+ out(` ${MARK[c.status]} ${c.id} (${c.durationMs}ms)${detail}`);
71
+ }
72
+ out(
73
+ `result: ${r.result} — ${r.summary.pass} pass, ${r.summary.fail} fail, ${r.summary.na} na (${r.summary.total} checks, ${r.durationMs}ms)`,
74
+ );
75
+ }
76
+
77
+ export function printReceipt(r: Receipt, json: boolean, out: (line: string) => void = console.log): void {
78
+ if (json) {
79
+ out(JSON.stringify(r, null, 2));
80
+ return;
81
+ }
82
+ printReceiptLines(r, out);
83
+ }
@@ -0,0 +1,268 @@
1
+ /**
2
+ * k-harness runner — executes the two non-bin modes:
3
+ *
4
+ * - `--profile <cli|daemon|managed>`: runs the tier-filtered tooth set
5
+ * (teethFor(profile), harness-design §1.5 分档执行), one fresh sandbox
6
+ * per tooth, into a structured receipt.
7
+ *
8
+ * - `--adapter <path>`: runs the adopter contract subset (§1.7: no
9
+ * fault-switch teeth) against an external HostAdapter driver. The
10
+ * module's default export must be a factory `(stateDir: string) =>
11
+ * HostDriver`. Ledger checks are marked na when the adapter has no
12
+ * workload driver; probe contract checks always run.
13
+ */
14
+ import { pathToFileURL } from "node:url";
15
+ import { teethFor, ALL_CAPABILITIES, type Profile, type ToothContext } from "./teeth/registry.ts";
16
+ import { checkAdapterReleaseKnob } from "./adapter/releaseKnob.ts";
17
+ import { checkAdapterProbeBindsLiveProcess } from "./adapter/probeChecks.ts";
18
+ import { createSandbox } from "./scenario/sandbox.ts";
19
+ import { buildReceipt, type CheckResult, type Receipt } from "./receipt.ts";
20
+ import {
21
+ checkLedgerEquivalence,
22
+ checkLedgerEquivalenceAfterRollback,
23
+ checkProbeVersionMatchesSlot,
24
+ checkProbeBindsCurrentIncarnation,
25
+ hasWorkloadDriver,
26
+ } from "./fake-host/checks.ts";
27
+ import type { HostDriver } from "./fake-host/inproc.ts";
28
+ import {
29
+ checkAdapterServiceUpgrade,
30
+ checkAdapterServiceRollback,
31
+ checkAdapterLifecycleConverged,
32
+ type ServiceAdapterFactory,
33
+ } from "./adapter/serviceChecks.ts";
34
+
35
+ /** Adapter contract subset: quiesce↔resume equivalence + probe 活性 (§1.7). */
36
+ const ADAPTER_CHECKS: Array<{ id: string; run: (ctx: ToothContext, host: HostDriver) => Promise<void> }> = [
37
+ {
38
+ id: "adapter.ledger-equivalence",
39
+ run: (ctx, host) => checkLedgerEquivalence(ctx, { host }),
40
+ },
41
+ {
42
+ id: "adapter.ledger-equivalence-after-rollback",
43
+ run: (ctx, host) => checkLedgerEquivalenceAfterRollback(ctx, { host }),
44
+ },
45
+ {
46
+ id: "adapter.probe-version-matches-slot",
47
+ run: (ctx, host) => checkProbeVersionMatchesSlot(ctx, { host }),
48
+ },
49
+ {
50
+ id: "adapter.probe-binds-current-incarnation",
51
+ run: (ctx, host) => checkProbeBindsCurrentIncarnation(ctx, { host }),
52
+ },
53
+ ];
54
+
55
+ async function runCheck(
56
+ id: string,
57
+ body: () => Promise<{ skipped: boolean }>,
58
+ ): Promise<CheckResult> {
59
+ const startedAtMs = Date.now();
60
+ try {
61
+ const { skipped } = await body();
62
+ return {
63
+ id,
64
+ status: skipped ? "na" : "pass",
65
+ error: null,
66
+ durationMs: Date.now() - startedAtMs,
67
+ };
68
+ } catch (err) {
69
+ return {
70
+ id,
71
+ status: "fail",
72
+ error: (err as Error).message,
73
+ durationMs: Date.now() - startedAtMs,
74
+ };
75
+ }
76
+ }
77
+
78
+ export async function runProfile(profile: Profile): Promise<Receipt> {
79
+ const startedAtMs = Date.now();
80
+ const teeth = teethFor(profile, ALL_CAPABILITIES);
81
+ // Fail-closed: an empty selection must never render as "all green"
82
+ // (zero teeth and all-passed are indistinguishable to CI otherwise).
83
+ if (teeth.length === 0) {
84
+ return buildReceipt({
85
+ mode: "profile",
86
+ profile,
87
+ target: null,
88
+ checks: [
89
+ {
90
+ id: "harness.empty-selection",
91
+ status: "fail",
92
+ error: `HARNESS_EMPTY_SELECTION: profile ${profile} selected 0 teeth`,
93
+ durationMs: 0,
94
+ },
95
+ ],
96
+ startedAtMs,
97
+ durationMs: Date.now() - startedAtMs,
98
+ });
99
+ }
100
+ const checks: CheckResult[] = [];
101
+ for (const tooth of teeth) {
102
+ checks.push(
103
+ await runCheck(tooth.id, async () => {
104
+ const sb = await createSandbox({ prefix: tooth.id.replaceAll(".", "-") });
105
+ try {
106
+ await tooth.run({ profile, sandboxDir: sb.dir });
107
+ } finally {
108
+ await sb.teardown();
109
+ }
110
+ return { skipped: false };
111
+ }),
112
+ );
113
+ }
114
+ return buildReceipt({
115
+ mode: "profile",
116
+ profile,
117
+ target: null,
118
+ checks,
119
+ startedAtMs,
120
+ durationMs: Date.now() - startedAtMs,
121
+ });
122
+ }
123
+
124
+ /** Load an adapter module: default export must be `(stateDir) => HostDriver`. */
125
+ export async function loadAdapter(adapterPath: string): Promise<ServiceAdapterFactory> {
126
+ const mod = (await import(pathToFileURL(adapterPath).href)) as { default?: unknown };
127
+ const factory = mod.default;
128
+ if (typeof factory !== "function") {
129
+ throw new TypeError(
130
+ `adapter ${adapterPath}: default export must be a factory function (stateDir) => HostDriver`,
131
+ );
132
+ }
133
+ return factory as ServiceAdapterFactory;
134
+ }
135
+
136
+ export async function runAdapter(profile: Profile, adapterPath: string): Promise<Receipt> {
137
+ const startedAtMs = Date.now();
138
+ const factory = await loadAdapter(adapterPath);
139
+ // Fail-closed: an empty contract-check list must never render green.
140
+ if (ADAPTER_CHECKS.length === 0) {
141
+ return buildReceipt({
142
+ mode: "adapter",
143
+ profile,
144
+ target: adapterPath,
145
+ checks: [
146
+ {
147
+ id: "harness.empty-selection",
148
+ status: "fail",
149
+ error: `HARNESS_EMPTY_SELECTION: adapter contract subset selected 0 checks`,
150
+ durationMs: 0,
151
+ },
152
+ ],
153
+ startedAtMs,
154
+ durationMs: Date.now() - startedAtMs,
155
+ });
156
+ }
157
+ const checks: CheckResult[] = [];
158
+ // Shape probe: the inproc-driver marker (doWork/ledger) selects the
159
+ // contract subset; the lifecycleSurfaces marker selects the service-tier
160
+ // checks. An adapter with NEITHER implements no recognizable contract.
161
+ const inprocDriver = await (async () => {
162
+ const sb = await createSandbox({ prefix: "adapter-probe-driver" });
163
+ try {
164
+ return hasWorkloadDriver(factory(sb.dir));
165
+ } finally {
166
+ await sb.teardown();
167
+ }
168
+ })();
169
+ const serviceTier = await (async () => {
170
+ const sb = await createSandbox({ prefix: "adapter-probe-service" });
171
+ try {
172
+ const host = factory(sb.dir);
173
+ return typeof (host as { lifecycleSurfaces?: unknown }).lifecycleSurfaces === "function";
174
+ } finally {
175
+ await sb.teardown();
176
+ }
177
+ })();
178
+ if (!inprocDriver && !serviceTier) {
179
+ checks.push({
180
+ id: "adapter.must-declare-a-contract",
181
+ status: "fail",
182
+ error:
183
+ "HARNESS_ADAPTER_SHAPE: the adapter implements neither the inproc workload driver (doWork/ledger) nor the service-tier lifecycle surfaces — nothing is testable without a backdoor",
184
+ durationMs: 0,
185
+ });
186
+ }
187
+
188
+ for (const { id, run } of ADAPTER_CHECKS) {
189
+ checks.push(
190
+ await runCheck(id, async () => {
191
+ const sb = await createSandbox({ prefix: id.replaceAll(".", "-") });
192
+ try {
193
+ if (!inprocDriver) return { skipped: true };
194
+ const host = factory(sb.dir);
195
+ await run({ profile, sandboxDir: sb.dir }, host);
196
+ } finally {
197
+ await sb.teardown();
198
+ }
199
+ return { skipped: false };
200
+ }),
201
+ );
202
+ }
203
+
204
+ // Service-tier adapter acceptance: the SAME assertions as the service
205
+ // teeth (upgrade / rollback / lifecycle-converged), host swapped for the
206
+ // external adapter (same teeth, a different host implementation). Gated on the
207
+ // adapter declaring lifecycle surfaces — the service-tier marker; an
208
+ // adapter without them is a contract-subset-only host and the checks are
209
+ // skipped (like the ledger checks' driver skip).
210
+ if (profile === "service") {
211
+ const serviceChecks: Array<{
212
+ id: string;
213
+ needsSurfaces?: boolean;
214
+ run: (ctx: ToothContext, f: ServiceAdapterFactory) => Promise<void>;
215
+ }> = [
216
+ // First: every check below has a negative control that works by serving
217
+ // a crash-on-start release. If the adopter's source ignores the knob,
218
+ // those controls are no-ops and the passes below mean nothing -- so this
219
+ // is reported before them, not after.
220
+ { id: "adapter.service-release-knob-bites", run: (ctx, f) => checkAdapterReleaseKnob(ctx, f) },
221
+ {
222
+ id: "adapter.service-probe-binds-live-process",
223
+ run: (ctx, f) => checkAdapterProbeBindsLiveProcess(ctx, f),
224
+ },
225
+ { id: "adapter.service-upgrade", run: (ctx, f) => checkAdapterServiceUpgrade(ctx, f) },
226
+ { id: "adapter.service-rollback", run: (ctx, f) => checkAdapterServiceRollback(ctx, f) },
227
+ {
228
+ id: "adapter.lifecycle-converged",
229
+ // Lifecycle convergence is an opt-in CAPABILITY, not part of being a
230
+ // service. An adopter that drives no OS-lifecycle surface (a plain
231
+ // detached owner, say) has nothing to converge, and failing it for
232
+ // that would push adopters toward declaring a surface they do not
233
+ // actually read -- the projection L3 bans, invited by the harness.
234
+ //
235
+ // It reports `na`, never `pass`: exactly what core does with
236
+ // `hostLifecycleConverged: null`. Silence keeps its own value here too,
237
+ // so a receipt can never be read as "convergence checked".
238
+ needsSurfaces: true,
239
+ run: (ctx, f) => checkAdapterLifecycleConverged(ctx, f),
240
+ },
241
+ ];
242
+ for (const { id, needsSurfaces, run } of serviceChecks) {
243
+ checks.push(
244
+ await runCheck(id, async () => {
245
+ const sb = await createSandbox({ prefix: id.replaceAll(".", "-") });
246
+ try {
247
+ if (!serviceTier) return { skipped: true };
248
+ if (needsSurfaces === true && (factory(sb.dir).lifecycleSurfaces?.() ?? []).length === 0) {
249
+ return { skipped: true };
250
+ }
251
+ await run({ profile, sandboxDir: sb.dir }, factory);
252
+ } finally {
253
+ await sb.teardown();
254
+ }
255
+ return { skipped: false };
256
+ }),
257
+ );
258
+ }
259
+ }
260
+ return buildReceipt({
261
+ mode: "adapter",
262
+ profile,
263
+ target: adapterPath,
264
+ checks,
265
+ startedAtMs,
266
+ durationMs: Date.now() - startedAtMs,
267
+ });
268
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Out-of-band process scan by marker token (the verify-dead primitive).
3
+ * Split from sandbox.ts, which owns the marker CONVENTION (see MARKER_ENV
4
+ * there: one literal token `NAME=value`, carried on BOTH env and argv).
5
+ */
6
+ import { execFileSync } from "node:child_process";
7
+
8
+ /**
9
+ * All pids observably carrying the marker token `NAME=value` — the
10
+ * sandbox-marker scan behind verify-dead.
11
+ *
12
+ * POSIX reads `ps eaxo pid=,command=`: the `e` flag appends each process's
13
+ * environment to its command line, so BOTH marker channels (env + argv) land
14
+ * in the same scanned text, and the marker survives the parent dying
15
+ * (orphans/zombies the host no longer tracks).
16
+ *
17
+ * Windows enumerates `Win32_Process` via PowerShell/CIM: only `CommandLine`
18
+ * is public there, so the ARGV channel is what makes a process claimable —
19
+ * which is exactly why the marker rides two channels.
20
+ */
21
+ export function findPidsByMarkerToken(name: string, value: string): number[] {
22
+ const token = `${name}=${value}`;
23
+ if (process.platform === "win32") {
24
+ const out = execFileSync(
25
+ "powershell.exe",
26
+ [
27
+ "-NoProfile",
28
+ "-NonInteractive",
29
+ "-Command",
30
+ "Get-CimInstance Win32_Process | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress -Depth 1",
31
+ ],
32
+ { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 },
33
+ );
34
+ const parsed: unknown = JSON.parse(out);
35
+ const rows = Array.isArray(parsed) ? parsed : [parsed]; // ConvertTo-Json unwraps single rows
36
+ const pids: number[] = [];
37
+ for (const row of rows) {
38
+ if (typeof row !== "object" || row === null) continue;
39
+ const pid = (row as { ProcessId?: unknown }).ProcessId;
40
+ const commandLine = (row as { CommandLine?: unknown }).CommandLine;
41
+ if (typeof pid !== "number" || typeof commandLine !== "string") continue;
42
+ if (commandLine.includes(token)) pids.push(pid);
43
+ }
44
+ return pids;
45
+ }
46
+ const out = execFileSync("ps", ["eaxo", "pid=,command="], { encoding: "utf8" });
47
+ const pids: number[] = [];
48
+ for (const line of out.split("\n")) {
49
+ if (!line.includes(token)) continue;
50
+ const m = /^\s*(\d+)/.exec(line);
51
+ const pid = m?.[1];
52
+ if (pid) pids.push(Number(pid));
53
+ }
54
+ return pids;
55
+ }