@botiverse/k-carrier 0.1.5 → 0.1.7

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.
@@ -32,42 +32,9 @@ import { ArtifactError } from "./errors.ts";
32
32
  import type { Release } from "./source.ts";
33
33
  import { collectStream } from "./collectStream.ts";
34
34
  import { partialPathFor } from "./partialPath.ts";
35
+ import type { DownloadOptions } from "./transferPolicy.ts";
35
36
  export { partialPathFor } from "./partialPath.ts";
36
-
37
- export interface DownloadOptions {
38
- /**
39
- * Byte progress. `downloaded` counts bytes ON DISK including any resumed
40
- * prefix, so a resumed download never appears to restart at zero.
41
- */
42
- onProgress?: (downloaded: number, total: number) => void;
43
- clock?: Clock;
44
- /** Abort the download after this many ms (0 = no timeout). Default 10000. */
45
- timeoutMs?: number;
46
- /**
47
- * Abort after this many ms with NO bytes arriving (0 = off). Default 0.
48
- *
49
- * Distinct from `timeoutMs`, and for large artifacts the more useful of the
50
- * two: a total budget must either be big enough for the slowest acceptable
51
- * download of a 150MB binary -- in which case a wedged connection holds for
52
- * just as long -- or small enough to kill a slow one that was making steady
53
- * progress. Bounding SILENCE instead follows liveness, so the limit does not
54
- * have to encode a guess about size or bandwidth.
55
- */
56
- stallTimeoutMs?: number;
57
- /**
58
- * HTTP client for the artifact bytes. `staticManifestSource` already takes
59
- * one; without the same seam here an adopter can point K at their own server
60
- * for the MANIFEST but not for the BYTES, which is half a seam and surprising
61
- * in exactly the place it matters (proxies, custom agents, and an adopter's
62
- * own integration tests all need both).
63
- */
64
- fetchImpl?: typeof fetch;
65
- /**
66
- * Directory for partial-download state. When set, an interrupted download
67
- * leaves its prefix here and the next attempt resumes via Range.
68
- */
69
- resumeDir?: string;
70
- }
37
+ export type { DownloadOptions } from "./transferPolicy.ts";
71
38
 
72
39
  /** Fetch and verify one release's bytes, resuming from a partial if present. */
73
40
  export async function downloadVerified(
@@ -92,7 +59,9 @@ export async function downloadVerified(
92
59
 
93
60
  const bytes = await fetchAndAppend(
94
61
  url, partialPath, partialSize, clock, timeoutMs, opts.onProgress, release.size,
95
- opts.stallTimeoutMs ?? 0, opts.fetchImpl ?? fetch,
62
+ opts.responseTimeoutMs ?? opts.stallTimeoutMs ?? 0,
63
+ opts.idleTimeoutMs ?? opts.stallTimeoutMs ?? 0,
64
+ opts.fetchImpl ?? fetch,
96
65
  );
97
66
 
98
67
  const sha = sha256Hex(bytes);
@@ -129,26 +98,24 @@ async function fetchAndAppend(
129
98
  timeoutMs: number,
130
99
  onProgress?: (downloaded: number, total: number) => void,
131
100
  total?: number,
132
- stallTimeoutMs = 0,
101
+ responseTimeoutMs = 0,
102
+ idleTimeoutMs = 0,
133
103
  doFetch: typeof fetch = fetch,
134
104
  ): Promise<Uint8Array> {
135
105
  const controller = new AbortController();
136
- // Rearmed on every chunk; fires only if the gap between chunks exceeds the
137
- // budget. `stalled` records WHY we aborted, because the abort itself cannot
138
- // say -- a stall and a total-timeout abort look identical at the signal.
139
- let stalled = false;
140
- let stallTimer: (() => void) | undefined;
106
+ // Body liveness is rearmed on every chunk. `timeoutKind` records WHY we
107
+ // aborted because the abort signal cannot distinguish the three budgets.
108
+ let timeoutKind: "overall" | "response" | "idle" | null = null;
109
+ let responseTimer: (() => void) | undefined;
110
+ let idleTimer: (() => void) | undefined;
141
111
  // Invoked when a deadline fires so the pending fetch cannot outlive it.
142
112
  let aborted: (() => void) | undefined;
143
- // Which phase we were in when the deadline fired. Reporting "awaiting
144
- // response" for a stall that happened mid-body sends the reader looking at
145
- // the wrong end of the transfer.
146
- let responded = false;
147
113
  const abortReason = (u: string): string =>
148
- stalled
149
- ? `download stalled: nothing received for ${stallTimeoutMs}ms ` +
150
- `(${responded ? "mid-body" : "awaiting response"}): ${u}`
151
- : `download timed out after ${timeoutMs}ms: ${u}`;
114
+ timeoutKind === "response"
115
+ ? `download response timed out after ${responseTimeoutMs}ms (awaiting response): ${u}`
116
+ : timeoutKind === "idle"
117
+ ? `download stalled: nothing received for ${idleTimeoutMs}ms (mid-body): ${u}`
118
+ : `download timed out after ${timeoutMs}ms: ${u}`;
152
119
  // Declared AFTER `aborted`: an immediate/virtual clock fires this callback
153
120
  // synchronously inside `clock.after`, so a timer created earlier would reach
154
121
  // `aborted` in its temporal dead zone. Real clocks hide that ordering; the
@@ -156,15 +123,25 @@ async function fetchAndAppend(
156
123
  const cancel =
157
124
  timeoutMs > 0
158
125
  ? clock.after(timeoutMs, () => {
126
+ timeoutKind = "overall";
159
127
  controller.abort();
160
128
  aborted?.();
161
129
  })
162
130
  : undefined;
163
- const armStall = (): void => {
164
- if (stallTimeoutMs <= 0) return;
165
- stallTimer?.();
166
- stallTimer = clock.after(stallTimeoutMs, () => {
167
- stalled = true;
131
+ const armResponse = (): void => {
132
+ if (responseTimeoutMs <= 0) return;
133
+ responseTimer?.();
134
+ responseTimer = clock.after(responseTimeoutMs, () => {
135
+ timeoutKind = "response";
136
+ controller.abort();
137
+ aborted?.();
138
+ });
139
+ };
140
+ const armIdle = (): void => {
141
+ if (idleTimeoutMs <= 0) return;
142
+ idleTimer?.();
143
+ idleTimer = clock.after(idleTimeoutMs, () => {
144
+ timeoutKind = "idle";
168
145
  controller.abort();
169
146
  aborted?.();
170
147
  });
@@ -173,7 +150,7 @@ async function fetchAndAppend(
173
150
  const headers: Record<string, string> = {};
174
151
  if (partialSize > 0) headers["Range"] = `bytes=${partialSize}-`;
175
152
  let res: Response;
176
- armStall(); // the response headers themselves must not hang forever
153
+ armResponse();
177
154
  try {
178
155
  // Race the abort, do not merely signal it. `AbortSignal` only works if the
179
156
  // fetch implementation honours it, and `fetchImpl` is an adopter-supplied
@@ -193,11 +170,7 @@ async function fetchAndAppend(
193
170
  const timedOut = controller.signal.aborted;
194
171
  throw new ArtifactError(
195
172
  "DOWNLOAD_FAILED",
196
- stalled
197
- ? `download stalled: nothing received for ${stallTimeoutMs}ms (awaiting response): ${url}`
198
- : timedOut
199
- ? `download timed out after ${timeoutMs}ms: ${url}`
200
- : `fetch failed: ${url}`,
173
+ timedOut ? abortReason(url) : `fetch failed: ${url}`,
201
174
  { cause: err },
202
175
  );
203
176
  }
@@ -209,8 +182,9 @@ async function fetchAndAppend(
209
182
  // server that answers slowly and then streams normally is killed by a
210
183
  // deadline that started before the request was even answered -- and the
211
184
  // failure reports "awaiting response" while bytes were on their way.
212
- responded = true;
213
- armStall();
185
+ responseTimer?.();
186
+ responseTimer = undefined;
187
+ armIdle();
214
188
 
215
189
  if (partialPath === null) {
216
190
  // Stream even with nowhere to resume to. `res.arrayBuffer()` is one
@@ -219,7 +193,7 @@ async function fetchAndAppend(
219
193
  // silently had no byte progress and no stall detection at all, while
220
194
  // both looked configured.
221
195
  try {
222
- return await collectStream(url, res.body, total, onProgress, armStall);
196
+ return await collectStream(url, res.body, total, onProgress, armIdle);
223
197
  } catch (err) {
224
198
  // Same classification as the resume path. Without this the in-memory
225
199
  // branch reported a bare stream error, so an abort we ourselves caused
@@ -248,10 +222,10 @@ async function fetchAndAppend(
248
222
  // reads as "it lost my download" to the person watching it.
249
223
  let onDisk = partialSize;
250
224
  onProgress?.(onDisk, total ?? 0);
251
- armStall();
225
+ armIdle();
252
226
  await streamToFile(res.body, fh, (chunk) => {
253
227
  onDisk += chunk;
254
- armStall();
228
+ armIdle();
255
229
  onProgress?.(onDisk, total ?? 0);
256
230
  });
257
231
  await fh.sync();
@@ -262,11 +236,7 @@ async function fetchAndAppend(
262
236
  const timedOut = controller.signal.aborted;
263
237
  throw new ArtifactError(
264
238
  "DOWNLOAD_FAILED",
265
- stalled
266
- ? `download stalled: nothing received for ${stallTimeoutMs}ms (mid-body): ${url}`
267
- : timedOut
268
- ? `download timed out after ${timeoutMs}ms: ${url}`
269
- : `download interrupted: ${url}`,
239
+ timedOut ? abortReason(url) : `download interrupted: ${url}`,
270
240
  { cause: err },
271
241
  );
272
242
  } finally {
@@ -275,7 +245,8 @@ async function fetchAndAppend(
275
245
  return new Uint8Array(await fs.readFile(partialPath));
276
246
  } finally {
277
247
  cancel?.();
278
- stallTimer?.();
248
+ responseTimer?.();
249
+ idleTimer?.();
279
250
  }
280
251
  }
281
252
 
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Production artifact-transfer budgets.
3
+ *
4
+ * Response latency, progress liveness, and total duration answer different
5
+ * questions. Keeping them separate prevents a healthy large transfer from
6
+ * being killed by the same deadline that bounds an unreachable server.
7
+ */
8
+ import type { Clock } from "../clock.ts";
9
+
10
+ export interface DownloadOptions {
11
+ /** Byte progress, including any prefix already present on disk. */
12
+ onProgress?: (downloaded: number, total: number) => void;
13
+ clock?: Clock;
14
+ /** Whole-download bound (0 = off). Legacy direct-call default is 10000. */
15
+ timeoutMs?: number;
16
+ /** Maximum wait for response headers (0 = off). */
17
+ responseTimeoutMs?: number;
18
+ /** Maximum silence between response-body chunks (0 = off). */
19
+ idleTimeoutMs?: number;
20
+ /** Legacy alias for both responseTimeoutMs and idleTimeoutMs. */
21
+ stallTimeoutMs?: number;
22
+ fetchImpl?: typeof fetch;
23
+ /** Directory holding a resumable partial artifact. */
24
+ resumeDir?: string;
25
+ }
26
+
27
+ export interface ArtifactTransferPolicy {
28
+ /** Maximum wait for response headers. */
29
+ responseTimeoutMs: number;
30
+ /** Maximum silence between response-body chunks. */
31
+ idleTimeoutMs: number;
32
+ /** Slowest sustained body rate accepted when deriving the size budget. */
33
+ minimumBytesPerSecond: number;
34
+ /** Absolute ceiling even when the declared artifact is very large. */
35
+ maximumOverallTimeoutMs: number;
36
+ }
37
+
38
+ export interface ArtifactTransferTimeouts {
39
+ responseTimeoutMs: number;
40
+ idleTimeoutMs: number;
41
+ overallTimeoutMs: number;
42
+ }
43
+
44
+ export const DEFAULT_ARTIFACT_TRANSFER_POLICY: ArtifactTransferPolicy = {
45
+ responseTimeoutMs: 30_000,
46
+ idleTimeoutMs: 30_000,
47
+ minimumBytesPerSecond: 64 * 1024,
48
+ maximumOverallTimeoutMs: 30 * 60_000,
49
+ };
50
+
51
+ function requirePositiveInteger(name: string, value: number): void {
52
+ if (!Number.isSafeInteger(value) || value <= 0) {
53
+ throw new Error(`ARTIFACT_TRANSFER_POLICY_INVALID: ${name} must be a positive safe integer`);
54
+ }
55
+ }
56
+
57
+ /** Derive the bounded total budget from the release authority's exact size. */
58
+ export function artifactTransferTimeouts(
59
+ artifactSize: number,
60
+ policy: ArtifactTransferPolicy = DEFAULT_ARTIFACT_TRANSFER_POLICY,
61
+ ): ArtifactTransferTimeouts {
62
+ requirePositiveInteger("artifactSize", artifactSize);
63
+ requirePositiveInteger("responseTimeoutMs", policy.responseTimeoutMs);
64
+ requirePositiveInteger("idleTimeoutMs", policy.idleTimeoutMs);
65
+ requirePositiveInteger("minimumBytesPerSecond", policy.minimumBytesPerSecond);
66
+ requirePositiveInteger("maximumOverallTimeoutMs", policy.maximumOverallTimeoutMs);
67
+ if (policy.maximumOverallTimeoutMs < policy.responseTimeoutMs) {
68
+ throw new Error(
69
+ "ARTIFACT_TRANSFER_POLICY_INVALID: maximumOverallTimeoutMs must cover responseTimeoutMs",
70
+ );
71
+ }
72
+
73
+ const bodyBudgetMs = Math.ceil((artifactSize * 1_000) / policy.minimumBytesPerSecond);
74
+ return {
75
+ responseTimeoutMs: policy.responseTimeoutMs,
76
+ idleTimeoutMs: policy.idleTimeoutMs,
77
+ overallTimeoutMs: Math.min(
78
+ policy.maximumOverallTimeoutMs,
79
+ policy.responseTimeoutMs + bodyBudgetMs,
80
+ ),
81
+ };
82
+ }
@@ -7,27 +7,26 @@
7
7
  * rollback is rare, not routine.
8
8
  */
9
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";
10
+ import { assertNever, buildTxnState, phaseAtRest, type TxnState } from "./txn/state.ts";
12
11
  import type { ProcessEvidence } from "./lifecycle/hostAdapter.ts";
13
12
  import { UpgradeEngine } from "./txn/engine.ts";
14
- import { fileEffects, materializeArtifact } from "./txn/fileEffects.ts";
13
+ import { fileEffects } from "./txn/fileEffects.ts";
15
14
  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
15
  import { systemClock, type Clock } from "./clock.ts";
20
16
  import { buildSurfaceAllowlist, evaluateLifecycleConvergence } from "./converge/lifecycle.ts";
21
17
  import type { ReadbackSurface, PredicateResult } from "./converge/predicates.ts";
22
18
  import { platformOpsFor } from "./platform/index.ts";
23
19
  import type { UpgradeProgress } from "./progress.ts";
24
20
  import { slotArtifactPath } from "./txn/fileEffects.ts";
25
- import { recordReconcile, type ProvenanceJournal } from "./provenance/journal.ts";
26
- import { finishUpgradeOutcome } from "./upgrade/outcome.ts";
21
+ import type { ProvenanceJournal } from "./provenance/journal.ts";
27
22
  import { retireReason } from "./upgrade/retire.ts";
28
23
  import { buildStatusReport, type StatusReport } from "./status/report.ts";
29
24
  import { persistReport, loadLastReport, type ReportRead } from "./status/reportStore.ts";
30
25
  import { recoverUpgrade } from "./upgrade/recover.ts";
26
+ import type { OperationDescriptor } from "./operation.ts";
27
+ import { createOperationLifecycle } from "./operationLifecycle.ts";
28
+ import { driveUpgrade } from "./upgrade/drive.ts";
29
+ import type { ArtifactTransferPolicy } from "./artifact/transferPolicy.ts";
31
30
 
32
31
  export interface CreateUpgraderOptions extends UpgraderConfig {
33
32
  clock?: Clock;
@@ -60,6 +59,12 @@ export interface CreateUpgraderOptions extends UpgraderConfig {
60
59
  provenance?: ProvenanceJournal;
61
60
  /** Identity recorded for reconciles that carry none (local auto-update). */
62
61
  provenanceIdentity?: ProvenanceIdentity;
62
+ /**
63
+ * Independent response, idle, and size-derived total budgets for artifact
64
+ * bytes. Defaults are safe for Computer-sized binaries; adopters may make
65
+ * them stricter, but cannot disable every bound.
66
+ */
67
+ artifactTransferPolicy?: ArtifactTransferPolicy;
63
68
  }
64
69
 
65
70
  export function createUpgrader(opts: CreateUpgraderOptions): Upgrader {
@@ -113,123 +118,70 @@ export function createUpgrader(opts: CreateUpgraderOptions): Upgrader {
113
118
  async function readState(): Promise<TxnState> {
114
119
  const slots = await effects.slots.slotVersions();
115
120
  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
- const current = (await readState()).stableVersion;
154
- reportStage({ stage: "checking" });
155
- let release: Release | null;
156
- try {
157
- release = await pick(current);
158
- } catch (err) {
159
- // Consent is to a SPECIFIC version: if the source can no longer
160
- // serve it (the publisher moved on), the approval is void — a typed
161
- // refusal, never a silent switch to whatever is current now.
162
- if (consented && err instanceof ArtifactError && err.code === "PINNED_VERSION_MISMATCH") {
163
- await notify("held", { reason: "consented-version-unavailable", version: current });
164
- return {
165
- result: "held",
166
- reason: `the approved version is no longer served; nothing was installed`,
167
- };
168
- }
169
- throw err;
170
- }
171
- if (release === null) return { result: "up-to-date" };
172
- if (!consented && opts.policy === "notify-only") {
173
- await notify("held", { reason: "notify-only", version: release.version });
174
- return { result: "held", reason: `policy is notify-only; ${release.version} is available` };
175
- }
176
- if (!consented && opts.policy === "confirm") {
177
- await notify("confirm-request", { version: release.version, current });
178
- return { result: "held", reason: `policy requires confirmation before upgrading to ${release.version}` };
179
- }
180
-
181
- if (opts.checkCompatibility) {
182
- const refusal = await opts.checkCompatibility(current, release.version);
183
- if (refusal !== null) {
184
- await notify("held", { reason: "incompatible", detail: refusal });
185
- return { result: "held", reason: `incompatible: ${refusal}` };
121
+ const phase = intents.at(-1) ?? "idle";
122
+ const stableVersion = slots.stable ?? "0.0.0";
123
+ const experimentVersion = slots.experiment;
124
+ switch (phase) {
125
+ case "idle":
126
+ case "promoted":
127
+ return buildTxnState({ phase, stableVersion });
128
+ case "rolled-back":
129
+ return buildTxnState({ phase, stableVersion });
130
+ case "staged":
131
+ case "handing-over":
132
+ case "running-experiment":
133
+ case "readback": {
134
+ // In-flight phases always carry an experiment. A persisted world that
135
+ // says an in-flight phase has an empty experiment slot is
136
+ // inconsistent, and we fail closed rather than fabricate a value that
137
+ // would be a type lie at this (the only runtime) boundary.
138
+ if (experimentVersion === null) {
139
+ throw new Error(
140
+ `TXN_STATE_INCONSISTENT: phase ${phase} is in-flight but the experiment slot is empty`,
141
+ );
186
142
  }
143
+ return buildTxnState({ phase, stableVersion, experimentVersion });
187
144
  }
188
-
189
- // Resume support: an interrupted download (process death
190
- // mid-fetch) leaves its prefix in stateDir/incoming and the next
191
- // attempt continues via Range instead of restarting from zero.
192
- reportStage({ stage: "downloading", version: release.version });
193
- const bytes = await downloadVerified(release, {
194
- clock,
195
- resumeDir: path.join(opts.stateDir, "incoming"),
196
- onProgress: (downloaded, total) =>
197
- reportStage({ stage: "downloading", version: release.version, downloaded, total }),
198
- });
199
- reportStage({ stage: "verifying", version: release.version });
200
-
201
- reportStage({ stage: "staging", version: release.version });
202
- const bytesRef = await materializeArtifact(opts.stateDir, bytes);
203
-
204
- // M6 provenance: record WHO drove this reconcile, write-ahead of the txn.
205
- if (opts.provenance) {
206
- await recordReconcile(opts.provenance, provenance ?? opts.provenanceIdentity, release.version);
207
- }
208
-
209
- reportStage({ stage: "handing-over", version: release.version });
210
- const outcome = await engine.upgrade({ version: release.version, bytesRef });
211
- const finished = await finishUpgradeOutcome(outcome, {
212
- notify,
213
- reportStage,
214
- targetVersion: release.version,
215
- declaredSurfaces: (opts.lifecycleSurfaces ?? []).length,
216
- nowMs: clock.nowMs(),
217
- evidence: lastEvidence,
218
- lifecycle: lastLifecycle,
219
- });
220
- if (finished.report !== null) {
221
- lastReport = { kind: "observed", report: finished.report };
222
- reportLoaded = true;
223
- await persistReport(opts.stateDir, finished.report);
224
- }
225
- return finished.outcome;
226
- } finally {
227
- await lock.release();
145
+ default:
146
+ // Matches phaseAtRest's exhaustive discipline: an unknown phase from a
147
+ // newer core is not silently reinterpreted.
148
+ return assertNever(phase);
228
149
  }
229
150
  }
230
151
 
152
+ const operationLifecycle = createOperationLifecycle(opts.stateDir, clock, readState);
153
+ const drive = (request: Parameters<typeof driveUpgrade>[1]): Promise<UpgradeOutcome> =>
154
+ driveUpgrade({
155
+ stateDir: opts.stateDir,
156
+ clock,
157
+ engine,
158
+ operation: operationLifecycle,
159
+ ownership,
160
+ readStableVersion: async () => (await readState()).stableVersion,
161
+ policy: opts.policy,
162
+ notificationSink: opts.notificationSink,
163
+ ...(opts.onProgress ? { onProgress: opts.onProgress } : {}),
164
+ ...(opts.checkCompatibility ? { checkCompatibility: opts.checkCompatibility } : {}),
165
+ lifecycleSurfaceCount: (opts.lifecycleSurfaces ?? []).length,
166
+ evidence: () => lastEvidence,
167
+ lifecycle: () => lastLifecycle,
168
+ persistConvergenceReport: async (report) => {
169
+ lastReport = { kind: "observed", report };
170
+ reportLoaded = true;
171
+ await persistReport(opts.stateDir, report);
172
+ },
173
+ ...(opts.provenance ? { provenanceJournal: opts.provenance } : {}),
174
+ ...(opts.provenanceIdentity ? { provenanceIdentity: opts.provenanceIdentity } : {}),
175
+ ...(opts.artifactTransferPolicy
176
+ ? { artifactTransferPolicy: opts.artifactTransferPolicy }
177
+ : {}),
178
+ }, request);
179
+
231
180
  return {
232
- recover: () => recoverUpgrade(opts.stateDir, clock, engine),
181
+ recover: async () => {
182
+ operationLifecycle.reset();
183
+ await recoverUpgrade(opts.stateDir, clock, engine, operationLifecycle.settleRecovery);
184
+ },
233
185
 
234
186
  async check(): Promise<{ current: string; target: string | null }> {
235
187
  const current = (await readState()).stableVersion;
@@ -241,21 +193,29 @@ export function createUpgrader(opts: CreateUpgraderOptions): Upgrader {
241
193
  },
242
194
 
243
195
  async upgrade(): Promise<UpgradeOutcome> {
244
- return run(async (current) =>
245
- opts.source.checkForUpdate({ currentVersion: current, platformKey: platformOpsFor().platformKey() }),
246
- );
196
+ return drive({
197
+ pick: async (current) => opts.source.checkForUpdate({
198
+ currentVersion: current,
199
+ platformKey: platformOpsFor().platformKey(),
200
+ }),
201
+ });
247
202
  },
248
203
 
249
- async upgradeTo(version: string, opts2?: { consented?: boolean; provenance?: ProvenanceIdentity }): Promise<UpgradeOutcome> {
250
- return run(
251
- async (current) =>
252
- opts.source.fetchRelease(version, {
204
+ async upgradeTo(version: string, opts2?: {
205
+ consented?: boolean;
206
+ provenance?: ProvenanceIdentity;
207
+ operation?: OperationDescriptor;
208
+ }): Promise<UpgradeOutcome> {
209
+ return drive({
210
+ pick: async (current) => opts.source.fetchRelease(version, {
253
211
  currentVersion: current,
254
212
  platformKey: platformOpsFor().platformKey(),
255
- }),
256
- opts2?.consented === true,
257
- opts2?.provenance ?? null,
258
- );
213
+ }),
214
+ consented: opts2?.consented === true,
215
+ ...(opts2?.provenance ? { provenance: opts2.provenance } : {}),
216
+ ...(opts2?.operation ? { operation: opts2.operation } : {}),
217
+ targetVersionHint: version,
218
+ });
259
219
  },
260
220
 
261
221
  async retireLegacyManager(): Promise<"retired" | { held: string }> {
@@ -272,12 +232,12 @@ export function createUpgrader(opts: CreateUpgraderOptions): Upgrader {
272
232
  const last = (await effects.journal.readAll()).at(-1)?.intent;
273
233
  const inFlight = last !== undefined && !phaseAtRest(last);
274
234
  if (!inFlight && ownership() === "managed-elsewhere") {
275
- await notify("held", { reason: "managed-elsewhere" });
235
+ await opts.notificationSink({ kind: "held", detail: { reason: "managed-elsewhere" } });
276
236
  return { held: "this install is managed by another manager; it does not roll itself back" };
277
237
  }
278
238
  await engine.recover();
279
239
  await effects.slots.clearExperiment();
280
- await notify("rolled-back", { reason });
240
+ await opts.notificationSink({ kind: "rolled-back", detail: { reason } });
281
241
  return "rolled-back";
282
242
  } finally {
283
243
  await lock.release();
@@ -296,5 +256,16 @@ export function createUpgrader(opts: CreateUpgraderOptions): Upgrader {
296
256
  provenance: opts.provenance ? await opts.provenance.read() : null,
297
257
  });
298
258
  },
259
+
260
+ operation: operationLifecycle.read,
261
+
262
+ async acknowledgeOperation(operationId) {
263
+ const lock = await acquireUpgradeLock(opts.stateDir, clock.nowMs());
264
+ try {
265
+ return await operationLifecycle.acknowledge(operationId);
266
+ } finally {
267
+ await lock.release();
268
+ }
269
+ },
299
270
  };
300
271
  }
package/core/src/index.ts CHANGED
@@ -13,10 +13,12 @@ export * from "./bootstrap.ts";
13
13
  // Core types: Upgrader, UpgraderConfig, UpgradeOutcome, ProvenanceIdentity,
14
14
  // NotificationEvent.
15
15
  export * from "./upgrader.ts";
16
+ export * from "./operation.ts";
16
17
 
17
18
  // The release-source boundary applications implement and the durable
18
19
  // provenance journal they wire into createUpgrader.
19
20
  export * from "./artifact/source.ts";
21
+ export * from "./artifact/transferPolicy.ts";
20
22
  export * from "./provenance/journal.ts";
21
23
 
22
24
  // The host boundary an adopter implements: HostAdapter, Slot, ProcessEvidence.
@@ -0,0 +1,182 @@
1
+ /**
2
+ * K-owned durable operation receipt.
3
+ *
4
+ * Hosts may project this record into their own UI or transport, but they do
5
+ * not maintain a second upgrade state machine. The operation receipt is the
6
+ * single durable answer to: what is running, which version was requested,
7
+ * what stable version can be restored, and whether the terminal receipt has
8
+ * already been acknowledged by the host transport.
9
+ */
10
+ import { promises as fs } from "node:fs";
11
+ import * as path from "node:path";
12
+ import { platformOpsFor } from "./platform/index.ts";
13
+ import type { ProvenanceIdentity } from "./upgrader.ts";
14
+
15
+ const OPERATION_FILE = "operation.json";
16
+ export const OPERATION_FORMAT_VERSION = 1;
17
+
18
+ export type OperationPhase =
19
+ | "checking"
20
+ | "downloading"
21
+ | "verifying"
22
+ | "staging"
23
+ | "handing-over"
24
+ | "probing"
25
+ | "recovering"
26
+ | "promoted"
27
+ | "rolled-back"
28
+ | "held"
29
+ | "up-to-date"
30
+ | "failed";
31
+
32
+ export type OperationOutcome = "promoted" | "rolled-back" | "held" | "up-to-date" | "failed";
33
+
34
+ const OPERATION_PHASES = new Set<OperationPhase>([
35
+ "checking",
36
+ "downloading",
37
+ "verifying",
38
+ "staging",
39
+ "handing-over",
40
+ "probing",
41
+ "recovering",
42
+ "promoted",
43
+ "rolled-back",
44
+ "held",
45
+ "up-to-date",
46
+ "failed",
47
+ ]);
48
+ const OPERATION_OUTCOMES = new Set<OperationOutcome>([
49
+ "promoted",
50
+ "rolled-back",
51
+ "held",
52
+ "up-to-date",
53
+ "failed",
54
+ ]);
55
+
56
+ export interface OperationDescriptor {
57
+ id: string;
58
+ startedAtMs: number;
59
+ provenance?: ProvenanceIdentity;
60
+ /** Opaque non-secret host correlation fields (for example server/request ids). */
61
+ metadata?: Record<string, string>;
62
+ }
63
+
64
+ export interface OperationRecord {
65
+ formatVersion: typeof OPERATION_FORMAT_VERSION;
66
+ id: string;
67
+ startedAtMs: number;
68
+ updatedAtMs: number;
69
+ fromVersion: string;
70
+ targetVersion: string;
71
+ /** The stable version that was current when this operation began. */
72
+ previousStableVersion: string;
73
+ phase: OperationPhase;
74
+ outcome: OperationOutcome | null;
75
+ reason: string | null;
76
+ provenance: ProvenanceIdentity | null;
77
+ metadata: Record<string, string>;
78
+ /** Host transport receipt, not a second transaction outcome. */
79
+ acknowledgedAtMs: number | null;
80
+ }
81
+
82
+ export type OperationRead =
83
+ | { kind: "genesis" }
84
+ | { kind: "observed"; operation: OperationRecord }
85
+ | { kind: "unreadable"; reason: string };
86
+
87
+ function operationPath(stateDir: string): string {
88
+ return path.join(stateDir, OPERATION_FILE);
89
+ }
90
+
91
+ function validIdentity(value: unknown): value is ProvenanceIdentity {
92
+ return Boolean(
93
+ value
94
+ && typeof value === "object"
95
+ && typeof (value as ProvenanceIdentity).who === "string"
96
+ && typeof (value as ProvenanceIdentity).carrier === "string",
97
+ );
98
+ }
99
+
100
+ function parseOperation(text: string): OperationRecord {
101
+ const parsed = JSON.parse(text) as Partial<OperationRecord>;
102
+ if (
103
+ parsed.formatVersion !== OPERATION_FORMAT_VERSION
104
+ || typeof parsed.id !== "string"
105
+ || parsed.id.length === 0
106
+ || typeof parsed.startedAtMs !== "number"
107
+ || !Number.isFinite(parsed.startedAtMs)
108
+ || typeof parsed.updatedAtMs !== "number"
109
+ || !Number.isFinite(parsed.updatedAtMs)
110
+ || typeof parsed.fromVersion !== "string"
111
+ || typeof parsed.targetVersion !== "string"
112
+ || typeof parsed.previousStableVersion !== "string"
113
+ || typeof parsed.phase !== "string"
114
+ || !OPERATION_PHASES.has(parsed.phase as OperationPhase)
115
+ || !(parsed.outcome === null || (
116
+ typeof parsed.outcome === "string"
117
+ && OPERATION_OUTCOMES.has(parsed.outcome as OperationOutcome)
118
+ ))
119
+ || !(parsed.reason === null || typeof parsed.reason === "string")
120
+ || !(parsed.provenance === null || validIdentity(parsed.provenance))
121
+ || typeof parsed.metadata !== "object"
122
+ || parsed.metadata === null
123
+ || Array.isArray(parsed.metadata)
124
+ || Object.values(parsed.metadata).some((value) => typeof value !== "string")
125
+ || !(parsed.acknowledgedAtMs === null || typeof parsed.acknowledgedAtMs === "number")
126
+ ) {
127
+ throw new Error("operation record has an invalid shape");
128
+ }
129
+ return parsed as OperationRecord;
130
+ }
131
+
132
+ /** Atomic and durable replacement. K's upgrade lock serializes writers. */
133
+ export async function persistOperation(stateDir: string, operation: OperationRecord): Promise<void> {
134
+ await fs.mkdir(stateDir, { recursive: true });
135
+ const target = operationPath(stateDir);
136
+ const tmp = `${target}.tmp`;
137
+ const fh = await fs.open(tmp, "w");
138
+ try {
139
+ await fh.writeFile(JSON.stringify(operation));
140
+ await fh.sync();
141
+ } finally {
142
+ await fh.close();
143
+ }
144
+ await platformOpsFor().renamePath(tmp, target);
145
+ }
146
+
147
+ export async function loadOperation(stateDir: string): Promise<OperationRead> {
148
+ let text: string;
149
+ try {
150
+ text = await fs.readFile(operationPath(stateDir), "utf8");
151
+ } catch (error) {
152
+ const code = (error as NodeJS.ErrnoException).code;
153
+ if (code === "ENOENT") return { kind: "genesis" };
154
+ return { kind: "unreadable", reason: `cannot read ${OPERATION_FILE} (${code ?? (error as Error).message})` };
155
+ }
156
+ try {
157
+ return { kind: "observed", operation: parseOperation(text) };
158
+ } catch (error) {
159
+ return { kind: "unreadable", reason: `corrupt ${OPERATION_FILE}: ${(error as Error).message}` };
160
+ }
161
+ }
162
+
163
+ export async function acknowledgeOperation(
164
+ stateDir: string,
165
+ operationId: string,
166
+ acknowledgedAtMs: number,
167
+ ): Promise<"acknowledged" | "not-terminal" | "not-found" | "changed"> {
168
+ const current = await loadOperation(stateDir);
169
+ if (current.kind === "genesis") return "not-found";
170
+ if (current.kind === "unreadable") throw new Error(current.reason);
171
+ if (current.operation.id !== operationId) return "changed";
172
+ if (current.operation.outcome === null) return "not-terminal";
173
+ // Exact replay is idempotent. The first delivery time is part of the audit
174
+ // receipt; a retry must not rewrite it or manufacture a later delivery.
175
+ if (current.operation.acknowledgedAtMs !== null) return "acknowledged";
176
+ await persistOperation(stateDir, {
177
+ ...current.operation,
178
+ updatedAtMs: acknowledgedAtMs,
179
+ acknowledgedAtMs,
180
+ });
181
+ return "acknowledged";
182
+ }
@@ -0,0 +1,113 @@
1
+ import type { Clock } from "./clock.ts";
2
+ import { phaseAtRest, type TxnState } from "./txn/state.ts";
3
+ import type { ProvenanceIdentity } from "./upgrader.ts";
4
+ import {
5
+ acknowledgeOperation,
6
+ loadOperation,
7
+ persistOperation,
8
+ type OperationDescriptor,
9
+ type OperationOutcome,
10
+ type OperationPhase,
11
+ type OperationRead,
12
+ type OperationRecord,
13
+ } from "./operation.ts";
14
+
15
+ export interface OperationLifecycle {
16
+ begin(
17
+ descriptor: OperationDescriptor | null,
18
+ fromVersion: string,
19
+ targetVersion: string,
20
+ provenance: ProvenanceIdentity | null,
21
+ ): Promise<void>;
22
+ transition(input: {
23
+ phase: OperationPhase;
24
+ outcome?: OperationOutcome | null;
25
+ reason?: string | null;
26
+ }): Promise<void>;
27
+ settleRecovery(): Promise<void>;
28
+ reset(): void;
29
+ read(): Promise<OperationRead>;
30
+ acknowledge(operationId: string): Promise<"acknowledged" | "not-terminal" | "not-found" | "changed">;
31
+ }
32
+
33
+ export function createOperationLifecycle(
34
+ stateDir: string,
35
+ clock: Clock,
36
+ readState: () => Promise<TxnState>,
37
+ ): OperationLifecycle {
38
+ let record: OperationRecord | null = null;
39
+
40
+ const transition: OperationLifecycle["transition"] = async (input) => {
41
+ if (record === null) return;
42
+ record = {
43
+ ...record,
44
+ updatedAtMs: clock.nowMs(),
45
+ phase: input.phase,
46
+ outcome: input.outcome === undefined ? record.outcome : input.outcome,
47
+ reason: input.reason === undefined ? record.reason : input.reason,
48
+ };
49
+ await persistOperation(stateDir, record);
50
+ };
51
+
52
+ return {
53
+ async begin(descriptor, fromVersion, targetVersion, provenance) {
54
+ if (descriptor === null) return;
55
+ const existing = await loadOperation(stateDir);
56
+ if (existing.kind === "unreadable") throw new Error(existing.reason);
57
+ if (existing.kind === "observed" && existing.operation.id !== descriptor.id) {
58
+ if (existing.operation.outcome === null) {
59
+ throw new Error(`OPERATION_IN_PROGRESS: ${existing.operation.id}`);
60
+ }
61
+ if (existing.operation.acknowledgedAtMs === null) {
62
+ throw new Error(`OPERATION_RECEIPT_PENDING: ${existing.operation.id}`);
63
+ }
64
+ }
65
+ if (existing.kind === "observed" && existing.operation.id === descriptor.id) {
66
+ record = existing.operation;
67
+ return;
68
+ }
69
+ record = {
70
+ formatVersion: 1,
71
+ id: descriptor.id,
72
+ startedAtMs: descriptor.startedAtMs,
73
+ updatedAtMs: clock.nowMs(),
74
+ fromVersion,
75
+ targetVersion,
76
+ previousStableVersion: fromVersion,
77
+ phase: "checking",
78
+ outcome: null,
79
+ reason: null,
80
+ provenance: descriptor.provenance ?? provenance,
81
+ metadata: { ...descriptor.metadata },
82
+ acknowledgedAtMs: null,
83
+ };
84
+ await persistOperation(stateDir, record);
85
+ },
86
+
87
+ transition,
88
+
89
+ async settleRecovery() {
90
+ const observed = await loadOperation(stateDir);
91
+ if (observed.kind !== "observed" || observed.operation.outcome !== null) return;
92
+ record = observed.operation;
93
+ const state = await readState();
94
+ if (state.phase === "promoted" && state.stableVersion === record.targetVersion) {
95
+ await transition({ phase: "promoted", outcome: "promoted" });
96
+ } else if (phaseAtRest(state.phase)) {
97
+ await transition({
98
+ phase: "rolled-back",
99
+ outcome: "rolled-back",
100
+ reason: state.rollbackReason ?? `recovery settled at ${state.phase} with stable ${state.stableVersion}`,
101
+ });
102
+ }
103
+ },
104
+
105
+ reset() {
106
+ record = null;
107
+ },
108
+
109
+ read: () => loadOperation(stateDir),
110
+
111
+ acknowledge: (operationId) => acknowledgeOperation(stateDir, operationId, clock.nowMs()),
112
+ };
113
+ }
@@ -30,7 +30,7 @@ export type TxnPhase =
30
30
  * default, never the exclusion-list trap where every future terminal
31
31
  * phase silently becomes "in-flight" = allowed to modify.
32
32
  */
33
- function assertNever(x: never): never {
33
+ export function assertNever(x: never): never {
34
34
  throw new Error(`unknown TxnPhase ${String(x)}; refusing to classify — a phase a newer core wrote must not be touched`);
35
35
  }
36
36
 
@@ -53,13 +53,34 @@ export function phaseAtRest(phase: TxnPhase): boolean {
53
53
  }
54
54
  }
55
55
 
56
- export interface TxnState {
57
- phase: TxnPhase;
58
- stableVersion: string;
59
- experimentVersion: string | null;
60
- /** Why the last rollback happened; null unless phase === "rolled-back". */
61
- rollbackReason: string | null;
62
- }
56
+ /**
57
+ * A discriminated union so that an "illegal" combination is not representable:
58
+ *
59
+ * - terminal / at-rest phases (`idle`, `promoted`, `rolled-back`) NEVER carry
60
+ * an experiment an experiment slot that outlives a terminal phase is a
61
+ * leaked transaction (k.terminal-leaves-no-experiment would fire at runtime;
62
+ * here it is a compile error instead).
63
+ * - in-flight phases (`staged`..`readback`) ALWAYS carry an experiment — that
64
+ * is exactly what "in flight" means.
65
+ * - only `rolled-back` may carry a `rollbackReason`.
66
+ *
67
+ * Construction must choose a member consistent with `phase`; a `switch` over
68
+ * `TxnState` narrows each member and forces every phase to make a decision
69
+ * (the same discipline as `phaseAtRest`, lifted to the whole record).
70
+ */
71
+ export type TxnState =
72
+ | { phase: "idle"; stableVersion: string; experimentVersion: null; rollbackReason: null }
73
+ | {
74
+ phase: "staged" | "handing-over" | "running-experiment" | "readback";
75
+ stableVersion: string;
76
+ experimentVersion: string;
77
+ rollbackReason: null;
78
+ }
79
+ | { phase: "promoted"; stableVersion: string; experimentVersion: null; rollbackReason: null }
80
+ | { phase: "rolled-back"; stableVersion: string; experimentVersion: null; rollbackReason: string | null };
81
+
82
+ /** The in-flight, experiment-carrying phases (a helper for exhaustive narrowing). */
83
+ export const IN_FLIGHT_PHASES = ["staged", "handing-over", "running-experiment", "readback"] as const;
63
84
 
64
85
  /** Append-only, write-ahead journal entry. fsync'd before the action runs. */
65
86
  export interface JournalEntry {
@@ -75,3 +96,51 @@ export interface JournalEntry {
75
96
  * silently reinterpret. Encoded as a version field checked before replay.
76
97
  */
77
98
  export const STATE_FORMAT_VERSION = 1;
99
+
100
+ /**
101
+ * The strongly-typed input to `buildTxnState`. The argument is itself a
102
+ * discriminated union over the phase, so an illegal input combination does
103
+ * not type-check: a terminal/at-rest phase simply cannot carry an experiment,
104
+ * only `rolled-back` may carry a reason, and an in-flight phase must carry
105
+ * one. This is the "impossible states" discipline applied to the constructor's
106
+ * arguments, not just its result.
107
+ */
108
+ export type TxnStateInput =
109
+ | { phase: "idle" | "promoted"; stableVersion: string }
110
+ | { phase: "rolled-back"; stableVersion: string; rollbackReason?: string | null }
111
+ | {
112
+ phase: "staged" | "handing-over" | "running-experiment" | "readback";
113
+ stableVersion: string;
114
+ experimentVersion: string;
115
+ };
116
+
117
+ /**
118
+ * Soundly construct a `TxnState` member from a discriminated input.
119
+ *
120
+ * The input type already forbids the illegal combinations (an in-flight phase
121
+ * cannot be given a missing experiment, a terminal phase cannot be given an
122
+ * experiment, only `rolled-back` may carry a reason), so the result is sound
123
+ * by construction — there is no per-phase coercion to get wrong here.
124
+ *
125
+ * The one place an inconsistent persisted world can slip in is the runtime
126
+ * boundary that reads the journal and slots but only knows the phase at
127
+ * runtime (see `createUpgrader.readState`); that boundary must decide the
128
+ * phase before calling this, failing closed if a phase is in flight but the
129
+ * experiment slot is empty.
130
+ */
131
+ export function buildTxnState(input: TxnStateInput): TxnState {
132
+ switch (input.phase) {
133
+ case "idle":
134
+ case "promoted":
135
+ return { phase: input.phase, stableVersion: input.stableVersion, experimentVersion: null, rollbackReason: null };
136
+ case "rolled-back":
137
+ return { phase: input.phase, stableVersion: input.stableVersion, experimentVersion: null, rollbackReason: input.rollbackReason ?? null };
138
+ case "staged":
139
+ case "handing-over":
140
+ case "running-experiment":
141
+ case "readback":
142
+ return { phase: input.phase, stableVersion: input.stableVersion, experimentVersion: input.experimentVersion, rollbackReason: null };
143
+ default:
144
+ return assertNever(input);
145
+ }
146
+ }
@@ -0,0 +1,188 @@
1
+ import * as path from "node:path";
2
+ import type { Release } from "../artifact/source.ts";
3
+ import { downloadVerified } from "../artifact/download.ts";
4
+ import {
5
+ artifactTransferTimeouts,
6
+ type ArtifactTransferPolicy,
7
+ type DownloadOptions,
8
+ } from "../artifact/transferPolicy.ts";
9
+ import { ArtifactError } from "../artifact/errors.ts";
10
+ import { materializeArtifact } from "../txn/fileEffects.ts";
11
+ import { acquireUpgradeLock } from "../txn/lock.ts";
12
+ import type { UpgradeEngine, EngineOutcome } from "../txn/engine.ts";
13
+ import type { Clock } from "../clock.ts";
14
+ import type { UpgradeProgress } from "../progress.ts";
15
+ import type { ProcessEvidence } from "../lifecycle/hostAdapter.ts";
16
+ import type { PredicateResult, ConvergenceReport } from "../converge/predicates.ts";
17
+ import type { OperationDescriptor } from "../operation.ts";
18
+ import type { OperationLifecycle } from "../operationLifecycle.ts";
19
+ import type {
20
+ NotificationEvent,
21
+ ProvenanceIdentity,
22
+ UpgradeOutcome,
23
+ } from "../upgrader.ts";
24
+ import type { ProvenanceJournal } from "../provenance/journal.ts";
25
+ import { recordReconcile } from "../provenance/journal.ts";
26
+ import { finishUpgradeOutcome } from "./outcome.ts";
27
+
28
+ export interface UpgradeDriveDeps {
29
+ stateDir: string;
30
+ clock: Clock;
31
+ engine: UpgradeEngine;
32
+ operation: OperationLifecycle;
33
+ ownership: () => "self" | "managed-elsewhere";
34
+ readStableVersion: () => Promise<string>;
35
+ policy: "auto" | "confirm" | "notify-only";
36
+ notificationSink: (event: NotificationEvent) => Promise<void>;
37
+ onProgress?: (progress: UpgradeProgress) => void;
38
+ checkCompatibility?: (from: string, to: string) => Promise<string | null>;
39
+ lifecycleSurfaceCount: number;
40
+ evidence: () => ProcessEvidence | null;
41
+ lifecycle: () => PredicateResult | null;
42
+ persistConvergenceReport: (report: ConvergenceReport) => Promise<void>;
43
+ provenanceJournal?: ProvenanceJournal;
44
+ provenanceIdentity?: ProvenanceIdentity;
45
+ artifactTransferPolicy?: ArtifactTransferPolicy;
46
+ downloadArtifact?: (release: Release, opts: DownloadOptions) => Promise<Uint8Array>;
47
+ }
48
+
49
+ export interface UpgradeDriveRequest {
50
+ pick: (current: string) => Promise<Release | null>;
51
+ consented?: boolean;
52
+ provenance?: ProvenanceIdentity;
53
+ operation?: OperationDescriptor;
54
+ targetVersionHint?: string;
55
+ }
56
+
57
+ export async function driveUpgrade(
58
+ deps: UpgradeDriveDeps,
59
+ request: UpgradeDriveRequest,
60
+ ): Promise<UpgradeOutcome> {
61
+ const notify = (kind: NotificationEvent["kind"], detail: Record<string, string>) =>
62
+ deps.notificationSink({ kind, detail });
63
+ const progress = (value: UpgradeProgress): void => {
64
+ try { deps.onProgress?.(value); } catch { /* observation cannot fail an upgrade */ }
65
+ };
66
+ if (deps.ownership() === "managed-elsewhere") {
67
+ await notify("held", { reason: "managed-elsewhere" });
68
+ return { result: "held", reason: "this install is managed by another manager; it does not upgrade itself" };
69
+ }
70
+
71
+ const lock = await acquireUpgradeLock(deps.stateDir, deps.clock.nowMs());
72
+ try {
73
+ await deps.engine.recover();
74
+ await deps.operation.settleRecovery();
75
+ const current = await deps.readStableVersion();
76
+ await deps.operation.begin(
77
+ request.operation ?? null,
78
+ current,
79
+ request.targetVersionHint ?? current,
80
+ request.provenance ?? null,
81
+ );
82
+ progress({ stage: "checking" });
83
+
84
+ let release: Release | null;
85
+ try {
86
+ release = await request.pick(current);
87
+ } catch (error) {
88
+ if (request.consented && error instanceof ArtifactError && error.code === "PINNED_VERSION_MISMATCH") {
89
+ await deps.operation.transition({ phase: "held", outcome: "held", reason: "consented-version-unavailable" });
90
+ await notify("held", { reason: "consented-version-unavailable", version: current });
91
+ return { result: "held", reason: "the approved version is no longer served; nothing was installed" };
92
+ }
93
+ await deps.operation.transition({
94
+ phase: "failed",
95
+ outcome: "failed",
96
+ reason: error instanceof Error ? error.message : String(error),
97
+ });
98
+ throw error;
99
+ }
100
+ if (release === null) {
101
+ await deps.operation.transition({ phase: "up-to-date", outcome: "up-to-date" });
102
+ return { result: "up-to-date" };
103
+ }
104
+ if (!request.consented && deps.policy === "notify-only") {
105
+ await deps.operation.transition({ phase: "held", outcome: "held", reason: "notify-only" });
106
+ await notify("held", { reason: "notify-only", version: release.version });
107
+ return { result: "held", reason: `policy is notify-only; ${release.version} is available` };
108
+ }
109
+ if (!request.consented && deps.policy === "confirm") {
110
+ await deps.operation.transition({ phase: "held", outcome: "held", reason: "confirmation-required" });
111
+ await notify("confirm-request", { version: release.version, current });
112
+ return { result: "held", reason: `policy requires confirmation before upgrading to ${release.version}` };
113
+ }
114
+ const refusal = deps.checkCompatibility
115
+ ? await deps.checkCompatibility(current, release.version)
116
+ : null;
117
+ if (refusal !== null) {
118
+ await deps.operation.transition({ phase: "held", outcome: "held", reason: refusal });
119
+ await notify("held", { reason: "incompatible", detail: refusal });
120
+ return { result: "held", reason: `incompatible: ${refusal}` };
121
+ }
122
+
123
+ await deps.operation.transition({ phase: "downloading" });
124
+ progress({ stage: "downloading", version: release.version });
125
+ const transfer = artifactTransferTimeouts(release.size, deps.artifactTransferPolicy);
126
+ const bytes = await (deps.downloadArtifact ?? downloadVerified)(release, {
127
+ clock: deps.clock,
128
+ resumeDir: path.join(deps.stateDir, "incoming"),
129
+ timeoutMs: transfer.overallTimeoutMs,
130
+ responseTimeoutMs: transfer.responseTimeoutMs,
131
+ idleTimeoutMs: transfer.idleTimeoutMs,
132
+ onProgress: (downloaded, total) =>
133
+ progress({ stage: "downloading", version: release.version, downloaded, total }),
134
+ });
135
+ await deps.operation.transition({ phase: "verifying" });
136
+ progress({ stage: "verifying", version: release.version });
137
+ await deps.operation.transition({ phase: "staging" });
138
+ progress({ stage: "staging", version: release.version });
139
+ const bytesRef = await materializeArtifact(deps.stateDir, bytes);
140
+ if (deps.provenanceJournal) {
141
+ await recordReconcile(
142
+ deps.provenanceJournal,
143
+ request.provenance ?? deps.provenanceIdentity,
144
+ release.version,
145
+ );
146
+ }
147
+
148
+ await deps.operation.transition({ phase: "handing-over" });
149
+ progress({ stage: "handing-over", version: release.version });
150
+ let engineOutcome: EngineOutcome;
151
+ try {
152
+ engineOutcome = await deps.engine.upgrade({ version: release.version, bytesRef });
153
+ } catch (error) {
154
+ await deps.operation.transition({
155
+ phase: "recovering",
156
+ reason: error instanceof Error ? error.message : String(error),
157
+ });
158
+ throw error;
159
+ }
160
+ const finished = await finishUpgradeOutcome(engineOutcome, {
161
+ notify,
162
+ reportStage: progress,
163
+ targetVersion: release.version,
164
+ declaredSurfaces: deps.lifecycleSurfaceCount,
165
+ nowMs: deps.clock.nowMs(),
166
+ evidence: deps.evidence(),
167
+ lifecycle: deps.lifecycle(),
168
+ });
169
+ if (finished.report !== null) await deps.persistConvergenceReport(finished.report);
170
+ switch (finished.outcome.result) {
171
+ case "promoted":
172
+ await deps.operation.transition({ phase: "promoted", outcome: "promoted" });
173
+ break;
174
+ case "rolled-back":
175
+ await deps.operation.transition({ phase: "rolled-back", outcome: "rolled-back", reason: finished.outcome.reason });
176
+ break;
177
+ case "held":
178
+ await deps.operation.transition({ phase: "held", outcome: "held", reason: finished.outcome.reason });
179
+ break;
180
+ case "up-to-date":
181
+ await deps.operation.transition({ phase: "up-to-date", outcome: "up-to-date" });
182
+ break;
183
+ }
184
+ return finished.outcome;
185
+ } finally {
186
+ await lock.release();
187
+ }
188
+ }
@@ -7,10 +7,12 @@ export async function recoverUpgrade(
7
7
  stateDir: string,
8
8
  clock: Clock,
9
9
  engine: UpgradeEngine,
10
+ afterRecover?: () => Promise<void>,
10
11
  ): Promise<void> {
11
12
  const lock = await acquireUpgradeLock(stateDir, clock.nowMs());
12
13
  try {
13
14
  await engine.recover();
15
+ await afterRecover?.();
14
16
  } finally {
15
17
  await lock.release();
16
18
  }
@@ -3,6 +3,7 @@ import type { TxnState } from "./txn/state.js";
3
3
  import type { ConvergenceReport } from "./converge/predicates.js";
4
4
  import type { StatusReport } from "./status/report.js";
5
5
  import type { ReleaseSource } from "./artifact/source.js";
6
+ import type { OperationDescriptor, OperationRead } from "./operation.js";
6
7
 
7
8
  /**
8
9
  * Who drove a reconcile, recorded in the provenance journal (M6, L5).
@@ -62,7 +63,11 @@ export interface Upgrader {
62
63
  * silently installing whatever is current now. Consent is to a SPECIFIC
63
64
  * version, never to "the upgrade" as an event.
64
65
  */
65
- upgradeTo(version: string, opts?: { consented?: boolean; provenance?: ProvenanceIdentity }): Promise<UpgradeOutcome>;
66
+ upgradeTo(version: string, opts?: {
67
+ consented?: boolean;
68
+ provenance?: ProvenanceIdentity;
69
+ operation?: OperationDescriptor;
70
+ }): Promise<UpgradeOutcome>;
66
71
 
67
72
  /**
68
73
  * Explicit rollback while an experiment is live (pre-promote).
@@ -95,6 +100,12 @@ export interface Upgrader {
95
100
  * are null (NOT_OBSERVED), never a fabricated pass.
96
101
  */
97
102
  status(): Promise<StatusReport>;
103
+
104
+ /** K's single durable operation receipt; hosts project it, never mirror it. */
105
+ operation(): Promise<OperationRead>;
106
+
107
+ /** Mark one exact terminal operation delivered by the host transport. */
108
+ acknowledgeOperation(operationId: string): Promise<"acknowledged" | "not-terminal" | "not-found" | "changed">;
98
109
  }
99
110
 
100
111
  export type UpgradeOutcome =
@@ -237,10 +237,62 @@ Three things worth knowing before you draw a bar with it:
237
237
  and discards anything it throws. An observation surface must never become
238
238
  a failure mode — if your renderer breaks, the upgrade still completes.
239
239
 
240
+ Artifact transfer has three independent fail-closed budgets. Response headers
241
+ must arrive promptly, body progress must not go silent, and the full transfer
242
+ has a hard ceiling derived from the release source's declared byte size. The
243
+ defaults accept a Computer-sized binary that takes longer than ten seconds
244
+ while still bounding an unreachable server and a wedged mid-body stream. An
245
+ adopter with stricter network requirements may provide all four policy fields:
246
+
247
+ ```ts
248
+ createUpgrader({
249
+ ...,
250
+ artifactTransferPolicy: {
251
+ responseTimeoutMs: 20_000,
252
+ idleTimeoutMs: 30_000,
253
+ minimumBytesPerSecond: 128 * 1024,
254
+ maximumOverallTimeoutMs: 20 * 60_000,
255
+ },
256
+ });
257
+ ```
258
+
259
+ The total budget is `responseTimeoutMs + size / minimumBytesPerSecond`, capped
260
+ by `maximumOverallTimeoutMs`. Invalid, zero, or effectively unbounded policies
261
+ are rejected before the byte request starts.
262
+
240
263
  The stages are not a parallel state machine: they are derived from the L1
241
264
  transaction phases (`stageForPhase`), so a progress display can never show a
242
265
  state the transaction does not have.
243
266
 
267
+ ## 4.6 One durable operation receipt
268
+
269
+ When a host detaches the transaction driver from the service it replaces,
270
+ pass an exact operation descriptor to `upgradeTo`. K then owns the only
271
+ durable operation state, including the previous stable version and terminal
272
+ outcome:
273
+
274
+ ```ts
275
+ await upgrader.upgradeTo("2.0.0", {
276
+ consented: true,
277
+ operation: {
278
+ id: requestId,
279
+ startedAtMs: Date.now(),
280
+ metadata: { originServerId }, // non-secret host correlation only
281
+ },
282
+ });
283
+
284
+ const receipt = await upgrader.operation();
285
+ if (receipt.kind === "observed" && receipt.operation.outcome !== null) {
286
+ await deliver(receipt.operation);
287
+ await upgrader.acknowledgeOperation(receipt.operation.id);
288
+ }
289
+ ```
290
+
291
+ The host may project this receipt into UI or transport, but must not maintain
292
+ a second pending/status/previous-version state machine. `recover()` settles an
293
+ active receipt under K's upgrade lock before the host reads it again. A corrupt
294
+ or future-version receipt is `unreadable`, never treated as genesis or success.
295
+
244
296
  ## 5. Publishing releases
245
297
 
246
298
  If you use the built-in `staticManifestSource`, its layout is:
@@ -39,7 +39,7 @@ function releaseFor(url: string): Release {
39
39
  async function serve(chunks: number, gapMs: number, hang = false, stallAfterFirst = false) {
40
40
  const server = http.createServer((_req, res) => {
41
41
  res.writeHead(200, { "Content-Length": String(BODY.length) });
42
- if (hang) return; // headers sent, bytes never follow
42
+ if (hang) { res.flushHeaders(); return; } // headers sent, bytes never follow
43
43
  const size = Math.ceil(BODY.length / chunks);
44
44
  let sent = 0;
45
45
  const push = (): void => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botiverse/k-carrier",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "packageManager": "pnpm@11.18.0",
5
5
  "description": "A fail-closed upgrade carrier for long-running managed services: two-slot upgrade transactions (promote/rollback), host-driven quiesce/resume handoff, and post-upgrade convergence read-back.",
6
6
  "repository": {