@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.
- package/core/src/artifact/download.ts +43 -72
- package/core/src/artifact/transferPolicy.ts +82 -0
- package/core/src/createUpgrader.ts +103 -132
- package/core/src/index.ts +2 -0
- package/core/src/operation.ts +182 -0
- package/core/src/operationLifecycle.ts +113 -0
- package/core/src/txn/state.ts +77 -8
- package/core/src/upgrade/drive.ts +188 -0
- package/core/src/upgrade/recover.ts +2 -0
- package/core/src/upgrader.ts +12 -1
- package/docs/integration.md +52 -0
- package/harness/src/artifact/downloadHoles.ts +1 -1
- package/package.json +1 -1
|
@@ -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.
|
|
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
|
-
|
|
101
|
+
responseTimeoutMs = 0,
|
|
102
|
+
idleTimeoutMs = 0,
|
|
133
103
|
doFetch: typeof fetch = fetch,
|
|
134
104
|
): Promise<Uint8Array> {
|
|
135
105
|
const controller = new AbortController();
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
let
|
|
140
|
-
let
|
|
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
|
-
|
|
149
|
-
? `download
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
164
|
-
if (
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
213
|
-
|
|
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,
|
|
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
|
-
|
|
225
|
+
armIdle();
|
|
252
226
|
await streamToFile(res.body, fh, (chunk) => {
|
|
253
227
|
onDisk += chunk;
|
|
254
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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: () =>
|
|
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
|
|
245
|
-
opts.source.checkForUpdate({
|
|
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?: {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|
package/core/src/txn/state.ts
CHANGED
|
@@ -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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
}
|
package/core/src/upgrader.ts
CHANGED
|
@@ -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?: {
|
|
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 =
|
package/docs/integration.md
CHANGED
|
@@ -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.
|
|
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": {
|