@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,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where an interrupted download parks its prefix.
|
|
3
|
+
*
|
|
4
|
+
* Its own module for the line budget, but the naming rule genuinely belongs in
|
|
5
|
+
* one place: resume works only if the NEXT attempt derives the same path from
|
|
6
|
+
* the same URL, so two copies of this rule that drift produce a resume that
|
|
7
|
+
* silently restarts from zero.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
|
|
12
|
+
function sha256Hex(data: Uint8Array): string {
|
|
13
|
+
return createHash("sha256").update(data).digest("hex");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function partialPathFor(resumeDir: string, url: string): string {
|
|
17
|
+
return path.join(resumeDir, `${sha256Hex(new TextEncoder().encode(url))}.part`);
|
|
18
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ReleaseSource — where releases come from, and what this install should be on.
|
|
3
|
+
*
|
|
4
|
+
* This is the ONLY place in K where version *semantics* live. Everywhere else
|
|
5
|
+
* (engine, journal, slots, predicates, invariants) a version is an opaque
|
|
6
|
+
* string compared with `===`. That boundary is deliberate:
|
|
7
|
+
*
|
|
8
|
+
* - "which version is latest" requires a version ORDER, and an order
|
|
9
|
+
* requires a versioning SCHEME (semver? dates? build numbers?). Choosing
|
|
10
|
+
* it for you would be K deciding your product's versioning.
|
|
11
|
+
* - anti-rollback has the same dependency, so it belongs here too.
|
|
12
|
+
*
|
|
13
|
+
* Two methods, because they answer two different QUESTIONS (not two steps of
|
|
14
|
+
* one operation):
|
|
15
|
+
* checkForUpdate — policy: should I upgrade, and to what?
|
|
16
|
+
* fetchRelease — named: give me exactly this version
|
|
17
|
+
*
|
|
18
|
+
* Channels ("stable", "nightly", "lts-2024") are not a K concept at all: they
|
|
19
|
+
* live inside your implementation of this interface. So does long-term
|
|
20
|
+
* pinning — a source that always returns the same version IS a pin.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export interface ReleaseContext {
|
|
24
|
+
/** Version currently installed in the stable slot. */
|
|
25
|
+
currentVersion: string;
|
|
26
|
+
/** Manifest target key for this machine, e.g. "linux-x64". */
|
|
27
|
+
platformKey: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Everything K needs to fetch and verify one release's bytes.
|
|
32
|
+
*
|
|
33
|
+
* ⚠️ There is deliberately no signature field: K checks integrity (sha256 +
|
|
34
|
+
* size) and does not verify who produced the artifact. Removed 2026-08-06;
|
|
35
|
+
* rationale and the OS-code-signing comparison live in docs/design-v1.md §L0.5.
|
|
36
|
+
*/
|
|
37
|
+
export interface Release {
|
|
38
|
+
version: string;
|
|
39
|
+
url: string;
|
|
40
|
+
sha256: string;
|
|
41
|
+
size: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ReleaseSource {
|
|
45
|
+
/**
|
|
46
|
+
* Policy question: what should this install upgrade to, if anything?
|
|
47
|
+
* `null` means "nothing to do" — already current by YOUR definition of
|
|
48
|
+
* current. Drives `upgrader.upgrade()` (CLI self-upgrade, background checks).
|
|
49
|
+
*/
|
|
50
|
+
checkForUpdate(ctx: ReleaseContext): Promise<Release | null>;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Named question: give me exactly this version.
|
|
54
|
+
* Drives `upgrader.upgradeTo(version)` — a user choosing a version, or a
|
|
55
|
+
* server pushing "go to 1.2.3". This is also the sanctioned DOWNGRADE path:
|
|
56
|
+
* downgrade is always explicit, never automatic.
|
|
57
|
+
*/
|
|
58
|
+
fetchRelease(version: string, ctx: ReleaseContext): Promise<Release>;
|
|
59
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* staticManifestSource — and the manifest.json format itself.
|
|
3
|
+
*
|
|
4
|
+
* The format lives HERE, not in a neutral core module, because it is this
|
|
5
|
+
* source's format: a ReleaseSource that reads a private API, date-stamped
|
|
6
|
+
* paths or an OCI registry never parses a manifest at all. Keeping it in
|
|
7
|
+
* core/artifact/manifest.ts implied K owned it, which was misleading.
|
|
8
|
+
*
|
|
9
|
+
* staticManifestSource — a batteries-included ReleaseSource for the common
|
|
10
|
+
* case: one static file host, one stream, semver ordering.
|
|
11
|
+
*
|
|
12
|
+
* This is ONE POLICY, not framework law. It encodes choices K has no business
|
|
13
|
+
* making for everyone:
|
|
14
|
+
* - versions are semver and ordered as such
|
|
15
|
+
* - "should I upgrade" means "is the served version greater than mine"
|
|
16
|
+
* - downgrades are refused here (explicit downgrade goes through
|
|
17
|
+
* upgradeTo/fetchRelease instead)
|
|
18
|
+
*
|
|
19
|
+
* Publish releases under a different scheme (dates, build numbers), or across
|
|
20
|
+
* several streams, and you write your own ReleaseSource — without touching K.
|
|
21
|
+
*/
|
|
22
|
+
import { ArtifactError } from "./errors.ts";
|
|
23
|
+
|
|
24
|
+
import type { Release, ReleaseContext, ReleaseSource } from "./source.ts";
|
|
25
|
+
|
|
26
|
+
export interface ManifestTarget {
|
|
27
|
+
/** Artifact filename relative to the release root (served at /<file>). */
|
|
28
|
+
file: string;
|
|
29
|
+
/** Lowercase hex sha256 of the artifact bytes (64 chars). */
|
|
30
|
+
sha256: string;
|
|
31
|
+
size: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface Manifest {
|
|
35
|
+
version: string;
|
|
36
|
+
/** Platform tag (e.g. "darwin-arm64") -> binary target. */
|
|
37
|
+
targets: Record<string, ManifestTarget>;
|
|
38
|
+
/** Track the release was published under (latest | alpha). Optional. */
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* NOTE: no field here may weaken a check K performs.
|
|
43
|
+
*
|
|
44
|
+
* K does not verify authenticity today (see design-v1 §L0.5), but the rule
|
|
45
|
+
* outlives that decision: the manifest is served BY the release source, i.e.
|
|
46
|
+
* exactly the party a signature chain would exist to distrust. An earlier
|
|
47
|
+
* version honoured a manifest-declared `unsigned: true`, which let anyone who
|
|
48
|
+
* controlled the source turn verification off for every client with one extra
|
|
49
|
+
* JSON field -- no crypto broken.
|
|
50
|
+
*
|
|
51
|
+
* "We accept bytes nobody vouched for" is a decision only the CLIENT can make.
|
|
52
|
+
* If signing ever lands, the opt-out belongs in the adopter's own code, never
|
|
53
|
+
* in the payload. A source can offer bytes; it cannot grant itself trust.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
const SHA256_RE = /^[0-9a-f]{64}$/;
|
|
57
|
+
|
|
58
|
+
export function parseManifest(text: string): Manifest {
|
|
59
|
+
let obj: unknown;
|
|
60
|
+
try {
|
|
61
|
+
obj = JSON.parse(text);
|
|
62
|
+
} catch {
|
|
63
|
+
throw new ArtifactError("MANIFEST_INVALID", "manifest is not valid JSON");
|
|
64
|
+
}
|
|
65
|
+
if (typeof obj !== "object" || obj === null) {
|
|
66
|
+
throw new ArtifactError("MANIFEST_INVALID", "manifest must be a JSON object");
|
|
67
|
+
}
|
|
68
|
+
const o = obj as Record<string, unknown>;
|
|
69
|
+
|
|
70
|
+
if (typeof o.version !== "string" || o.version.trim() === "") {
|
|
71
|
+
throw new ArtifactError("MANIFEST_INVALID", "manifest.version must be a non-empty string");
|
|
72
|
+
}
|
|
73
|
+
if (typeof o.targets !== "object" || o.targets === null) {
|
|
74
|
+
throw new ArtifactError("MANIFEST_INVALID", "manifest.targets must be an object");
|
|
75
|
+
}
|
|
76
|
+
const targets: Manifest["targets"] = {};
|
|
77
|
+
for (const [platform, raw] of Object.entries(o.targets as Record<string, unknown>)) {
|
|
78
|
+
if (typeof raw !== "object" || raw === null) {
|
|
79
|
+
throw new ArtifactError("MANIFEST_INVALID", `targets.${platform} must be an object`);
|
|
80
|
+
}
|
|
81
|
+
const t = raw as Record<string, unknown>;
|
|
82
|
+
if (typeof t.file !== "string" || t.file.trim() === "") {
|
|
83
|
+
throw new ArtifactError("MANIFEST_INVALID", `targets.${platform}.file must be a non-empty string`);
|
|
84
|
+
}
|
|
85
|
+
if (typeof t.sha256 !== "string" || !SHA256_RE.test(t.sha256)) {
|
|
86
|
+
throw new ArtifactError("MANIFEST_INVALID", `targets.${platform}.sha256 must be 64-char hex`);
|
|
87
|
+
}
|
|
88
|
+
if (typeof t.size !== "number" || !Number.isInteger(t.size) || t.size < 0) {
|
|
89
|
+
throw new ArtifactError("MANIFEST_INVALID", `targets.${platform}.size must be a non-negative integer`);
|
|
90
|
+
}
|
|
91
|
+
targets[platform] = { file: t.file, sha256: t.sha256, size: t.size };
|
|
92
|
+
}
|
|
93
|
+
if (Object.keys(targets).length === 0) {
|
|
94
|
+
throw new ArtifactError("MANIFEST_INVALID", "manifest.targets must contain at least one platform");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const out: Manifest = { version: o.version, targets };
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The platform key the client resolves against (os-arch, e.g. darwin-arm64). */
|
|
102
|
+
export function currentPlatformKey(): string {
|
|
103
|
+
return `${process.platform}-${process.arch}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface StaticManifestSourceOptions {
|
|
107
|
+
/** Base URL holding manifest.json and the artifacts, e.g. https://cdn.x/mytool/stable */
|
|
108
|
+
baseUrl: string;
|
|
109
|
+
fetchImpl?: typeof fetch;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function numericParts(v: string): number[] {
|
|
113
|
+
return v.split("-")[0]!.split(".").map((n) => Math.trunc(Number(n)) || 0);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Compare two dotted numeric versions; returns >0 when a is newer. */
|
|
117
|
+
function compareSemver(a: string, b: string): number {
|
|
118
|
+
const [pa, pb] = [numericParts(a), numericParts(b)];
|
|
119
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
|
|
120
|
+
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
121
|
+
if (diff !== 0) return diff;
|
|
122
|
+
}
|
|
123
|
+
// A prerelease (1.2.3-rc.1) sorts BELOW its release (1.2.3).
|
|
124
|
+
const preA = a.includes("-");
|
|
125
|
+
const preB = b.includes("-");
|
|
126
|
+
if (preA !== preB) return preA ? -1 : 1;
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function releaseFrom(
|
|
131
|
+
manifest: Manifest,
|
|
132
|
+
baseUrl: string,
|
|
133
|
+
platformKey: string,
|
|
134
|
+
): Release {
|
|
135
|
+
const target = manifest.targets[platformKey];
|
|
136
|
+
if (!target) {
|
|
137
|
+
throw new ArtifactError(
|
|
138
|
+
"UNSUPPORTED_PLATFORM",
|
|
139
|
+
`manifest has no target for ${platformKey} (have: ${Object.keys(manifest.targets).join(", ") || "none"})`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
const base = baseUrl.replace(/\/$/u, "");
|
|
143
|
+
const release: Release = {
|
|
144
|
+
version: manifest.version,
|
|
145
|
+
url: `${base}/${target.file}`,
|
|
146
|
+
sha256: target.sha256,
|
|
147
|
+
size: target.size,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
return release;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function staticManifestSource(opts: StaticManifestSourceOptions): ReleaseSource {
|
|
154
|
+
const base = opts.baseUrl.replace(/\/$/u, "");
|
|
155
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
156
|
+
|
|
157
|
+
async function loadManifest(): Promise<Manifest> {
|
|
158
|
+
const res = await doFetch(`${base}/manifest.json`);
|
|
159
|
+
if (!res.ok) {
|
|
160
|
+
throw new ArtifactError("DOWNLOAD_FAILED", `manifest.json fetch failed: HTTP ${res.status}`);
|
|
161
|
+
}
|
|
162
|
+
return parseManifest(await res.text());
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
async checkForUpdate(ctx: ReleaseContext): Promise<Release | null> {
|
|
167
|
+
const manifest = await loadManifest();
|
|
168
|
+
// POLICY: only move forward. A served version older than ours is not an
|
|
169
|
+
// "update" — taking it silently would be an automatic downgrade.
|
|
170
|
+
if (compareSemver(manifest.version, ctx.currentVersion) <= 0) return null;
|
|
171
|
+
return releaseFrom(manifest, base, ctx.platformKey);
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
async fetchRelease(version: string, ctx: ReleaseContext): Promise<Release> {
|
|
175
|
+
const manifest = await loadManifest();
|
|
176
|
+
if (manifest.version !== version) {
|
|
177
|
+
throw new ArtifactError(
|
|
178
|
+
"PINNED_VERSION_MISMATCH",
|
|
179
|
+
`asked for ${version} but this source serves ${manifest.version}`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
return releaseFrom(manifest, base, ctx.platformKey);
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L0 byte swap — delegates the OS-shaped sequence to the platform seam and
|
|
3
|
+
* keeps the artifact layer's typed-error contract.
|
|
4
|
+
*
|
|
5
|
+
* The seam decides HOW bytes get replaced (POSIX rename vs the Windows
|
|
6
|
+
* move-the-running-image dance); this layer guarantees that a failure is a
|
|
7
|
+
* typed SWAP_FAILED rather than a raw errno leaking to callers.
|
|
8
|
+
*/
|
|
9
|
+
import { platformOpsFor } from "../platform/index.ts";
|
|
10
|
+
import { ArtifactError } from "./errors.ts";
|
|
11
|
+
|
|
12
|
+
export async function atomicWriteFile(filePath: string, data: Uint8Array): Promise<void> {
|
|
13
|
+
try {
|
|
14
|
+
await platformOpsFor().swapExecutable(filePath, data);
|
|
15
|
+
} catch (err) {
|
|
16
|
+
throw new ArtifactError(
|
|
17
|
+
"SWAP_FAILED",
|
|
18
|
+
`could not replace ${filePath}: ${(err as Error).message}`,
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clock seam — core's ONLY access to time.
|
|
3
|
+
*
|
|
4
|
+
* Production-grade injection surface (transparency principle §1.8): core
|
|
5
|
+
* never calls Date.now()/setTimeout directly; everything time-related goes
|
|
6
|
+
* through an injected Clock. The default is the real system clock, so this
|
|
7
|
+
* is dependency injection, not a test mode. A future CI ratchet forbids
|
|
8
|
+
* direct time APIs inside core/ (same technique as Raft's clock-ratchet).
|
|
9
|
+
*/
|
|
10
|
+
export interface Clock {
|
|
11
|
+
nowMs(): number;
|
|
12
|
+
/** Schedule fn after ms; returns a cancel function. */
|
|
13
|
+
after(ms: number, fn: () => void): () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const systemClock: Clock = {
|
|
17
|
+
nowMs: () => Date.now(),
|
|
18
|
+
after: (ms, fn) => {
|
|
19
|
+
const t = setTimeout(fn, ms);
|
|
20
|
+
return () => clearTimeout(t);
|
|
21
|
+
},
|
|
22
|
+
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* host_lifecycle_converged — the lifecycle read-back predicate (design-v1
|
|
3
|
+
* §L3, test-plan M5). Lifted from the supervisor-retirement design.
|
|
4
|
+
*
|
|
5
|
+
* An upgrade that "says it happened" must be mechanically provable. Beyond
|
|
6
|
+
* binary_at_target (the live process), the OS-lifecycle surface must agree:
|
|
7
|
+
* the supervisor/auto-start entry must actually reference what the upgrade
|
|
8
|
+
* installed. Evidence comes from a NAMED SSOT surface the platform adapter
|
|
9
|
+
* declares — a surface that cannot be read back on a real machine may not
|
|
10
|
+
* claim same-source, and a surface NOT on the adapter's allowlist is
|
|
11
|
+
* refused outright (未注册面被引用 ⇒ 拒).
|
|
12
|
+
*
|
|
13
|
+
* PROJECTION BAN: version strings, release metadata and upgrade counts may
|
|
14
|
+
* never satisfy this predicate. The surface value must reference the
|
|
15
|
+
* artifact PATH that was installed — a value derived from metadata (e.g.
|
|
16
|
+
* "I registered version 2.0.0") is not evidence that the OS will actually
|
|
17
|
+
* start that artifact. The evaluator checks the read-back against the
|
|
18
|
+
* expected target PATH; anything else is a refusal.
|
|
19
|
+
*/
|
|
20
|
+
import { ArtifactError } from "../artifact/errors.ts";
|
|
21
|
+
import type { PredicateResult } from "./predicates.ts";
|
|
22
|
+
|
|
23
|
+
/** A registered (allowlisted) surface with its expected read-back. */
|
|
24
|
+
export interface AllowlistedSurface {
|
|
25
|
+
surface: ReadbackSurfaceLike;
|
|
26
|
+
/** What convergence requires the surface to read back (the artifact path). */
|
|
27
|
+
expectedTarget: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
import type { ReadbackSurface } from "./predicates.ts";
|
|
31
|
+
type ReadbackSurfaceLike = ReadbackSurface;
|
|
32
|
+
|
|
33
|
+
export type SurfaceAllowlist = ReadonlyMap<string, AllowlistedSurface>;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build the allowlist from the app's declared surfaces + their expected
|
|
37
|
+
* targets. The allowlist IS the trust boundary: an id not on it cannot be
|
|
38
|
+
* read as evidence.
|
|
39
|
+
*/
|
|
40
|
+
export function buildSurfaceAllowlist(
|
|
41
|
+
entries: Array<{ surface: ReadbackSurface; expectedTarget: string }>,
|
|
42
|
+
): SurfaceAllowlist {
|
|
43
|
+
const map = new Map<string, AllowlistedSurface>();
|
|
44
|
+
for (const entry of entries) {
|
|
45
|
+
if (map.has(entry.surface.id)) {
|
|
46
|
+
throw new Error(`duplicate readback surface id ${entry.surface.id}`);
|
|
47
|
+
}
|
|
48
|
+
map.set(entry.surface.id, entry);
|
|
49
|
+
}
|
|
50
|
+
return map;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Read an allowlisted surface; an unregistered id is a typed refusal. */
|
|
54
|
+
export async function readAllowlisted(
|
|
55
|
+
allowlist: SurfaceAllowlist,
|
|
56
|
+
id: string,
|
|
57
|
+
): Promise<{ value: string; source: string }> {
|
|
58
|
+
const entry = allowlist.get(id);
|
|
59
|
+
if (entry === undefined) {
|
|
60
|
+
throw new ArtifactError(
|
|
61
|
+
"UNREGISTERED_SURFACE",
|
|
62
|
+
`readback surface ${JSON.stringify(id)} is not on the adapter's allowlist`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return entry.surface.read();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Evaluate host_lifecycle_converged over every allowlisted surface. All
|
|
70
|
+
* must read back their expected target PATH (projection ban: a metadata
|
|
71
|
+
* string cannot reference a path). Returns the predicate result; `source`
|
|
72
|
+
* names the surfaces that vouched (auditable, not prose).
|
|
73
|
+
*/
|
|
74
|
+
export async function evaluateLifecycleConvergence(
|
|
75
|
+
allowlist: SurfaceAllowlist,
|
|
76
|
+
observedAtMs: number,
|
|
77
|
+
): Promise<PredicateResult> {
|
|
78
|
+
const detail: Record<string, string> = {};
|
|
79
|
+
const sources: string[] = [];
|
|
80
|
+
for (const [id, entry] of allowlist) {
|
|
81
|
+
let value: string;
|
|
82
|
+
try {
|
|
83
|
+
const read = await readAllowlisted(allowlist, id);
|
|
84
|
+
value = read.value;
|
|
85
|
+
} catch (err) {
|
|
86
|
+
return {
|
|
87
|
+
passed: false,
|
|
88
|
+
source: id,
|
|
89
|
+
observedAtMs,
|
|
90
|
+
detail: { error: (err as Error).message },
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
detail[id] = value;
|
|
94
|
+
if (!value.includes(entry.expectedTarget)) {
|
|
95
|
+
return {
|
|
96
|
+
passed: false,
|
|
97
|
+
source: id,
|
|
98
|
+
observedAtMs,
|
|
99
|
+
detail: {
|
|
100
|
+
expected: entry.expectedTarget,
|
|
101
|
+
got: value,
|
|
102
|
+
reason: "surface read-back does not reference the installed artifact path",
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
sources.push(id);
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
passed: true,
|
|
110
|
+
source: sources.join(","),
|
|
111
|
+
observedAtMs,
|
|
112
|
+
detail,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convergence predicates: an upgrade must mechanically PROVE it happened.
|
|
3
|
+
* Lifted from the supervisor-retirement design; the part no existing updater has.
|
|
4
|
+
*
|
|
5
|
+
* Rules (each enforced by a harness tooth):
|
|
6
|
+
* - Evidence for binary_at_target comes from ONE live process
|
|
7
|
+
* (ProcessEvidence.pid + startId), never from files/caches.
|
|
8
|
+
* - Evidence for host_lifecycle_converged comes from a NAMED SSOT surface
|
|
9
|
+
* declared by the platform adapter (e.g. macOS login-item via Electron
|
|
10
|
+
* `app.getLoginItemSettings().openAtLogin`). A surface that cannot be
|
|
11
|
+
* read back on a real machine may not claim same-source.
|
|
12
|
+
* - PROJECTION BAN: version strings, release metadata, upgrade counts and
|
|
13
|
+
* any metadata field must NOT satisfy either predicate. The teeth
|
|
14
|
+
* feed true metadata + false surfaces and require non-green.
|
|
15
|
+
* - FAIL-CLOSED RETIREMENT: legacy lifecycle managers (e.g. an old OS
|
|
16
|
+
* supervisor entry) may be retired only AFTER host_lifecycle_converged
|
|
17
|
+
* passed; otherwise keep the legacy manager and surface a typed HOLD.
|
|
18
|
+
*/
|
|
19
|
+
export interface PredicateResult {
|
|
20
|
+
passed: boolean;
|
|
21
|
+
/** Named surface the evidence was read from (auditable, not prose). */
|
|
22
|
+
source: string;
|
|
23
|
+
observedAtMs: number;
|
|
24
|
+
detail: Record<string, string>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ConvergenceReport {
|
|
28
|
+
/**
|
|
29
|
+
* The version this report evaluated (binary_at_target's TARGET — the
|
|
30
|
+
* experiment). Consumers MUST join on this, never on the status's
|
|
31
|
+
* current experiment/stable: a real conclusion pasted onto the wrong
|
|
32
|
+
* version reads like a lie and is harder to spot than a fake.
|
|
33
|
+
*/
|
|
34
|
+
version: string;
|
|
35
|
+
binaryAtTarget: PredicateResult;
|
|
36
|
+
/**
|
|
37
|
+
* null = the app declared no OS-lifecycle read-back surface, so this was
|
|
38
|
+
* never observed. NOT the same as passing.
|
|
39
|
+
*
|
|
40
|
+
* It used to be reported as `{passed: true, source:
|
|
41
|
+
* "no-lifecycle-surfaces-configured"}`, which reads like a checked property
|
|
42
|
+
* and unlocked `retireLegacyManager()` -- retiring the machine's supervisor
|
|
43
|
+
* on the strength of something nobody looked at. Silence must never be
|
|
44
|
+
* spendable as evidence, so the absence has its own value in the type and
|
|
45
|
+
* every consumer has to handle it.
|
|
46
|
+
*/
|
|
47
|
+
hostLifecycleConverged: PredicateResult | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Platform adapters declare their readback surfaces up front; converge
|
|
52
|
+
* refuses surfaces not on this allowlist (the named-surface discipline).
|
|
53
|
+
*/
|
|
54
|
+
export interface ReadbackSurface {
|
|
55
|
+
id: string; // e.g. "electron.getLoginItemSettings.openAtLogin"
|
|
56
|
+
read(): Promise<{ value: string; source: string }>;
|
|
57
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Building the ConvergenceReport, which is only worth anything if it never
|
|
3
|
+
* invents a verdict.
|
|
4
|
+
*
|
|
5
|
+
* Two rules the shape enforces:
|
|
6
|
+
* - a predicate reports what was OBSERVED, never a default. `binaryAtTarget`
|
|
7
|
+
* passes because a live process said so, not because we got this far.
|
|
8
|
+
* - a property nobody looked at is `null` (undeclared), not `passed: true`.
|
|
9
|
+
* Reporting an unexamined property as passing once unlocked
|
|
10
|
+
* `retireLegacyManager()` -- retiring the machine's supervisor on the
|
|
11
|
+
* strength of silence.
|
|
12
|
+
*/
|
|
13
|
+
import type { ConvergenceReport, PredicateResult } from "./predicates.ts";
|
|
14
|
+
import type { ProcessEvidence } from "../lifecycle/hostAdapter.ts";
|
|
15
|
+
|
|
16
|
+
export function buildConvergenceReport(input: {
|
|
17
|
+
version: string;
|
|
18
|
+
evidence: ProcessEvidence | null;
|
|
19
|
+
lifecycle: PredicateResult | null;
|
|
20
|
+
declaredSurfaces: number;
|
|
21
|
+
nowMs: number;
|
|
22
|
+
}): ConvergenceReport {
|
|
23
|
+
const { version, evidence, lifecycle, declaredSurfaces, nowMs } = input;
|
|
24
|
+
const binaryAtTarget: PredicateResult = {
|
|
25
|
+
passed: evidence !== null && evidence.version === version,
|
|
26
|
+
source: "host.healthProbe",
|
|
27
|
+
observedAtMs: nowMs,
|
|
28
|
+
detail: evidence
|
|
29
|
+
? { version: evidence.version, startId: evidence.startId, pid: String(evidence.pid) }
|
|
30
|
+
: {},
|
|
31
|
+
};
|
|
32
|
+
const hostLifecycleConverged: PredicateResult | null =
|
|
33
|
+
lifecycle ??
|
|
34
|
+
(declaredSurfaces > 0
|
|
35
|
+
? { passed: false, source: "not-converged", observedAtMs: nowMs, detail: {} }
|
|
36
|
+
: null); // the app declared no surface: never observed, never claimed
|
|
37
|
+
return { version, binaryAtTarget, hostLifecycleConverged };
|
|
38
|
+
}
|