@zq-silk/yui 0.14.2 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ARCHITECTURE.md CHANGED
@@ -366,18 +366,33 @@ cleanup revalidates ownership and fails safely when concurrent state changes;
366
366
  manual retry is the recovery boundary rather than another durable state
367
367
  machine.
368
368
 
369
- Storage still records layout, aggregate, and record-family versions, but this
370
- release deliberately re-baselines all three axes at the current contract. The
371
- production migration registry is empty. Ordinary opening, Controller startup,
372
- doctor, update preflight, and the storage upgrade entry point therefore accept
373
- only an exact current manifest and current record shapes. An older Home is
374
- unsupported and must not be normalized, rewritten, or switched in place.
375
-
376
- SQLite bootstrap DDL is an implementation detail for initializing a fresh Home.
377
- Its ledger must be complete on every later open; a partial or older ledger is
378
- rejected rather than advanced. This keeps one durable model for Turn,
379
- TaskRoleSessionSet, WorkMailbox, and Provider Runtime Binding and prevents an
380
- old writer or migration transform from recreating removed delivery state.
369
+ Storage has one compatibility authority: the highest contiguous, checksummed
370
+ version in SQLite's append-only migration ledger. The CLI exposes that release's
371
+ current version and minimum supported migration version. Ordinary opening and
372
+ Controller startup accept only the exact current version; historical decoding
373
+ and rewriting exist only inside `yui upgrade` and the migration phase of
374
+ `yui update`.
375
+
376
+ Yui 0.15.0 establishes storage version 1 as the clean single-version baseline:
377
+ `schema.json` has no authority, and layout/aggregate/record-version axes are
378
+ absent from Home metadata. Pre-0.15.0 Homes are outside the migration floor.
379
+ Future releases append one immutable migration per storage version and retain
380
+ the complete chain from version 1. A fresh Home replays that same chain, so
381
+ fresh and upgraded Homes converge on one current schema.
382
+
383
+ The staged `upgrade --update-preflight` / `--update-apply` machine handshake is
384
+ also a compatibility contract: target releases keep its established outcomes
385
+ and required fields, plus the parent-owned handover-lock proof, readable by
386
+ every updater released since version 1. Additive detail is allowed; renaming or
387
+ removing consumed fields would strand an otherwise valid cross-version
388
+ migration path.
389
+
390
+ An upgrade runs only while the Controller is quiesced, creates a consistent
391
+ database backup, applies all missing migrations in one transaction, and
392
+ validates the current record model before writes resume. A missing or changed
393
+ ledger row, a migration gap, a Home below the migration floor, a future Home,
394
+ or malformed data fails closed. Runtime code contains no dual readers,
395
+ historical normalizers, or independently writable compatibility state.
381
396
 
382
397
  The Web control room is loopback-only and never receives Controller socket
383
398
  credentials. It presents durable records and native terminal access without
package/README.md CHANGED
@@ -72,12 +72,12 @@ export YUI_HOME=/absolute/path/to/yui-home
72
72
  yui setup
73
73
  ```
74
74
 
75
- The home contains `schema.json`, the authoritative SQLite database `yui.db`,
76
- Project Catalog and knowledge, and Controller discovery files. Stable Project
77
- checkouts and managed worktrees live under the configured workspace, outside
78
- Yui home. Runtime storage accepts only the exact current contract; it never
79
- falls back to `state.json`, normalizes an older shape, or repairs a historical
80
- Home in place.
75
+ The home contains the authoritative SQLite database `yui.db`, Project Catalog
76
+ and knowledge, and Controller discovery files. Stable Project checkouts and
77
+ managed worktrees live under the configured workspace, outside Yui home.
78
+ Legacy `schema.json` and `state.json` files are evidence only. Runtime storage
79
+ accepts only the exact current contract; supported earlier storage versions
80
+ enter only through the explicit upgrade boundary.
81
81
 
82
82
  Every Task-owned record family allocates a monotonically increasing local ID
83
83
  inside its Task. Different Tasks may therefore both contain `work-item-1`,
@@ -89,43 +89,41 @@ and `task integration start`, keep their subordinate IDs local to that Task.
89
89
  Candidate IDs are local to their WorkItem and carry both Task and WorkItem
90
90
  provenance.
91
91
 
92
- Yui records layout, aggregate, and per-record-family versions in `schema.json`.
93
- Runtime admission has only two outcomes: exact current, or rejected; a
94
- Controller never migrates storage while serving work. `yui upgrade --dry-run`
95
- is read-only, while `yui upgrade` validates only the release's exact current
96
- contract. Older, newer, incomplete, and malformed Homes fail closed.
92
+ Yui has one Home storage version, recorded by the append-only SQLite migration
93
+ ledger. Every CLI reports both its current storage version and its minimum
94
+ supported migration version. Runtime admission still has only two outcomes:
95
+ exact current, or rejected; a Controller never migrates storage while serving
96
+ work. `yui upgrade --dry-run` is read-only. `yui upgrade` quiesces a running
97
+ Controller, creates a consistent backup, applies every missing migration in
98
+ one transaction, validates the current contract, and then restarts the
99
+ Controller when it was running before the upgrade.
97
100
 
98
101
  `yui update` stages and pins one exact package, runs that staged binary's
99
102
  storage preflight, stops the exact old Controller, activates the same artifact,
100
- verifies the installed binary and current Home, and starts the replacement
101
- Controller. If the staged binary does not support the current Home exactly,
102
- the update stops before activation and leaves both the Home and current
103
- installation unchanged.
104
-
105
- To retain an old Home, keep it byte-for-byte and open it only with its original
106
- Yui version for read-only inspection. For unfinished work, initialize a new
107
- Home and let the Operator create a new Task from the old Task's objective,
108
- relevant WorkItems, current repository state, and available exact Turn results.
109
- The Operator creates new identities and may consult available exact Turn
110
- results; it does not import old runtime/session state or pretend that the old
111
- Task continued.
103
+ applies the staged release's migration chain when required, verifies the
104
+ installed binary and current Home, and starts the replacement Controller. A
105
+ Home inside the staged release's supported range can upgrade directly across
106
+ multiple versions without installing intermediate releases. A newer Home, a
107
+ Home below the migration floor, an incomplete migration ledger, or malformed
108
+ data fails closed without a guessed repair.
109
+
110
+ Yui 0.15.0 establishes storage version 1 and the migration floor. Homes created
111
+ by earlier releases, including 0.14.2, are not on this compatibility line:
112
+ preserve them for inspection with their matching Yui release or initialize a
113
+ new Home. From 0.15.0 onward, each release retains the complete chain, so later
114
+ `yui update` invocations can cross versions directly.
112
115
 
113
116
  See [Task-local identity](docs/task-local-identity.md) for the current reference
114
117
  contract.
115
118
 
116
- Schema work across Tasks is not serialized: any Task may advance a version axis
117
- (`layout`, `aggregate`, or a `record` family) on its own isolated branch without
118
- waiting for another Task's schema change to land. The later-integrating branch
119
- owns the reconciliation — rebasing onto the latest project head, resolving schema
120
- and code conflicts, re-advancing the schema versions and record-version-map
121
- entries the rebase requires, rebuilding and re-validating the wiring, and
122
- re-running the isolated E2E and docs. This is a deliberate scheduling trade-off
123
- that avoids cross-Task blocking, not an accident to repair ad hoc. The
124
- current manifest descriptor map is re-derived against the newest head, while the
125
- post-baseline descriptor snapshot remains frozen. If another Task later lands a
126
- record-schema change, the integrating branch must rebase, allocate the next
127
- current contract without collision, and re-test to convergence. It does not
128
- preserve an upgrade path for the displaced intermediate contract.
119
+ Schema work across Tasks is not serialized: any Task may propose the next
120
+ storage migration on its own isolated branch without waiting for another Task's
121
+ schema change to land. The later-integrating branch owns the reconciliation:
122
+ rebase onto the latest project head, preserve every already released migration
123
+ unchanged, allocate the next contiguous storage version, resolve schema and code
124
+ conflicts, and re-run the bounded validation. A migration may update physical
125
+ tables and current record payloads together; it must not introduce another
126
+ writable compatibility axis.
129
127
 
130
128
  Yui provides four reusable Worker Profile definitions through
131
129
  `yui config profile reset`; minimum setup makes each one inherit the current
@@ -917,10 +915,9 @@ Global Context entry:
917
915
  yui session enter <global-role>
918
916
  ```
919
917
 
920
- `yui update` accepts only the current Home contract. Unsupported older Homes
921
- remain untouched; inspect those with a compatible Yui version and let the
922
- current Operator recreate unfinished intent as new Tasks in a newly initialized
923
- Home.
918
+ `yui update` accepts either the current Home contract or any valid historical
919
+ contract at or above the staged CLI's minimum supported migration version.
920
+ Unsupported newer Homes and Homes below that floor remain untouched.
924
921
 
925
922
  tmux fixes a pane's history capacity when that pane is created. Existing panes
926
923
  retain their configured capacity; managed runtime output remains observable in
@@ -1003,9 +1000,10 @@ hiding the resources that remain. Use `--all` to include discovered Yui homes.
1003
1000
  `controller restart` replaces the Controller process and its scheduler/socket services with the currently installed Yui version. It can recover a lost discovery record only when the old process still matches the current UID, Controller entrypoint, physical Home, PID, and process-start identity. It does not stop or restart managed tmux/Agent sessions.
1004
1001
 
1005
1002
  Successful `setup` and `update` commands ensure that the current Home has a
1006
- running Controller, starting one when the Home was previously idle. `upgrade`
1007
- is read-only and does not start a Controller. `update` replaces an
1008
- already-running Controller only after the new binary passes its health checks.
1003
+ running Controller, starting one when the Home was previously idle. A
1004
+ successful `upgrade` restores a Controller only when it stopped one for the
1005
+ migration. `update` starts the replacement only after migration and health
1006
+ checks pass.
1009
1007
 
1010
1008
  Its recovery reconciliation runs every 120 seconds by default. Normal durable state changes enqueue a Task, Role, or Operator key and return immediately; keys received in the same fixed 100 ms window trigger one non-overlapping targeted pass. Operator presentation has an independent lane, so a blocked Task workspace operation cannot delay a user question. Periodic Git/worktree work is limited to Tasks with durable Task-mailbox work, while active Role liveness uses one tmux inventory. Structured Agent Driver observations, whether received from native provider events or supported Hooks, are exact-fenced before they reach the durable runtime inbox. A terminal Turn observation atomically records the exact Turn result. Durable mailboxes freeze the current batch while new signals merge into the next batch. Task-orchestration failures retain the exact Controller-owned processing batch for two bounded fast retries and later periodic recovery; a successful retry completes that batch before newer pending work is claimed. Recommended InputRequest and pending Turn deadlines share one nearest-deadline selector and therefore do not wait for the recovery interval. Explicit `task reconcile` still requests an immediate recovery pass. The retained loop is:
1011
1009
 
@@ -1097,15 +1095,17 @@ yui project reset|replace|retire|delete
1097
1095
  ```
1098
1096
 
1099
1097
  `yui update` stages the newly published package side by side and asks that exact
1100
- binary to verify the current Home. Only then does it stop the exact old
1101
- Controller, activate the same concrete package version, validate the actually
1102
- installed binary and Home, and start the replacement Controller. Unsupported
1103
- Homes block preflight and remain untouched.
1104
-
1105
- `yui upgrade --dry-run` validates an exact current Home without writing.
1106
- `yui upgrade` performs the same validation. This release provides no migration
1107
- from older aggregate contracts; use their matching Yui version or initialize a
1108
- new Home.
1098
+ binary to classify the Home. Only then does it stop the exact old Controller,
1099
+ activate the same concrete package version, apply the complete missing
1100
+ migration chain when needed, validate the actually installed binary and Home,
1101
+ and start the replacement Controller. Unsupported Homes block preflight and
1102
+ remain untouched.
1103
+
1104
+ `yui upgrade --dry-run` prints the ordered migration plan without writing.
1105
+ `yui upgrade` creates a timestamped SQLite backup and upgrades any valid Home
1106
+ from the CLI's minimum supported storage version to its current version. Yui
1107
+ 0.15.0 is storage version 1; pre-0.15.0 Homes remain outside that migration
1108
+ line and are never rewritten.
1109
1109
 
1110
1110
  Agent environment bindings store process-environment variable names, never secret values. Adapter-owned lifecycle arguments cannot be overridden through raw arguments.
1111
1111
 
@@ -995,7 +995,7 @@ export const ROOT_COMMAND = buildNode({
995
995
  { name: "update", summary: "Install the latest published Yui package globally." },
996
996
  {
997
997
  name: "upgrade",
998
- summary: "Validate this Home against the exact current storage contract.",
998
+ summary: "Plan or apply supported storage migrations for this Home.",
999
999
  usage: "yui upgrade [--dry-run]",
1000
1000
  options: ["--dry-run"]
1001
1001
  },
@@ -5,9 +5,9 @@ import { runUpdate } from "./updateOrchestrator.js";
5
5
  * Run `yui update` as a side-by-side, recoverable orchestration.
6
6
  *
7
7
  * The new package is staged beside the live install and used to run a read-only
8
- * preflight against the target Home. Only the exact current storage contract is
9
- * accepted; the exact Controller handoff then promotes and verifies the binary
10
- * without changing the Home. Older Homes remain untouched.
8
+ * preflight against the target Home. A current Home proceeds directly; a Home
9
+ * inside the staged release's supported range is migrated after the exact
10
+ * Controller handoff and before post-update verification.
11
11
  *
12
12
  * Returns a process exit code: 0 on success or already-current, 5 on abort.
13
13
  */
@@ -27,15 +27,23 @@ export function renderUpdateResult(result) {
27
27
  case "already-current":
28
28
  return "Yui is already up to date; nothing to install.";
29
29
  case "updated":
30
- return `Updated Yui to ${result.version}; the current Home was not modified.`;
30
+ return result.backupPath === undefined
31
+ ? `Updated Yui to ${result.version}; storage was already current.`
32
+ : `Updated Yui to ${result.version} and migrated storage. Backup: ${result.backupPath}`;
31
33
  case "aborted":
32
34
  return [
33
35
  `Update aborted during ${result.phase}: ${result.message}`,
34
- result.recoverable
35
- ? "The current install and Home remain usable."
36
- : result.phase === "activate-binary"
37
- ? "The Home was unchanged, but binary health is unknown; do not assume the current install is usable."
38
- : "Manual recovery is required (see below).",
36
+ result.phase === "migrate-storage"
37
+ || (result.phase === "post-verify" && result.recoverable)
38
+ ? "The target binary is installed; the Home remains quiesced pending successful verification."
39
+ : result.recoverable
40
+ ? "The current install and Home remain usable."
41
+ : result.phase === "activate-binary"
42
+ ? "The Home was unchanged, but binary health is unknown; do not assume the current install is usable."
43
+ : "Manual recovery is required (see below).",
44
+ ...(result.backupPath === undefined
45
+ ? []
46
+ : [`Storage backup: ${result.backupPath}`]),
39
47
  `Action: ${result.action}`
40
48
  ].join("\n");
41
49
  }
@@ -1,8 +1,10 @@
1
1
  /**
2
- * Side-by-side `yui update` orchestration for the current storage contract.
2
+ * Side-by-side `yui update` orchestration for the supported storage range.
3
3
  *
4
- * The staged binary proves an exact-current Home before the exact Controller
5
- * is stopped and the replacement is activated, verified, and restarted.
4
+ * The staged binary proves either an exact-current Home or a complete
5
+ * migration path before the exact Controller is stopped. The replacement is
6
+ * then activated, any required storage migration is applied, and the result is
7
+ * verified before the Controller is restarted.
6
8
  */
7
9
  export function runUpdate(ports, options) {
8
10
  let staged;
@@ -61,6 +63,26 @@ function runStagedUpdate(ports, staged, home) {
61
63
  ...(preflight.sceneUnchanged === true ? { sceneUnchanged: true } : {})
62
64
  };
63
65
  }
66
+ if (preflight.status === "migration-ready" && ports.migrateStorage === undefined) {
67
+ return {
68
+ outcome: "aborted",
69
+ phase: "preflight",
70
+ message: "The staged binary requires a storage migration, but no migration operation is available.",
71
+ action: "Use the complete Yui updater or run the staged release's `yui upgrade` explicitly.",
72
+ recoverable: true,
73
+ version: staged.version
74
+ };
75
+ }
76
+ if (preflight.status === "migration-ready" && !hasCompleteControllerLifecycle(ports)) {
77
+ return {
78
+ outcome: "aborted",
79
+ phase: "preflight",
80
+ message: "Storage migration requires a complete, fenced Controller handoff.",
81
+ action: "Provide status, exact stop, replacement start, and exact restore operations.",
82
+ recoverable: true,
83
+ version: staged.version
84
+ };
85
+ }
64
86
  let releaseHandover;
65
87
  try {
66
88
  releaseHandover = ports.beginControllerHandover?.(home);
@@ -79,13 +101,13 @@ function runStagedUpdate(ports, staged, home) {
79
101
  const captured = captureControllerLifecycle(ports, staged.version, home);
80
102
  if ("outcome" in captured)
81
103
  return captured;
82
- return activateAndVerify(ports, staged, home, captured.lifecycle);
104
+ return activateAndVerify(ports, staged, home, captured.lifecycle, preflight);
83
105
  }
84
106
  finally {
85
107
  releaseHandover?.();
86
108
  }
87
109
  }
88
- function activateAndVerify(ports, staged, home, lifecycle) {
110
+ function activateAndVerify(ports, staged, home, lifecycle, preflight) {
89
111
  try {
90
112
  ports.activateBinary(staged);
91
113
  }
@@ -99,19 +121,46 @@ function activateAndVerify(ports, staged, home, lifecycle) {
99
121
  version: staged.version
100
122
  });
101
123
  }
124
+ let migration;
125
+ if (preflight.status === "migration-ready") {
126
+ try {
127
+ migration = ports.migrateStorage(staged, home);
128
+ if (!isStorageMigrationResult(migration)) {
129
+ throw new Error("Storage migration returned a malformed result.");
130
+ }
131
+ }
132
+ catch (error) {
133
+ return {
134
+ outcome: "aborted",
135
+ phase: "migrate-storage",
136
+ message: `Storage migration failed: ${messageOf(error)}`,
137
+ action: "The target Yui binary is installed and the Home remains quiesced. "
138
+ + "Resolve the reported problem, rerun `yui upgrade`, then verify "
139
+ + "`yui doctor` before starting the Controller.",
140
+ recoverable: true,
141
+ version: staged.version
142
+ };
143
+ }
144
+ }
102
145
  try {
103
146
  ports.verify(staged, home);
104
147
  }
105
148
  catch (error) {
149
+ const storageMigrated = preflight.status === "migration-ready";
106
150
  const failure = {
107
151
  outcome: "aborted",
108
152
  phase: "post-verify",
109
153
  message: `Post-update health check failed: ${messageOf(error)}`,
110
154
  action: binaryHealthUncertainAction(),
111
- recoverable: false,
112
- version: staged.version
155
+ recoverable: storageMigrated,
156
+ version: staged.version,
157
+ ...(migration?.backupPath === undefined
158
+ ? {}
159
+ : { backupPath: migration.backupPath })
113
160
  };
114
- return restoreControllerOrReport(ports, home, lifecycle, failure);
161
+ return storageMigrated
162
+ ? failure
163
+ : restoreControllerOrReport(ports, home, lifecycle, failure);
115
164
  }
116
165
  if (lifecycle.ensureRunning) {
117
166
  try {
@@ -119,6 +168,7 @@ function activateAndVerify(ports, staged, home, lifecycle) {
119
168
  }
120
169
  catch (error) {
121
170
  const unknownActive = isUnknownActiveControllerFailure(error);
171
+ const storageMigrated = preflight.status === "migration-ready";
122
172
  const failure = {
123
173
  outcome: "aborted",
124
174
  phase: "post-verify",
@@ -127,21 +177,27 @@ function activateAndVerify(ports, staged, home, lifecycle) {
127
177
  : "The replacement Controller could not start after activation and verification"}: ${messageOf(error)}.`,
128
178
  action: unknownActive
129
179
  ? unknownActiveControllerAction(home)
130
- : lifecycle.wasRunning
131
- ? "Keep the Home quiesced and restore the captured Controller identity before retrying."
132
- : "Verify the activated binary, then start the Controller explicitly.",
133
- recoverable: false,
180
+ : storageMigrated
181
+ ? "Keep the Home quiesced, verify the activated binary, then start the current Controller explicitly."
182
+ : lifecycle.wasRunning
183
+ ? "Keep the Home quiesced and restore the captured Controller identity before retrying."
184
+ : "Verify the activated binary, then start the Controller explicitly.",
185
+ recoverable: storageMigrated && !unknownActive,
134
186
  version: staged.version,
135
- controllerOwnershipUnknown: true
187
+ ...(migration?.backupPath === undefined
188
+ ? {}
189
+ : { backupPath: migration.backupPath }),
190
+ ...(unknownActive ? { controllerOwnershipUnknown: true } : {})
136
191
  };
137
- return unknownActive
192
+ return unknownActive || storageMigrated
138
193
  ? failure
139
194
  : restoreControllerOrReport(ports, home, lifecycle, failure);
140
195
  }
141
196
  }
142
197
  return {
143
198
  outcome: "updated",
144
- version: staged.version
199
+ version: staged.version,
200
+ ...(migration?.backupPath === undefined ? {} : { backupPath: migration.backupPath })
145
201
  };
146
202
  }
147
203
  function captureControllerLifecycle(ports, version, home) {
@@ -259,6 +315,16 @@ function isControllerLifecycleStatus(value) {
259
315
  function isControllerStopResult(value) {
260
316
  return isRecord(value) && typeof value.stopped === "boolean";
261
317
  }
318
+ function hasCompleteControllerLifecycle(ports) {
319
+ return ports.controllerStatus !== undefined
320
+ && ports.stopController !== undefined
321
+ && ports.startController !== undefined
322
+ && ports.restoreController !== undefined;
323
+ }
324
+ function isStorageMigrationResult(value) {
325
+ return isRecord(value)
326
+ && (value.backupPath === undefined || typeof value.backupPath === "string");
327
+ }
262
328
  function isPositivePid(value) {
263
329
  return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
264
330
  }
@@ -7,9 +7,10 @@
7
7
  * `npm install --global --prefix <tmp>`, so the live global install is never
8
8
  * touched until the binary-activation step. Preflight invokes the STAGED binary's
9
9
  * internal `yui upgrade --update-preflight` contract so the target version
10
- * proves that the Home exactly matches its current storage contract. After the
11
- * parent stops the exact old Controller, the binary is promoted and the
12
- * activated binary verifies the unchanged Home.
10
+ * proves that the Home is current or has a complete supported migration path.
11
+ * After the parent stops the exact old Controller, the binary is promoted,
12
+ * required migrations run through that same staged artifact, and the activated
13
+ * binary verifies the resulting current Home.
13
14
  *
14
15
  * Two hardening guarantees this module enforces:
15
16
  *
@@ -146,13 +147,26 @@ export function createUpdatePorts(environment, spawn = spawnSync, stagingRoot =
146
147
  const result = run("npm", ["install", "--global", spec], { cwd: process.cwd(), env: environment, shell: false, stdio: "inherit" });
147
148
  assertSpawnOk(result, "activate the new binary");
148
149
  },
150
+ migrateStorage(staged, home) {
151
+ const result = run(staged.binaryPath, ["--json", "upgrade", "--update-apply"], {
152
+ cwd: process.cwd(),
153
+ env: {
154
+ ...environment,
155
+ YUI_HOME: home,
156
+ YUI_UPDATE_HANDOVER_OWNER_PID: String(process.pid)
157
+ },
158
+ shell: false
159
+ });
160
+ return interpretStorageMigration(result);
161
+ },
149
162
  verify(staged, home) {
150
163
  // Verify the ACTUALLY-ACTIVATED global binary, not the staging path (P1-3).
151
164
  const activeBinary = resolveGlobalBinary(environment, run);
152
165
  if (activeBinary === null || !existsSync(activeBinary)) {
153
166
  throw runtimeError("Post-update health check failed: could not locate the activated global `yui` binary.");
154
167
  }
155
- // 1) Health check the unchanged current Home through the activated binary.
168
+ // 1) Health check the current (possibly just migrated) Home through the
169
+ // activated binary.
156
170
  // POST-VERIFY PARSES THE MACHINE-READABLE RESULT FIRST, THEN THE EXIT STATUS
157
171
  // (R2-F2). `yui --json doctor` deliberately sets a non-zero exit when storage
158
172
  // is unhealthy, so interpreting the exit status before the envelope would
@@ -784,21 +798,69 @@ function interpretPreflight(result) {
784
798
  ...(data.sceneUnchanged === true ? { sceneUnchanged: true } : {})
785
799
  };
786
800
  }
787
- /** Accept only an exact current Home. */
801
+ /** Accept either an exact current Home or a complete supported migration path. */
788
802
  function parseUpdatePreflightResult(data) {
789
- if (data.status !== "already-current"
803
+ if ((data.status !== "already-current" && data.status !== "migration-ready")
790
804
  || !Number.isSafeInteger(data.stepCount)
791
- || data.stepCount !== 0
792
805
  || !Array.isArray(data.steps)
793
- || data.steps.length !== 0)
806
+ || data.steps.length !== data.stepCount)
794
807
  return null;
795
808
  const homeClassification = data.classification;
796
809
  if (!isRecord(homeClassification) || !isRecord(homeClassification.classification))
797
810
  return null;
798
811
  const classification = homeClassification.classification;
799
- if (classification.verdict !== "USABLE" || classification.status !== "current")
812
+ if (data.status === "already-current") {
813
+ if (data.stepCount !== 0
814
+ || classification.verdict !== "USABLE"
815
+ || classification.status !== "current")
816
+ return null;
817
+ return { status: "already-current", stepCount: 0 };
818
+ }
819
+ if (data.stepCount <= 0
820
+ || classification.verdict !== "MIGRATABLE"
821
+ || classification.status !== "migration-ready"
822
+ || !data.steps.every(isStorageMigrationStep))
800
823
  return null;
801
- return { status: "already-current", stepCount: 0 };
824
+ return { status: "migration-ready", stepCount: data.stepCount };
825
+ }
826
+ function interpretStorageMigration(result) {
827
+ const data = parseSuccessEnvelopeData(result);
828
+ if (data === null) {
829
+ throw runtimeError("The staged binary did not return a successful storage migration result "
830
+ + `(exit ${result.status ?? "null"}${result.signal === null ? "" : `, signal ${result.signal}`}).`);
831
+ }
832
+ if (data.outcome === "blocked" || data.outcome === "failed") {
833
+ const message = typeof data.message === "string"
834
+ ? data.message
835
+ : "The staged binary refused the storage migration.";
836
+ const action = typeof data.action === "string" ? ` Action: ${data.action}` : "";
837
+ const backup = typeof data.backupPath === "string"
838
+ ? ` Backup: ${data.backupPath}`
839
+ : "";
840
+ throw runtimeError(`${message}${action}${backup}`);
841
+ }
842
+ if (result.status !== 0) {
843
+ throw runtimeError(`The staged binary returned outcome=${String(data.outcome)} but exited `
844
+ + `with status ${result.status ?? "null"}.`);
845
+ }
846
+ if (data.outcome === "already-current")
847
+ return {};
848
+ if (data.outcome !== "upgraded" || !isRecord(data.report)) {
849
+ throw runtimeError(`The staged binary returned unexpected storage migration outcome=${String(data.outcome)}.`);
850
+ }
851
+ const backupPath = data.report.backupPath;
852
+ if (typeof backupPath !== "string" || backupPath.length === 0) {
853
+ throw runtimeError("The staged binary did not report the required storage backup path.");
854
+ }
855
+ return { backupPath };
856
+ }
857
+ function isStorageMigrationStep(value) {
858
+ return isRecord(value)
859
+ && Number.isSafeInteger(value.fromVersion)
860
+ && Number.isSafeInteger(value.toVersion)
861
+ && value.toVersion === value.fromVersion + 1
862
+ && typeof value.name === "string"
863
+ && value.name.length > 0;
802
864
  }
803
865
  function parseUpdateBlockers(value) {
804
866
  if (value === undefined)