@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,76 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { SimulationResult } from "./run.ts";
|
|
4
|
+
|
|
5
|
+
interface FailureRecord {
|
|
6
|
+
seed: number;
|
|
7
|
+
failure: string;
|
|
8
|
+
replay: string;
|
|
9
|
+
transcriptSha256: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface FailureCorpus {
|
|
13
|
+
formatVersion: 1;
|
|
14
|
+
failures: FailureRecord[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Atomically merge failures into a replay corpus. There are deliberately no
|
|
19
|
+
* timestamps: the same failure set produces the same bytes.
|
|
20
|
+
*/
|
|
21
|
+
export async function recordFailures(filePath: string, results: readonly SimulationResult[]): Promise<number> {
|
|
22
|
+
const failures = results.filter(
|
|
23
|
+
(result): result is SimulationResult & { failure: string } => result.failure !== null,
|
|
24
|
+
);
|
|
25
|
+
if (failures.length === 0) return 0;
|
|
26
|
+
|
|
27
|
+
let existing: FailureCorpus = { formatVersion: 1, failures: [] };
|
|
28
|
+
try {
|
|
29
|
+
const parsed = JSON.parse(await fs.readFile(filePath, "utf8")) as unknown;
|
|
30
|
+
if (isFailureCorpus(parsed)) existing = parsed;
|
|
31
|
+
else throw new Error("existing failure corpus has an unsupported shape");
|
|
32
|
+
} catch (err) {
|
|
33
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const byKey = new Map(
|
|
37
|
+
existing.failures.map((failure) => [`${failure.seed}:${failure.transcriptSha256}`, failure] as const),
|
|
38
|
+
);
|
|
39
|
+
for (const result of failures) {
|
|
40
|
+
const record: FailureRecord = {
|
|
41
|
+
seed: result.seed,
|
|
42
|
+
failure: result.failure,
|
|
43
|
+
replay: result.replay,
|
|
44
|
+
transcriptSha256: result.transcriptSha256,
|
|
45
|
+
};
|
|
46
|
+
byKey.set(`${record.seed}:${record.transcriptSha256}`, record);
|
|
47
|
+
}
|
|
48
|
+
const corpus: FailureCorpus = {
|
|
49
|
+
formatVersion: 1,
|
|
50
|
+
failures: [...byKey.values()].toSorted(
|
|
51
|
+
(left, right) => left.seed - right.seed || left.transcriptSha256.localeCompare(right.transcriptSha256),
|
|
52
|
+
),
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
56
|
+
const temporary = `${filePath}.tmp-${process.pid}`;
|
|
57
|
+
await fs.writeFile(temporary, `${JSON.stringify(corpus, null, 2)}\n`, "utf8");
|
|
58
|
+
await fs.rename(temporary, filePath);
|
|
59
|
+
return failures.length;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isFailureCorpus(value: unknown): value is FailureCorpus {
|
|
63
|
+
if (typeof value !== "object" || value === null) return false;
|
|
64
|
+
const candidate = value as { formatVersion?: unknown; failures?: unknown };
|
|
65
|
+
if (candidate.formatVersion !== 1 || !Array.isArray(candidate.failures)) return false;
|
|
66
|
+
return candidate.failures.every((failure) => {
|
|
67
|
+
if (typeof failure !== "object" || failure === null) return false;
|
|
68
|
+
const item = failure as Partial<FailureRecord>;
|
|
69
|
+
return (
|
|
70
|
+
typeof item.seed === "number" &&
|
|
71
|
+
typeof item.failure === "string" &&
|
|
72
|
+
typeof item.replay === "string" &&
|
|
73
|
+
typeof item.transcriptSha256 === "string"
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { UpgradeEngine } from "../../../core/src/txn/engine.ts";
|
|
3
|
+
import type { WorldSnapshot } from "../../../core/src/invariants.ts";
|
|
4
|
+
import { emptyCoverage, type FaultCoverage } from "./scheduler.ts";
|
|
5
|
+
import { SimWorld, type SimulationMutation } from "./world.ts";
|
|
6
|
+
import { SimulationError } from "./error.ts";
|
|
7
|
+
|
|
8
|
+
export interface SimulationResult {
|
|
9
|
+
seed: number;
|
|
10
|
+
scenario: "promote" | "predicate-rollback";
|
|
11
|
+
mutation: SimulationMutation | null;
|
|
12
|
+
status: "pass" | "fail";
|
|
13
|
+
restarts: number;
|
|
14
|
+
effects: number;
|
|
15
|
+
coverage: FaultCoverage;
|
|
16
|
+
final: WorldSnapshot;
|
|
17
|
+
failure: string | null;
|
|
18
|
+
replay: string;
|
|
19
|
+
trace: string[];
|
|
20
|
+
transcriptSha256: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SimulationBatch {
|
|
24
|
+
mode: "sim";
|
|
25
|
+
seeds: number[];
|
|
26
|
+
results: SimulationResult[];
|
|
27
|
+
coverage: FaultCoverage;
|
|
28
|
+
summary: { pass: number; fail: number; total: number };
|
|
29
|
+
result: "pass" | "fail";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RunSimulationOptions {
|
|
33
|
+
mutation?: SimulationMutation;
|
|
34
|
+
maxRestarts?: number;
|
|
35
|
+
faults?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const TARGET = { version: "2.0.0", bytesRef: "sha256:simulated-target" };
|
|
39
|
+
|
|
40
|
+
export async function runSimulation(
|
|
41
|
+
seed: number,
|
|
42
|
+
opts: RunSimulationOptions = {},
|
|
43
|
+
): Promise<SimulationResult> {
|
|
44
|
+
const normalizedSeed = seed >>> 0;
|
|
45
|
+
const world = new SimWorld({
|
|
46
|
+
seed: normalizedSeed,
|
|
47
|
+
...(opts.mutation ? { mutation: opts.mutation } : {}),
|
|
48
|
+
...(opts.faults === undefined ? {} : { faults: opts.faults }),
|
|
49
|
+
});
|
|
50
|
+
const predicateRefuses = normalizedSeed % 5 === 0;
|
|
51
|
+
const maxRestarts = opts.maxRestarts ?? 128;
|
|
52
|
+
let restarts = 0;
|
|
53
|
+
let failure: string | null = null;
|
|
54
|
+
|
|
55
|
+
for (; restarts <= maxRestarts; restarts++) {
|
|
56
|
+
const engine = new UpgradeEngine({
|
|
57
|
+
effects: world.effects,
|
|
58
|
+
host: world.host,
|
|
59
|
+
clock: world.clock,
|
|
60
|
+
evaluatePredicates: () => world.evaluatePredicates(predicateRefuses),
|
|
61
|
+
});
|
|
62
|
+
try {
|
|
63
|
+
await engine.recover();
|
|
64
|
+
if (world.currentPhase === "promoted" || world.currentPhase === "rolled-back") {
|
|
65
|
+
const problem = world.terminalProblem();
|
|
66
|
+
if (problem !== null) {
|
|
67
|
+
failure = `SIM_TERMINAL_NOT_SETTLED: ${problem}`;
|
|
68
|
+
}
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Nothing durable began (for example a crash before the first journal
|
|
73
|
+
// fsync): retry the requested transaction. Once anything is durable,
|
|
74
|
+
// recover() owns the outcome and will settle it instead.
|
|
75
|
+
if (world.durableEntries.length === 0) {
|
|
76
|
+
await engine.upgrade(TARGET);
|
|
77
|
+
} else {
|
|
78
|
+
failure = `SIM_RECOVERY_STALLED: recovery returned in phase ${world.currentPhase}`;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const problem = world.terminalProblem();
|
|
83
|
+
if (problem !== null) failure = `SIM_TERMINAL_NOT_SETTLED: ${problem}`;
|
|
84
|
+
break;
|
|
85
|
+
} catch (err) {
|
|
86
|
+
if (err instanceof SimulationError) {
|
|
87
|
+
if (err.kind === "invariant") {
|
|
88
|
+
failure = err.message;
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
world.reboot(err.message);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
failure = `SIM_UNEXPECTED: ${(err as Error).stack ?? String(err)}`;
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (failure === null && !world.isSettled()) {
|
|
100
|
+
failure =
|
|
101
|
+
restarts > maxRestarts
|
|
102
|
+
? `SIM_LIVENESS_BUDGET: did not settle within ${maxRestarts} restarts`
|
|
103
|
+
: `SIM_NOT_SETTLED: ${world.terminalProblem() ?? `phase ${world.currentPhase}`}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const base = {
|
|
107
|
+
seed: normalizedSeed,
|
|
108
|
+
scenario: predicateRefuses ? ("predicate-rollback" as const) : ("promote" as const),
|
|
109
|
+
mutation: opts.mutation ?? null,
|
|
110
|
+
status: failure === null ? ("pass" as const) : ("fail" as const),
|
|
111
|
+
restarts,
|
|
112
|
+
effects: world.trace.filter((line) => /^\d+:/.test(line)).length,
|
|
113
|
+
coverage: world.coverage,
|
|
114
|
+
final: world.snapshot(),
|
|
115
|
+
failure,
|
|
116
|
+
replay: `k-harness sim --seed ${normalizedSeed} --json`,
|
|
117
|
+
trace: [...world.trace],
|
|
118
|
+
};
|
|
119
|
+
const transcriptSha256 = createHash("sha256").update(JSON.stringify(base)).digest("hex");
|
|
120
|
+
return { ...base, transcriptSha256 };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function runSimulationBatch(seeds: readonly number[]): Promise<SimulationBatch> {
|
|
124
|
+
if (seeds.length === 0) throw new Error("SIM_EMPTY_SEED_SET: at least one seed is required");
|
|
125
|
+
const results: SimulationResult[] = [];
|
|
126
|
+
const coverage = emptyCoverage();
|
|
127
|
+
for (const seed of seeds) {
|
|
128
|
+
const result = await runSimulation(seed);
|
|
129
|
+
results.push(result);
|
|
130
|
+
for (const key of Object.keys(coverage) as Array<keyof FaultCoverage>) {
|
|
131
|
+
coverage[key] += result.coverage[key];
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const pass = results.filter((result) => result.status === "pass").length;
|
|
135
|
+
const fail = results.length - pass;
|
|
136
|
+
return {
|
|
137
|
+
mode: "sim",
|
|
138
|
+
seeds: results.map((result) => result.seed),
|
|
139
|
+
results,
|
|
140
|
+
coverage,
|
|
141
|
+
summary: { pass, fail, total: results.length },
|
|
142
|
+
result: fail === 0 ? "pass" : "fail",
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function replayBytes(result: SimulationResult): string {
|
|
147
|
+
return JSON.stringify(result);
|
|
148
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { SeededPrng } from "./prng.ts";
|
|
2
|
+
|
|
3
|
+
export type EffectKind =
|
|
4
|
+
| "journal-write"
|
|
5
|
+
| "journal-fsync"
|
|
6
|
+
| "journal-read"
|
|
7
|
+
| "slot-read"
|
|
8
|
+
| "slot-write"
|
|
9
|
+
| "host"
|
|
10
|
+
| "predicate";
|
|
11
|
+
|
|
12
|
+
export type FaultDecision =
|
|
13
|
+
| "none"
|
|
14
|
+
| "delay"
|
|
15
|
+
| "crash-before"
|
|
16
|
+
| "crash-after"
|
|
17
|
+
| "fail-before"
|
|
18
|
+
| "partial-write"
|
|
19
|
+
| "reorder-volatile";
|
|
20
|
+
|
|
21
|
+
/** Counts are part of every simulation receipt: coverage is observable. */
|
|
22
|
+
export type FaultCoverage = Record<FaultDecision, number>;
|
|
23
|
+
|
|
24
|
+
export function emptyCoverage(): FaultCoverage {
|
|
25
|
+
return {
|
|
26
|
+
none: 0,
|
|
27
|
+
delay: 0,
|
|
28
|
+
"crash-before": 0,
|
|
29
|
+
"crash-after": 0,
|
|
30
|
+
"fail-before": 0,
|
|
31
|
+
"partial-write": 0,
|
|
32
|
+
"reorder-volatile": 0,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Seeded fault scheduler. Every effect asks once; no effect is invisible to
|
|
38
|
+
* the schedule. Journal writes additionally expose partial/reordered volatile
|
|
39
|
+
* tails. Those tails are never allowed to cross a successful fsync barrier.
|
|
40
|
+
*/
|
|
41
|
+
export class FaultScheduler {
|
|
42
|
+
private readonly prng: SeededPrng;
|
|
43
|
+
private readonly enabled: boolean;
|
|
44
|
+
readonly coverage = emptyCoverage();
|
|
45
|
+
|
|
46
|
+
constructor(seed: number, enabled = true) {
|
|
47
|
+
this.prng = new SeededPrng(seed);
|
|
48
|
+
this.enabled = enabled;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
decide(kind: EffectKind): FaultDecision {
|
|
52
|
+
if (!this.enabled) {
|
|
53
|
+
this.coverage.none += 1;
|
|
54
|
+
return "none";
|
|
55
|
+
}
|
|
56
|
+
const roll = this.prng.below(100);
|
|
57
|
+
let decision: FaultDecision;
|
|
58
|
+
if (kind === "journal-write" && roll < 5) decision = "partial-write";
|
|
59
|
+
else if (kind === "journal-write" && roll < 9) decision = "reorder-volatile";
|
|
60
|
+
else if (roll < 16) decision = "crash-before";
|
|
61
|
+
else if (roll < 23) decision = "crash-after";
|
|
62
|
+
else if (roll < 29) decision = "fail-before";
|
|
63
|
+
else if (roll < 39) decision = "delay";
|
|
64
|
+
else decision = "none";
|
|
65
|
+
this.coverage[decision] += 1;
|
|
66
|
+
return decision;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
delayMs(): number {
|
|
70
|
+
return 1 + this.prng.below(50);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { checkInvariants, type WorldSnapshot } from "../../../core/src/invariants.ts";
|
|
2
|
+
import type {
|
|
3
|
+
HostAdapter,
|
|
4
|
+
ProcessEvidence,
|
|
5
|
+
Slot,
|
|
6
|
+
} from "../../../core/src/lifecycle/hostAdapter.ts";
|
|
7
|
+
import type { TxnEffects } from "../../../core/src/txn/effects.ts";
|
|
8
|
+
import type { JournalEntry, TxnPhase } from "../../../core/src/txn/state.ts";
|
|
9
|
+
import type { FaultCoverage } from "./scheduler.ts";
|
|
10
|
+
import { EffectRuntime } from "./effectRuntime.ts";
|
|
11
|
+
import { SimulationError } from "./error.ts";
|
|
12
|
+
|
|
13
|
+
export type SimulationMutation =
|
|
14
|
+
| "drop-journal-durability"
|
|
15
|
+
| "skip-stable-stop"
|
|
16
|
+
| "skip-terminal-resume";
|
|
17
|
+
|
|
18
|
+
interface PendingJournalWrite {
|
|
19
|
+
entry: JournalEntry;
|
|
20
|
+
shape: "complete" | "partial" | "reordered";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface LiveProcess {
|
|
24
|
+
slot: Slot;
|
|
25
|
+
pid: number;
|
|
26
|
+
startId: string;
|
|
27
|
+
version: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SimWorldOptions {
|
|
31
|
+
seed: number;
|
|
32
|
+
mutation?: SimulationMutation;
|
|
33
|
+
faults?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Persistent, in-memory machine used by DST. It implements the production
|
|
38
|
+
* seams rather than a second transaction engine: UpgradeEngine is still the
|
|
39
|
+
* thing being exercised.
|
|
40
|
+
*/
|
|
41
|
+
export class SimWorld {
|
|
42
|
+
readonly mutation: SimulationMutation | undefined;
|
|
43
|
+
private readonly runtime: EffectRuntime;
|
|
44
|
+
|
|
45
|
+
private readonly durableJournal: JournalEntry[] = [];
|
|
46
|
+
private pendingJournal: PendingJournalWrite | null = null;
|
|
47
|
+
private readonly slots: Record<Slot, string | null> = {
|
|
48
|
+
stable: "1.0.0",
|
|
49
|
+
experiment: null,
|
|
50
|
+
};
|
|
51
|
+
private live: LiveProcess[] = [
|
|
52
|
+
{ slot: "stable", pid: 1000, startId: "incarnation-1", version: "1.0.0" },
|
|
53
|
+
];
|
|
54
|
+
private phase: TxnPhase = "idle";
|
|
55
|
+
private quiesced = false;
|
|
56
|
+
private nextPid = 1001;
|
|
57
|
+
private nextIncarnation = 2;
|
|
58
|
+
|
|
59
|
+
constructor(opts: SimWorldOptions) {
|
|
60
|
+
this.mutation = opts.mutation;
|
|
61
|
+
this.runtime = new EffectRuntime(opts.seed, opts.faults ?? true, (name) =>
|
|
62
|
+
this.assertInvariants(name),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
get coverage(): FaultCoverage {
|
|
67
|
+
return this.runtime.coverage;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
get trace(): string[] {
|
|
71
|
+
return this.runtime.trace;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
get currentPhase(): TxnPhase {
|
|
75
|
+
return this.phase;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
get isQuiesced(): boolean {
|
|
79
|
+
return this.quiesced;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
get durableEntries(): readonly JournalEntry[] {
|
|
83
|
+
return this.durableJournal;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
get slotState(): Readonly<Record<Slot, string | null>> {
|
|
87
|
+
return this.slots;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
get liveProcesses(): readonly LiveProcess[] {
|
|
91
|
+
return this.live;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
get clock() {
|
|
95
|
+
return this.runtime.clock;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
readonly effects: TxnEffects = {
|
|
99
|
+
journal: {
|
|
100
|
+
appendAndSync: async (entry) => {
|
|
101
|
+
await this.runtime.effect(
|
|
102
|
+
`journal.write.${entry.intent}`,
|
|
103
|
+
"journal-write",
|
|
104
|
+
() => {
|
|
105
|
+
this.pendingJournal = { entry: cloneEntry(entry), shape: "complete" };
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
partial: () => {
|
|
109
|
+
this.pendingJournal = { entry: cloneEntry(entry), shape: "partial" };
|
|
110
|
+
},
|
|
111
|
+
reorder: () => {
|
|
112
|
+
this.pendingJournal = { entry: cloneEntry(entry), shape: "reordered" };
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
await this.runtime.effect(`journal.fsync.${entry.intent}`, "journal-fsync", () => {
|
|
117
|
+
const pending = this.pendingJournal;
|
|
118
|
+
if (pending === null || pending.shape !== "complete") {
|
|
119
|
+
throw new SimulationError(
|
|
120
|
+
"effect-failure",
|
|
121
|
+
`journal.fsync.${entry.intent}`,
|
|
122
|
+
`SIM_EFFECT_FAIL: journal.fsync.${entry.intent}: volatile tail is ${pending?.shape ?? "missing"}; refusing to acknowledge fsync`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
if (this.mutation !== "drop-journal-durability") {
|
|
126
|
+
this.durableJournal.push(cloneEntry(pending.entry));
|
|
127
|
+
this.phaseAfterDurableIntent(pending.entry.intent);
|
|
128
|
+
}
|
|
129
|
+
this.pendingJournal = null;
|
|
130
|
+
});
|
|
131
|
+
},
|
|
132
|
+
readAll: async () =>
|
|
133
|
+
this.runtime.effect("journal.read-all", "journal-read", () =>
|
|
134
|
+
this.durableJournal.map(cloneEntry),
|
|
135
|
+
),
|
|
136
|
+
},
|
|
137
|
+
slots: {
|
|
138
|
+
stageExperiment: async (artifact) => {
|
|
139
|
+
await this.runtime.effect("slots.stage-experiment", "slot-write", () => {
|
|
140
|
+
this.slots.experiment = artifact.version;
|
|
141
|
+
this.phase = "staged";
|
|
142
|
+
});
|
|
143
|
+
},
|
|
144
|
+
slotVersions: async () =>
|
|
145
|
+
this.runtime.effect("slots.read-versions", "slot-read", () => ({ ...this.slots })),
|
|
146
|
+
promoteExperiment: async () => {
|
|
147
|
+
await this.runtime.effect("slots.promote-experiment", "slot-write", () => {
|
|
148
|
+
if (this.slots.experiment !== null) {
|
|
149
|
+
this.slots.stable = this.slots.experiment;
|
|
150
|
+
this.slots.experiment = null;
|
|
151
|
+
this.live = this.live.map((process) =>
|
|
152
|
+
process.slot === "experiment" ? { ...process, slot: "stable" } : process,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
this.phase = "promoted";
|
|
156
|
+
});
|
|
157
|
+
},
|
|
158
|
+
clearExperiment: async () => {
|
|
159
|
+
await this.runtime.effect("slots.clear-experiment", "slot-write", () => {
|
|
160
|
+
this.slots.experiment = null;
|
|
161
|
+
this.live = this.live.filter((process) => process.slot !== "experiment");
|
|
162
|
+
this.phase = "rolled-back";
|
|
163
|
+
});
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
readonly host: HostAdapter = {
|
|
169
|
+
quiesce: async () => {
|
|
170
|
+
await this.runtime.effect("host.quiesce", "host", () => {
|
|
171
|
+
this.quiesced = true;
|
|
172
|
+
});
|
|
173
|
+
},
|
|
174
|
+
stop: async (slot) => {
|
|
175
|
+
await this.runtime.effect(`host.stop.${slot}`, "host", () => {
|
|
176
|
+
if (slot === "stable" && this.mutation === "skip-stable-stop") return;
|
|
177
|
+
this.live = this.live.filter((process) => process.slot !== slot);
|
|
178
|
+
});
|
|
179
|
+
},
|
|
180
|
+
start: async (slot) => {
|
|
181
|
+
await this.runtime.effect(`host.start.${slot}`, "host", () => {
|
|
182
|
+
const version = this.slots[slot];
|
|
183
|
+
if (version === null) throw new Error(`cannot start empty ${slot} slot`);
|
|
184
|
+
if (this.live.some((process) => process.slot === slot)) return; // idempotent
|
|
185
|
+
this.live.push({
|
|
186
|
+
slot,
|
|
187
|
+
version,
|
|
188
|
+
pid: this.nextPid++,
|
|
189
|
+
startId: `incarnation-${this.nextIncarnation++}`,
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
},
|
|
193
|
+
healthProbe: async (): Promise<ProcessEvidence> =>
|
|
194
|
+
this.runtime.effect("host.health-probe", "host", () => {
|
|
195
|
+
if (this.live.length === 0) throw new Error("no live process");
|
|
196
|
+
const process = this.live.at(-1)!;
|
|
197
|
+
return {
|
|
198
|
+
version: process.version,
|
|
199
|
+
pid: process.pid,
|
|
200
|
+
startId: process.startId,
|
|
201
|
+
};
|
|
202
|
+
}),
|
|
203
|
+
resume: async () => {
|
|
204
|
+
await this.runtime.effect("host.resume", "host", () => {
|
|
205
|
+
if (this.mutation !== "skip-terminal-resume") this.quiesced = false;
|
|
206
|
+
});
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
async evaluatePredicates(refuse: boolean): Promise<string | null> {
|
|
211
|
+
return this.runtime.effect("predicate.evaluate", "predicate", () =>
|
|
212
|
+
refuse ? "seed selected the rollback scenario" : null,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** A process crash discards every non-fsync'd fragment and pending timer. */
|
|
217
|
+
reboot(reason: string): void {
|
|
218
|
+
const tail = this.pendingJournal?.shape ?? "none";
|
|
219
|
+
this.runtime.reboot(reason, tail);
|
|
220
|
+
this.pendingJournal = null;
|
|
221
|
+
this.assertInvariants("reboot");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
snapshot(): WorldSnapshot {
|
|
225
|
+
return {
|
|
226
|
+
phase: this.phase,
|
|
227
|
+
slots: { ...this.slots },
|
|
228
|
+
liveProcesses: this.live.map((process) => ({ ...process })),
|
|
229
|
+
journalIntents: this.durableJournal.map((entry) => entry.intent),
|
|
230
|
+
workloadDigest: "workload-v1",
|
|
231
|
+
priorIncarnationStartId: "incarnation-1",
|
|
232
|
+
installOwnership: "self",
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
isSettled(): boolean {
|
|
237
|
+
const terminal = this.phase === "promoted" || this.phase === "rolled-back";
|
|
238
|
+
if (!terminal || this.slots.experiment !== null || this.quiesced) return false;
|
|
239
|
+
return (
|
|
240
|
+
this.live.length === 1 &&
|
|
241
|
+
this.live[0]!.slot === "stable" &&
|
|
242
|
+
this.live[0]!.version === this.slots.stable
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
terminalProblem(): string | null {
|
|
247
|
+
if (this.phase !== "promoted" && this.phase !== "rolled-back") {
|
|
248
|
+
return `phase ${this.phase} is not terminal`;
|
|
249
|
+
}
|
|
250
|
+
if (this.slots.experiment !== null) return `terminal phase left experiment ${this.slots.experiment}`;
|
|
251
|
+
if (this.quiesced) return "terminal phase left workloads quiesced";
|
|
252
|
+
if (this.live.length !== 1) return `terminal phase has ${this.live.length} live processes`;
|
|
253
|
+
const live = this.live[0]!;
|
|
254
|
+
if (live.slot !== "stable" || live.version !== this.slots.stable) {
|
|
255
|
+
return `terminal live process is ${live.slot}@${live.version}, stable is ${this.slots.stable}`;
|
|
256
|
+
}
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private phaseAfterDurableIntent(intent: TxnPhase): void {
|
|
261
|
+
// staged/promoted/rolled-back are intents whose named slot action has not
|
|
262
|
+
// happened yet. The other intents describe an entered control phase.
|
|
263
|
+
if (intent === "handing-over" || intent === "running-experiment" || intent === "readback") {
|
|
264
|
+
this.phase = intent;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private assertInvariants(effectName: string): void {
|
|
269
|
+
const violations = checkInvariants(this.snapshot());
|
|
270
|
+
if (violations.length > 0) {
|
|
271
|
+
throw new SimulationError(
|
|
272
|
+
"invariant",
|
|
273
|
+
effectName,
|
|
274
|
+
`SIM_INVARIANT: after ${effectName}: ${violations
|
|
275
|
+
.map((violation) => `${violation.invariantId}: ${violation.reason}`)
|
|
276
|
+
.join("; ")}`,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function cloneEntry(entry: JournalEntry): JournalEntry {
|
|
283
|
+
return { ...entry, detail: { ...entry.detail } };
|
|
284
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Black-box target — code-defined command declarations (harness-design
|
|
3
|
+
* §1.76 ②; final ruling: NO zero-config defaults, NO
|
|
4
|
+
* convention probing).
|
|
5
|
+
*
|
|
6
|
+
* `k.target.ts` (or `--target <path>`) MUST exist next to the binary,
|
|
7
|
+
* default-exporting a BlackBoxTarget with ALL command names explicitly
|
|
8
|
+
* declared. Missing = an immediate typed FAIL (BLACKBOX_TARGET_REQUIRED)
|
|
9
|
+
* that tells the adopter what to declare — the harness never guesses
|
|
10
|
+
* (`--version` / `self upgrade` are not implied).
|
|
11
|
+
*
|
|
12
|
+
* A wrong shape is a COMPILE-TIME error on the adopter's side via
|
|
13
|
+
* `satisfies BlackBoxTarget`; the harness also validates at load because
|
|
14
|
+
* a target that skips `satisfies` is only catchable at runtime (reported
|
|
15
|
+
* to the author).
|
|
16
|
+
*/
|
|
17
|
+
import { promises as fs } from "node:fs";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
import { pathToFileURL } from "node:url";
|
|
20
|
+
|
|
21
|
+
export interface BlackBoxTarget {
|
|
22
|
+
/** Declared version command args. REQUIRED — no implicit default. */
|
|
23
|
+
version: string[];
|
|
24
|
+
/** Declared self-upgrade command args. REQUIRED — no implicit default. */
|
|
25
|
+
selfUpgrade: string[];
|
|
26
|
+
/** Declared status command args (daemon/managed contract). Optional. */
|
|
27
|
+
status?: string[];
|
|
28
|
+
/** Extra env for the commands (the adopter's config surface). */
|
|
29
|
+
env?: Record<string, string>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const TARGET_FILE = "k.target.ts";
|
|
33
|
+
|
|
34
|
+
export interface LoadedTarget {
|
|
35
|
+
target: BlackBoxTarget;
|
|
36
|
+
/** Absolute path of the loaded target file. */
|
|
37
|
+
path: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function loadTarget(opts: {
|
|
41
|
+
binDir: string;
|
|
42
|
+
/** Explicit --target <path>; defaults to <binDir>/k.target.ts. */
|
|
43
|
+
explicitPath?: string;
|
|
44
|
+
}): Promise<LoadedTarget> {
|
|
45
|
+
const targetPath = opts.explicitPath ?? path.join(opts.binDir, TARGET_FILE);
|
|
46
|
+
try {
|
|
47
|
+
await fs.access(targetPath);
|
|
48
|
+
} catch {
|
|
49
|
+
throw new Error(
|
|
50
|
+
opts.explicitPath
|
|
51
|
+
? `BLACKBOX_TARGET_REQUIRED: --target file not found: ${targetPath}`
|
|
52
|
+
: `BLACKBOX_TARGET_REQUIRED: ${TARGET_FILE} must exist next to the binary (${opts.binDir}) — declare version + selfUpgrade commands explicitly; the harness does not guess`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let mod: { default?: unknown };
|
|
57
|
+
try {
|
|
58
|
+
mod = (await import(pathToFileURL(targetPath).href)) as { default?: unknown };
|
|
59
|
+
} catch (err) {
|
|
60
|
+
throw new Error(`BLACKBOX_TARGET_INVALID: could not load ${targetPath}: ${(err as Error).message}`, {
|
|
61
|
+
cause: err,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const declared = mod.default;
|
|
66
|
+
if (typeof declared !== "object" || declared === null) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`BLACKBOX_TARGET_INVALID: ${targetPath} must default-export an object (BlackBoxTarget); got ${typeof declared} — write "satisfies BlackBoxTarget" to catch this at compile time`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
const t = declared as BlackBoxTarget;
|
|
72
|
+
if (!isStringArray(t.version) || t.version.length === 0) {
|
|
73
|
+
throw new Error(`BLACKBOX_TARGET_INVALID: ${targetPath} must declare version: string[] (BlackBoxTarget)`);
|
|
74
|
+
}
|
|
75
|
+
if (!isStringArray(t.selfUpgrade) || t.selfUpgrade.length === 0) {
|
|
76
|
+
throw new Error(`BLACKBOX_TARGET_INVALID: ${targetPath} must declare selfUpgrade: string[] (BlackBoxTarget)`);
|
|
77
|
+
}
|
|
78
|
+
if (t.status !== undefined && !isStringArray(t.status)) {
|
|
79
|
+
throw new Error(`BLACKBOX_TARGET_INVALID: ${targetPath} status must be string[] (BlackBoxTarget)`);
|
|
80
|
+
}
|
|
81
|
+
return { target: t, path: targetPath };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isStringArray(v: unknown): v is string[] {
|
|
85
|
+
return Array.isArray(v) && v.every((x) => typeof x === "string");
|
|
86
|
+
}
|