@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.
- package/LICENSE +202 -0
- package/NOTICE +24 -0
- package/README.md +62 -0
- package/core/src/artifact/README.md +1 -0
- package/core/src/artifact/collectStream.ts +49 -0
- package/core/src/artifact/download.ts +300 -0
- package/core/src/artifact/errors.ts +24 -0
- package/core/src/artifact/partialPath.ts +18 -0
- package/core/src/artifact/source.ts +59 -0
- package/core/src/artifact/staticManifestSource.ts +185 -0
- package/core/src/artifact/swap.ts +21 -0
- package/core/src/clock.ts +22 -0
- package/core/src/converge/lifecycle.ts +114 -0
- package/core/src/converge/predicates.ts +57 -0
- package/core/src/converge/report.ts +38 -0
- package/core/src/createUpgrader.ts +300 -0
- package/core/src/drive/README.md +1 -0
- package/core/src/index.ts +17 -0
- package/core/src/invariants.ts +243 -0
- package/core/src/lifecycle/hostAdapter.ts +77 -0
- package/core/src/liveness.ts +80 -0
- package/core/src/platform/README.md +1 -0
- package/core/src/platform/index.ts +11 -0
- package/core/src/platform/ops.ts +53 -0
- package/core/src/platform/posix.ts +99 -0
- package/core/src/platform/windows.ts +73 -0
- package/core/src/policy/README.md +1 -0
- package/core/src/progress.ts +54 -0
- package/core/src/provenance/journal.ts +216 -0
- package/core/src/status/report.ts +76 -0
- package/core/src/status/reportStore.ts +67 -0
- package/core/src/txn/effects.ts +40 -0
- package/core/src/txn/engine.ts +275 -0
- package/core/src/txn/fileEffects.ts +129 -0
- package/core/src/txn/hostCallBudget.ts +28 -0
- package/core/src/txn/lock.ts +124 -0
- package/core/src/txn/state.ts +77 -0
- package/core/src/txn/transitions.ts +49 -0
- package/core/src/upgrade/outcome.ts +53 -0
- package/core/src/upgrade/retire.ts +44 -0
- package/core/src/upgrader.ts +124 -0
- package/docs/design-v1.md +246 -0
- package/docs/harness-design.md +170 -0
- package/docs/integration.md +326 -0
- package/docs/prior-art.md +150 -0
- package/docs/test-plan.md +100 -0
- package/harness/README.md +20 -0
- package/harness/src/adapter/probeChecks.ts +93 -0
- package/harness/src/adapter/releaseKnob.ts +65 -0
- package/harness/src/adapter/serviceChecks.ts +260 -0
- package/harness/src/artifact/checks.ts +232 -0
- package/harness/src/artifact/downloadHoles.ts +300 -0
- package/harness/src/artifact/downloadHolesMutations.ts +170 -0
- package/harness/src/artifact/m1.ts +204 -0
- package/harness/src/artifact/m1Resume.ts +141 -0
- package/harness/src/artifact/m3.ts +296 -0
- package/harness/src/artifact/m3Hosts.ts +241 -0
- package/harness/src/artifact/m4.ts +201 -0
- package/harness/src/artifact/m5.ts +239 -0
- package/harness/src/artifact/m6.ts +274 -0
- package/harness/src/artifact/m6Drive.ts +296 -0
- package/harness/src/artifact/m6DriveMutations.ts +70 -0
- package/harness/src/artifact/m6Mutations.ts +136 -0
- package/harness/src/artifact/m6Status.ts +224 -0
- package/harness/src/artifact-factory/checks.ts +68 -0
- package/harness/src/artifact-factory/demo.ts +70 -0
- package/harness/src/artifact-factory/factory.ts +134 -0
- package/harness/src/artifact-factory/run.ts +107 -0
- package/harness/src/blackbox.ts +284 -0
- package/harness/src/cli.ts +281 -0
- package/harness/src/crash/enumerate.ts +66 -0
- package/harness/src/crash/runner.ts +162 -0
- package/harness/src/examples/checks.ts +286 -0
- package/harness/src/fake-host/checks.ts +188 -0
- package/harness/src/fake-host/daemon-entry.ts +39 -0
- package/harness/src/fake-host/daemon.ts +159 -0
- package/harness/src/fake-host/inproc.ts +256 -0
- package/harness/src/fake-server/manifest.ts +68 -0
- package/harness/src/fake-server/server.ts +205 -0
- package/harness/src/fake-server/store.ts +208 -0
- package/harness/src/fixtures/minimal-adapter.ts +108 -0
- package/harness/src/fixtures/service-adapter.ts +187 -0
- package/harness/src/index.ts +11 -0
- package/harness/src/receipt.ts +83 -0
- package/harness/src/runner.ts +268 -0
- package/harness/src/scenario/processScan.ts +55 -0
- package/harness/src/scenario/sandbox.ts +259 -0
- package/harness/src/scenario/virtualClock.ts +56 -0
- package/harness/src/selfverify/selfVerify.ts +136 -0
- package/harness/src/sim/checks.ts +46 -0
- package/harness/src/sim/corpus.ts +36 -0
- package/harness/src/sim/effectRuntime.ts +113 -0
- package/harness/src/sim/error.ts +14 -0
- package/harness/src/sim/prng.ts +29 -0
- package/harness/src/sim/record.ts +76 -0
- package/harness/src/sim/run.ts +148 -0
- package/harness/src/sim/scheduler.ts +72 -0
- package/harness/src/sim/world.ts +284 -0
- package/harness/src/target.ts +86 -0
- package/harness/src/targetCheck.ts +47 -0
- package/harness/src/teeth/artifact.ts +215 -0
- package/harness/src/teeth/artifactDownload.ts +136 -0
- package/harness/src/teeth/artifactFactory.ts +41 -0
- package/harness/src/teeth/artifactM5.ts +68 -0
- package/harness/src/teeth/artifactM6.ts +264 -0
- package/harness/src/teeth/blackbox.ts +22 -0
- package/harness/src/teeth/checks.ts +278 -0
- package/harness/src/teeth/examples.ts +57 -0
- package/harness/src/teeth/fakeHost.ts +120 -0
- package/harness/src/teeth/index.ts +16 -0
- package/harness/src/teeth/m0.ts +135 -0
- package/harness/src/teeth/registry.ts +267 -0
- package/harness/src/teeth/selfCheck.ts +45 -0
- package/harness/src/teeth/sim.ts +61 -0
- 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
|
+
}
|