@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,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createUpgrader — the one construction every entrypoint uses.
|
|
3
|
+
*
|
|
4
|
+
* Order of gates: lock -> ownership -> source -> policy -> compat ->
|
|
5
|
+
* download -> engine. Everything before the engine can only produce `held`
|
|
6
|
+
* or `up-to-date`: nothing on disk has changed. A refusal is cheap; a
|
|
7
|
+
* rollback is rare, not routine.
|
|
8
|
+
*/
|
|
9
|
+
import type { Upgrader, UpgraderConfig, UpgradeOutcome, ProvenanceIdentity } from "./upgrader.ts";
|
|
10
|
+
import { phaseAtRest, type TxnState } from "./txn/state.ts";
|
|
11
|
+
import type { Release } from "./artifact/source.ts";
|
|
12
|
+
import type { ProcessEvidence } from "./lifecycle/hostAdapter.ts";
|
|
13
|
+
import { UpgradeEngine } from "./txn/engine.ts";
|
|
14
|
+
import { fileEffects, materializeArtifact } from "./txn/fileEffects.ts";
|
|
15
|
+
import { acquireUpgradeLock } from "./txn/lock.ts";
|
|
16
|
+
import { downloadVerified } from "./artifact/download.ts";
|
|
17
|
+
import { ArtifactError } from "./artifact/errors.ts";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
import { systemClock, type Clock } from "./clock.ts";
|
|
20
|
+
import { buildSurfaceAllowlist, evaluateLifecycleConvergence } from "./converge/lifecycle.ts";
|
|
21
|
+
import type { ReadbackSurface, PredicateResult } from "./converge/predicates.ts";
|
|
22
|
+
import { platformOpsFor } from "./platform/index.ts";
|
|
23
|
+
import type { UpgradeProgress } from "./progress.ts";
|
|
24
|
+
import { slotArtifactPath } from "./txn/fileEffects.ts";
|
|
25
|
+
import { recordReconcile, type ProvenanceJournal } from "./provenance/journal.ts";
|
|
26
|
+
import { finishUpgradeOutcome } from "./upgrade/outcome.ts";
|
|
27
|
+
import { retireReason } from "./upgrade/retire.ts";
|
|
28
|
+
import { buildStatusReport, type StatusReport } from "./status/report.ts";
|
|
29
|
+
import { persistReport, loadLastReport, type ReportRead } from "./status/reportStore.ts";
|
|
30
|
+
|
|
31
|
+
export interface CreateUpgraderOptions extends UpgraderConfig {
|
|
32
|
+
clock?: Clock;
|
|
33
|
+
/** Reports who owns this install; default: we own it. */
|
|
34
|
+
installOwnership?: () => "self" | "managed-elsewhere";
|
|
35
|
+
/** Optional host semantic gate; a string result refuses the transition. */
|
|
36
|
+
checkCompatibility?: (from: string, to: string) => Promise<string | null>;
|
|
37
|
+
/**
|
|
38
|
+
* Progress for a host that wants to show something while this runs.
|
|
39
|
+
*
|
|
40
|
+
* Without it, a long upgrade is indistinguishable from a hung one, and the
|
|
41
|
+
* user's remedy for "hung" is to kill the process mid-transaction. Purely
|
|
42
|
+
* observational: never awaited for control flow, and a throwing sink must
|
|
43
|
+
* not fail the upgrade.
|
|
44
|
+
*/
|
|
45
|
+
onProgress?: (progress: UpgradeProgress) => void;
|
|
46
|
+
/**
|
|
47
|
+
* The OS-lifecycle read-back surfaces the app's platform adapter
|
|
48
|
+
* declares (host_lifecycle_converged, design-v1 §L3). Each surface is
|
|
49
|
+
* read during readback; convergence requires it to reference the
|
|
50
|
+
* artifact being promoted. Surfaces NOT declared here are refused —
|
|
51
|
+
* an app can only vouch for surfaces it actually ships.
|
|
52
|
+
*/
|
|
53
|
+
lifecycleSurfaces?: ReadbackSurface[];
|
|
54
|
+
/**
|
|
55
|
+
* M6 provenance journal (L5): every reconcile that reaches the transaction
|
|
56
|
+
* records WHO drove it (who/carrier/version), write-ahead. Local
|
|
57
|
+
* auto-updates use `provenanceIdentity` (default: the local operator).
|
|
58
|
+
*/
|
|
59
|
+
provenance?: ProvenanceJournal;
|
|
60
|
+
/** Identity recorded for reconciles that carry none (local auto-update). */
|
|
61
|
+
provenanceIdentity?: ProvenanceIdentity;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function createUpgrader(opts: CreateUpgraderOptions): Upgrader {
|
|
65
|
+
const clock = opts.clock ?? systemClock;
|
|
66
|
+
const effects = fileEffects(opts.stateDir);
|
|
67
|
+
const ownership = opts.installOwnership ?? ((): "self" => "self");
|
|
68
|
+
|
|
69
|
+
// The last predicate evidence, captured for the promote report (the
|
|
70
|
+
// engine only carries pass/fail; the report needs the real results).
|
|
71
|
+
let lastEvidence: ProcessEvidence | null = null;
|
|
72
|
+
let lastLifecycle: PredicateResult | null = null;
|
|
73
|
+
/** The report state of the last promote (status + retirement read it). */
|
|
74
|
+
let lastReport: ReportRead | null = null;
|
|
75
|
+
let reportLoaded = false;
|
|
76
|
+
/** Load the persisted report once — a restart must not erase a real observation. */
|
|
77
|
+
async function currentReport(): Promise<ReportRead> {
|
|
78
|
+
if (!reportLoaded) {
|
|
79
|
+
lastReport = await loadLastReport(opts.stateDir);
|
|
80
|
+
reportLoaded = true;
|
|
81
|
+
}
|
|
82
|
+
return lastReport ?? { kind: "genesis" };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const engine = new UpgradeEngine({
|
|
86
|
+
effects,
|
|
87
|
+
host: opts.host,
|
|
88
|
+
clock,
|
|
89
|
+
evaluatePredicates: async (evidence: ProcessEvidence, targetVersion: string) => {
|
|
90
|
+
lastEvidence = evidence;
|
|
91
|
+
if (evidence.version !== targetVersion) {
|
|
92
|
+
return `live process reports ${evidence.version}, expected ${targetVersion}`;
|
|
93
|
+
}
|
|
94
|
+
if (opts.lifecycleSurfaces && opts.lifecycleSurfaces.length > 0) {
|
|
95
|
+
// host_lifecycle_converged: every declared surface must read back
|
|
96
|
+
// the artifact being promoted (projection ban: metadata cannot).
|
|
97
|
+
const allowlist = buildSurfaceAllowlist(
|
|
98
|
+
opts.lifecycleSurfaces.map((surface) => ({
|
|
99
|
+
surface,
|
|
100
|
+
expectedTarget: slotArtifactPath(opts.stateDir, "experiment"),
|
|
101
|
+
})),
|
|
102
|
+
);
|
|
103
|
+
const result = await evaluateLifecycleConvergence(allowlist, clock.nowMs());
|
|
104
|
+
lastLifecycle = result;
|
|
105
|
+
if (!result.passed) {
|
|
106
|
+
return `lifecycle surface ${result.source} did not converge: ${JSON.stringify(result.detail)}`;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
async function readState(): Promise<TxnState> {
|
|
114
|
+
const slots = await effects.slots.slotVersions();
|
|
115
|
+
const intents = (await effects.journal.readAll()).map((e) => e.intent);
|
|
116
|
+
return {
|
|
117
|
+
phase: intents.at(-1) ?? "idle",
|
|
118
|
+
stableVersion: slots.stable ?? "0.0.0",
|
|
119
|
+
experimentVersion: slots.experiment,
|
|
120
|
+
rollbackReason: null,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Report a stage. Observational only: a broken sink cannot break upgrades. */
|
|
125
|
+
function reportStage(progress: UpgradeProgress): void {
|
|
126
|
+
try {
|
|
127
|
+
opts.onProgress?.(progress);
|
|
128
|
+
} catch {
|
|
129
|
+
// a host's progress bar must never be able to fail an upgrade
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function notify(kind: Parameters<UpgraderConfig["notificationSink"]>[0]["kind"], detail: Record<string, string>): Promise<void> {
|
|
134
|
+
await opts.notificationSink({ kind, detail });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Gates 1-7. `consented` = the user approved THIS version (the confirm was
|
|
138
|
+
* shown and answered) — the policy gate is skipped, everything else stands. */
|
|
139
|
+
async function run(
|
|
140
|
+
pick: (current: string) => Promise<Release | null>,
|
|
141
|
+
consented = false,
|
|
142
|
+
provenance: ProvenanceIdentity | null = null,
|
|
143
|
+
): Promise<UpgradeOutcome> {
|
|
144
|
+
const owner = ownership();
|
|
145
|
+
if (owner === "managed-elsewhere") {
|
|
146
|
+
await notify("held", { reason: "managed-elsewhere" });
|
|
147
|
+
return { result: "held", reason: "this install is managed by another manager; it does not upgrade itself" };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const lock = await acquireUpgradeLock(opts.stateDir, clock.nowMs());
|
|
151
|
+
try {
|
|
152
|
+
await engine.recover(); // finish or undo anything a previous crash left
|
|
153
|
+
|
|
154
|
+
const current = (await readState()).stableVersion;
|
|
155
|
+
reportStage({ stage: "checking" });
|
|
156
|
+
let release: Release | null;
|
|
157
|
+
try {
|
|
158
|
+
release = await pick(current);
|
|
159
|
+
} catch (err) {
|
|
160
|
+
// Consent is to a SPECIFIC version: if the source can no longer
|
|
161
|
+
// serve it (the publisher moved on), the approval is void — a typed
|
|
162
|
+
// refusal, never a silent switch to whatever is current now.
|
|
163
|
+
if (consented && err instanceof ArtifactError && err.code === "PINNED_VERSION_MISMATCH") {
|
|
164
|
+
await notify("held", { reason: "consented-version-unavailable", version: current });
|
|
165
|
+
return {
|
|
166
|
+
result: "held",
|
|
167
|
+
reason: `the approved version is no longer served; nothing was installed`,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
throw err;
|
|
171
|
+
}
|
|
172
|
+
if (release === null) return { result: "up-to-date" };
|
|
173
|
+
|
|
174
|
+
if (!consented && opts.policy === "notify-only") {
|
|
175
|
+
await notify("held", { reason: "notify-only", version: release.version });
|
|
176
|
+
return { result: "held", reason: `policy is notify-only; ${release.version} is available` };
|
|
177
|
+
}
|
|
178
|
+
if (!consented && opts.policy === "confirm") {
|
|
179
|
+
await notify("confirm-request", { version: release.version, current });
|
|
180
|
+
return { result: "held", reason: `policy requires confirmation before upgrading to ${release.version}` };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (opts.checkCompatibility) {
|
|
184
|
+
const refusal = await opts.checkCompatibility(current, release.version);
|
|
185
|
+
if (refusal !== null) {
|
|
186
|
+
await notify("held", { reason: "incompatible", detail: refusal });
|
|
187
|
+
return { result: "held", reason: `incompatible: ${refusal}` };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Resume support: an interrupted download (process death
|
|
192
|
+
// mid-fetch) leaves its prefix in stateDir/incoming and the next
|
|
193
|
+
// attempt continues via Range instead of restarting from zero.
|
|
194
|
+
reportStage({ stage: "downloading", version: release.version });
|
|
195
|
+
const bytes = await downloadVerified(release, {
|
|
196
|
+
clock,
|
|
197
|
+
resumeDir: path.join(opts.stateDir, "incoming"),
|
|
198
|
+
onProgress: (downloaded, total) =>
|
|
199
|
+
reportStage({ stage: "downloading", version: release.version, downloaded, total }),
|
|
200
|
+
});
|
|
201
|
+
reportStage({ stage: "verifying", version: release.version });
|
|
202
|
+
|
|
203
|
+
reportStage({ stage: "staging", version: release.version });
|
|
204
|
+
const bytesRef = await materializeArtifact(opts.stateDir, bytes);
|
|
205
|
+
|
|
206
|
+
// M6 provenance: record WHO drove this reconcile, write-ahead of the txn.
|
|
207
|
+
if (opts.provenance) {
|
|
208
|
+
await recordReconcile(opts.provenance, provenance ?? opts.provenanceIdentity, release.version);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
reportStage({ stage: "handing-over", version: release.version });
|
|
212
|
+
const outcome = await engine.upgrade({ version: release.version, bytesRef });
|
|
213
|
+
const finished = await finishUpgradeOutcome(outcome, {
|
|
214
|
+
notify,
|
|
215
|
+
reportStage,
|
|
216
|
+
targetVersion: release.version,
|
|
217
|
+
declaredSurfaces: (opts.lifecycleSurfaces ?? []).length,
|
|
218
|
+
nowMs: clock.nowMs(),
|
|
219
|
+
evidence: lastEvidence,
|
|
220
|
+
lifecycle: lastLifecycle,
|
|
221
|
+
});
|
|
222
|
+
if (finished.report !== null) {
|
|
223
|
+
lastReport = { kind: "observed", report: finished.report };
|
|
224
|
+
reportLoaded = true;
|
|
225
|
+
await persistReport(opts.stateDir, finished.report);
|
|
226
|
+
}
|
|
227
|
+
return finished.outcome;
|
|
228
|
+
} finally {
|
|
229
|
+
await lock.release();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
async check(): Promise<{ current: string; target: string | null }> {
|
|
235
|
+
const current = (await readState()).stableVersion;
|
|
236
|
+
const release = await opts.source.checkForUpdate({
|
|
237
|
+
currentVersion: current,
|
|
238
|
+
platformKey: platformOpsFor().platformKey(),
|
|
239
|
+
});
|
|
240
|
+
return { current, target: release?.version ?? null };
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
async upgrade(): Promise<UpgradeOutcome> {
|
|
244
|
+
return run(async (current) =>
|
|
245
|
+
opts.source.checkForUpdate({ currentVersion: current, platformKey: platformOpsFor().platformKey() }),
|
|
246
|
+
);
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
async upgradeTo(version: string, opts2?: { consented?: boolean; provenance?: ProvenanceIdentity }): Promise<UpgradeOutcome> {
|
|
250
|
+
return run(
|
|
251
|
+
async (current) =>
|
|
252
|
+
opts.source.fetchRelease(version, {
|
|
253
|
+
currentVersion: current,
|
|
254
|
+
platformKey: platformOpsFor().platformKey(),
|
|
255
|
+
}),
|
|
256
|
+
opts2?.consented === true,
|
|
257
|
+
opts2?.provenance ?? null,
|
|
258
|
+
);
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
async retireLegacyManager(): Promise<"retired" | { held: string }> {
|
|
262
|
+
return retireReason(await currentReport());
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
async rollback(reason: string): Promise<"rolled-back" | { held: string }> {
|
|
266
|
+
const lock = await acquireUpgradeLock(opts.stateDir, clock.nowMs());
|
|
267
|
+
try {
|
|
268
|
+
// Gate on the action's nature: settling K's own in-flight
|
|
269
|
+
// transaction is ALWAYS allowed (a held mid-transaction is a
|
|
270
|
+
// brick); only NEW modification of a machine AT REST managed
|
|
271
|
+
// elsewhere is refused.
|
|
272
|
+
const last = (await effects.journal.readAll()).at(-1)?.intent;
|
|
273
|
+
const inFlight = last !== undefined && !phaseAtRest(last);
|
|
274
|
+
if (!inFlight && ownership() === "managed-elsewhere") {
|
|
275
|
+
await notify("held", { reason: "managed-elsewhere" });
|
|
276
|
+
return { held: "this install is managed by another manager; it does not roll itself back" };
|
|
277
|
+
}
|
|
278
|
+
await engine.recover();
|
|
279
|
+
await effects.slots.clearExperiment();
|
|
280
|
+
await notify("rolled-back", { reason });
|
|
281
|
+
return "rolled-back";
|
|
282
|
+
} finally {
|
|
283
|
+
await lock.release();
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
|
|
287
|
+
async state(): Promise<TxnState> {
|
|
288
|
+
return readState();
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
async status(): Promise<StatusReport> {
|
|
292
|
+
return buildStatusReport({
|
|
293
|
+
state: await readState(),
|
|
294
|
+
lastReport: await currentReport(),
|
|
295
|
+
policy: opts.policy,
|
|
296
|
+
provenance: opts.provenance ? await opts.provenance.read() : null,
|
|
297
|
+
});
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# drive — see docs/design-v1.md for this layer's spec. Interfaces land here next.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Public API of @botiverse/k-carrier.
|
|
2
|
+
//
|
|
3
|
+
// This barrel is the single supported entry point for the core framework;
|
|
4
|
+
// deep imports into ./core/src/** are internal and not part of the public API.
|
|
5
|
+
|
|
6
|
+
// The upgrader factory and its configuration.
|
|
7
|
+
export * from "./createUpgrader.ts";
|
|
8
|
+
|
|
9
|
+
// Core types: Upgrader, UpgraderConfig, UpgradeOutcome, ProvenanceIdentity,
|
|
10
|
+
// NotificationEvent.
|
|
11
|
+
export * from "./upgrader.ts";
|
|
12
|
+
|
|
13
|
+
// The host boundary an adopter implements: HostAdapter, Slot, ProcessEvidence.
|
|
14
|
+
export * from "./lifecycle/hostAdapter.ts";
|
|
15
|
+
|
|
16
|
+
// The built-in invariants and their types (WorldSnapshot, Invariant, ...).
|
|
17
|
+
export * from "./invariants.ts";
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Invariants — K's shared, executable vocabulary of "what must always be
|
|
3
|
+
* true", exported as a library rather than buried in K's own tests.
|
|
4
|
+
*
|
|
5
|
+
* One definition, three consumers:
|
|
6
|
+
* 1. K's harness teeth (assert after scripted steps)
|
|
7
|
+
* 2. the deterministic simulator (assert after EVERY effect, every seed)
|
|
8
|
+
* 3. adopters — in their own tests and simulations, alongside their own
|
|
9
|
+
* app invariants registered in the same shape
|
|
10
|
+
*
|
|
11
|
+
* Why this shape: an invariant is a pure predicate over an observable
|
|
12
|
+
* snapshot. It never reaches into internals, so the same check works
|
|
13
|
+
* in-process, in simulation, and black-box against `status --json`.
|
|
14
|
+
* Returning a *reason string* (not a boolean) means a violation explains
|
|
15
|
+
* itself wherever it fires — including from a seed replay hours later.
|
|
16
|
+
*
|
|
17
|
+
* ---------------------------------------------------------------------------
|
|
18
|
+
* SCOPE — what "the service" means here, stated so nothing is assumed:
|
|
19
|
+
*
|
|
20
|
+
* K reasons about exactly ONE service identity per Upgrader: the process(es)
|
|
21
|
+
* its HostAdapter starts and stops. It is NOT a process supervisor for the
|
|
22
|
+
* machine and knows nothing about other processes that happen to run the
|
|
23
|
+
* same binary.
|
|
24
|
+
*
|
|
25
|
+
* - swap profile: concurrent old-version processes are NORMAL (think many
|
|
26
|
+
* open CLI sessions). They are not K-managed, so no invariant here says
|
|
27
|
+
* anything about them. `never-dual-run` is vacuous in this profile rather
|
|
28
|
+
* than accidentally satisfied.
|
|
29
|
+
* - service / hosted: exactly one live incarnation of that identity, which
|
|
30
|
+
* is what `never-dual-run` constrains.
|
|
31
|
+
*
|
|
32
|
+
* PROCESS MODEL (v0, declared rather than inferable from call order):
|
|
33
|
+
* EXCLUSIVE HANDOFF. At most one incarnation of the K-managed identity is
|
|
34
|
+
* alive at a time; a handoff is quiesce -> stop(old) -> start(new) -> prove.
|
|
35
|
+
* There is a downtime window by construction, and K makes it short rather
|
|
36
|
+
* than pretending it does not exist.
|
|
37
|
+
*
|
|
38
|
+
* Zero-downtime overlap (start new, drain old, then stop old) is a
|
|
39
|
+
* DIFFERENT process model, not a flag on this one: it has a legitimate
|
|
40
|
+
* "both alive" state, needs a drain contract instead of quiesce, and
|
|
41
|
+
* changes which invariants hold. It is NOT implemented (ruled niche for
|
|
42
|
+
* v0). Anything claiming K supports it would be claiming a guarantee K
|
|
43
|
+
* does not make.
|
|
44
|
+
*
|
|
45
|
+
* OUT OF SCOPE for v0 (deliberately, not by oversight): one binary running
|
|
46
|
+
* several instances with different arguments. One Upgrader owns one service
|
|
47
|
+
* identity; run one Upgrader per instance (separate stateDir), or have the
|
|
48
|
+
* adapter treat the whole set as a single identity. K does not orchestrate
|
|
49
|
+
* across instances, and no invariant here should be read as if it did.
|
|
50
|
+
* ---------------------------------------------------------------------------
|
|
51
|
+
*/
|
|
52
|
+
import type { TxnPhase } from "./txn/state.ts";
|
|
53
|
+
|
|
54
|
+
/** What an invariant is allowed to see. Mirrors `status --json`. */
|
|
55
|
+
export interface WorldSnapshot {
|
|
56
|
+
phase: TxnPhase;
|
|
57
|
+
slots: { stable: string | null; experiment: string | null };
|
|
58
|
+
/** Live service incarnations observed right now. */
|
|
59
|
+
liveProcesses: Array<{ slot: "stable" | "experiment"; pid: number; startId: string; version: string }>;
|
|
60
|
+
/** Journal intents in append order. */
|
|
61
|
+
journalIntents: TxnPhase[];
|
|
62
|
+
/**
|
|
63
|
+
* Opaque, comparable digest of host workload state (sessions/jobs).
|
|
64
|
+
* Hosts that preserve workloads across upgrade emit a stable digest.
|
|
65
|
+
*/
|
|
66
|
+
workloadDigest?: string;
|
|
67
|
+
/**
|
|
68
|
+
* startId of the incarnation that was live BEFORE the current transition.
|
|
69
|
+
* Present in real status readbacks; lets oracles prove a restart actually
|
|
70
|
+
* happened rather than trusting a version string (the same-PID-readiness family).
|
|
71
|
+
*/
|
|
72
|
+
priorIncarnationStartId?: string;
|
|
73
|
+
/** Who owns this install: ourselves, or an external manager (PM/injector)? */
|
|
74
|
+
installOwnership?: "self" | "managed-elsewhere";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** null = holds; string = why it was violated (shown to humans and in seed replays). */
|
|
78
|
+
export type InvariantResult = string | null;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* What the HOST must do for a conditional guarantee to hold (assume-guarantee).
|
|
82
|
+
* The left side of "if the app satisfies A, K guarantees G" — the app's
|
|
83
|
+
* responsibility, verifiable by the harness against its adapter. Closed set:
|
|
84
|
+
* if you need a new one, it is a design decision, not a free-text note.
|
|
85
|
+
*/
|
|
86
|
+
export type HostAssumption =
|
|
87
|
+
| "quiesce-resume-inverse" // parked workloads are unchanged while parked
|
|
88
|
+
| "probe-from-live-process" // evidence comes from the running process, not files
|
|
89
|
+
| "compatibility-declared" // checkCompatibility is implemented and correct
|
|
90
|
+
| "data-format-backward-compatible" // version N can read what N+1 wrote
|
|
91
|
+
| "exclusive-handoff" // one live incarnation at a time: stop old, then start new
|
|
92
|
+
| "resident-service"; // the host runs a process that is supposed to BE alive
|
|
93
|
+
|
|
94
|
+
export interface Invariant<S = WorldSnapshot> {
|
|
95
|
+
id: string;
|
|
96
|
+
description: string;
|
|
97
|
+
/**
|
|
98
|
+
* Empty/absent = K guarantees this UNCONDITIONALLY.
|
|
99
|
+
* Non-empty = K guarantees it only if the host satisfies these assumptions.
|
|
100
|
+
* This is how an adopter tells, from the library alone, which guarantees are
|
|
101
|
+
* ours and which depend on their own code.
|
|
102
|
+
*/
|
|
103
|
+
assumes?: readonly HostAssumption[];
|
|
104
|
+
check: (snapshot: S) => InvariantResult;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Never two live incarnations OF THE SERVICE IDENTITY K MANAGES.
|
|
109
|
+
* Says nothing about other processes running the same binary (see SCOPE):
|
|
110
|
+
* in the swap profile liveProcesses is empty, so this is vacuous by design.
|
|
111
|
+
*/
|
|
112
|
+
export const neverDualRun: Invariant = {
|
|
113
|
+
id: "k.never-dual-run",
|
|
114
|
+
description:
|
|
115
|
+
"at most one live incarnation of the K-managed service identity (vacuous in the swap profile)",
|
|
116
|
+
// NOT a universal truth about upgrades — it holds under K's declared process
|
|
117
|
+
// model (exclusive handoff). An app that starts the new incarnation before
|
|
118
|
+
// draining the old one is not violating a law of nature; it is running a
|
|
119
|
+
// model K does not implement (see PROCESS MODEL below).
|
|
120
|
+
assumes: ["exclusive-handoff"],
|
|
121
|
+
check: (s) =>
|
|
122
|
+
s.liveProcesses.length > 1
|
|
123
|
+
? `${s.liveProcesses.length} live incarnations: ${s.liveProcesses
|
|
124
|
+
.map((p) => `${p.slot}#${p.pid}@${p.version}`)
|
|
125
|
+
.join(", ")}`
|
|
126
|
+
: null,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Never a host with nothing runnable. Concerns the SLOTS (bytes on disk), not
|
|
131
|
+
* whether a process is currently up: a stopped service with intact stable
|
|
132
|
+
* bytes is not bricked.
|
|
133
|
+
*/
|
|
134
|
+
export const neverBricked: Invariant = {
|
|
135
|
+
id: "k.never-bricked",
|
|
136
|
+
description: "at least one slot always holds runnable bytes (about slots, not liveness)",
|
|
137
|
+
check: (s) =>
|
|
138
|
+
s.slots.stable === null && s.slots.experiment === null
|
|
139
|
+
? "both slots empty — nothing left to run"
|
|
140
|
+
: null,
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A live incarnation's version matches the slot it was started from.
|
|
145
|
+
* Only constrains processes K started; slots reporting null are ignored.
|
|
146
|
+
*/
|
|
147
|
+
export const liveProcessMatchesSlot: Invariant = {
|
|
148
|
+
id: "k.live-process-matches-slot",
|
|
149
|
+
description: "a live incarnation's version matches the slot it was started from",
|
|
150
|
+
check: (s) => {
|
|
151
|
+
for (const p of s.liveProcesses) {
|
|
152
|
+
const slotVersion = s.slots[p.slot];
|
|
153
|
+
if (slotVersion !== null && slotVersion !== p.version) {
|
|
154
|
+
return `${p.slot} slot holds ${slotVersion} but a live process reports ${p.version}`;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/** Intent is journaled before the phase is entered (WAL ordering). */
|
|
162
|
+
export const journalPrecedesPhase: Invariant = {
|
|
163
|
+
id: "k.journal-precedes-phase",
|
|
164
|
+
description: "the current phase was journaled before being entered",
|
|
165
|
+
check: (s) =>
|
|
166
|
+
s.phase !== "idle" && !s.journalIntents.includes(s.phase)
|
|
167
|
+
? `phase ${s.phase} entered with no journal entry for it`
|
|
168
|
+
: null,
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* After promote/rollback the experiment slot is cleared. This is about the
|
|
173
|
+
* TRANSACTION's own bookkeeping, not about whether a restart has happened yet.
|
|
174
|
+
*/
|
|
175
|
+
export const terminalLeavesNoExperiment: Invariant = {
|
|
176
|
+
id: "k.terminal-leaves-no-experiment",
|
|
177
|
+
description: "after promote/rollback the experiment slot is cleared",
|
|
178
|
+
check: (s) =>
|
|
179
|
+
(s.phase === "promoted" || s.phase === "rolled-back") && s.slots.experiment !== null
|
|
180
|
+
? `phase ${s.phase} but experiment slot still holds ${s.slots.experiment}`
|
|
181
|
+
: null,
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/** A copy owned by another manager never upgrades itself. */
|
|
185
|
+
export const managedCopyNeverSelfUpgrades: Invariant = {
|
|
186
|
+
id: "k.managed-copy-never-self-upgrades",
|
|
187
|
+
description: "an install owned by an external manager performs no upgrade of its own",
|
|
188
|
+
check: (s) =>
|
|
189
|
+
s.installOwnership === "managed-elsewhere" && s.phase !== "idle"
|
|
190
|
+
? `install is managed elsewhere but a transaction reached phase ${s.phase}`
|
|
191
|
+
: null,
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/** The built-in set. Adopters concat their own app invariants. */
|
|
195
|
+
export const BUILT_IN_INVARIANTS: readonly Invariant[] = [
|
|
196
|
+
neverDualRun,
|
|
197
|
+
neverBricked,
|
|
198
|
+
liveProcessMatchesSlot,
|
|
199
|
+
journalPrecedesPhase,
|
|
200
|
+
terminalLeavesNoExperiment,
|
|
201
|
+
managedCopyNeverSelfUpgrades,
|
|
202
|
+
];
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Deliberately NOT snapshot invariants — stated here so their absence is a
|
|
206
|
+
* decision, not an oversight:
|
|
207
|
+
* - "no side effect before consent" and "every reconcile is journaled" are
|
|
208
|
+
* properties of an EVENT SEQUENCE, not of a single state. Forcing them into
|
|
209
|
+
* a snapshot predicate would require smuggling history into the snapshot;
|
|
210
|
+
* they are teeth over a scenario trace instead (policy/provenance teeth).
|
|
211
|
+
*/
|
|
212
|
+
|
|
213
|
+
export interface Violation {
|
|
214
|
+
invariantId: string;
|
|
215
|
+
reason: string;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Check a snapshot against a set; returns every violation (not just the first). */
|
|
219
|
+
export function checkInvariants(
|
|
220
|
+
snapshot: WorldSnapshot,
|
|
221
|
+
invariants: readonly Invariant[] = BUILT_IN_INVARIANTS,
|
|
222
|
+
): Violation[] {
|
|
223
|
+
const out: Violation[] = [];
|
|
224
|
+
for (const inv of invariants) {
|
|
225
|
+
const reason = inv.check(snapshot);
|
|
226
|
+
if (reason !== null) out.push({ invariantId: inv.id, reason });
|
|
227
|
+
}
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Workload preservation across a transition. Kept separate because it
|
|
233
|
+
* compares two snapshots rather than judging one — hosts that declare a
|
|
234
|
+
* workloadDigest get this for free, in upgrade AND rollback paths.
|
|
235
|
+
*/
|
|
236
|
+
export const WORKLOAD_PRESERVED_ASSUMES: readonly HostAssumption[] = ["quiesce-resume-inverse"];
|
|
237
|
+
|
|
238
|
+
export function workloadPreserved(before: WorldSnapshot, after: WorldSnapshot): InvariantResult {
|
|
239
|
+
if (before.workloadDigest === undefined || after.workloadDigest === undefined) return null;
|
|
240
|
+
return before.workloadDigest === after.workloadDigest
|
|
241
|
+
? null
|
|
242
|
+
: `workload digest changed across the transition: ${before.workloadDigest} -> ${after.workloadDigest}`;
|
|
243
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HostAdapter is the entire surface a host service must implement to be
|
|
3
|
+
* upgraded by K. The core calls ONLY this interface — it never knows
|
|
4
|
+
* host internals. This is the mechanical guarantee behind "any daemon can
|
|
5
|
+
* use this", and the harness's fake host implements exactly this.
|
|
6
|
+
*
|
|
7
|
+
* Contract highlights:
|
|
8
|
+
* - quiesce(): workloads (sessions, agents, jobs) must be safely pausable
|
|
9
|
+
* and durably parked; state before quiesce() and after resume() must be
|
|
10
|
+
* equivalent — including when resume() happens on the ROLLED-BACK slot.
|
|
11
|
+
* - healthProbe(): evidence must be bound to one live process (same-PID /
|
|
12
|
+
* startId), never assembled from files or caches. A probe that cannot
|
|
13
|
+
* prove which process answered is not a probe.
|
|
14
|
+
* - start(): may return BEFORE the process exists. Some hosts cannot start
|
|
15
|
+
* themselves at all -- a service that is replaced by exiting, letting its
|
|
16
|
+
* supervisor respawn it from the new bytes, is started by that supervisor,
|
|
17
|
+
* asynchronously. So start() means "the successor has been asked for",
|
|
18
|
+
* never "the successor is running": only healthProbe() can say that.
|
|
19
|
+
*
|
|
20
|
+
* A consequence worth stating, because it decides who finishes an upgrade:
|
|
21
|
+
* on such hosts the process driving the transaction DIES on the success path.
|
|
22
|
+
* The successor finds a journal that stops mid-handover -- indistinguishable
|
|
23
|
+
* from a crash -- and K resolves it by EVIDENCE (a live process reporting the
|
|
24
|
+
* experiment version from a different incarnation), never by a flag saying the
|
|
25
|
+
* restart was planned. A crash could set that flag just as easily.
|
|
26
|
+
*/
|
|
27
|
+
export interface HostAdapter {
|
|
28
|
+
/** Park all hosted workloads durably. Idempotent. */
|
|
29
|
+
quiesce(): Promise<void>;
|
|
30
|
+
|
|
31
|
+
/** Stop the resident service process tree for the given slot. */
|
|
32
|
+
stop(slot: Slot): Promise<void>;
|
|
33
|
+
|
|
34
|
+
/** Start the resident service from the given slot's binaries. */
|
|
35
|
+
start(slot: Slot): Promise<void>;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Probe the LIVE process. Returned evidence must all come from the same
|
|
39
|
+
* process instance (pid + startId bind the answer to one incarnation).
|
|
40
|
+
*/
|
|
41
|
+
healthProbe(): Promise<ProcessEvidence>;
|
|
42
|
+
|
|
43
|
+
/** Resume workloads parked by quiesce(). Must also work after rollback. */
|
|
44
|
+
resume(): Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A SLOT is a place on disk that holds one version's bytes — not a channel,
|
|
49
|
+
* not a release track, not a feature flag.
|
|
50
|
+
*
|
|
51
|
+
* K keeps exactly two so that installing never destroys the thing that
|
|
52
|
+
* currently works:
|
|
53
|
+
*
|
|
54
|
+
* stable the version you are running and can always fall back to
|
|
55
|
+
* experiment the version being tried right now; discarded if it fails
|
|
56
|
+
*
|
|
57
|
+
* "Promote" means the experiment's bytes become the stable ones; "roll back"
|
|
58
|
+
* means the experiment is thrown away and stable was never touched. That is
|
|
59
|
+
* the whole reason the transaction can be safe: the fallback is not
|
|
60
|
+
* reconstructed after a failure, it was never disturbed.
|
|
61
|
+
*
|
|
62
|
+
* ⚠️ `stable` here is a POSITION, not the name of a release channel. If your
|
|
63
|
+
* product also has a channel called "stable" (ours does), they are unrelated:
|
|
64
|
+
* a channel says which stream you follow, a slot says which copy on disk.
|
|
65
|
+
*/
|
|
66
|
+
export type Slot = "stable" | "experiment";
|
|
67
|
+
|
|
68
|
+
export interface ProcessEvidence {
|
|
69
|
+
/** Version string reported by the live process itself. */
|
|
70
|
+
version: string;
|
|
71
|
+
pid: number;
|
|
72
|
+
/**
|
|
73
|
+
* Monotonic per-incarnation identity (e.g. start timestamp + random),
|
|
74
|
+
* so evidence cannot be satisfied by a pid reused by another process.
|
|
75
|
+
*/
|
|
76
|
+
startId: string;
|
|
77
|
+
}
|