@botiverse/k-carrier 0.1.5 → 0.1.6

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.
@@ -8,26 +8,24 @@
8
8
  */
9
9
  import type { Upgrader, UpgraderConfig, UpgradeOutcome, ProvenanceIdentity } from "./upgrader.ts";
10
10
  import { phaseAtRest, type TxnState } from "./txn/state.ts";
11
- import type { Release } from "./artifact/source.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";
31
29
 
32
30
  export interface CreateUpgraderOptions extends UpgraderConfig {
33
31
  clock?: Clock;
@@ -121,115 +119,36 @@ export function createUpgrader(opts: CreateUpgraderOptions): Upgrader {
121
119
  };
122
120
  }
123
121
 
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}` };
186
- }
187
- }
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 };
122
+ const operationLifecycle = createOperationLifecycle(opts.stateDir, clock, readState);
123
+ const drive = (request: Parameters<typeof driveUpgrade>[1]): Promise<UpgradeOutcome> =>
124
+ driveUpgrade({
125
+ stateDir: opts.stateDir,
126
+ clock,
127
+ engine,
128
+ operation: operationLifecycle,
129
+ ownership,
130
+ readStableVersion: async () => (await readState()).stableVersion,
131
+ policy: opts.policy,
132
+ notificationSink: opts.notificationSink,
133
+ ...(opts.onProgress ? { onProgress: opts.onProgress } : {}),
134
+ ...(opts.checkCompatibility ? { checkCompatibility: opts.checkCompatibility } : {}),
135
+ lifecycleSurfaceCount: (opts.lifecycleSurfaces ?? []).length,
136
+ evidence: () => lastEvidence,
137
+ lifecycle: () => lastLifecycle,
138
+ persistConvergenceReport: async (report) => {
139
+ lastReport = { kind: "observed", report };
222
140
  reportLoaded = true;
223
- await persistReport(opts.stateDir, finished.report);
224
- }
225
- return finished.outcome;
226
- } finally {
227
- await lock.release();
228
- }
229
- }
141
+ await persistReport(opts.stateDir, report);
142
+ },
143
+ ...(opts.provenance ? { provenanceJournal: opts.provenance } : {}),
144
+ ...(opts.provenanceIdentity ? { provenanceIdentity: opts.provenanceIdentity } : {}),
145
+ }, request);
230
146
 
231
147
  return {
232
- recover: () => recoverUpgrade(opts.stateDir, clock, engine),
148
+ recover: async () => {
149
+ operationLifecycle.reset();
150
+ await recoverUpgrade(opts.stateDir, clock, engine, operationLifecycle.settleRecovery);
151
+ },
233
152
 
234
153
  async check(): Promise<{ current: string; target: string | null }> {
235
154
  const current = (await readState()).stableVersion;
@@ -241,21 +160,29 @@ export function createUpgrader(opts: CreateUpgraderOptions): Upgrader {
241
160
  },
242
161
 
243
162
  async upgrade(): Promise<UpgradeOutcome> {
244
- return run(async (current) =>
245
- opts.source.checkForUpdate({ currentVersion: current, platformKey: platformOpsFor().platformKey() }),
246
- );
163
+ return drive({
164
+ pick: async (current) => opts.source.checkForUpdate({
165
+ currentVersion: current,
166
+ platformKey: platformOpsFor().platformKey(),
167
+ }),
168
+ });
247
169
  },
248
170
 
249
- async upgradeTo(version: string, opts2?: { consented?: boolean; provenance?: ProvenanceIdentity }): Promise<UpgradeOutcome> {
250
- return run(
251
- async (current) =>
252
- opts.source.fetchRelease(version, {
171
+ async upgradeTo(version: string, opts2?: {
172
+ consented?: boolean;
173
+ provenance?: ProvenanceIdentity;
174
+ operation?: OperationDescriptor;
175
+ }): Promise<UpgradeOutcome> {
176
+ return drive({
177
+ pick: async (current) => opts.source.fetchRelease(version, {
253
178
  currentVersion: current,
254
179
  platformKey: platformOpsFor().platformKey(),
255
- }),
256
- opts2?.consented === true,
257
- opts2?.provenance ?? null,
258
- );
180
+ }),
181
+ consented: opts2?.consented === true,
182
+ ...(opts2?.provenance ? { provenance: opts2.provenance } : {}),
183
+ ...(opts2?.operation ? { operation: opts2.operation } : {}),
184
+ targetVersionHint: version,
185
+ });
259
186
  },
260
187
 
261
188
  async retireLegacyManager(): Promise<"retired" | { held: string }> {
@@ -272,12 +199,12 @@ export function createUpgrader(opts: CreateUpgraderOptions): Upgrader {
272
199
  const last = (await effects.journal.readAll()).at(-1)?.intent;
273
200
  const inFlight = last !== undefined && !phaseAtRest(last);
274
201
  if (!inFlight && ownership() === "managed-elsewhere") {
275
- await notify("held", { reason: "managed-elsewhere" });
202
+ await opts.notificationSink({ kind: "held", detail: { reason: "managed-elsewhere" } });
276
203
  return { held: "this install is managed by another manager; it does not roll itself back" };
277
204
  }
278
205
  await engine.recover();
279
206
  await effects.slots.clearExperiment();
280
- await notify("rolled-back", { reason });
207
+ await opts.notificationSink({ kind: "rolled-back", detail: { reason } });
281
208
  return "rolled-back";
282
209
  } finally {
283
210
  await lock.release();
@@ -296,5 +223,16 @@ export function createUpgrader(opts: CreateUpgraderOptions): Upgrader {
296
223
  provenance: opts.provenance ? await opts.provenance.read() : null,
297
224
  });
298
225
  },
226
+
227
+ operation: operationLifecycle.read,
228
+
229
+ async acknowledgeOperation(operationId) {
230
+ const lock = await acquireUpgradeLock(opts.stateDir, clock.nowMs());
231
+ try {
232
+ return await operationLifecycle.acknowledge(operationId);
233
+ } finally {
234
+ await lock.release();
235
+ }
236
+ },
299
237
  };
300
238
  }
package/core/src/index.ts CHANGED
@@ -13,6 +13,7 @@ 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.
@@ -0,0 +1,179 @@
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
+ await persistOperation(stateDir, {
174
+ ...current.operation,
175
+ updatedAtMs: acknowledgedAtMs,
176
+ acknowledgedAtMs,
177
+ });
178
+ return "acknowledged";
179
+ }
@@ -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
+ }
@@ -0,0 +1,177 @@
1
+ import * as path from "node:path";
2
+ import type { Release } from "../artifact/source.ts";
3
+ import { downloadVerified } from "../artifact/download.ts";
4
+ import { ArtifactError } from "../artifact/errors.ts";
5
+ import { materializeArtifact } from "../txn/fileEffects.ts";
6
+ import { acquireUpgradeLock } from "../txn/lock.ts";
7
+ import type { UpgradeEngine, EngineOutcome } from "../txn/engine.ts";
8
+ import type { Clock } from "../clock.ts";
9
+ import type { UpgradeProgress } from "../progress.ts";
10
+ import type { ProcessEvidence } from "../lifecycle/hostAdapter.ts";
11
+ import type { PredicateResult, ConvergenceReport } from "../converge/predicates.ts";
12
+ import type { OperationDescriptor } from "../operation.ts";
13
+ import type { OperationLifecycle } from "../operationLifecycle.ts";
14
+ import type {
15
+ NotificationEvent,
16
+ ProvenanceIdentity,
17
+ UpgradeOutcome,
18
+ } from "../upgrader.ts";
19
+ import type { ProvenanceJournal } from "../provenance/journal.ts";
20
+ import { recordReconcile } from "../provenance/journal.ts";
21
+ import { finishUpgradeOutcome } from "./outcome.ts";
22
+
23
+ export interface UpgradeDriveDeps {
24
+ stateDir: string;
25
+ clock: Clock;
26
+ engine: UpgradeEngine;
27
+ operation: OperationLifecycle;
28
+ ownership: () => "self" | "managed-elsewhere";
29
+ readStableVersion: () => Promise<string>;
30
+ policy: "auto" | "confirm" | "notify-only";
31
+ notificationSink: (event: NotificationEvent) => Promise<void>;
32
+ onProgress?: (progress: UpgradeProgress) => void;
33
+ checkCompatibility?: (from: string, to: string) => Promise<string | null>;
34
+ lifecycleSurfaceCount: number;
35
+ evidence: () => ProcessEvidence | null;
36
+ lifecycle: () => PredicateResult | null;
37
+ persistConvergenceReport: (report: ConvergenceReport) => Promise<void>;
38
+ provenanceJournal?: ProvenanceJournal;
39
+ provenanceIdentity?: ProvenanceIdentity;
40
+ }
41
+
42
+ export interface UpgradeDriveRequest {
43
+ pick: (current: string) => Promise<Release | null>;
44
+ consented?: boolean;
45
+ provenance?: ProvenanceIdentity;
46
+ operation?: OperationDescriptor;
47
+ targetVersionHint?: string;
48
+ }
49
+
50
+ export async function driveUpgrade(
51
+ deps: UpgradeDriveDeps,
52
+ request: UpgradeDriveRequest,
53
+ ): Promise<UpgradeOutcome> {
54
+ const notify = (kind: NotificationEvent["kind"], detail: Record<string, string>) =>
55
+ deps.notificationSink({ kind, detail });
56
+ const progress = (value: UpgradeProgress): void => {
57
+ try { deps.onProgress?.(value); } catch { /* observation cannot fail an upgrade */ }
58
+ };
59
+ if (deps.ownership() === "managed-elsewhere") {
60
+ await notify("held", { reason: "managed-elsewhere" });
61
+ return { result: "held", reason: "this install is managed by another manager; it does not upgrade itself" };
62
+ }
63
+
64
+ const lock = await acquireUpgradeLock(deps.stateDir, deps.clock.nowMs());
65
+ try {
66
+ await deps.engine.recover();
67
+ await deps.operation.settleRecovery();
68
+ const current = await deps.readStableVersion();
69
+ await deps.operation.begin(
70
+ request.operation ?? null,
71
+ current,
72
+ request.targetVersionHint ?? current,
73
+ request.provenance ?? null,
74
+ );
75
+ progress({ stage: "checking" });
76
+
77
+ let release: Release | null;
78
+ try {
79
+ release = await request.pick(current);
80
+ } catch (error) {
81
+ if (request.consented && error instanceof ArtifactError && error.code === "PINNED_VERSION_MISMATCH") {
82
+ await deps.operation.transition({ phase: "held", outcome: "held", reason: "consented-version-unavailable" });
83
+ await notify("held", { reason: "consented-version-unavailable", version: current });
84
+ return { result: "held", reason: "the approved version is no longer served; nothing was installed" };
85
+ }
86
+ await deps.operation.transition({
87
+ phase: "failed",
88
+ outcome: "failed",
89
+ reason: error instanceof Error ? error.message : String(error),
90
+ });
91
+ throw error;
92
+ }
93
+ if (release === null) {
94
+ await deps.operation.transition({ phase: "up-to-date", outcome: "up-to-date" });
95
+ return { result: "up-to-date" };
96
+ }
97
+ if (!request.consented && deps.policy === "notify-only") {
98
+ await deps.operation.transition({ phase: "held", outcome: "held", reason: "notify-only" });
99
+ await notify("held", { reason: "notify-only", version: release.version });
100
+ return { result: "held", reason: `policy is notify-only; ${release.version} is available` };
101
+ }
102
+ if (!request.consented && deps.policy === "confirm") {
103
+ await deps.operation.transition({ phase: "held", outcome: "held", reason: "confirmation-required" });
104
+ await notify("confirm-request", { version: release.version, current });
105
+ return { result: "held", reason: `policy requires confirmation before upgrading to ${release.version}` };
106
+ }
107
+ const refusal = deps.checkCompatibility
108
+ ? await deps.checkCompatibility(current, release.version)
109
+ : null;
110
+ if (refusal !== null) {
111
+ await deps.operation.transition({ phase: "held", outcome: "held", reason: refusal });
112
+ await notify("held", { reason: "incompatible", detail: refusal });
113
+ return { result: "held", reason: `incompatible: ${refusal}` };
114
+ }
115
+
116
+ await deps.operation.transition({ phase: "downloading" });
117
+ progress({ stage: "downloading", version: release.version });
118
+ const bytes = await downloadVerified(release, {
119
+ clock: deps.clock,
120
+ resumeDir: path.join(deps.stateDir, "incoming"),
121
+ onProgress: (downloaded, total) =>
122
+ progress({ stage: "downloading", version: release.version, downloaded, total }),
123
+ });
124
+ await deps.operation.transition({ phase: "verifying" });
125
+ progress({ stage: "verifying", version: release.version });
126
+ await deps.operation.transition({ phase: "staging" });
127
+ progress({ stage: "staging", version: release.version });
128
+ const bytesRef = await materializeArtifact(deps.stateDir, bytes);
129
+ if (deps.provenanceJournal) {
130
+ await recordReconcile(
131
+ deps.provenanceJournal,
132
+ request.provenance ?? deps.provenanceIdentity,
133
+ release.version,
134
+ );
135
+ }
136
+
137
+ await deps.operation.transition({ phase: "handing-over" });
138
+ progress({ stage: "handing-over", version: release.version });
139
+ let engineOutcome: EngineOutcome;
140
+ try {
141
+ engineOutcome = await deps.engine.upgrade({ version: release.version, bytesRef });
142
+ } catch (error) {
143
+ await deps.operation.transition({
144
+ phase: "recovering",
145
+ reason: error instanceof Error ? error.message : String(error),
146
+ });
147
+ throw error;
148
+ }
149
+ const finished = await finishUpgradeOutcome(engineOutcome, {
150
+ notify,
151
+ reportStage: progress,
152
+ targetVersion: release.version,
153
+ declaredSurfaces: deps.lifecycleSurfaceCount,
154
+ nowMs: deps.clock.nowMs(),
155
+ evidence: deps.evidence(),
156
+ lifecycle: deps.lifecycle(),
157
+ });
158
+ if (finished.report !== null) await deps.persistConvergenceReport(finished.report);
159
+ switch (finished.outcome.result) {
160
+ case "promoted":
161
+ await deps.operation.transition({ phase: "promoted", outcome: "promoted" });
162
+ break;
163
+ case "rolled-back":
164
+ await deps.operation.transition({ phase: "rolled-back", outcome: "rolled-back", reason: finished.outcome.reason });
165
+ break;
166
+ case "held":
167
+ await deps.operation.transition({ phase: "held", outcome: "held", reason: finished.outcome.reason });
168
+ break;
169
+ case "up-to-date":
170
+ await deps.operation.transition({ phase: "up-to-date", outcome: "up-to-date" });
171
+ break;
172
+ }
173
+ return finished.outcome;
174
+ } finally {
175
+ await lock.release();
176
+ }
177
+ }
@@ -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 =
@@ -241,6 +241,35 @@ The stages are not a parallel state machine: they are derived from the L1
241
241
  transaction phases (`stageForPhase`), so a progress display can never show a
242
242
  state the transaction does not have.
243
243
 
244
+ ## 4.6 One durable operation receipt
245
+
246
+ When a host detaches the transaction driver from the service it replaces,
247
+ pass an exact operation descriptor to `upgradeTo`. K then owns the only
248
+ durable operation state, including the previous stable version and terminal
249
+ outcome:
250
+
251
+ ```ts
252
+ await upgrader.upgradeTo("2.0.0", {
253
+ consented: true,
254
+ operation: {
255
+ id: requestId,
256
+ startedAtMs: Date.now(),
257
+ metadata: { originServerId }, // non-secret host correlation only
258
+ },
259
+ });
260
+
261
+ const receipt = await upgrader.operation();
262
+ if (receipt.kind === "observed" && receipt.operation.outcome !== null) {
263
+ await deliver(receipt.operation);
264
+ await upgrader.acknowledgeOperation(receipt.operation.id);
265
+ }
266
+ ```
267
+
268
+ The host may project this receipt into UI or transport, but must not maintain
269
+ a second pending/status/previous-version state machine. `recover()` settles an
270
+ active receipt under K's upgrade lock before the host reads it again. A corrupt
271
+ or future-version receipt is `unreadable`, never treated as genesis or success.
272
+
244
273
  ## 5. Publishing releases
245
274
 
246
275
  If you use the built-in `staticManifestSource`, its layout is:
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.6",
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": {