@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,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crash-point enumerator (harness-design §1.4) — the load-bearing piece.
|
|
3
|
+
*
|
|
4
|
+
* Coverage is GENERATED from the engine's transition table, never hand-listed.
|
|
5
|
+
* For every transition we crash at each durability-relevant instant:
|
|
6
|
+
*
|
|
7
|
+
* before-journal : intent not yet durable -> recovery must see the OLD phase
|
|
8
|
+
* after-journal : intent durable, action NOT done -> recovery must finish or undo
|
|
9
|
+
* after-action : action done, before the next intent -> same obligation
|
|
10
|
+
*
|
|
11
|
+
* "after-journal" is the interesting one: it is precisely the window where a
|
|
12
|
+
* naive implementation loses track of what it was doing. Enumeration makes it
|
|
13
|
+
* impossible to forget, and the completeness tooth makes it impossible to add
|
|
14
|
+
* a transition without covering it.
|
|
15
|
+
*/
|
|
16
|
+
import { TRANSITIONS, type Transition } from "../../../core/src/txn/transitions.ts";
|
|
17
|
+
|
|
18
|
+
export type CrashInstant = "before-journal" | "after-journal" | "after-action";
|
|
19
|
+
|
|
20
|
+
export const CRASH_INSTANTS: readonly CrashInstant[] = [
|
|
21
|
+
"before-journal",
|
|
22
|
+
"after-journal",
|
|
23
|
+
"after-action",
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export interface CrashPoint {
|
|
27
|
+
/** Stable id, e.g. "staged->handing-over@after-journal". */
|
|
28
|
+
id: string;
|
|
29
|
+
transition: Transition;
|
|
30
|
+
instant: CrashInstant;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The full matrix: every transition x every crash instant. */
|
|
34
|
+
export function enumerateCrashPoints(
|
|
35
|
+
transitions: readonly Transition[] = TRANSITIONS,
|
|
36
|
+
): CrashPoint[] {
|
|
37
|
+
const points: CrashPoint[] = [];
|
|
38
|
+
for (const transition of transitions) {
|
|
39
|
+
for (const instant of CRASH_INSTANTS) {
|
|
40
|
+
points.push({
|
|
41
|
+
id: `${transition.from}->${transition.to}@${instant}`,
|
|
42
|
+
transition,
|
|
43
|
+
instant,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return points;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Completeness accounting: the matrix must cover every transition in the
|
|
52
|
+
* table. Returns transitions that have no crash point (should always be
|
|
53
|
+
* empty; the tooth asserts it).
|
|
54
|
+
*/
|
|
55
|
+
export function uncoveredTransitions(
|
|
56
|
+
points: readonly CrashPoint[],
|
|
57
|
+
transitions: readonly Transition[] = TRANSITIONS,
|
|
58
|
+
): Transition[] {
|
|
59
|
+
const covered = new Set(points.map((p) => `${p.transition.from}->${p.transition.to}`));
|
|
60
|
+
return transitions.filter((t) => !covered.has(`${t.from}->${t.to}`));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Crash points whose instant is not one of the declared instants (guards typos). */
|
|
64
|
+
export function malformedPoints(points: readonly CrashPoint[]): CrashPoint[] {
|
|
65
|
+
return points.filter((p) => !CRASH_INSTANTS.includes(p.instant));
|
|
66
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crash runner — drives the REAL engine to an enumerated crash point, kills
|
|
3
|
+
* it there, then runs recovery on a fresh engine over the same durable state
|
|
4
|
+
* and asserts the invariants (harness-design §1.4).
|
|
5
|
+
*
|
|
6
|
+
* "Kill" is modelled by throwing from inside the effect at the chosen
|
|
7
|
+
* instant: the engine's in-memory state dies, only journaled/durable state
|
|
8
|
+
* survives — exactly what a kill -9 leaves behind. (The real-process tier
|
|
9
|
+
* repeats this against a spawned daemon; the logic tier runs every point
|
|
10
|
+
* cheaply and deterministically.)
|
|
11
|
+
*/
|
|
12
|
+
import { UpgradeEngine, type EngineDeps } from "../../../core/src/txn/engine.ts";
|
|
13
|
+
import type { JournalEntry, TxnPhase } from "../../../core/src/txn/state.ts";
|
|
14
|
+
import type { Slot, ProcessEvidence } from "../../../core/src/lifecycle/hostAdapter.ts";
|
|
15
|
+
import { checkInvariants, type Violation, type WorldSnapshot } from "../../../core/src/invariants.ts";
|
|
16
|
+
import type { CrashPoint } from "./enumerate.ts";
|
|
17
|
+
|
|
18
|
+
class SimulatedCrash extends Error {
|
|
19
|
+
constructor(pointId: string) {
|
|
20
|
+
super(`simulated crash at ${pointId}`);
|
|
21
|
+
this.name = "SimulatedCrash";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface DurableWorld {
|
|
26
|
+
journal: JournalEntry[];
|
|
27
|
+
slots: { stable: string | null; experiment: string | null };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface CrashRunResult {
|
|
31
|
+
point: CrashPoint;
|
|
32
|
+
crashed: boolean;
|
|
33
|
+
violationsAtCrash: Violation[];
|
|
34
|
+
violationsAfterRecovery: Violation[];
|
|
35
|
+
finalPhase: TxnPhase;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function snapshot(world: DurableWorld, live: WorldSnapshot["liveProcesses"]): WorldSnapshot {
|
|
39
|
+
const intents = world.journal.map((e) => e.intent);
|
|
40
|
+
return {
|
|
41
|
+
phase: intents.at(-1) ?? "idle",
|
|
42
|
+
slots: { ...world.slots },
|
|
43
|
+
liveProcesses: live,
|
|
44
|
+
journalIntents: intents,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Run one crash point end to end.
|
|
50
|
+
* targetPhase/instant decide where the injected failure fires.
|
|
51
|
+
*/
|
|
52
|
+
export async function runCrashPoint(point: CrashPoint): Promise<CrashRunResult> {
|
|
53
|
+
// Durable state survives the crash; in-memory engine state does not.
|
|
54
|
+
const world: DurableWorld = { journal: [], slots: { stable: "1.0.0", experiment: null } };
|
|
55
|
+
let live: WorldSnapshot["liveProcesses"] = [
|
|
56
|
+
{ slot: "stable", pid: 100, startId: "s-100", version: "1.0.0" },
|
|
57
|
+
];
|
|
58
|
+
let crashed = false;
|
|
59
|
+
let startCounter = 100;
|
|
60
|
+
|
|
61
|
+
const targetIntent = point.transition.to;
|
|
62
|
+
// A crash point on a rollback edge can only fire if the run actually
|
|
63
|
+
// rolls back, so drive predicates to refuse for those points.
|
|
64
|
+
const rollbackScenario = point.transition.to === "rolled-back";
|
|
65
|
+
const shouldCrash = (instant: CrashPoint["instant"], intent: TxnPhase): boolean =>
|
|
66
|
+
!crashed && intent === targetIntent && instant === point.instant;
|
|
67
|
+
|
|
68
|
+
const makeDeps = (armed: boolean): EngineDeps => ({
|
|
69
|
+
effects: {
|
|
70
|
+
journal: {
|
|
71
|
+
appendAndSync: async (entry) => {
|
|
72
|
+
if (armed && shouldCrash("before-journal", entry.intent)) {
|
|
73
|
+
crashed = true;
|
|
74
|
+
throw new SimulatedCrash(point.id);
|
|
75
|
+
}
|
|
76
|
+
world.journal.push(entry);
|
|
77
|
+
if (armed && shouldCrash("after-journal", entry.intent)) {
|
|
78
|
+
crashed = true;
|
|
79
|
+
throw new SimulatedCrash(point.id);
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
readAll: async () => [...world.journal],
|
|
83
|
+
},
|
|
84
|
+
slots: {
|
|
85
|
+
stageExperiment: async (a) => {
|
|
86
|
+
world.slots.experiment = a.version;
|
|
87
|
+
maybeCrashAfterAction("staged");
|
|
88
|
+
},
|
|
89
|
+
slotVersions: async () => ({ ...world.slots }),
|
|
90
|
+
promoteExperiment: async () => {
|
|
91
|
+
// Production slot adapters promise idempotent redo: terminal
|
|
92
|
+
// recovery may call promote again after the experiment was already
|
|
93
|
+
// moved into stable. The crash model must preserve that contract or
|
|
94
|
+
// it manufactures a brick by assigning null to stable on replay.
|
|
95
|
+
if (world.slots.experiment !== null) {
|
|
96
|
+
world.slots.stable = world.slots.experiment;
|
|
97
|
+
world.slots.experiment = null;
|
|
98
|
+
}
|
|
99
|
+
maybeCrashAfterAction("promoted");
|
|
100
|
+
},
|
|
101
|
+
clearExperiment: async () => {
|
|
102
|
+
world.slots.experiment = null;
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
host: {
|
|
107
|
+
quiesce: async () => {},
|
|
108
|
+
stop: async () => {
|
|
109
|
+
live = [];
|
|
110
|
+
},
|
|
111
|
+
start: async (slot: Slot) => {
|
|
112
|
+
startCounter += 1;
|
|
113
|
+
const version = world.slots[slot];
|
|
114
|
+
live = version === null ? [] : [{ slot, pid: startCounter, startId: `s-${startCounter}`, version }];
|
|
115
|
+
maybeCrashAfterAction(slot === "experiment" ? "handing-over" : "rolled-back");
|
|
116
|
+
},
|
|
117
|
+
healthProbe: async (): Promise<ProcessEvidence> => {
|
|
118
|
+
const p = live.at(0);
|
|
119
|
+
if (!p) throw new Error("no live process to probe");
|
|
120
|
+
const evidence = { version: p.version, pid: p.pid, startId: p.startId };
|
|
121
|
+
maybeCrashAfterAction("running-experiment");
|
|
122
|
+
return evidence;
|
|
123
|
+
},
|
|
124
|
+
resume: async () => {},
|
|
125
|
+
},
|
|
126
|
+
clock: { nowMs: () => world.journal.length, after: () => () => {} },
|
|
127
|
+
evaluatePredicates: async () => {
|
|
128
|
+
maybeCrashAfterAction("readback");
|
|
129
|
+
// Rollback edges are only reachable when predicates refuse; the
|
|
130
|
+
// scenario is chosen by the crash point being exercised.
|
|
131
|
+
return rollbackScenario ? "predicates refused (scenario)" : null;
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
function maybeCrashAfterAction(intent: TxnPhase): void {
|
|
136
|
+
if (shouldCrash("after-action", intent)) {
|
|
137
|
+
crashed = true;
|
|
138
|
+
throw new SimulatedCrash(point.id);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Phase 1: run until the injected crash (or completion).
|
|
143
|
+
try {
|
|
144
|
+
await new UpgradeEngine(makeDeps(true)).upgrade({ version: "2.0.0", bytesRef: "ref" });
|
|
145
|
+
} catch (err) {
|
|
146
|
+
if (!(err instanceof SimulatedCrash)) throw err;
|
|
147
|
+
}
|
|
148
|
+
const violationsAtCrash = checkInvariants(snapshot(world, live));
|
|
149
|
+
|
|
150
|
+
// Phase 2: fresh process over the same durable state -> recover.
|
|
151
|
+
const recovered = new UpgradeEngine(makeDeps(false));
|
|
152
|
+
await recovered.recover();
|
|
153
|
+
const after = snapshot(world, live);
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
point,
|
|
157
|
+
crashed,
|
|
158
|
+
violationsAtCrash,
|
|
159
|
+
violationsAfterRecovery: checkInvariants(after),
|
|
160
|
+
finalPhase: after.phase,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Examples acceptance checks — the run bodies of the demo teeth
|
|
3
|
+
* (registered in teeth/examples.ts). Each throws on violation.
|
|
4
|
+
*
|
|
5
|
+
* Each demo is the credential for its profile's support claim
|
|
6
|
+
* (examples/README.md: "if a profile has no green example, the claim does
|
|
7
|
+
* not exist"):
|
|
8
|
+
* - swap-tool: black-box upgrade loop (L0/L0.5/L1') — version command,
|
|
9
|
+
* self-upgrade swaps bytes, next run reports the served version;
|
|
10
|
+
* - service-daemon: process-reality L2/L3 — real spawn, startId-bound
|
|
11
|
+
* probe evidence, OS-confirmed stop, upgrade effective next spawn;
|
|
12
|
+
* - hosted-service: adapter contract subset (§1.7) — session preservation
|
|
13
|
+
* incl. after rollback + probe veracity/binding.
|
|
14
|
+
*/
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
import { promises as fs } from "node:fs";
|
|
18
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
19
|
+
import { pathToFileURL } from "node:url";
|
|
20
|
+
import { type ToothContext } from "../teeth/registry.ts";
|
|
21
|
+
import { ArtifactFactory, type Behavior } from "../artifact-factory/factory.ts";
|
|
22
|
+
import { commandForArtifact, runCommand } from "../artifact-factory/run.ts";
|
|
23
|
+
import { FakeServer } from "../fake-server/server.ts";
|
|
24
|
+
import { sha256Hex } from "../fake-server/manifest.ts";
|
|
25
|
+
import { processAlive } from "../fake-host/daemon.ts";
|
|
26
|
+
import { currentPlatformKey } from "../../../core/src/artifact/staticManifestSource.ts";
|
|
27
|
+
import {
|
|
28
|
+
checkLedgerEquivalence,
|
|
29
|
+
checkLedgerEquivalenceAfterRollback,
|
|
30
|
+
checkProbeVersionMatchesSlot,
|
|
31
|
+
checkProbeBindsCurrentIncarnation,
|
|
32
|
+
} from "../fake-host/checks.ts";
|
|
33
|
+
import type { HostDriver } from "../fake-host/inproc.ts";
|
|
34
|
+
import { CLI_TOOL_SOURCE } from "../../../examples/swap-tool/source.ts";
|
|
35
|
+
import { PLAIN_DAEMON_SOURCE } from "../../../examples/service-daemon/source.ts";
|
|
36
|
+
|
|
37
|
+
const RELEASE_BASE_ENV = "K_RELEASE_BASE";
|
|
38
|
+
|
|
39
|
+
/** The swap-tool's explicit target declaration (mirrors k.target.ts). */
|
|
40
|
+
export const CLI_TOOL_TARGET_TS = `export default { version: ["--version"], selfUpgrade: ["self", "upgrade"] };
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
async function buildDemoBinary(
|
|
44
|
+
ctx: ToothContext,
|
|
45
|
+
opts: { source: string; version: string; behavior: Behavior; name: string },
|
|
46
|
+
): Promise<{ binPath: string }> {
|
|
47
|
+
const factory = new ArtifactFactory({
|
|
48
|
+
cacheDir: path.join(ctx.sandboxDir, "cache"),
|
|
49
|
+
demoSource: opts.source,
|
|
50
|
+
});
|
|
51
|
+
const server = new FakeServer({ storeDir: path.join(ctx.sandboxDir, "store") });
|
|
52
|
+
await server.start();
|
|
53
|
+
try {
|
|
54
|
+
const rel = await factory.makeRelease({
|
|
55
|
+
version: opts.version,
|
|
56
|
+
behavior: opts.behavior,
|
|
57
|
+
store: server.store,
|
|
58
|
+
platform: currentPlatformKey(),
|
|
59
|
+
});
|
|
60
|
+
const binDir = path.join(ctx.sandboxDir, "app");
|
|
61
|
+
await fs.mkdir(binDir, { recursive: true });
|
|
62
|
+
const binPath = path.join(binDir, opts.name);
|
|
63
|
+
await fs.writeFile(binPath, rel.artifactBytes, { mode: 0o755 });
|
|
64
|
+
return { binPath };
|
|
65
|
+
} finally {
|
|
66
|
+
await server.stop();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function fileSha256(p: string): Promise<string> {
|
|
71
|
+
return sha256Hex(new Uint8Array(await fs.readFile(p)));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The swap-tool demo's @botiverse/k-carrier wiring (createUpgrader module URL). */
|
|
75
|
+
function coreUpgraderUrl(): string {
|
|
76
|
+
return pathToFileURL(path.join(import.meta.dirname, "../../../core/src/createUpgrader.ts")).href;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// swap-tool
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
export async function checkCliToolBlackbox(
|
|
84
|
+
ctx: ToothContext,
|
|
85
|
+
opts: { serveSameVersion?: boolean } = {},
|
|
86
|
+
): Promise<void> {
|
|
87
|
+
const { binPath } = await buildDemoBinary(ctx, {
|
|
88
|
+
source: CLI_TOOL_SOURCE,
|
|
89
|
+
version: "1.0.0",
|
|
90
|
+
behavior: "ok",
|
|
91
|
+
name: "swap-tool",
|
|
92
|
+
});
|
|
93
|
+
await fs.writeFile(path.join(ctx.sandboxDir, "app", "k.target.ts"), CLI_TOOL_TARGET_TS);
|
|
94
|
+
|
|
95
|
+
// serve the target release
|
|
96
|
+
const server = new FakeServer({ storeDir: path.join(ctx.sandboxDir, "store2") });
|
|
97
|
+
await server.start();
|
|
98
|
+
const factory = new ArtifactFactory({
|
|
99
|
+
cacheDir: path.join(ctx.sandboxDir, "cache2"),
|
|
100
|
+
demoSource: CLI_TOOL_SOURCE,
|
|
101
|
+
});
|
|
102
|
+
try {
|
|
103
|
+
const targetVersion = opts.serveSameVersion ? "1.0.0" : "2.0.0";
|
|
104
|
+
await factory.makeRelease({ version: targetVersion, behavior: "ok", store: server.store, platform: currentPlatformKey() });
|
|
105
|
+
|
|
106
|
+
const before = await fileSha256(binPath);
|
|
107
|
+
const v1 = await runCommand(binPath, ["--version"]);
|
|
108
|
+
assert.equal(v1.code, 0, `version command must exit 0 (${v1.stderr.trim()})`);
|
|
109
|
+
assert.equal(v1.stdout.trim(), "1.0.0");
|
|
110
|
+
|
|
111
|
+
const up = await runCommand(binPath, ["self", "upgrade"], {
|
|
112
|
+
env: {
|
|
113
|
+
[RELEASE_BASE_ENV]: server.url,
|
|
114
|
+
K_CORE_UPGRADER: coreUpgraderUrl(),
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
assert.equal(up.code, 0, `self upgrade must exit 0 (${up.stderr.trim()})`);
|
|
118
|
+
const after = await fileSha256(binPath);
|
|
119
|
+
assert.notEqual(after, before, "self upgrade must change the binary bytes on disk");
|
|
120
|
+
|
|
121
|
+
const v2 = await runCommand(binPath, ["--version"]);
|
|
122
|
+
assert.equal(v2.code, 0);
|
|
123
|
+
assert.equal(v2.stdout.trim(), targetVersion, "next run must report the served version");
|
|
124
|
+
|
|
125
|
+
// the app's real command still works on the new version
|
|
126
|
+
const greet = await runCommand(binPath, ["greet", "K"]);
|
|
127
|
+
assert.equal(greet.code, 0);
|
|
128
|
+
assert.match(greet.stdout, new RegExp(`Hello, K! \\(v${targetVersion}\\)`));
|
|
129
|
+
} finally {
|
|
130
|
+
await server.stop();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// service-daemon
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
function readLine(child: ChildProcess, prefix: string, timeoutMs = 5000): Promise<string> {
|
|
139
|
+
return new Promise((resolve, reject) => {
|
|
140
|
+
let buffer = "";
|
|
141
|
+
const timer = setTimeout(() => {
|
|
142
|
+
cleanup();
|
|
143
|
+
reject(new Error(`timed out waiting for "${prefix}" from service-daemon`));
|
|
144
|
+
}, timeoutMs);
|
|
145
|
+
const onData = (chunk: Buffer): void => {
|
|
146
|
+
buffer += chunk.toString("utf8");
|
|
147
|
+
for (const line of buffer.split("\n")) {
|
|
148
|
+
if (line.startsWith(`${prefix} `)) {
|
|
149
|
+
cleanup();
|
|
150
|
+
resolve(line.slice(prefix.length + 1));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
const cleanup = (): void => {
|
|
156
|
+
clearTimeout(timer);
|
|
157
|
+
child.stdout?.off("data", onData);
|
|
158
|
+
};
|
|
159
|
+
child.stdout?.on("data", onData);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function waitDead(pid: number, timeoutMs = 5000): Promise<void> {
|
|
164
|
+
const deadline = Date.now() + timeoutMs;
|
|
165
|
+
while (processAlive(pid)) {
|
|
166
|
+
if (Date.now() > deadline) throw new Error(`pid ${pid} still alive`);
|
|
167
|
+
await new Promise((r) => {
|
|
168
|
+
setTimeout(r, 10);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function spawnDaemon(binPath: string): { child: ChildProcess; pid: number } {
|
|
174
|
+
// Windows cannot spawn a shebang artifact directly (see commandForArtifact).
|
|
175
|
+
const routed = commandForArtifact(binPath, []);
|
|
176
|
+
const child = spawn(routed.cmd, routed.args, { stdio: ["pipe", "pipe", "ignore"] });
|
|
177
|
+
if (!child.pid) throw new Error("daemon did not get a pid");
|
|
178
|
+
return { child, pid: child.pid };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function checkPlainDaemonContract(
|
|
182
|
+
ctx: ToothContext,
|
|
183
|
+
opts: { behavior?: Behavior } = {},
|
|
184
|
+
): Promise<void> {
|
|
185
|
+
const { binPath } = await buildDemoBinary(ctx, {
|
|
186
|
+
source: PLAIN_DAEMON_SOURCE,
|
|
187
|
+
version: "1.0.0",
|
|
188
|
+
behavior: opts.behavior ?? "ok",
|
|
189
|
+
name: "service-daemon",
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// 1) process-reality lifecycle: spawn -> probe -> stop, OS-confirmed dead
|
|
193
|
+
const { child, pid } = spawnDaemon(binPath);
|
|
194
|
+
try {
|
|
195
|
+
const ready = JSON.parse(await readLine(child, "ready")) as {
|
|
196
|
+
version: string;
|
|
197
|
+
pid: number;
|
|
198
|
+
startId: string;
|
|
199
|
+
};
|
|
200
|
+
assert.equal(ready.version, "1.0.0");
|
|
201
|
+
assert.equal(ready.pid, pid);
|
|
202
|
+
assert.ok(processAlive(pid), "OS must report the daemon alive after start");
|
|
203
|
+
|
|
204
|
+
child.stdin?.write("probe\n");
|
|
205
|
+
const evidence = JSON.parse(await readLine(child, "evidence")) as {
|
|
206
|
+
version: string;
|
|
207
|
+
pid: number;
|
|
208
|
+
startId: string;
|
|
209
|
+
};
|
|
210
|
+
assert.equal(evidence.version, "1.0.0", "probe must report the running version");
|
|
211
|
+
assert.equal(evidence.pid, pid, "evidence must bind the live pid");
|
|
212
|
+
assert.equal(evidence.startId, ready.startId, "evidence must bind the current incarnation");
|
|
213
|
+
|
|
214
|
+
child.stdin?.write("exit\n");
|
|
215
|
+
await waitDead(pid);
|
|
216
|
+
assert.ok(!processAlive(pid), "stop must leave the daemon OS-dead");
|
|
217
|
+
} finally {
|
|
218
|
+
if (processAlive(pid)) child.kill("SIGKILL");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 2) upgrade through core's Upgrader with the real service host: the new
|
|
222
|
+
// version becomes the RUNNING service (registered, alive), not a byte swap.
|
|
223
|
+
const server = new FakeServer({ storeDir: path.join(ctx.sandboxDir, "store2") });
|
|
224
|
+
await server.start();
|
|
225
|
+
const factory = new ArtifactFactory({
|
|
226
|
+
cacheDir: path.join(ctx.sandboxDir, "cache2"),
|
|
227
|
+
demoSource: PLAIN_DAEMON_SOURCE,
|
|
228
|
+
});
|
|
229
|
+
try {
|
|
230
|
+
await factory.makeRelease({
|
|
231
|
+
version: "2.0.0",
|
|
232
|
+
behavior: "ok",
|
|
233
|
+
store: server.store,
|
|
234
|
+
platform: currentPlatformKey(),
|
|
235
|
+
});
|
|
236
|
+
const stateDir = path.join(ctx.sandboxDir, "app", "state");
|
|
237
|
+
const up = await runCommand(binPath, ["self", "upgrade"], {
|
|
238
|
+
env: {
|
|
239
|
+
[RELEASE_BASE_ENV]: server.url,
|
|
240
|
+
K_CORE_UPGRADER: coreUpgraderUrl(),
|
|
241
|
+
K_STATE_DIR: stateDir,
|
|
242
|
+
K_HOST_SHAPE: "spawn",
|
|
243
|
+
},
|
|
244
|
+
timeoutMs: 30000,
|
|
245
|
+
});
|
|
246
|
+
assert.equal(up.code, 0, `self upgrade must exit 0 (${up.stderr.trim()})`);
|
|
247
|
+
|
|
248
|
+
// the running service is now the new version, registered and alive
|
|
249
|
+
const inc = JSON.parse(
|
|
250
|
+
await fs.readFile(path.join(stateDir, "incarnation.json"), "utf8"),
|
|
251
|
+
) as { version: string; pid: number };
|
|
252
|
+
assert.equal(inc.version, "2.0.0", "the running service must be the new version");
|
|
253
|
+
assert.ok(processAlive(inc.pid), "the upgraded service must be alive");
|
|
254
|
+
try {
|
|
255
|
+
process.kill(inc.pid, "SIGKILL");
|
|
256
|
+
} catch {
|
|
257
|
+
// already gone
|
|
258
|
+
}
|
|
259
|
+
await waitDead(inc.pid);
|
|
260
|
+
} finally {
|
|
261
|
+
await server.stop();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// hosted-service
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
export async function checkManagedHostAdapter(
|
|
270
|
+
ctx: ToothContext,
|
|
271
|
+
opts: { hostOverride?: () => HostDriver } = {},
|
|
272
|
+
): Promise<void> {
|
|
273
|
+
// Each contract check needs a FRESH host (checks leave it running);
|
|
274
|
+
// hostOverride is a factory so known-red can inject a broken host.
|
|
275
|
+
const makeHost = async (): Promise<HostDriver> =>
|
|
276
|
+
opts.hostOverride
|
|
277
|
+
? opts.hostOverride()
|
|
278
|
+
: (await import("../../../examples/hosted-service/host.ts")).createManagedHost(
|
|
279
|
+
path.join(ctx.sandboxDir, "host"),
|
|
280
|
+
);
|
|
281
|
+
// the same contract subset k-harness --adapter runs (§1.7)
|
|
282
|
+
await checkLedgerEquivalence(ctx, { host: await makeHost() });
|
|
283
|
+
await checkLedgerEquivalenceAfterRollback(ctx, { host: await makeHost() });
|
|
284
|
+
await checkProbeVersionMatchesSlot(ctx, { host: await makeHost() });
|
|
285
|
+
await checkProbeBindsCurrentIncarnation(ctx, { host: await makeHost() });
|
|
286
|
+
}
|