@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,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Liveness and progress — deliberately NOT in invariants.ts.
|
|
3
|
+
*
|
|
4
|
+
* Everything in the safety set there is satisfied by a host that does nothing
|
|
5
|
+
* at all: a DEAD service violates none of them (never-bricked judges slots,
|
|
6
|
+
* live-process-matches-slot is vacuous with nothing alive, the rest read
|
|
7
|
+
* journals). Safety therefore cannot say an updater works. These two functions
|
|
8
|
+
* are what says it, and they are judged over time rather than over one
|
|
9
|
+
* snapshot, which is exactly why they do not fit the Invariant shape.
|
|
10
|
+
*/
|
|
11
|
+
import type { TxnPhase } from "./txn/state.ts";
|
|
12
|
+
import type { WorldSnapshot, InvariantResult, HostAssumption } from "./invariants.ts";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* LIVENESS (progress), deliberately separate from the safety set above.
|
|
16
|
+
*
|
|
17
|
+
* Every safety invariant here is satisfiable by doing nothing at all, so
|
|
18
|
+
* safety alone cannot say an updater works. This is the minimal progress
|
|
19
|
+
* property: a transaction that started must reach a terminal phase within a
|
|
20
|
+
* bounded number of steps — never parked in `staged`/`readback` forever.
|
|
21
|
+
*/
|
|
22
|
+
export function reachesTerminalWithin(
|
|
23
|
+
phaseTrace: readonly TxnPhase[],
|
|
24
|
+
maxSteps: number,
|
|
25
|
+
): InvariantResult {
|
|
26
|
+
const terminal = new Set<TxnPhase>(["idle", "promoted", "rolled-back"]);
|
|
27
|
+
const last = phaseTrace.at(-1);
|
|
28
|
+
if (last !== undefined && terminal.has(last)) return null;
|
|
29
|
+
return phaseTrace.length >= maxSteps
|
|
30
|
+
? `transaction still in ${last ?? "unknown"} after ${phaseTrace.length} steps (limit ${maxSteps}) — no progress to a terminal phase`
|
|
31
|
+
: null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* PROGRESS, and the reason it cannot be a plain invariant.
|
|
36
|
+
*
|
|
37
|
+
* Every safety invariant above is satisfied by a host with NO live process:
|
|
38
|
+
* `never-bricked` is about slots (its own description says so),
|
|
39
|
+
* `live-process-matches-slot` is vacuous when nothing is live, and the journal
|
|
40
|
+
* ones only look at journals. So a service that is simply DEAD passes the
|
|
41
|
+
* entire safety set — which is the "doing nothing satisfies safety" hole, in
|
|
42
|
+
* our own code.
|
|
43
|
+
*
|
|
44
|
+
* What an adopter actually needs promised (once session
|
|
45
|
+
* preservation was ruled out): *you always come back*. That is a liveness
|
|
46
|
+
* property, and it needs two things a single snapshot cannot give:
|
|
47
|
+
* - TIME: right after a terminal phase nothing is live yet; starting takes a
|
|
48
|
+
* moment. Judging one instant would fail a healthy upgrade.
|
|
49
|
+
* - OBSERVATION: it must be checkable from outside, from the same status
|
|
50
|
+
* readback anyone else can take — not from our internal logs.
|
|
51
|
+
*
|
|
52
|
+
* So it is expressed over a sequence of observed snapshots: once the
|
|
53
|
+
* transaction is terminal, some later observation within the budget must show
|
|
54
|
+
* a live incarnation running the stable slot's version.
|
|
55
|
+
*/
|
|
56
|
+
export const SERVICE_SETTLES_LIVE_ASSUMES: readonly HostAssumption[] = ["resident-service"];
|
|
57
|
+
|
|
58
|
+
export function serviceSettlesLive(
|
|
59
|
+
observations: readonly WorldSnapshot[],
|
|
60
|
+
maxObservations: number,
|
|
61
|
+
): InvariantResult {
|
|
62
|
+
const terminal = new Set<TxnPhase>(["idle", "promoted", "rolled-back"]);
|
|
63
|
+
const firstTerminal = observations.findIndex((s) => terminal.has(s.phase));
|
|
64
|
+
if (firstTerminal === -1) return null; // still in flight: reachesTerminalWithin judges that
|
|
65
|
+
const after = observations.slice(firstTerminal, firstTerminal + maxObservations);
|
|
66
|
+
for (const s of after) {
|
|
67
|
+
const expected = s.slots.stable;
|
|
68
|
+
if (expected === null) continue;
|
|
69
|
+
if (s.liveProcesses.some((p) => p.version === expected)) return null;
|
|
70
|
+
}
|
|
71
|
+
const last = after.at(-1);
|
|
72
|
+
const seen =
|
|
73
|
+
last === undefined || last.liveProcesses.length === 0
|
|
74
|
+
? "nothing live"
|
|
75
|
+
: last.liveProcesses.map((p) => p.version).join(", ");
|
|
76
|
+
return (
|
|
77
|
+
`transaction settled at ${observations[firstTerminal]!.phase} but no live process reports the ` +
|
|
78
|
+
`stable version ${last?.slots.stable ?? "unknown"} within ${maxObservations} observations (saw: ${seen})`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# platform — see docs/design-v1.md for this layer's spec. Interfaces land here next.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { PlatformOps } from "./ops.ts";
|
|
2
|
+
import { posixOps } from "./posix.ts";
|
|
3
|
+
import { windowsOps } from "./windows.ts";
|
|
4
|
+
|
|
5
|
+
/** Pick the seam implementation for the host we are running on. */
|
|
6
|
+
export function platformOpsFor(platform: NodeJS.Platform = process.platform): PlatformOps {
|
|
7
|
+
return platform === "win32" ? windowsOps : posixOps;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type { PlatformOps } from "./ops.ts";
|
|
11
|
+
export { PlatformUnsupportedError } from "./ops.ts";
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PlatformOps — the seam where OS-shaped behaviour lives.
|
|
3
|
+
*
|
|
4
|
+
* Multi-platform from day 1, because these are not implementation details of
|
|
5
|
+
* one operation: the STEPS differ per OS.
|
|
6
|
+
*
|
|
7
|
+
* swapExecutable POSIX: write tmp -> fsync -> rename over the target.
|
|
8
|
+
* Windows: you cannot rename over a RUNNING .exe (file
|
|
9
|
+
* lock), so the running image must be moved aside first
|
|
10
|
+
* and the new bytes put in its place. Different sequence,
|
|
11
|
+
* not a different syscall.
|
|
12
|
+
* isProcessAlive POSIX: signal 0. Windows: no signals at all.
|
|
13
|
+
* killProcess POSIX: SIGKILL. Windows: TerminateProcess semantics.
|
|
14
|
+
*
|
|
15
|
+
* A ratchet keeps signal names / process.kill / rename out of the rest of
|
|
16
|
+
* core, so a POSIX assumption cannot quietly grow into the engine.
|
|
17
|
+
*/
|
|
18
|
+
export interface PlatformOps {
|
|
19
|
+
/**
|
|
20
|
+
* Replace an executable's bytes. Contract:
|
|
21
|
+
* - crash-safe: a reader sees the old file or the whole new one, never half;
|
|
22
|
+
* - PRESERVES the target's mode/permissions. A self-upgrading binary that
|
|
23
|
+
* loses +x fails on its NEXT launch, far from the upgrade that caused it
|
|
24
|
+
* (found in review: every self-upgrading binary hits this).
|
|
25
|
+
*/
|
|
26
|
+
swapExecutable(filePath: string, data: Uint8Array): Promise<void>;
|
|
27
|
+
/** Does the OS still know this pid? */
|
|
28
|
+
isProcessAlive(pid: number): boolean;
|
|
29
|
+
/** Terminate without giving the process a chance to clean up (crash sim / teardown). */
|
|
30
|
+
killProcess(pid: number): void;
|
|
31
|
+
/** Mark a file executable where the concept exists; a no-op where it does not. */
|
|
32
|
+
makeExecutable(filePath: string): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* Move a path (file or directory) into place. Used to publish a slot
|
|
35
|
+
* atomically. Separate from swapExecutable because that one is about a
|
|
36
|
+
* RUNNING image; this one is plain state.
|
|
37
|
+
*/
|
|
38
|
+
renamePath(from: string, to: string): Promise<void>;
|
|
39
|
+
/** Identifier used to select a manifest target, e.g. "linux-x64". */
|
|
40
|
+
platformKey(): string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class PlatformUnsupportedError extends Error {
|
|
44
|
+
readonly code = "PLATFORM_UNSUPPORTED";
|
|
45
|
+
|
|
46
|
+
constructor(operation: string, platform: string) {
|
|
47
|
+
super(
|
|
48
|
+
`[PLATFORM_UNSUPPORTED] ${operation} is not implemented for ${platform} yet — ` +
|
|
49
|
+
`K refuses to approximate it rather than corrupt an install`,
|
|
50
|
+
);
|
|
51
|
+
this.name = "PlatformUnsupportedError";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POSIX implementation of the platform seam (linux, darwin).
|
|
3
|
+
* The only place in core allowed to name rename/signals.
|
|
4
|
+
*/
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { promises as fs } from "node:fs";
|
|
7
|
+
import type { PlatformOps } from "./ops.ts";
|
|
8
|
+
|
|
9
|
+
async function atomicReplace(filePath: string, data: Uint8Array): Promise<void> {
|
|
10
|
+
const tmpPath = `${filePath}.tmp`;
|
|
11
|
+
try {
|
|
12
|
+
// Preserve the target's mode across the swap: a self-replacing
|
|
13
|
+
// executable must stay executable (a leftover tmp from a previous
|
|
14
|
+
// crash must not dictate the new file's permissions either).
|
|
15
|
+
let mode: number | undefined;
|
|
16
|
+
try {
|
|
17
|
+
mode = (await fs.stat(filePath)).mode;
|
|
18
|
+
} catch {
|
|
19
|
+
// target does not exist yet: default permissions
|
|
20
|
+
}
|
|
21
|
+
const fh = await fs.open(tmpPath, "w");
|
|
22
|
+
try {
|
|
23
|
+
if (mode !== undefined) await fh.chmod(mode & 0o777);
|
|
24
|
+
await fh.writeFile(data);
|
|
25
|
+
await fh.sync(); // durable before the rename makes it visible
|
|
26
|
+
} finally {
|
|
27
|
+
await fh.close();
|
|
28
|
+
}
|
|
29
|
+
// Atomic on POSIX: a reader sees either the old file or the whole new one.
|
|
30
|
+
await fs.rename(tmpPath, filePath);
|
|
31
|
+
} catch (err) {
|
|
32
|
+
await fs.unlink(tmpPath).catch(() => {});
|
|
33
|
+
throw err;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Does this Mac have arm64 hardware, whatever this process is running as?
|
|
39
|
+
*
|
|
40
|
+
* Untestable on a non-Mac, so it is one line delegating to the OS while the
|
|
41
|
+
* DECISION it feeds lives in `platformKeyFor`, which is pure and pinned.
|
|
42
|
+
*/
|
|
43
|
+
function hardwareSupportsDarwinArm64(): boolean {
|
|
44
|
+
try {
|
|
45
|
+
return (
|
|
46
|
+
execFileSync("/usr/sbin/sysctl", ["-in", "hw.optional.arm64"], {
|
|
47
|
+
encoding: "utf8",
|
|
48
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
49
|
+
}).trim() === "1"
|
|
50
|
+
);
|
|
51
|
+
} catch {
|
|
52
|
+
return false; // not a Mac, sysctl missing, or no such key
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Which manifest target this machine should be served.
|
|
58
|
+
*
|
|
59
|
+
* The one non-obvious case is Rosetta: an x64 Node on Apple Silicon reports
|
|
60
|
+
* `process.arch === "x64"`, so the naive `platform-arch` picks the x64 target
|
|
61
|
+
* FOREVER. The machine never moves to the native build, and an adopter who
|
|
62
|
+
* publishes arm64 sees it silently ignored on exactly the hardware it is for.
|
|
63
|
+
* Nothing errors -- the wrong artifact is a perfectly valid artifact.
|
|
64
|
+
*
|
|
65
|
+
* So on darwin+x64 we ask the HARDWARE, not the process.
|
|
66
|
+
*/
|
|
67
|
+
export function platformKeyFor(
|
|
68
|
+
platform: string,
|
|
69
|
+
processArch: string,
|
|
70
|
+
supportsDarwinArm64: () => boolean,
|
|
71
|
+
): string {
|
|
72
|
+
const arch =
|
|
73
|
+
platform === "darwin" && processArch === "x64" && supportsDarwinArm64() ? "arm64" : processArch;
|
|
74
|
+
return `${platform}-${arch}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const posixOps: PlatformOps = {
|
|
78
|
+
swapExecutable: atomicReplace,
|
|
79
|
+
isProcessAlive(pid) {
|
|
80
|
+
try {
|
|
81
|
+
process.kill(pid, 0);
|
|
82
|
+
return true;
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
killProcess(pid) {
|
|
88
|
+
process.kill(pid, "SIGKILL");
|
|
89
|
+
},
|
|
90
|
+
async renamePath(from, to) {
|
|
91
|
+
await fs.rename(from, to);
|
|
92
|
+
},
|
|
93
|
+
async makeExecutable(filePath) {
|
|
94
|
+
await fs.chmod(filePath, 0o755);
|
|
95
|
+
},
|
|
96
|
+
platformKey() {
|
|
97
|
+
return platformKeyFor(process.platform, process.arch, hardwareSupportsDarwinArm64);
|
|
98
|
+
},
|
|
99
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows implementation of the platform seam.
|
|
3
|
+
*
|
|
4
|
+
* The swap sequence differs from POSIX because a RUNNING .exe is
|
|
5
|
+
* NTFS-locked: it can be RENAMED (moved aside) but not deleted or
|
|
6
|
+
* overwritten in place. So the swap is move-aside → place-new →
|
|
7
|
+
* pending-delete — the shape prior-art.md documents from Tailscale's
|
|
8
|
+
* Windows updater. The aside file stays until the old process exits; the
|
|
9
|
+
* next successful swap removes it.
|
|
10
|
+
*
|
|
11
|
+
* Process ops use `process.kill` like POSIX, but on Windows the semantics
|
|
12
|
+
* are libuv's: signal 0 is OpenProcess + GetExitCodeProcess (an existence
|
|
13
|
+
* check — the honest "is it alive" question, since Windows has no signals)
|
|
14
|
+
* and SIGKILL is TerminateProcess. There is no signal delivery, so there is
|
|
15
|
+
* nothing a process could catch or ignore.
|
|
16
|
+
*/
|
|
17
|
+
import { promises as fs } from "node:fs";
|
|
18
|
+
import type { PlatformOps } from "./ops.ts";
|
|
19
|
+
|
|
20
|
+
async function swapExecutable(filePath: string, data: Uint8Array): Promise<void> {
|
|
21
|
+
const tmpPath = `${filePath}.tmp`;
|
|
22
|
+
const asidePath = `${filePath}.old`;
|
|
23
|
+
const fh = await fs.open(tmpPath, "w");
|
|
24
|
+
try {
|
|
25
|
+
await fh.writeFile(data);
|
|
26
|
+
await fh.sync(); // durable before it becomes visible at the real path
|
|
27
|
+
} finally {
|
|
28
|
+
await fh.close();
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
// Move the running image aside. Renaming a locked .exe is allowed on
|
|
32
|
+
// Windows; deleting or overwriting it is not.
|
|
33
|
+
await fs.rename(filePath, asidePath);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
// First install (or a previous swap already moved it): no old image.
|
|
36
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
37
|
+
}
|
|
38
|
+
// Place the new image at the canonical path. The old name is now free, so
|
|
39
|
+
// a plain move works — the target does not exist.
|
|
40
|
+
await fs.rename(tmpPath, filePath);
|
|
41
|
+
// Best-effort: the aside may be the still-running old image (locked until
|
|
42
|
+
// the process exits). A leftover is removed by the next swap.
|
|
43
|
+
await fs.rm(asidePath, { force: true }).catch(() => {});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const windowsOps: PlatformOps = {
|
|
47
|
+
swapExecutable,
|
|
48
|
+
isProcessAlive(pid) {
|
|
49
|
+
// libuv maps signal 0 to an OpenProcess + GetExitCodeProcess existence
|
|
50
|
+
// check on Windows — the honest liveness question, not a signal.
|
|
51
|
+
try {
|
|
52
|
+
process.kill(pid, 0);
|
|
53
|
+
return true;
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
killProcess(pid) {
|
|
59
|
+
// libuv maps SIGKILL to TerminateProcess: no cleanup, no escape.
|
|
60
|
+
process.kill(pid, "SIGKILL");
|
|
61
|
+
},
|
|
62
|
+
async renamePath(from, to) {
|
|
63
|
+
// Plain state moves DO work on Windows (unlike replacing a running .exe).
|
|
64
|
+
await fs.rename(from, to);
|
|
65
|
+
},
|
|
66
|
+
async makeExecutable() {
|
|
67
|
+
// No executable bit on Windows; nothing to do (a real no-op).
|
|
68
|
+
await Promise.resolve();
|
|
69
|
+
},
|
|
70
|
+
platformKey() {
|
|
71
|
+
return `${process.platform}-${process.arch}`;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# policy — see docs/design-v1.md for this layer's spec. Interfaces land here next.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upgrade progress: what stage we are in, and how far the download got.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS IS NOT UI SUGAR. Between "start upgrading" and "done" there can be
|
|
5
|
+
* a 150MB download, a file swap, a process restart and a probe — minutes on a
|
|
6
|
+
* slow machine. With no signal, **"slow" and "hung" look identical from
|
|
7
|
+
* outside**, so a user kills it, and a kill lands in the middle of a
|
|
8
|
+
* transaction, which is the worst possible moment.
|
|
9
|
+
*
|
|
10
|
+
* The stages are NOT new vocabulary: they are the transaction's own phases,
|
|
11
|
+
* exposed. If a stage here ever disagrees with the state machine, the state
|
|
12
|
+
* machine is right and this is a bug.
|
|
13
|
+
*/
|
|
14
|
+
import type { TxnPhase } from "./txn/state.ts";
|
|
15
|
+
|
|
16
|
+
export type UpgradeStage =
|
|
17
|
+
| "checking" // asking the source what we should be on
|
|
18
|
+
| "downloading" // fetching bytes (carries downloaded/total when known)
|
|
19
|
+
| "verifying" // sha256 + size on the assembled artifact
|
|
20
|
+
| "staging" // writing the experiment slot
|
|
21
|
+
| "handing-over" // quiesce, stop, start — the live process changes
|
|
22
|
+
| "probing" // asking the new incarnation to prove itself
|
|
23
|
+
| "promoted" // experiment became stable
|
|
24
|
+
| "rolled-back"; // experiment discarded, stable untouched
|
|
25
|
+
|
|
26
|
+
export interface UpgradeProgress {
|
|
27
|
+
stage: UpgradeStage;
|
|
28
|
+
/** Target version, once the source has told us. */
|
|
29
|
+
version?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Bytes fetched so far and the expected total, when the stage is
|
|
32
|
+
* `downloading`.
|
|
33
|
+
*
|
|
34
|
+
* ⚠️ RESUME: `downloaded` counts bytes ON DISK, including a partial from a
|
|
35
|
+
* previous attempt — not bytes fetched this attempt. Otherwise a resumed
|
|
36
|
+
* download would show the bar jumping backwards to 0%, which reads as "it
|
|
37
|
+
* restarted" to the one person the number exists for.
|
|
38
|
+
*/
|
|
39
|
+
downloaded?: number;
|
|
40
|
+
total?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Map a journalled phase onto the stage a user should see. */
|
|
44
|
+
export function stageForPhase(phase: TxnPhase): UpgradeStage | null {
|
|
45
|
+
switch (phase) {
|
|
46
|
+
case "staged": return "staging";
|
|
47
|
+
case "handing-over": return "handing-over";
|
|
48
|
+
case "running-experiment":
|
|
49
|
+
case "readback": return "probing";
|
|
50
|
+
case "promoted": return "promoted";
|
|
51
|
+
case "rolled-back": return "rolled-back";
|
|
52
|
+
default: return null; // idle: nothing in flight, nothing to report
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provenance journal (M6, L5) — WHO reconciled this machine, forward-only.
|
|
3
|
+
*
|
|
4
|
+
* Every reconcile that reaches the upgrade transaction appends an entry
|
|
5
|
+
* {who, carrier, when, version} WRITE-AHEAD of the action, mirroring the
|
|
6
|
+
* txn journal's intent-before-action discipline. A machine that never had
|
|
7
|
+
* the machinery is GENESIS — permanently NOT_OBSERVED and mechanically
|
|
8
|
+
* distinct from "observed with zero reconciles": no-data is not no-problem.
|
|
9
|
+
*
|
|
10
|
+
* Forward-only invariants (each is a harness tooth):
|
|
11
|
+
* - appends only ever add a line; a seq at or below the last one is a
|
|
12
|
+
* typed refusal (PROVENANCE_SEQ_REWRITE) — provenance is not rewritable
|
|
13
|
+
* - a torn final line (crash mid-append) is dropped; earlier lines stand;
|
|
14
|
+
* a parse failure in a MIDDLE line is corruption: the history is
|
|
15
|
+
* UNREADABLE, never a short one — appending on it is refused, so one bad
|
|
16
|
+
* line can never re-open the seq (the rewrite-under-a-truncated-view
|
|
17
|
+
* attack)
|
|
18
|
+
* - only ENOENT is genesis: an unreadable journal (EACCES/EISDIR/EIO,
|
|
19
|
+
* corruption) is a THIRD state, and aggregation must never fold it into
|
|
20
|
+
* "recorded" OR into "not observed"
|
|
21
|
+
* - `version` (the artifact version the reconcile drove) is recorded —
|
|
22
|
+
* K removed the channel concept; the record is who/carrier/version
|
|
23
|
+
*/
|
|
24
|
+
import { promises as fs } from "node:fs";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import type { Clock } from "../clock.ts";
|
|
27
|
+
|
|
28
|
+
const PROVENANCE_FILE = "provenance.jsonl";
|
|
29
|
+
|
|
30
|
+
export interface ProvenanceEntry {
|
|
31
|
+
/** Monotonic, file order = history order. */
|
|
32
|
+
seq: number;
|
|
33
|
+
/** The driving identity (server/operator) of this reconcile. */
|
|
34
|
+
who: string;
|
|
35
|
+
/** The carrier channel the command travelled on. */
|
|
36
|
+
carrier: string;
|
|
37
|
+
/** Clock-provided timestamp; core never touches Date.now directly. */
|
|
38
|
+
when: number;
|
|
39
|
+
/** The artifact version this reconcile drove (channel is gone from K). */
|
|
40
|
+
version: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Three states, and the distinction is the whole point:
|
|
45
|
+
* - genesis: no journal file — the machine never had the machinery. The
|
|
46
|
+
* value is NOT_OBSERVED, not "zero reconciles".
|
|
47
|
+
* - observed: the machinery exists; entries may be zero (reconciled never).
|
|
48
|
+
* - unreadable: the journal exists but cannot be read (EACCES/EISDIR/EIO)
|
|
49
|
+
* or is corrupt (middle-line parse failure). This is NOT genesis — "I
|
|
50
|
+
* cannot see the data" and "there is no data" are different buckets.
|
|
51
|
+
*/
|
|
52
|
+
export type ProvenanceRead =
|
|
53
|
+
| { kind: "genesis" }
|
|
54
|
+
| { kind: "observed"; entries: ProvenanceEntry[] }
|
|
55
|
+
| { kind: "unreadable"; reason: string };
|
|
56
|
+
|
|
57
|
+
export type ProvenanceErrorCode = "PROVENANCE_SEQ_REWRITE" | "PROVENANCE_HISTORY_UNREADABLE";
|
|
58
|
+
|
|
59
|
+
/** The journal's single error type: a code plus a human reason.
|
|
60
|
+
* - PROVENANCE_SEQ_REWRITE: an explicit seq at or below the last one —
|
|
61
|
+
* provenance is forward-only and does not rewrite history.
|
|
62
|
+
* - PROVENANCE_HISTORY_UNREADABLE: the journal exists but cannot be read
|
|
63
|
+
* (EACCES/EISDIR/EIO) or is corrupt (middle-line parse failure). Appending
|
|
64
|
+
* on top of an unreadable history would GUESS the next seq, and a guessed
|
|
65
|
+
* seq is how history becomes rewritable — so append refuses. */
|
|
66
|
+
export class ProvenanceError extends Error {
|
|
67
|
+
readonly code: ProvenanceErrorCode;
|
|
68
|
+
|
|
69
|
+
constructor(code: ProvenanceErrorCode, message: string) {
|
|
70
|
+
super(`${code}: ${message}`);
|
|
71
|
+
this.name = "ProvenanceError";
|
|
72
|
+
this.code = code;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ProvenanceJournal {
|
|
77
|
+
/**
|
|
78
|
+
* Append one entry. `when` and `seq` come from the journal (clock +
|
|
79
|
+
* history); the caller supplies who/carrier/version. An explicit seq at
|
|
80
|
+
* or below the last one is refused. An unreadable/corrupt history refuses
|
|
81
|
+
* ANY append — never append on top of a view that may be truncated.
|
|
82
|
+
*/
|
|
83
|
+
append(entry: { who: string; carrier: string; version: string }, explicitSeq?: number): Promise<ProvenanceEntry>;
|
|
84
|
+
read(): Promise<ProvenanceRead>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function fileProvenanceJournal(stateDir: string, clock: Clock): ProvenanceJournal {
|
|
88
|
+
const filePath = path.join(stateDir, PROVENANCE_FILE);
|
|
89
|
+
|
|
90
|
+
/** Read the raw file. Throws ProvenanceHistoryUnreadableError when the
|
|
91
|
+
* journal exists but cannot be read or is corrupt. */
|
|
92
|
+
async function readRaw(): Promise<{ exists: boolean; entries: ProvenanceEntry[] }> {
|
|
93
|
+
let text: string;
|
|
94
|
+
try {
|
|
95
|
+
text = await fs.readFile(filePath, "utf8");
|
|
96
|
+
} catch (err) {
|
|
97
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
98
|
+
if (code === "ENOENT") return { exists: false, entries: [] };
|
|
99
|
+
throw new ProvenanceError("PROVENANCE_HISTORY_UNREADABLE",
|
|
100
|
+
`cannot read ${PROVENANCE_FILE} (${code ?? (err as Error).message}); ` +
|
|
101
|
+
`unreadable is NOT genesis — the machine had the machinery and its record is hidden, not absent`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
const lines = text.split("\n").filter((l) => l.trim());
|
|
105
|
+
const entries: ProvenanceEntry[] = [];
|
|
106
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
107
|
+
try {
|
|
108
|
+
entries.push(JSON.parse(lines[i]!) as ProvenanceEntry);
|
|
109
|
+
} catch {
|
|
110
|
+
// Only the LAST non-empty line may be torn (crash mid-append). A
|
|
111
|
+
// middle-line failure is corruption: everything after it is
|
|
112
|
+
// unverifiable, and reading a truncated history as the truth is how
|
|
113
|
+
// the seq gets re-issued.
|
|
114
|
+
if (i < lines.length - 1) {
|
|
115
|
+
throw new ProvenanceError("PROVENANCE_HISTORY_UNREADABLE",
|
|
116
|
+
`corrupt entry at line ${i + 1} (middle of the journal); history unreadable ≠ history short`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { exists: true, entries };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
async append(entry, explicitSeq) {
|
|
127
|
+
await fs.mkdir(stateDir, { recursive: true });
|
|
128
|
+
const { exists, entries } = await readRaw();
|
|
129
|
+
const lastSeq = exists ? (entries.at(-1)?.seq ?? -1) : -1;
|
|
130
|
+
const seq = explicitSeq ?? lastSeq + 1;
|
|
131
|
+
if (seq <= lastSeq) {
|
|
132
|
+
throw new ProvenanceError("PROVENANCE_SEQ_REWRITE", `seq ${seq} <= last ${lastSeq}; provenance is forward-only and does not rewrite history`);
|
|
133
|
+
}
|
|
134
|
+
const full: ProvenanceEntry = {
|
|
135
|
+
seq,
|
|
136
|
+
who: entry.who,
|
|
137
|
+
carrier: entry.carrier,
|
|
138
|
+
when: clock.nowMs(),
|
|
139
|
+
version: entry.version,
|
|
140
|
+
};
|
|
141
|
+
const fh = await fs.open(filePath, "a");
|
|
142
|
+
try {
|
|
143
|
+
await fh.writeFile(`${JSON.stringify(full)}\n`);
|
|
144
|
+
await fh.sync(); // durable BEFORE the action it records: same promise as the txn WAL
|
|
145
|
+
} finally {
|
|
146
|
+
await fh.close();
|
|
147
|
+
}
|
|
148
|
+
return full;
|
|
149
|
+
},
|
|
150
|
+
async read() {
|
|
151
|
+
try {
|
|
152
|
+
const { exists, entries } = await readRaw();
|
|
153
|
+
return exists ? { kind: "observed" as const, entries } : { kind: "genesis" as const };
|
|
154
|
+
} catch (err) {
|
|
155
|
+
if (err instanceof ProvenanceError) {
|
|
156
|
+
return { kind: "unreadable" as const, reason: err.message };
|
|
157
|
+
}
|
|
158
|
+
throw err;
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface ProvenanceSummary {
|
|
165
|
+
/** Machines with an observed journal (any entry count). */
|
|
166
|
+
recorded: number;
|
|
167
|
+
/** Total entries across recorded machines. */
|
|
168
|
+
reconciles: number;
|
|
169
|
+
/** Machines with NO journal (genesis). MUST never be folded into recorded. */
|
|
170
|
+
notObserved: number;
|
|
171
|
+
/** Machines whose journal exists but cannot be read. NEVER recorded, and
|
|
172
|
+
* NEVER notObserved — "I didn't see it" is not "it isn't there". */
|
|
173
|
+
unreadable: number;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Record one reconcile in the journal (used by createUpgrader before the
|
|
178
|
+
* transaction). The VERSION comes from the release the reconcile drove —
|
|
179
|
+
* the journal records what was attempted, not the caller's claim. Identity
|
|
180
|
+
* defaults to the local operator.
|
|
181
|
+
*/
|
|
182
|
+
export async function recordReconcile(
|
|
183
|
+
journal: ProvenanceJournal,
|
|
184
|
+
identity: { who: string; carrier: string } | null | undefined,
|
|
185
|
+
version: string,
|
|
186
|
+
): Promise<void> {
|
|
187
|
+
const id = identity ?? { who: "local", carrier: "auto" };
|
|
188
|
+
await journal.append({ who: id.who, carrier: id.carrier, version });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Fleet aggregation of per-machine reads. The mechanical guards:
|
|
193
|
+
* - a genesis read counts in notObserved, never in recorded
|
|
194
|
+
* - an unreadable read counts in unreadable, never in recorded and never
|
|
195
|
+
* in notObserved
|
|
196
|
+
* "No data" and "zero data" are different buckets, and "can't see" is a
|
|
197
|
+
* third — any caller that merges them is the downgrade this module exists
|
|
198
|
+
* to prevent.
|
|
199
|
+
*/
|
|
200
|
+
export function summarizeProvenance(reads: readonly ProvenanceRead[]): ProvenanceSummary {
|
|
201
|
+
let recorded = 0;
|
|
202
|
+
let reconciles = 0;
|
|
203
|
+
let notObserved = 0;
|
|
204
|
+
let unreadable = 0;
|
|
205
|
+
for (const read of reads) {
|
|
206
|
+
if (read.kind === "genesis") {
|
|
207
|
+
notObserved += 1;
|
|
208
|
+
} else if (read.kind === "unreadable") {
|
|
209
|
+
unreadable += 1;
|
|
210
|
+
} else {
|
|
211
|
+
recorded += 1;
|
|
212
|
+
reconciles += read.entries.length;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return { recorded, reconciles, notObserved, unreadable };
|
|
216
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status report (M6, L5) — the fleet read-back surface: what a machine
|
|
3
|
+
* reports about itself is a READ-BACK, never an invention.
|
|
4
|
+
*
|
|
5
|
+
* Every field comes from the live sources at the moment of the call:
|
|
6
|
+
* - phase / stable / experiment: the txn state (slots + journal intent)
|
|
7
|
+
* - predicates: the last REAL convergence report's results — a machine
|
|
8
|
+
* that has never observed a promote reports `null` (NOT_OBSERVED), NOT
|
|
9
|
+
* `passed: true`. "never observed" has its own value (the M5 rule extended
|
|
10
|
+
* to the fleet surface: silence cannot be spent as evidence).
|
|
11
|
+
* - policy: the configured policy, verbatim.
|
|
12
|
+
* - provenance: the journal's read (genesis / observed / unreadable), or
|
|
13
|
+
* null when the app never wired a journal.
|
|
14
|
+
*/
|
|
15
|
+
import type { TxnState } from "../txn/state.ts";
|
|
16
|
+
import type { PredicateResult } from "../converge/predicates.ts";
|
|
17
|
+
import type { ProvenanceRead } from "../provenance/journal.ts";
|
|
18
|
+
import type { ReportRead } from "./reportStore.ts";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The predicates' state — three shapes, mechanically distinct:
|
|
22
|
+
* - genesis: no report was ever written. The machine NEVER OBSERVED a
|
|
23
|
+
* promote — NOT_OBSERVED, never a fabricated pass.
|
|
24
|
+
* - unreadable: a report exists but cannot be read (EACCES/corrupt). The
|
|
25
|
+
* machine DID observe; its record is hidden, not absent. "I cannot see
|
|
26
|
+
* the data" is not "there is no data".
|
|
27
|
+
* - observed: the last real report, verbatim — `version` is the JOIN KEY
|
|
28
|
+
* (consumers join on it, never on the current stable/experiment: a real
|
|
29
|
+
* conclusion about 2.0.0 read as 3.0.0's is worse than a fake).
|
|
30
|
+
*/
|
|
31
|
+
export type StatusPredicates =
|
|
32
|
+
| { kind: "genesis" }
|
|
33
|
+
| { kind: "unreadable"; reason: string }
|
|
34
|
+
| {
|
|
35
|
+
kind: "observed";
|
|
36
|
+
version: string;
|
|
37
|
+
binaryAtTarget: PredicateResult;
|
|
38
|
+
hostLifecycleConverged: PredicateResult | null;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export interface StatusReport {
|
|
42
|
+
phase: TxnState["phase"];
|
|
43
|
+
stable: string;
|
|
44
|
+
experiment: string | null;
|
|
45
|
+
predicates: StatusPredicates;
|
|
46
|
+
policy: "auto" | "confirm" | "notify-only";
|
|
47
|
+
/** The provenance journal read; null = the app never wired a journal. */
|
|
48
|
+
provenance: ProvenanceRead | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function buildStatusReport(input: {
|
|
52
|
+
state: TxnState;
|
|
53
|
+
lastReport: ReportRead;
|
|
54
|
+
policy: "auto" | "confirm" | "notify-only";
|
|
55
|
+
provenance: ProvenanceRead | null;
|
|
56
|
+
}): StatusReport {
|
|
57
|
+
return {
|
|
58
|
+
phase: input.state.phase,
|
|
59
|
+
stable: input.state.stableVersion,
|
|
60
|
+
experiment: input.state.experimentVersion,
|
|
61
|
+
predicates: reportPredicates(input.lastReport),
|
|
62
|
+
policy: input.policy,
|
|
63
|
+
provenance: input.provenance,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function reportPredicates(read: ReportRead): StatusPredicates {
|
|
68
|
+
if (read.kind === "genesis") return { kind: "genesis" };
|
|
69
|
+
if (read.kind === "unreadable") return { kind: "unreadable", reason: read.reason };
|
|
70
|
+
return {
|
|
71
|
+
kind: "observed",
|
|
72
|
+
version: read.report.version,
|
|
73
|
+
binaryAtTarget: read.report.binaryAtTarget,
|
|
74
|
+
hostLifecycleConverged: read.report.hostLifecycleConverged,
|
|
75
|
+
};
|
|
76
|
+
}
|