@zq-silk/yui 0.2.0 → 0.4.2

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.
Files changed (208) hide show
  1. package/ARCHITECTURE.md +603 -133
  2. package/README.md +806 -31
  3. package/dist/agent/agent.js +2 -1
  4. package/dist/agent/argumentPolicy.js +3 -1
  5. package/dist/agent/launchEnvironment.js +106 -0
  6. package/dist/agent/managedRuntimeEnvironment.js +34 -0
  7. package/dist/brief/taskBrief.js +11 -1
  8. package/dist/cli/agentConfigurationPicker.js +287 -0
  9. package/dist/cli/commandCatalog.js +488 -60
  10. package/dist/cli/completion.js +146 -22
  11. package/dist/cli/helpRenderer.js +3 -1
  12. package/dist/cli/interactionCandidates.js +53 -15
  13. package/dist/cli/interactionPolicy.js +267 -30
  14. package/dist/cli/interactiveSelection.js +6 -2
  15. package/dist/cli/invocationRouter.js +5 -1
  16. package/dist/cli/operatorWizard.js +87 -0
  17. package/dist/cli/roleOptionCatalog.js +1 -0
  18. package/dist/cli/roleWizard.js +185 -21
  19. package/dist/cli/updateCommand.js +62 -19
  20. package/dist/cli/updateOrchestrator.js +539 -0
  21. package/dist/cli/updatePorts.js +1119 -0
  22. package/dist/cli/upgradeCommand.js +112 -0
  23. package/dist/cli.js +1420 -86
  24. package/dist/commands/agentCommands.js +146 -3
  25. package/dist/commands/configCommands.js +126 -0
  26. package/dist/commands/controllerCommands.js +365 -0
  27. package/dist/commands/globalRoleCommands.js +168 -126
  28. package/dist/commands/jobCommands.js +18 -8
  29. package/dist/commands/operatorCommands.js +159 -9
  30. package/dist/commands/profileCommands.js +203 -0
  31. package/dist/commands/projectCommands.js +650 -0
  32. package/dist/commands/roleConfiguration.js +85 -24
  33. package/dist/commands/roleRuntimeGuard.js +12 -0
  34. package/dist/commands/roleSkillValidation.js +47 -0
  35. package/dist/commands/taskActor.js +127 -0
  36. package/dist/commands/taskCommands.js +4201 -313
  37. package/dist/commands/taskCompletionGate.js +131 -0
  38. package/dist/commands/taskContextCommand.js +244 -30
  39. package/dist/commands/taskInputCommands.js +177 -59
  40. package/dist/commands/taskIntegrationCommands.js +303 -0
  41. package/dist/commands/taskOverviewCommand.js +363 -0
  42. package/dist/commands/taskRoleRuntimeStatus.js +125 -19
  43. package/dist/commands/textInput.js +15 -0
  44. package/dist/completion/completionInstaller.js +26 -22
  45. package/dist/config/yuiConfig.js +4 -3
  46. package/dist/context/dispatchContext.js +90 -38
  47. package/dist/context/roleSessionContext.js +119 -0
  48. package/dist/controller/claudeLifecycleHook.js +203 -0
  49. package/dist/controller/clientRuntime.js +408 -56
  50. package/dist/controller/codexLifecycleHook.js +108 -0
  51. package/dist/controller/controller.js +1089 -32
  52. package/dist/controller/domainIdentity.js +505 -0
  53. package/dist/controller/ephemeralResourceReaper.js +131 -0
  54. package/dist/controller/fileSchedulerStoreAdapter.js +2153 -103
  55. package/dist/controller/providerHookRunFence.js +127 -0
  56. package/dist/controller/resourceCleanupLinux.js +286 -0
  57. package/dist/controller/resourceInventory.js +531 -0
  58. package/dist/controller/resourceInventoryLinux.js +610 -0
  59. package/dist/controller/runtime.js +629 -10
  60. package/dist/controller/runtimeEventInbox.js +564 -0
  61. package/dist/controller/runtimeEventProcessor.js +248 -0
  62. package/dist/controller/runtimeLaunchCoordinator.js +477 -0
  63. package/dist/controller/sessionNotify.js +121 -78
  64. package/dist/coordination/deadlineScheduler.js +15 -0
  65. package/dist/coordination/mailboxScheduler.js +108 -0
  66. package/dist/coordination/workMailbox.js +329 -0
  67. package/dist/coordination/workMailboxQueue.js +86 -0
  68. package/dist/core/controllerClient.js +19 -5
  69. package/dist/core/controllerEndpoint.js +37 -0
  70. package/dist/core/controllerServer.js +218 -10
  71. package/dist/core/protocol.js +6 -2
  72. package/dist/decision/decision.js +2 -1
  73. package/dist/doctor/doctor.js +681 -32
  74. package/dist/domain/validation.js +53 -0
  75. package/dist/errors/cliError.js +5 -3
  76. package/dist/event/taskEvent.js +7 -3
  77. package/dist/execution/codexThreadNaming.js +160 -0
  78. package/dist/execution/executionGroup.js +579 -0
  79. package/dist/executor/agentAdapter.js +255 -40
  80. package/dist/executor/agentConfigurationCatalog.js +326 -0
  81. package/dist/executor/agentConfigurationProbe.js +506 -0
  82. package/dist/executor/agentExecutor.js +625 -10
  83. package/dist/executor/codexConfigConflict.js +290 -0
  84. package/dist/executor/effectiveLaunch.js +340 -0
  85. package/dist/executor/executorRegistry.js +238 -36
  86. package/dist/executor/fileRoleLaunchPlanner.js +550 -40
  87. package/dist/executor/turnCompletion.js +126 -0
  88. package/dist/input/inputRequest.js +30 -9
  89. package/dist/integration/changeSet.js +36 -0
  90. package/dist/integration/checkResult.js +24 -0
  91. package/dist/integration/gitIntegrationService.js +695 -0
  92. package/dist/integration/integrationAttempt.js +142 -0
  93. package/dist/interaction/operatorPresentation.js +96 -0
  94. package/dist/lifecycle/canonicalLifecycleEvent.js +342 -0
  95. package/dist/lifecycle/exactRunTerminalization.js +572 -0
  96. package/dist/lifecycle/providerLifecycleMapping.js +190 -0
  97. package/dist/lifecycle/taskRoleSessionReset.js +124 -0
  98. package/dist/message/message.js +23 -7
  99. package/dist/milestone/milestone.js +2 -1
  100. package/dist/operator/operatorSessionHistory.js +124 -0
  101. package/dist/output/agentConfigurationPresentation.js +43 -0
  102. package/dist/output/rolePresentation.js +34 -10
  103. package/dist/output/terminal.js +8 -0
  104. package/dist/output/timePresentation.js +55 -0
  105. package/dist/profile/agentProfile.js +128 -0
  106. package/dist/repository/gitWorkspace.js +578 -24
  107. package/dist/repository/project.js +213 -0
  108. package/dist/repository/taskWorkspaceCoordinator.js +392 -0
  109. package/dist/repository/taskWorkspacePreparer.js +1688 -191
  110. package/dist/review/reviewConfig.js +11 -0
  111. package/dist/review/reviewRound.js +399 -0
  112. package/dist/review/taskFinalReviewContract.js +90 -0
  113. package/dist/role/role.js +124 -23
  114. package/dist/run/agentRun.js +155 -12
  115. package/dist/run/runIdentity.js +82 -0
  116. package/dist/runtime/exactControlPlane.js +472 -0
  117. package/dist/runtime/index.js +8 -0
  118. package/dist/runtime/lifecycleReservation.js +38 -0
  119. package/dist/runtime/ports.js +11 -0
  120. package/dist/runtime/preallocatedNativeSession.js +13 -0
  121. package/dist/runtime/promptEnvelope.js +30 -0
  122. package/dist/runtime/runtimeBinding.js +31 -0
  123. package/dist/runtime/runtimeOwner.js +14 -0
  124. package/dist/runtime/sessionLaunchRequest.js +62 -0
  125. package/dist/runtime/sessionTitle.js +54 -0
  126. package/dist/runtime/taskRuntimeIsolation.js +643 -0
  127. package/dist/runtime/tmuxAdapters.js +315 -0
  128. package/dist/runtime/turnCompletion.js +3 -0
  129. package/dist/runtime/validation.js +23 -0
  130. package/dist/scheduler/activeRoleRunDelivery.js +342 -32
  131. package/dist/scheduler/activeTaskProgress.js +63 -0
  132. package/dist/scheduler/leaderFailure.js +2 -1
  133. package/dist/scheduler/leaderWakeupProcessor.js +307 -66
  134. package/dist/scheduler/operatorInputNotificationProcessor.js +109 -46
  135. package/dist/scheduler/operatorNotification.js +44 -2
  136. package/dist/scheduler/ports.js +28 -1
  137. package/dist/scheduler/roleRunLiveness.js +131 -25
  138. package/dist/scheduler/roleRunStall.js +951 -0
  139. package/dist/scheduler/taskExecutionProjection.js +544 -0
  140. package/dist/scheduler/wakeupQueue.js +3 -0
  141. package/dist/setup/setupCommand.js +302 -52
  142. package/dist/storage/compatibleTaskStore.js +102 -0
  143. package/dist/storage/migration/baseline.js +78 -0
  144. package/dist/storage/migration/classifier.js +51 -0
  145. package/dist/storage/migration/compatibleCodec.js +53 -0
  146. package/dist/storage/migration/engine.js +147 -0
  147. package/dist/storage/migration/index.js +33 -0
  148. package/dist/storage/migration/planner.js +154 -0
  149. package/dist/storage/migration/productionRegistry.js +486 -0
  150. package/dist/storage/migration/registry.js +169 -0
  151. package/dist/storage/migration/report.js +54 -0
  152. package/dist/storage/migration/types.js +31 -0
  153. package/dist/storage/storageSchema.js +147 -123
  154. package/dist/storage/storageVersions.js +11 -0
  155. package/dist/storage/taskStore.js +1793 -197
  156. package/dist/storage/upgrade/homeClassification.js +156 -0
  157. package/dist/storage/upgrade/homeMigrationTarget.js +595 -0
  158. package/dist/storage/upgrade/offlineUpgradeInventory.js +315 -0
  159. package/dist/storage/upgrade/productionMigrationRegistry.js +6 -0
  160. package/dist/storage/upgrade/recordVersionScan.js +176 -0
  161. package/dist/storage/upgrade/recordVersions.js +159 -0
  162. package/dist/storage/upgrade/switchProgress.js +80 -0
  163. package/dist/storage/upgrade/upgradeOrchestrator.js +948 -0
  164. package/dist/storage/upgrade/upgradeReceipt.js +161 -0
  165. package/dist/storage/upgradeCoordination.js +186 -0
  166. package/dist/storage/upgradeFence.js +366 -0
  167. package/dist/task/task.js +132 -26
  168. package/dist/task/taskRecordReference.js +66 -0
  169. package/dist/tmux/commandExecutor.js +75 -2
  170. package/dist/tmux/tmuxManager.js +747 -49
  171. package/dist/version.js +23 -0
  172. package/dist/web/assets/assetManifest.js +62 -0
  173. package/dist/web/assets/client/app.js +631 -0
  174. package/dist/web/assets/client/components.js +605 -0
  175. package/dist/web/assets/client/dom.js +14 -0
  176. package/dist/web/assets/client/format.js +28 -0
  177. package/dist/web/assets/client/i18n.js +494 -0
  178. package/dist/web/assets/client/markdown.js +114 -0
  179. package/dist/web/assets/client/theme.js +32 -0
  180. package/dist/web/assets/client/view.js +458 -0
  181. package/dist/web/assets/fontData.js +12 -0
  182. package/dist/web/assets/fonts.js +12 -0
  183. package/dist/web/assets/shell.js +114 -0
  184. package/dist/web/assets/styles/cards.js +135 -0
  185. package/dist/web/assets/styles/layout.js +47 -0
  186. package/dist/web/assets/styles/markdown.js +29 -0
  187. package/dist/web/assets/styles/responsive.js +39 -0
  188. package/dist/web/assets/styles/tokens.js +101 -0
  189. package/dist/web/assets/styles/widgets.js +147 -0
  190. package/dist/web/tmuxWebTerminal.js +158 -0
  191. package/dist/web/webServer.js +463 -0
  192. package/dist/web/webSnapshot.js +148 -0
  193. package/dist/workItem/workItem.js +642 -23
  194. package/dist/workspace/gitChangeSetCapture.js +86 -0
  195. package/dist/workspace/workItemChangeSetManager.js +445 -0
  196. package/dist/worktree/managedWorkspace.js +202 -0
  197. package/docs/task-local-identity.md +62 -0
  198. package/i18n/README.zh-CN.md +406 -31
  199. package/package.json +10 -2
  200. package/skills/yui-leader/SKILL.md +601 -39
  201. package/skills/yui-operator/SKILL.md +255 -34
  202. package/skills/yui-reviewer/SKILL.md +57 -0
  203. package/skills/yui-worker/SKILL.md +214 -17
  204. package/dist/commands/repositoryCommands.js +0 -86
  205. package/dist/operator/operatorContext.js +0 -66
  206. package/dist/repository/repository.js +0 -55
  207. package/dist/scheduler/archivedTaskRuntime.js +0 -12
  208. package/dist/worktree/roleWorkspace.js +0 -62
@@ -0,0 +1,1119 @@
1
+ /**
2
+ * Real {@link UpdatePorts} for `yui update`: side-by-side npm staging plus a
3
+ * staged-binary path-specific read-only preflight, wired into the recoverable
4
+ * orchestration in {@link runUpdate}.
5
+ *
6
+ * Staging installs the latest package into a throwaway prefix with
7
+ * `npm install --global --prefix <tmp>`, so the live global install is never
8
+ * touched until the binary-activation step. Preflight invokes the STAGED binary's
9
+ * internal `yui upgrade --update-preflight` contract so the target version
10
+ * classifies the Home, validates a compatible source in memory, or reads the
11
+ * authoritative offline inventory as required by that path. After the parent
12
+ * stops the exact old Controller, storage activation performs the full staged
13
+ * validation/switch. Post-verify invokes the actually activated global binary.
14
+ *
15
+ * Two hardening guarantees this module enforces:
16
+ *
17
+ * - **Promote the SAME artifact that was staged (P1-3).** `stage` resolves the
18
+ * exact version it installed from the staged package's own metadata; both the
19
+ * `StagedPackage.version` and `activateBinary` use that pinned version
20
+ * (`@zq-silk/yui@<version>`), never a second bare `@latest` that could resolve
21
+ * to a different build than the one that passed preflight.
22
+ * - **Verify the ACTUALLY-ACTIVATED binary (P1-3).** `verify` resolves the live
23
+ * global `yui` (via `npm prefix -g`), runs its `--json doctor`, AND confirms
24
+ * the promoted binary's reported version matches the staged version. A
25
+ * mismatch fails closed.
26
+ *
27
+ * And the ambiguity guarantee (P1-2): `activateStorage` returns `ambiguous`
28
+ * (never a false "unchanged") when the child leaves no parseable receipt, and
29
+ * `probeStorage` reads the durable receipt + backup + schema so the orchestrator
30
+ * can resolve the true state.
31
+ */
32
+ import { spawnSync } from "node:child_process";
33
+ import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
34
+ import { tmpdir } from "node:os";
35
+ import { dirname, isAbsolute, join, resolve } from "node:path";
36
+ import { fileURLToPath } from "node:url";
37
+ import { runtimeError } from "../errors/cliError.js";
38
+ import { STORAGE_DOCTOR_CHECK_NAMES } from "../doctor/doctor.js";
39
+ import { inspectStorageSchema } from "../storage/storageSchema.js";
40
+ import { correlateUpgradeReceipt } from "../storage/upgrade/upgradeOrchestrator.js";
41
+ import { readSwitchProgress } from "../storage/upgrade/switchProgress.js";
42
+ const PACKAGE_NAME = "@zq-silk/yui";
43
+ const PACKAGE_SPEC = `${PACKAGE_NAME}@latest`;
44
+ /** Build the real ports. `spawn` is injectable so tests avoid real installs. */
45
+ export function createUpdatePorts(environment, spawn = spawnSync, stagingRoot = tmpdir()) {
46
+ // Keep the exact verified artifact in memory for this update attempt. This is
47
+ // deliberately not persisted as a retry or recovery protocol.
48
+ let verifiedActivatedBinary;
49
+ let verifiedActivatedVersion;
50
+ const stopReplacementController = (home, pid) => (stopReplacementControllerForUpdate(home, pid, environment, spawn));
51
+ return {
52
+ stage() {
53
+ const stagingPath = mkdtempSync(join(stagingRoot, "yui-update-stage-"));
54
+ let ownsStaging = true;
55
+ try {
56
+ const result = spawn("npm", ["install", "--global", "--prefix", stagingPath, PACKAGE_SPEC], { cwd: process.cwd(), env: environment, shell: false, stdio: "inherit" });
57
+ assertSpawnOk(result, "stage the new package");
58
+ const binaryPath = join(stagingPath, "bin", "yui");
59
+ // Resolve the EXACT version that was staged (from the staged install's own
60
+ // package.json, else the staged binary itself). If neither yields a concrete
61
+ // version, FAIL the stage (R2-F1): we must never fall back to a bare
62
+ // `@latest`, which would let activation promote — and verify wave through —
63
+ // a different build than the one that passed preflight.
64
+ const version = resolveStagedVersion(stagingPath, binaryPath, environment, spawn);
65
+ if (version === null) {
66
+ throw runtimeError("Failed to resolve the exact staged package version (neither the staged package.json "
67
+ + "nor `yui --json version` returned a concrete version). Refusing to proceed with a "
68
+ + "`@latest` fallback that could promote a different build than the one preflighted.");
69
+ }
70
+ // Successful staging transfers cleanup ownership to runUpdate's finally
71
+ // block. Every assertion, spawn, npm/network, or version-resolution
72
+ // failure before that handoff removes the throwaway prefix here.
73
+ ownsStaging = false;
74
+ return { binaryPath, version, stagingPath };
75
+ }
76
+ finally {
77
+ if (ownsStaging)
78
+ rmSync(stagingPath, { recursive: true, force: true });
79
+ }
80
+ },
81
+ preflight(staged, home) {
82
+ const result = spawn(staged.binaryPath, ["--json", "upgrade", "--update-preflight"], { cwd: process.cwd(), env: { ...environment, YUI_HOME: home }, shell: false });
83
+ return interpretPreflight(result);
84
+ },
85
+ activateStorage(staged, home) {
86
+ const result = spawn(staged.binaryPath, ["--json", "upgrade"], {
87
+ cwd: process.cwd(),
88
+ // The parent update process captures/stops/drains the old Controller
89
+ // before invoking the staged child. Mark this internal call so the
90
+ // child performs storage migration only and never starts a Controller
91
+ // from the temporary staging installation.
92
+ env: {
93
+ ...environment,
94
+ YUI_HOME: home,
95
+ YUI_UPDATE_EXTERNALLY_QUIESCED: "1"
96
+ },
97
+ shell: false
98
+ });
99
+ return interpretActivation(result);
100
+ },
101
+ activateBinary(staged) {
102
+ verifiedActivatedBinary = undefined;
103
+ verifiedActivatedVersion = undefined;
104
+ // Promote the SAME resolved artifact, pinned by exact version — never a bare
105
+ // `@latest` (R2-F1: `stage` guarantees `staged.version` is a concrete
106
+ // version, so there is no `latest` sentinel to fall back to).
107
+ const spec = `${PACKAGE_NAME}@${staged.version}`;
108
+ const result = spawn("npm", ["install", "--global", spec], { cwd: process.cwd(), env: environment, shell: false, stdio: "inherit" });
109
+ assertSpawnOk(result, "activate the new binary");
110
+ },
111
+ verify(staged, home) {
112
+ // Verify the ACTUALLY-ACTIVATED global binary, not the staging path (P1-3).
113
+ const activeBinary = resolveGlobalBinary(environment, spawn);
114
+ if (activeBinary === null || !existsSync(activeBinary)) {
115
+ throw runtimeError("Post-update health check failed: could not locate the activated global `yui` binary.");
116
+ }
117
+ // 1) Health check the migrated Home through the activated binary's loader.
118
+ // POST-VERIFY PARSES THE MACHINE-READABLE RESULT FIRST, THEN THE EXIT STATUS
119
+ // (R2-F2). `yui --json doctor` deliberately sets a non-zero exit when storage
120
+ // is unhealthy, so interpreting the exit status before the envelope would
121
+ // reduce a precise "storage unsupported/corrupted" verdict to a generic
122
+ // "exited with status 5". We therefore parse+validate the structured storage
123
+ // health first: only a valid success envelope with every expected storage
124
+ // check present-and-ok, no blocking checks, AND exit 0 is healthy.
125
+ const doctor = spawn(activeBinary, ["--json", "doctor"], { cwd: process.cwd(), env: { ...environment, YUI_HOME: home }, shell: false });
126
+ assertDoctorStorageHealthy(doctor);
127
+ // 2) Confirm the activated binary's identity matches the staged artifact.
128
+ // `staged.version` is always a concrete version (R2-F1), so we REQUIRE the
129
+ // activated binary to report a concrete version that equals it; a missing,
130
+ // unparseable, or non-zero `version` result fails closed rather than being
131
+ // skipped — we must never trust a build we cannot positively identify.
132
+ const activeVersion = resolveBinaryVersion(activeBinary, environment, spawn);
133
+ if (activeVersion === null) {
134
+ throw runtimeError("Post-update health check failed: could not determine the activated binary's version "
135
+ + `(expected ${staged.version}). Refusing to trust a build whose identity cannot be `
136
+ + "confirmed against the staged/preflighted artifact.");
137
+ }
138
+ if (activeVersion !== staged.version) {
139
+ throw runtimeError(`Post-update health check failed: the activated binary is version ${activeVersion}, `
140
+ + `but the staged/verified artifact was ${staged.version}. Refusing to trust a `
141
+ + "different build than the one that passed preflight.");
142
+ }
143
+ // Retain the exact path used by both doctor and version verification. The
144
+ // replacement start must not resolve UPDATE_CLI_PATH or npm again.
145
+ verifiedActivatedBinary = activeBinary;
146
+ verifiedActivatedVersion = activeVersion;
147
+ },
148
+ probeStorage(home) {
149
+ // Resolve an ambiguous activation from durable on-disk evidence (P1-2),
150
+ // but only trust a receipt that CORRESPONDS to the current Home/backup
151
+ // (P2-6): a leftover receipt from a prior attempt, a different Home, or one
152
+ // whose backup was already restored/cleaned is NOT evidence that THIS
153
+ // attempt's switch committed. When it does not correspond, fall back to the
154
+ // on-disk schema and report `switched: false` so the caller re-probes the
155
+ // real state instead of giving a recovery instruction from a stale receipt.
156
+ const schema = inspectStorageSchema(home);
157
+ // A crash mid-switch leaves a durable progress marker. A marker of ANY phase
158
+ // — `backing-up`, `promoting`, or `interrupted` — is only actionable as an
159
+ // interrupted switch when the FILESYSTEM still corroborates it: the backup
160
+ // exists AND the Home is not in place (missing/uninitialized), so the
161
+ // authoritative data lives only at the backup and the recovery is a precise
162
+ // restore. This gate now applies to `interrupted` too (R2-F3): a leftover
163
+ // `interrupted` marker after a manual recovery — Home already restored, or
164
+ // the backup already gone — must NOT be trusted to emit a restore path; we
165
+ // ignore the stale marker and re-probe the real state below.
166
+ const progress = readSwitchProgress(home);
167
+ if (progress !== null) {
168
+ const backupPresent = progress.backupPath !== undefined
169
+ && existsSync(progress.backupPath);
170
+ // The Home is "in place" only when storage is actually initialized there;
171
+ // a missing/uninitialized Home after a mid-switch crash means the data is
172
+ // at the backup.
173
+ const homeInitialized = schema.status !== "uninitialized";
174
+ if (backupPresent && !homeInitialized) {
175
+ // Original at the backup, Home missing/uninitialized: recover by restore.
176
+ // The Home is uninitialized here, so it is definitively not current.
177
+ return {
178
+ switched: false,
179
+ interrupted: true,
180
+ schemaCurrent: false,
181
+ ...(progress.backupPath === undefined ? {} : { backupPath: progress.backupPath })
182
+ };
183
+ }
184
+ // Otherwise the marker is stale relative to the current filesystem (Home
185
+ // intact, or no backup to restore). Do not emit a restore path from it —
186
+ // fall through and reconcile against the receipt/schema below.
187
+ }
188
+ const correlation = correlateUpgradeReceipt(home);
189
+ if (!correlation.corresponds) {
190
+ return { switched: false, schemaCurrent: schema.status === "current" };
191
+ }
192
+ const receipt = correlation.receipt;
193
+ return {
194
+ switched: true,
195
+ schemaCurrent: schema.status === "current",
196
+ ...(receipt.backupPath === undefined ? {} : { backupPath: receipt.backupPath })
197
+ };
198
+ },
199
+ cleanup(staged) {
200
+ if (staged.stagingPath !== undefined) {
201
+ rmSync(staged.stagingPath, { recursive: true, force: true });
202
+ }
203
+ },
204
+ // `runUpdate` is intentionally synchronous because the npm/staged-binary
205
+ // ports use spawnSync. Keep Controller ownership in this same owner by
206
+ // using the CLI's structured lifecycle commands; tests can replace these
207
+ // seams with deterministic fakes without touching a real Controller.
208
+ controllerStatus(home) {
209
+ return readControllerLifecycle(home, environment, spawn);
210
+ },
211
+ stopController(home, expectedPid) {
212
+ return stopControllerForUpdate(home, expectedPid, environment, spawn);
213
+ },
214
+ stopReplacementController,
215
+ startController(home) {
216
+ if (verifiedActivatedBinary === undefined || verifiedActivatedVersion === undefined) {
217
+ throw runtimeError("Replacement Controller cannot start before the activated global binary has "
218
+ + "passed doctor/version verification.");
219
+ }
220
+ restartControllerForUpdate(home, environment, spawn, verifiedActivatedBinary, verifiedActivatedVersion, stopReplacementController);
221
+ },
222
+ restoreController(home, identity) {
223
+ restoreControllerIdentity(home, identity, environment, spawn);
224
+ }
225
+ };
226
+ }
227
+ const UPDATE_CLI_PATH = fileURLToPath(new URL("../cli.js", import.meta.url));
228
+ const UPDATE_CLIENT_RUNTIME_PATH = fileURLToPath(new URL("../controller/clientRuntime.js", import.meta.url));
229
+ /**
230
+ * Capture running state plus an authenticated exact process identity before
231
+ * stopping. Public `controller status` is an inventory and intentionally
232
+ * redacts argv, so current/orphan/stale/uncertain inventory states are never
233
+ * guessed into "stopped".
234
+ */
235
+ function readControllerLifecycle(home, environment, spawn, cliBinary) {
236
+ const data = runControllerCommand(home, environment, spawn, "status", cliBinary);
237
+ if (!Array.isArray(data.resources)) {
238
+ throw new Error("Controller inventory is malformed; treating ownership as unknown-active.");
239
+ }
240
+ const resources = data.resources;
241
+ if (data.warnings !== undefined
242
+ && (!Array.isArray(data.warnings) || data.warnings.some((warning) => typeof warning !== "string"))) {
243
+ throw new Error("Controller inventory warnings are malformed; treating ownership as unknown-active.");
244
+ }
245
+ if (Array.isArray(data.warnings) && data.warnings.length > 0) {
246
+ throw new Error(`Controller inventory is uncertain: ${data.warnings.join("; ")}`);
247
+ }
248
+ const resolvedHome = resolve(home);
249
+ const homeResources = resources.filter((resource) => (isRecord(resource) && resource.yuiHome === resolvedHome));
250
+ const controllerResources = homeResources.filter((resource) => (resource.kind === "controller"));
251
+ const controllerArtifacts = homeResources.filter((resource) => (resource.kind === "artifact"
252
+ && isRecord(resource.artifact)
253
+ && (resource.artifact.artifactKind === "controller-discovery"
254
+ || resource.artifact.artifactKind === "controller-socket")));
255
+ const currentResources = controllerResources.filter((resource) => (resource.state === "current"));
256
+ if (controllerArtifacts.length > 0
257
+ || controllerResources.some((resource) => resource.state !== "current")
258
+ || currentResources.length > 1) {
259
+ throw new Error("Controller inventory found an orphaned, stale, invalid, or ambiguous resource; "
260
+ + "treating ownership as unknown-active.");
261
+ }
262
+ const current = resources.find((resource) => (isRecord(resource)
263
+ && resource.kind === "controller"
264
+ && resource.state === "current"
265
+ && resource.yuiHome === resolvedHome));
266
+ const processes = isRecord(current) && Array.isArray(current.processes)
267
+ ? current.processes
268
+ : [];
269
+ const processInfo = processes.find((value) => isRecord(value));
270
+ if (!isRecord(processInfo)) {
271
+ if (current !== undefined) {
272
+ throw new Error("Controller inventory is current but has no process proof; treating ownership as unknown-active.");
273
+ }
274
+ return proveControllerAbsent(home, environment, spawn);
275
+ }
276
+ const identity = parseControllerIdentity(runControllerCommand(home, environment, spawn, "identity", cliBinary));
277
+ return {
278
+ running: true,
279
+ ...(isPositivePid(processInfo.pid) ? { pid: processInfo.pid } : {}),
280
+ identity
281
+ };
282
+ }
283
+ function proveControllerAbsent(home, environment, spawn) {
284
+ try {
285
+ runControllerCommand(home, environment, spawn, "identity");
286
+ }
287
+ catch (error) {
288
+ if (controllerErrorCode(error) === "CONTROLLER_NOT_RUNNING") {
289
+ return { running: false };
290
+ }
291
+ throw new Error(`Controller absence could not be authenticated: ${messageOf(error)}`, { cause: error });
292
+ }
293
+ throw new Error("Controller identity is reachable but inventory is currentless; treating ownership as unknown-active.");
294
+ }
295
+ function stopControllerForUpdate(home, expectedPid, environment, spawn) {
296
+ // Parent update owns this lifecycle boundary. Invoke the runtime client
297
+ // directly in a short-lived child so a migratable old-schema Home is not
298
+ // forced through the public `controller stop` command's current-schema gate.
299
+ // The public command remains gated; this helper is reachable only from the
300
+ // update-owned port and returns the runtime's structured confirmation.
301
+ if (!isPositivePid(expectedPid)) {
302
+ throw new Error("Controller stop requires the exact positive PID captured by status.");
303
+ }
304
+ const result = runSchemaIndependentControllerStop(home, environment, spawn, expectedPid);
305
+ if (typeof result.stopped !== "boolean") {
306
+ throw new Error("Controller stop returned no structured stopped confirmation.");
307
+ }
308
+ if (result.alreadyStopped !== undefined && result.alreadyStopped !== true) {
309
+ throw new Error("Controller stop returned an invalid alreadyStopped confirmation.");
310
+ }
311
+ if (result.pid !== undefined && !isPositivePid(result.pid)) {
312
+ throw new Error("Controller stop returned an invalid PID confirmation.");
313
+ }
314
+ if (result.stopped !== true || result.pid !== expectedPid) {
315
+ throw new Error(`Controller stop did not confirm the captured PID ${expectedPid}; refusing an unfenced handoff.`);
316
+ }
317
+ return result;
318
+ }
319
+ /**
320
+ * Stop a replacement only after the caller has authenticated this exact PID
321
+ * against the restart/readiness boundary. The client/runtime path sends the
322
+ * PID fence to the Controller server itself, so a socket/discovery race cannot
323
+ * silently turn this cleanup into a stop of a foreign owner.
324
+ */
325
+ function stopReplacementControllerForUpdate(home, expectedPid, environment, spawn) {
326
+ if (!isPositivePid(expectedPid)) {
327
+ throw unknownActiveControllerError("replacement PID was not a positive integer");
328
+ }
329
+ const result = runSchemaIndependentControllerStop(home, environment, spawn, expectedPid);
330
+ if (result.stopped !== true
331
+ || result.pid !== expectedPid) {
332
+ throw unknownActiveControllerError(`fenced stop did not confirm the authenticated replacement PID ${expectedPid}`);
333
+ }
334
+ return result;
335
+ }
336
+ /**
337
+ * Stop only the Controller owned by parent update, without dispatching the
338
+ * public CLI command. `stopFileTaskController` authenticates the discovery,
339
+ * issues exactly one stop RPC, and drains the owned discovery before returning.
340
+ */
341
+ function runSchemaIndependentControllerStop(home, environment, spawn, expectedPid) {
342
+ const helper = [
343
+ "const values = process.argv.slice(1);",
344
+ "const expectedPid = values.length === 3 ? Number(values.pop()) : undefined;",
345
+ "const home = values.pop();",
346
+ "const runtimeModule = values.pop();",
347
+ "(async () => {",
348
+ " const { stopFileTaskController } = await import(runtimeModule);",
349
+ " const data = await stopFileTaskController(home, {",
350
+ " environment: process.env,",
351
+ " ...(expectedPid === undefined ? {} : { expectedPid })",
352
+ " });",
353
+ " process.stdout.write(JSON.stringify({ ok: true, data }));",
354
+ "})().catch((error) => {",
355
+ " const code = typeof error?.code === 'string' ? error.code : 'RUNTIME_ERROR';",
356
+ " const message = error instanceof Error ? error.message : String(error);",
357
+ " process.stderr.write(JSON.stringify({ ok: false, code, message }));",
358
+ " process.exitCode = 5;",
359
+ "});"
360
+ ].join(" ");
361
+ const result = spawn(process.execPath, [
362
+ "-e",
363
+ helper,
364
+ UPDATE_CLIENT_RUNTIME_PATH,
365
+ home,
366
+ ...(expectedPid === undefined ? [] : [String(expectedPid)])
367
+ ], { cwd: process.cwd(), env: { ...environment, YUI_HOME: home }, shell: false });
368
+ if (result.error !== undefined || result.status !== 0) {
369
+ const detail = result.stderr.toString("utf8").trim();
370
+ throw new Error(`Controller stop failed (exit ${result.status ?? "null"})${detail.length === 0 ? "." : `: ${detail}`}`);
371
+ }
372
+ let parsed;
373
+ try {
374
+ parsed = JSON.parse(result.stdout.toString("utf8"));
375
+ }
376
+ catch (error) {
377
+ throw new Error("Controller stop returned an invalid structured result.", { cause: error });
378
+ }
379
+ if (!isRecord(parsed) || parsed.ok !== true || !isRecord(parsed.data)) {
380
+ throw new Error("Controller stop returned an invalid structured result.");
381
+ }
382
+ const data = parsed.data;
383
+ if (typeof data.stopped !== "boolean"
384
+ || (data.alreadyStopped !== undefined && data.alreadyStopped !== true)
385
+ || (data.pid !== undefined && !isPositivePid(data.pid))) {
386
+ throw new Error("Controller stop returned an invalid structured confirmation.");
387
+ }
388
+ return {
389
+ stopped: data.stopped,
390
+ ...(data.alreadyStopped === true ? { alreadyStopped: true } : {}),
391
+ ...(data.pid === undefined ? {} : { pid: data.pid })
392
+ };
393
+ }
394
+ function restartControllerForUpdate(home, environment, spawn, activatedBinary, activatedVersion, stopReplacementController) {
395
+ let restart;
396
+ try {
397
+ restart = runControllerCommandOutput(home, environment, spawn, "restart", activatedBinary);
398
+ }
399
+ catch (error) {
400
+ // A restart transport/exit failure may have occurred after the activated
401
+ // runtime spawned its replacement. No PID ownership proof exists in that
402
+ // case, so never fall through to old-identity restore.
403
+ throw unknownActiveControllerError(`activated Controller restart outcome is unknown: ${messageOf(error)}`);
404
+ }
405
+ const replacementPid = parseReplacementPid(restart.data);
406
+ let identityFailure;
407
+ try {
408
+ const identity = parseControllerIdentity(runControllerCommand(home, environment, spawn, "identity", activatedBinary));
409
+ assertActivatedControllerIdentity(identity, activatedBinary, activatedVersion);
410
+ }
411
+ catch (error) {
412
+ identityFailure = error;
413
+ }
414
+ // Readiness is authenticated twice: the restart result carries the PID that
415
+ // the activated runtime started, and an inventory/identity read through that
416
+ // same binary proves that PID still owns this Home. A mismatch may therefore
417
+ // stop only the process proven to belong to this update; if that proof is
418
+ // unavailable, fail closed with an explicit unknown-active blocker.
419
+ let readiness;
420
+ try {
421
+ readiness = readControllerLifecycle(home, environment, spawn, activatedBinary);
422
+ if (!readiness.running || readiness.pid !== replacementPid) {
423
+ throw new Error(`replacement readiness PID ${readiness.pid === undefined ? "none" : readiness.pid} `
424
+ + `does not match the authenticated restart PID ${replacementPid}`);
425
+ }
426
+ assertActivatedControllerIdentity(readiness.identity, activatedBinary, activatedVersion);
427
+ }
428
+ catch (error) {
429
+ throw stopMismatchedReplacementOrBlock(home, environment, spawn, activatedBinary, replacementPid, stopReplacementController, identityFailure ?? new Error(`replacement readiness could not authenticate PID ${replacementPid}: ${messageOf(error)}`));
430
+ }
431
+ if (identityFailure !== undefined) {
432
+ throw stopMismatchedReplacementOrBlock(home, environment, spawn, activatedBinary, replacementPid, stopReplacementController, identityFailure);
433
+ }
434
+ }
435
+ function parseReplacementPid(data) {
436
+ if (data === undefined
437
+ || data.restarted !== true
438
+ || !isPositivePid(data.pid)
439
+ || (data.previousPid !== undefined && !isPositivePid(data.previousPid))) {
440
+ throw unknownActiveControllerError("activated Controller restart returned no authenticated replacement PID");
441
+ }
442
+ return data.pid;
443
+ }
444
+ function stopMismatchedReplacementOrBlock(home, environment, spawn, activatedBinary, replacementPid, stopReplacementController, mismatch) {
445
+ try {
446
+ const ownership = readControllerLifecycle(home, environment, spawn, activatedBinary);
447
+ if (!ownership.running || ownership.pid !== replacementPid) {
448
+ throw new Error(`authenticated replacement ownership was lost (expected PID ${replacementPid}, `
449
+ + `found ${ownership.pid === undefined ? "none" : ownership.pid})`);
450
+ }
451
+ const stopped = stopReplacementController(home, replacementPid);
452
+ if (stopped.stopped !== true || stopped.pid !== replacementPid) {
453
+ throw new Error(`fenced replacement stop did not confirm PID ${replacementPid}`);
454
+ }
455
+ }
456
+ catch (error) {
457
+ return unknownActiveControllerError(`${messageOf(mismatch)}; cannot prove safe ownership-aware cleanup: ${messageOf(error)}`);
458
+ }
459
+ const error = new Error(`${messageOf(mismatch)} The replacement Controller PID ${replacementPid} was authenticated `
460
+ + `as this update's owner and stopped; refusing replacement readiness.`);
461
+ Object.assign(error, {
462
+ code: "UPDATE_CONTROLLER_IDENTITY_MISMATCH",
463
+ replacementPid,
464
+ replacementStopped: true
465
+ });
466
+ return error;
467
+ }
468
+ function unknownActiveControllerError(reason) {
469
+ const error = new Error(`Replacement Controller is unknown-active: ${reason}. Do not resume writes or restore `
470
+ + "the old identity blindly.");
471
+ Object.assign(error, { code: "UPDATE_CONTROLLER_UNKNOWN_ACTIVE" });
472
+ return error;
473
+ }
474
+ /** Restore the captured process identity, never the staged/new `yui` launcher. */
475
+ function restoreControllerIdentity(home, identity, environment, spawn) {
476
+ const launchEnvironment = { ...environment, YUI_HOME: home };
477
+ // Spawn a detached child through a short-lived Node helper so the synchronous
478
+ // update process can still use the authenticated, bounded readiness handshake
479
+ // shared by Controller startup. No retry, sleep, or new identity inference is
480
+ // hidden here.
481
+ const helper = [
482
+ "const { spawn } = require('node:child_process');",
483
+ "const values = process.argv.slice(1);",
484
+ "const version = values.pop();",
485
+ "const args = JSON.parse(values.pop());",
486
+ "const executable = values.pop();",
487
+ "const home = values.pop();",
488
+ "const runtimeModule = values.pop();",
489
+ "(async () => {",
490
+ " const { ensureFileTaskControllerIdentity } = await import(runtimeModule);",
491
+ " await ensureFileTaskControllerIdentity(home, { executablePath: executable, args, version }, {",
492
+ " environment: process.env,",
493
+ " spawnController: (_home, launchEnv) => {",
494
+ " const child = spawn(executable, args, { detached: true, stdio: 'ignore', env: launchEnv });",
495
+ " child.unref();",
496
+ " }",
497
+ " });",
498
+ "})().catch((error) => { process.stderr.write(String(error?.stack || error)); process.exitCode = 1; });"
499
+ ].join(" ");
500
+ const result = spawn(process.execPath, [
501
+ "-e",
502
+ helper,
503
+ UPDATE_CLIENT_RUNTIME_PATH,
504
+ home,
505
+ identity.executablePath,
506
+ JSON.stringify(identity.args),
507
+ identity.version
508
+ ], { cwd: process.cwd(), env: launchEnvironment, shell: false, stdio: "pipe" });
509
+ assertSpawnOk(result, "restore the previously running Controller identity");
510
+ }
511
+ function runControllerCommand(home, environment, spawn, method, cliBinary) {
512
+ const command = cliBinary ?? process.execPath;
513
+ const args = cliBinary === undefined
514
+ ? [UPDATE_CLI_PATH, "--json", "controller", method]
515
+ : ["--json", "controller", method];
516
+ const result = spawn(command, args, { cwd: process.cwd(), env: { ...environment, YUI_HOME: home }, shell: false });
517
+ if (result.error !== undefined || result.status !== 0) {
518
+ const error = new Error(`Controller ${method} failed (exit ${result.status ?? "null"}).`);
519
+ const code = controllerErrorCodeFromResult(result);
520
+ if (code !== undefined)
521
+ Object.assign(error, { code });
522
+ throw error;
523
+ }
524
+ const parsed = JSON.parse(result.stdout.toString("utf8"));
525
+ if (!isRecord(parsed) || parsed.ok !== true || !isRecord(parsed.data)) {
526
+ throw new Error(`Controller ${method} returned an invalid structured result.`);
527
+ }
528
+ return parsed.data;
529
+ }
530
+ function parseControllerIdentity(value) {
531
+ if (typeof value.executablePath !== "string"
532
+ || value.executablePath.length === 0
533
+ || !Array.isArray(value.args)
534
+ || value.args.some((arg) => typeof arg !== "string")
535
+ || typeof value.version !== "string"
536
+ || value.version.length === 0) {
537
+ throw new Error("Authenticated Controller identity is malformed; treating ownership as unknown-active.");
538
+ }
539
+ return {
540
+ executablePath: value.executablePath,
541
+ args: value.args,
542
+ version: value.version
543
+ };
544
+ }
545
+ function controllerErrorCodeFromResult(result) {
546
+ for (const buffer of [result.stdout, result.stderr]) {
547
+ try {
548
+ const parsed = JSON.parse(buffer.toString("utf8"));
549
+ if (isRecord(parsed) && typeof parsed.code === "string")
550
+ return parsed.code;
551
+ }
552
+ catch {
553
+ // The generic command failure below remains the structured blocker.
554
+ }
555
+ }
556
+ return undefined;
557
+ }
558
+ function controllerErrorCode(error) {
559
+ return isRecord(error) && typeof error.code === "string" ? error.code : undefined;
560
+ }
561
+ function isPositivePid(value) {
562
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
563
+ }
564
+ function messageOf(error) {
565
+ return error instanceof Error ? error.message : String(error);
566
+ }
567
+ function runControllerCommandOutput(home, environment, spawn, method, cliBinary) {
568
+ const command = cliBinary ?? process.execPath;
569
+ const args = cliBinary === undefined
570
+ ? [UPDATE_CLI_PATH, "--json", "controller", method]
571
+ : ["--json", "controller", method];
572
+ const result = spawn(command, args, { cwd: process.cwd(), env: { ...environment, YUI_HOME: home }, shell: false });
573
+ if (result.error !== undefined || result.status !== 0) {
574
+ throw new Error(`Controller ${method} failed (exit ${result.status ?? "null"}).`);
575
+ }
576
+ const parsed = JSON.parse(result.stdout.toString("utf8"));
577
+ if (!isRecord(parsed) || parsed.ok !== true) {
578
+ throw new Error(`Controller ${method} returned an invalid structured result.`);
579
+ }
580
+ if (parsed.output !== undefined && typeof parsed.output !== "string") {
581
+ throw new Error(`Controller ${method} returned an invalid output field.`);
582
+ }
583
+ if (parsed.data !== undefined && !isRecord(parsed.data)) {
584
+ throw new Error(`Controller ${method} returned malformed structured data.`);
585
+ }
586
+ if (method === "restart") {
587
+ if (parsed.output === undefined && parsed.data === undefined) {
588
+ throw new Error(`Controller ${method} returned no output or structured data.`);
589
+ }
590
+ if (parsed.data !== undefined) {
591
+ validateRestartEnvelopeData(parsed.data);
592
+ }
593
+ }
594
+ else if (parsed.output === undefined) {
595
+ throw new Error(`Controller ${method} returned no output.`);
596
+ }
597
+ return {
598
+ ...(parsed.output === undefined ? {} : { output: parsed.output }),
599
+ ...(parsed.data === undefined ? {} : { data: parsed.data })
600
+ };
601
+ }
602
+ function validateRestartEnvelopeData(data) {
603
+ if (data.restarted !== true
604
+ || !isPositivePid(data.pid)
605
+ || (data.previousPid !== undefined && !isPositivePid(data.previousPid))) {
606
+ throw new Error("Controller restart returned malformed structured data.");
607
+ }
608
+ }
609
+ /**
610
+ * Authenticate a replacement Controller against the activated artifact. The
611
+ * package version identifies the artifact, while the exact controller entrypoint
612
+ * identifies which global installation supplied the running process.
613
+ */
614
+ function assertActivatedControllerIdentity(identity, activatedBinary, activatedVersion) {
615
+ if (identity.version !== activatedVersion) {
616
+ throw new Error(`Replacement Controller version ${identity.version} does not match the activated `
617
+ + `binary version ${activatedVersion}; refusing readiness.`);
618
+ }
619
+ const expectedEntrypoint = activatedControllerEntrypoint(activatedBinary);
620
+ if (identity.executablePath !== process.execPath
621
+ || identity.args.length !== 1
622
+ || identity.args[0] !== expectedEntrypoint) {
623
+ throw new Error("Replacement Controller launch identity does not match the activated global binary "
624
+ + "runtime/entrypoint; refusing readiness.");
625
+ }
626
+ }
627
+ /** Resolve the Controller entrypoint beside the activated package's CLI. */
628
+ function activatedControllerEntrypoint(activatedBinary) {
629
+ let resolvedBinary;
630
+ try {
631
+ resolvedBinary = realpathSync(activatedBinary);
632
+ }
633
+ catch {
634
+ // `verify` already checked existsSync. Keep the fallback deterministic for
635
+ // test seams and fail closed later if the identity does not match it.
636
+ resolvedBinary = resolve(activatedBinary);
637
+ }
638
+ const direct = join(dirname(resolvedBinary), "controller", "controllerMain.js");
639
+ if (existsSync(direct))
640
+ return direct;
641
+ // npm may expose a non-symlink launcher under <prefix>/bin. Resolve the
642
+ // package's canonical global layout when it is present.
643
+ const prefix = resolve(dirname(resolvedBinary), "..");
644
+ const packageEntrypoint = join(prefix, "lib", "node_modules", PACKAGE_NAME, "dist", "controller", "controllerMain.js");
645
+ return existsSync(packageEntrypoint) ? packageEntrypoint : direct;
646
+ }
647
+ function interpretPreflight(result) {
648
+ // Require a valid `{ ok:true, data }` success envelope before trusting any
649
+ // outcome (R3-F3). An error envelope, unparseable output, kill, or transport
650
+ // error is not a safe preflight — block rather than proceed to a switch.
651
+ const data = parseSuccessEnvelopeData(result);
652
+ if (data === null) {
653
+ return {
654
+ status: "blocked",
655
+ stage: "preflight",
656
+ message: "The staged binary's preflight did not return a valid success envelope "
657
+ + `(exit ${result.status ?? "null"}${result.signal === null ? "" : `, signal ${result.signal}`}); `
658
+ + "refusing to proceed on an unverifiable preflight.",
659
+ action: "Investigate the staged binary; do not force an update on an unverifiable preflight."
660
+ };
661
+ }
662
+ const outcome = typeof data.outcome === "string" ? data.outcome : undefined;
663
+ // EXIT/OUTCOME CONSISTENCY (P1-2): the one success-class internal preflight
664
+ // outcome must exit 0. A user dry-run, legacy direct classification outcome,
665
+ // or any other spelling is not this contract and is never promoted to green.
666
+ if (outcome === "update-preflight" && result.status !== 0) {
667
+ return {
668
+ status: "blocked",
669
+ stage: "preflight",
670
+ message: `The staged binary reported outcome=${outcome} but exited with status `
671
+ + `${result.status ?? "null"}; a safe preflight must exit 0. Refusing to proceed.`,
672
+ action: "Investigate the staged binary; do not force an update on an inconsistent preflight."
673
+ };
674
+ }
675
+ if (outcome === "update-preflight") {
676
+ const parsed = parseUpdatePreflightResult(data);
677
+ if (parsed !== null)
678
+ return parsed;
679
+ return {
680
+ status: "blocked",
681
+ stage: "preflight",
682
+ message: "The staged binary returned a malformed update-preflight result; refusing to infer a storage path.",
683
+ action: "Investigate the staged binary; do not force an update on a malformed preflight."
684
+ };
685
+ }
686
+ if (outcome !== "blocked") {
687
+ return {
688
+ status: "blocked",
689
+ stage: "preflight",
690
+ message: `The staged binary returned unexpected outcome=${outcome ?? "missing"}; the internal `
691
+ + "update-preflight contract was not satisfied.",
692
+ action: "Use a staged binary that supports the update-preflight contract; do not force the update."
693
+ };
694
+ }
695
+ const blockers = parseUpdateBlockers(data.blockers);
696
+ return {
697
+ status: "blocked",
698
+ stage: typeof data.stage === "string" ? data.stage : "preflight",
699
+ message: typeof data.message === "string" ? data.message : "Preflight was not safe.",
700
+ action: typeof data.action === "string"
701
+ ? data.action
702
+ : "Resolve the reported condition and retry.",
703
+ ...(blockers === undefined ? {} : { blockers }),
704
+ ...(typeof data.retryCommand === "string" ? { retryCommand: data.retryCommand } : {}),
705
+ ...(data.sceneUnchanged === true ? { sceneUnchanged: true } : {})
706
+ };
707
+ }
708
+ /** Strictly parse the three green states of the internal update preflight. */
709
+ function parseUpdatePreflightResult(data) {
710
+ const status = data.status;
711
+ const stepCount = data.stepCount;
712
+ if ((status !== "already-current" && status !== "compatible" && status !== "migration-required")
713
+ || !Number.isSafeInteger(stepCount)
714
+ || stepCount < 0) {
715
+ return null;
716
+ }
717
+ const homeClassification = data.classification;
718
+ if (!isRecord(homeClassification) || !isRecord(homeClassification.classification))
719
+ return null;
720
+ const classification = homeClassification.classification;
721
+ const expected = status === "already-current"
722
+ ? { verdict: "USABLE", classificationStatus: "current" }
723
+ : status === "compatible"
724
+ ? { verdict: "COMPATIBLE", classificationStatus: "compatible-old" }
725
+ : { verdict: "MIGRATABLE", classificationStatus: "migration-required" };
726
+ if (classification.verdict !== expected.verdict
727
+ || classification.status !== expected.classificationStatus
728
+ || (status === "already-current"
729
+ ? stepCount !== 0
730
+ : classification.stepCount !== stepCount || stepCount < 1)) {
731
+ return null;
732
+ }
733
+ if (status === "already-current")
734
+ return { status };
735
+ const evidence = status === "compatible"
736
+ ? `${stepCount} compatible step(s) classified and the compatible source validated in memory`
737
+ : `${stepCount} offline migration step(s) classified and the offline runtime inventory confirmed clear`;
738
+ return {
739
+ status,
740
+ summary: `${evidence}. `
741
+ + "No staged Home or staged-output loader validation was performed during update preflight."
742
+ };
743
+ }
744
+ function parseUpdateBlockers(value) {
745
+ if (value === undefined)
746
+ return undefined;
747
+ if (!Array.isArray(value))
748
+ return undefined;
749
+ const parsed = [];
750
+ for (const item of value) {
751
+ if (!isRecord(item) || typeof item.reason !== "string" || item.reason.length === 0) {
752
+ return undefined;
753
+ }
754
+ const optional = ["taskId", "roleName", "runId", "nativeSessionId", "launchId"];
755
+ if (optional.some((key) => item[key] !== undefined && typeof item[key] !== "string")) {
756
+ return undefined;
757
+ }
758
+ parsed.push({
759
+ ...(typeof item.taskId === "string" ? { taskId: item.taskId } : {}),
760
+ ...(typeof item.roleName === "string" ? { roleName: item.roleName } : {}),
761
+ ...(typeof item.runId === "string" ? { runId: item.runId } : {}),
762
+ ...(typeof item.nativeSessionId === "string"
763
+ ? { nativeSessionId: item.nativeSessionId }
764
+ : {}),
765
+ ...(typeof item.launchId === "string" ? { launchId: item.launchId } : {}),
766
+ reason: item.reason
767
+ });
768
+ }
769
+ return parsed;
770
+ }
771
+ function interpretActivation(result) {
772
+ // A spawn transport error (could not even run) is a clean pre-switch failure.
773
+ if (result.error !== undefined) {
774
+ return {
775
+ status: "ambiguous",
776
+ detail: `the activation process could not be run: ${result.error.message}`
777
+ };
778
+ }
779
+ // Require a valid `{ ok:true, data }` success envelope (R3-F3). Killed by a
780
+ // signal, no parseable JSON, or an `ok:false`/malformed envelope: the child may
781
+ // have died after the atomic switch but before printing a valid result. This is
782
+ // AMBIGUOUS, never a false "recoverable/unchanged" (P1-2).
783
+ const data = parseSuccessEnvelopeData(result);
784
+ if (data === null) {
785
+ const how = result.signal !== null
786
+ ? `terminated by ${result.signal}`
787
+ : result.status === null
788
+ ? "terminated without an exit code"
789
+ : `exited with status ${result.status} and no valid success envelope`;
790
+ return {
791
+ status: "ambiguous",
792
+ detail: `the activation process ${how}`
793
+ };
794
+ }
795
+ const outcome = typeof data.outcome === "string" ? data.outcome : undefined;
796
+ // EXIT STATUS / OUTCOME CONSISTENCY (P1-2)
797
+ // A success-class outcome is only trustworthy when the child ALSO exited 0. A
798
+ // contradiction (e.g. stdout says `upgraded` but the process exited non-zero)
799
+ // means the child's own contract was violated mid-flight — the switch may or
800
+ // may not have committed — so it is AMBIGUOUS, never a false success. Blocker-
801
+ // class outcomes are exempt: `yui upgrade` deliberately exits non-zero (5) for
802
+ // a clean `blocked`, so a non-zero exit there is expected and consistent.
803
+ if ((outcome === "already-current" || outcome === "upgraded") && result.status !== 0) {
804
+ return {
805
+ status: "ambiguous",
806
+ detail: `the activation process reported outcome=${outcome} but exited with status `
807
+ + `${result.status ?? "null"} (a success outcome must exit 0); the switch state is unknown`
808
+ };
809
+ }
810
+ if (outcome === "already-current")
811
+ return { status: "already-current" };
812
+ if (outcome === "upgraded") {
813
+ const backupPath = data.backupPath;
814
+ if (typeof backupPath !== "string"
815
+ || backupPath.length === 0
816
+ || backupPath.trim() !== backupPath
817
+ || backupPath.includes("\0")
818
+ || !isAbsolute(backupPath)) {
819
+ return {
820
+ status: "ambiguous",
821
+ detail: "the activation process reported outcome=upgraded without a non-empty absolute backupPath"
822
+ };
823
+ }
824
+ return {
825
+ status: "migrated",
826
+ backupPath
827
+ };
828
+ }
829
+ if (outcome === "blocked") {
830
+ const stage = typeof data?.stage === "string" ? data.stage : "activate-storage";
831
+ // A `switch-ambiguous` blocker is NOT a clean, recoverable refusal (P1-4):
832
+ // the upgrade's atomic switch was left partially applied (original moved to
833
+ // backup, promotion + rollback both failed). Route it to AMBIGUOUS so the
834
+ // orchestrator probes the interrupted marker and reports a restore, never a
835
+ // false "the current install and Home remain usable".
836
+ if (stage === "switch-ambiguous") {
837
+ return {
838
+ status: "ambiguous",
839
+ detail: typeof data?.message === "string"
840
+ ? data.message
841
+ : "the storage switch was left partially applied"
842
+ };
843
+ }
844
+ // A post-verify blocker is emitted only after the atomic Home switch has
845
+ // committed. Treat it as AMBIGUOUS in the parent update flow so a stopped
846
+ // old Controller is never restored against the migrated Home; the receipt,
847
+ // backup, and switch-progress marker are the recovery evidence.
848
+ if (stage === "post-verify") {
849
+ return {
850
+ status: "ambiguous",
851
+ detail: typeof data?.message === "string"
852
+ ? data.message
853
+ : "storage switched but post-switch verification did not complete"
854
+ };
855
+ }
856
+ const blockers = parseUpdateBlockers(data.blockers);
857
+ return {
858
+ status: "blocked",
859
+ stage,
860
+ message: typeof data?.message === "string" ? data.message : "Storage activation was refused.",
861
+ action: typeof data?.action === "string"
862
+ ? data.action
863
+ : "Resolve the reported condition and retry.",
864
+ ...(blockers === undefined ? {} : { blockers }),
865
+ ...(typeof data.retryCommand === "string" ? { retryCommand: data.retryCommand } : {}),
866
+ ...(data.sceneUnchanged === true ? { sceneUnchanged: true } : {})
867
+ };
868
+ }
869
+ // Parseable JSON but an unrecognized outcome: we cannot classify it, so it is
870
+ // ambiguous rather than silently treated as a clean refusal.
871
+ return {
872
+ status: "ambiguous",
873
+ detail: `the activation process returned an unrecognized outcome (${String(outcome)})`
874
+ };
875
+ }
876
+ /**
877
+ * Extract the validated `data` object from a spawn result's `{ ok: true, data }`
878
+ * JSON envelope, or `null` when the result is not a trustworthy success envelope.
879
+ *
880
+ * Returns `null` when the process errored, was killed, produced no parseable
881
+ * JSON, the envelope's `ok` is not exactly `true`, or `data` is not an object
882
+ * (R3-F3). It does NOT reject a non-zero exit on its own — the deliberate
883
+ * non-zero exit for a `blocked`/unhealthy outcome still carries a valid success
884
+ * envelope, and callers apply their own exit/outcome consistency rules. Every
885
+ * caller must treat `null` as unresolved (fail-closed / ambiguous / blocked).
886
+ */
887
+ function parseSuccessEnvelopeData(result) {
888
+ if (result.error !== undefined)
889
+ return null;
890
+ if (result.signal !== null || result.status === null)
891
+ return null;
892
+ let parsed;
893
+ try {
894
+ const text = result.stdout.toString("utf8").trim();
895
+ if (text.length === 0)
896
+ return null;
897
+ parsed = JSON.parse(text);
898
+ }
899
+ catch {
900
+ return null;
901
+ }
902
+ // The top-level value must be a real object — never `null`, an array, or a
903
+ // primitive. `JSON.parse("null")`/`"[]"`/`"5"` all parse successfully but are
904
+ // not valid envelopes, and reading `.ok` off `null` would throw (R4-F1); guard
905
+ // the shape first so a malformed child result becomes a clean `null` (which the
906
+ // caller maps to blocked/ambiguous) rather than an uncaught TypeError.
907
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
908
+ return null;
909
+ }
910
+ const envelope = parsed;
911
+ // Require a success envelope: ok === true and a real data object. A `{ ok:false }`
912
+ // error envelope, or one with a non-object `data`, is never a success (R3-F3).
913
+ if (envelope.ok !== true)
914
+ return null;
915
+ if (typeof envelope.data !== "object" || envelope.data === null || Array.isArray(envelope.data)) {
916
+ return null;
917
+ }
918
+ return envelope.data;
919
+ }
920
+ /**
921
+ * Resolve the exact, CONCRETE version of the staged install, or `null` when no
922
+ * concrete version can be determined. A dist-tag sentinel like `latest` (or any
923
+ * non-semver-shaped value) is NOT a concrete version and yields `null` (R3-F1):
924
+ * callers MUST fail closed rather than pin `@latest`, which would let activation
925
+ * promote a different build than the one preflighted.
926
+ */
927
+ function resolveStagedVersion(stagingPath, binaryPath, environment, spawn) {
928
+ // Prefer the staged package.json (deterministic, no extra process).
929
+ const manifest = join(stagingPath, "lib", "node_modules", PACKAGE_NAME, "package.json");
930
+ const fromManifest = readVersionFromPackageJson(manifest);
931
+ if (fromManifest !== null)
932
+ return fromManifest;
933
+ // Fall back to asking the staged binary itself; `null` if it too cannot answer
934
+ // with a successful, concrete version.
935
+ return resolveBinaryVersion(binaryPath, environment, spawn);
936
+ }
937
+ /**
938
+ * True for a concrete, pinnable package version — a semver-shaped `X.Y.Z` with an
939
+ * optional pre-release/build suffix. Rejects dist-tag sentinels (`latest`, `next`,
940
+ * …), empty/whitespace, and anything not anchored to a numeric `major.minor.patch`
941
+ * so a sentinel can never be spliced into an activation spec (R3-F1).
942
+ */
943
+ function isConcreteVersion(value) {
944
+ return /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(value.trim());
945
+ }
946
+ /** Read a CONCRETE `version` from a package.json, or `null` when absent/non-concrete. */
947
+ function readVersionFromPackageJson(path) {
948
+ try {
949
+ const value = JSON.parse(readFileSync(path, "utf8"));
950
+ if (typeof value.version !== "string")
951
+ return null;
952
+ const version = value.version.trim();
953
+ return isConcreteVersion(version) ? version : null;
954
+ }
955
+ catch {
956
+ return null;
957
+ }
958
+ }
959
+ /**
960
+ * Ask a `yui` binary for its version via `--json version`; returns the CONCRETE
961
+ * version only from a valid `{ ok:true, data }` success envelope at exit 0
962
+ * (R3-F1/R3-F3), else `null`. A non-zero exit, `ok:false`, missing/non-concrete
963
+ * `version`, or unparseable output all yield `null` so the caller fails closed.
964
+ */
965
+ function resolveBinaryVersion(binaryPath, environment, spawn) {
966
+ const result = spawn(binaryPath, ["--json", "version"], { cwd: process.cwd(), env: environment, shell: false });
967
+ // A version probe is only trustworthy from a successful, zero-exit envelope.
968
+ if (result.status !== 0)
969
+ return null;
970
+ const data = parseSuccessEnvelopeData(result);
971
+ if (data === null || typeof data.version !== "string")
972
+ return null;
973
+ const version = data.version.trim();
974
+ return isConcreteVersion(version) ? version : null;
975
+ }
976
+ /** Resolve the live global `yui` binary path from `npm prefix -g`. */
977
+ function resolveGlobalBinary(environment, spawn) {
978
+ const result = spawn("npm", ["prefix", "--global"], { cwd: process.cwd(), env: environment, shell: false });
979
+ if (result.error !== undefined || result.status !== 0)
980
+ return null;
981
+ const prefix = result.stdout.toString("utf8").trim();
982
+ if (prefix.length === 0)
983
+ return null;
984
+ return join(prefix, "bin", "yui");
985
+ }
986
+ /**
987
+ * Fail closed unless the doctor result PROVES the migrated Home's storage is
988
+ * healthy (P1-3 / R2-F2). The envelope and storage checks are validated BEFORE
989
+ * the exit status is interpreted, because `yui --json doctor` deliberately exits
990
+ * non-zero on unhealthy storage — so keying off the exit first would reduce a
991
+ * precise structured verdict to a generic "exited with status N".
992
+ *
993
+ * Healthy requires ALL of:
994
+ * - a valid `{ ok: true, data: { storage, checks } }` success envelope,
995
+ * - `storage.healthy === true` with an empty `storage.blocking`,
996
+ * - every expected storage check present AND `ok`, and
997
+ * - exit status 0.
998
+ * A parseable-but-unhealthy result (typically exit 5) throws a precise, recovery-
999
+ * oriented blocker. An unparseable, non-success, or self-contradictory envelope
1000
+ * (e.g. `healthy: true` yet a non-`ok`/blocking check, or `ok: false`) fails
1001
+ * closed — an unverifiable health check must never pass silently.
1002
+ */
1003
+ function assertDoctorStorageHealthy(result) {
1004
+ // 1) Envelope must be a parseable `{ ok:true, data:<object> }` success envelope.
1005
+ // A spawn transport error, kill, missing exit code, empty/garbage stdout, a
1006
+ // top-level `null`/array/primitive, or `ok !== true` is unverifiable -> fail
1007
+ // closed. Shared with the update ports' parser so `null`/primitive envelopes
1008
+ // can never crash the check (R4-F1).
1009
+ if (result.error !== undefined || result.signal !== null || result.status === null) {
1010
+ throw doctorUnverifiable(result.signal !== null
1011
+ ? `the doctor process was terminated by ${result.signal}`
1012
+ : result.error !== undefined
1013
+ ? `the doctor process could not be run: ${result.error.message}`
1014
+ : "the doctor process terminated without an exit code");
1015
+ }
1016
+ const data = parseSuccessEnvelopeData(result);
1017
+ if (data === null) {
1018
+ throw doctorUnverifiable("the activated binary's `doctor` did not return a parseable success envelope");
1019
+ }
1020
+ const storage = data.storage;
1021
+ const checks = Array.isArray(data.checks) ? data.checks : null;
1022
+ if (storage === undefined || typeof storage.healthy !== "boolean" || checks === null) {
1023
+ throw doctorUnverifiable("the activated binary's `doctor` did not return a parseable storage-health result");
1024
+ }
1025
+ // 2) Require EVERY expected storage check to be present exactly once AND ok
1026
+ // (R3-F2). A `healthy: true` flag with an empty `blocking` array must NOT be
1027
+ // trusted when an expected check is missing, duplicated, or malformed — the
1028
+ // authoritative signal is the checks array itself, not the summary flag.
1029
+ const expectedNames = STORAGE_DOCTOR_CHECK_NAMES;
1030
+ const byName = new Map();
1031
+ for (const c of checks) {
1032
+ if (typeof c.name === "string") {
1033
+ const list = byName.get(c.name) ?? [];
1034
+ list.push(c);
1035
+ byName.set(c.name, list);
1036
+ }
1037
+ }
1038
+ const missingOrMalformed = [];
1039
+ for (const name of expectedNames) {
1040
+ const entries = byName.get(name) ?? [];
1041
+ if (entries.length !== 1) {
1042
+ missingOrMalformed.push(`${name}=${entries.length === 0 ? "missing" : `duplicated x${entries.length}`}`);
1043
+ continue;
1044
+ }
1045
+ if (typeof entries[0].status !== "string") {
1046
+ missingOrMalformed.push(`${name}=malformed`);
1047
+ }
1048
+ }
1049
+ if (missingOrMalformed.length > 0) {
1050
+ throw doctorUnverifiable(`the doctor result is missing or has malformed storage checks (${missingOrMalformed.join("; ")}); `
1051
+ + "refusing to trust the health flag without every expected check present");
1052
+ }
1053
+ // The blocking (non-ok) storage checks, from the now-complete authoritative set.
1054
+ const storageChecks = expectedNames.map((name) => byName.get(name)[0]);
1055
+ const blockingChecks = storageChecks.filter((c) => c.status !== "ok");
1056
+ // `storage.blocking` MUST be a well-formed array of check-shaped objects (R4-F2).
1057
+ // A missing field, a non-array value (e.g. a string), or a malformed element is
1058
+ // an incomplete/unknown doctor result — fail closed rather than silently coerce
1059
+ // it to an empty array (which would let an unverifiable result read as healthy).
1060
+ if (!Array.isArray(storage.blocking)) {
1061
+ throw doctorUnverifiable("the doctor result's storage.blocking is missing or not an array; the storage-health "
1062
+ + "result is incomplete and cannot be trusted");
1063
+ }
1064
+ const declaredBlocking = storage.blocking;
1065
+ const malformedDeclared = declaredBlocking.filter((entry) => {
1066
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry))
1067
+ return true;
1068
+ const record = entry;
1069
+ return typeof record.name !== "string" || typeof record.status !== "string";
1070
+ });
1071
+ if (malformedDeclared.length > 0) {
1072
+ throw doctorUnverifiable(`the doctor result's storage.blocking has ${malformedDeclared.length} malformed entr(ies) `
1073
+ + "(each must be an object with string name/status); refusing to trust an unverifiable result");
1074
+ }
1075
+ const declaredBlockingChecks = declaredBlocking;
1076
+ // 3) Contradiction guard: `healthy: true` must agree with the checks. If it
1077
+ // claims healthy yet a storage check is non-ok (or it declares blocking checks),
1078
+ // the result is self-contradictory -> fail closed rather than trust the flag.
1079
+ if (storage.healthy === true && (blockingChecks.length > 0 || declaredBlockingChecks.length > 0)) {
1080
+ throw doctorUnverifiable("the doctor result is self-contradictory (reports healthy storage yet lists a non-ok "
1081
+ + "storage check); refusing to trust it");
1082
+ }
1083
+ // 4) Unhealthy (typically a deliberate non-zero exit): a precise blocker.
1084
+ if (storage.healthy !== true || blockingChecks.length > 0 || declaredBlockingChecks.length > 0) {
1085
+ const detail = [...blockingChecks, ...declaredBlockingChecks]
1086
+ .map((c) => `${String(c.name)}=${String(c.status)} (${String(c.detail)})`)
1087
+ .join("; ");
1088
+ throw runtimeError("Post-update health check failed: the migrated Home is not healthy per the activated "
1089
+ + `binary's doctor: ${detail || "(no detail)"}. The storage did not come up cleanly on `
1090
+ + "the new version. Restore the timestamped backup to recover the original Home before "
1091
+ + "resuming writes.");
1092
+ }
1093
+ // 5) Healthy checks AND a healthy flag — but a non-zero exit still contradicts a
1094
+ // clean bill of health, so require exit 0 as the final gate (R2-F2).
1095
+ if (result.status !== 0) {
1096
+ throw doctorUnverifiable(`the doctor result reports healthy storage but the process exited with status ${result.status}; `
1097
+ + "a clean health check must exit 0");
1098
+ }
1099
+ }
1100
+ /** A fail-closed post-update health-check error for an unverifiable doctor result. */
1101
+ function doctorUnverifiable(reason) {
1102
+ return runtimeError(`Post-update health check failed: ${reason}, so the migrated Home cannot be confirmed `
1103
+ + "healthy. Investigate with `yui doctor` before resuming; if storage was migrated, restore "
1104
+ + "the timestamped backup to recover.");
1105
+ }
1106
+ function isRecord(value) {
1107
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1108
+ }
1109
+ function assertSpawnOk(result, action) {
1110
+ if (result.error !== undefined) {
1111
+ throw runtimeError(`Failed to ${action}: ${result.error.message}`);
1112
+ }
1113
+ if (result.status === null) {
1114
+ throw runtimeError(`Failed to ${action}: process terminated${result.signal === null ? "" : ` by ${result.signal}`}.`);
1115
+ }
1116
+ if (result.status !== 0) {
1117
+ throw runtimeError(`Failed to ${action}: exited with status ${result.status}.`);
1118
+ }
1119
+ }