@zq-silk/yui 0.13.9 → 0.14.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.
Files changed (229) hide show
  1. package/ARCHITECTURE.md +69 -428
  2. package/README.md +108 -298
  3. package/dist/agent/managedRuntimeEnvironment.js +2 -2
  4. package/dist/cli/commandCatalog.js +39 -77
  5. package/dist/cli/interactionCandidates.js +4 -4
  6. package/dist/cli/interactionPolicy.js +7 -10
  7. package/dist/cli/invocationRouter.js +1 -1
  8. package/dist/cli/updateCommand.js +6 -27
  9. package/dist/cli/updateOrchestrator.js +63 -484
  10. package/dist/cli/updatePorts.js +15 -333
  11. package/dist/cli/upgradeCommand.js +8 -65
  12. package/dist/cli.js +57 -242
  13. package/dist/commands/configCommands.js +11 -62
  14. package/dist/commands/executionAuditCommands.js +23 -23
  15. package/dist/commands/globalRoleCommands.js +2 -7
  16. package/dist/commands/jobCommands.js +1 -4
  17. package/dist/commands/projectCommands.js +5 -5
  18. package/dist/commands/releaseCommands.js +3 -10
  19. package/dist/commands/resourcesCommands.js +2 -2
  20. package/dist/commands/sessionCommands.js +2 -2
  21. package/dist/commands/taskActor.js +31 -39
  22. package/dist/commands/taskChangeSetCommands.js +5 -12
  23. package/dist/commands/taskCommands.js +464 -1249
  24. package/dist/commands/taskContextCommand.js +35 -36
  25. package/dist/commands/taskExecutionCommands.js +33 -46
  26. package/dist/commands/taskInputCommands.js +20 -42
  27. package/dist/commands/taskNextActionCommand.js +9 -9
  28. package/dist/commands/taskOverviewCommand.js +56 -95
  29. package/dist/commands/taskRoleRuntimeStatus.js +77 -107
  30. package/dist/commands/taskUpstreamCommands.js +3 -3
  31. package/dist/commands/taskWorkspaceCommands.js +7 -79
  32. package/dist/commands/telemetryCommands.js +32 -124
  33. package/dist/commands/workflowCommands.js +3 -3
  34. package/dist/completion/fileCompletionManager.js +1 -1
  35. package/dist/config/configCatalog.js +4 -6
  36. package/dist/config/yuiConfig.js +6 -43
  37. package/dist/context/dispatchContext.js +14 -30
  38. package/dist/context/roleSessionContext.js +2 -2
  39. package/dist/context/sessionBootstrapManifest.js +2 -2
  40. package/dist/context/{runContextPack.js → turnContextPack.js} +58 -73
  41. package/dist/context/turnInputContract.js +204 -0
  42. package/dist/context/wakeNotification.js +2 -2
  43. package/dist/controller/agentRuntimeObserver.js +18 -18
  44. package/dist/controller/clientRuntime.js +13 -20
  45. package/dist/controller/controller.js +89 -116
  46. package/dist/controller/fileSchedulerStoreAdapter.js +511 -742
  47. package/dist/controller/jobControl.js +17 -17
  48. package/dist/controller/resourceInventory.js +6 -6
  49. package/dist/controller/resourceInventoryLinux.js +38 -59
  50. package/dist/controller/resourceInventoryRpc.js +3 -3
  51. package/dist/controller/resourceInventoryWorker.js +1 -1
  52. package/dist/controller/runtime.js +46 -53
  53. package/dist/controller/runtimeEventInbox.js +23 -31
  54. package/dist/controller/runtimeEventProcessor.js +15 -15
  55. package/dist/controller/{runtimeHookRunFence.js → runtimeHookTurnFence.js} +87 -81
  56. package/dist/controller/runtimeLaunchCoordinator.js +3 -3
  57. package/dist/controller/runtimeObservationHook.js +11 -8
  58. package/dist/controller/sessionNotify.js +11 -11
  59. package/dist/controller/structuredProviderObservation.js +89 -16
  60. package/dist/coordination/workMailbox.js +309 -579
  61. package/dist/coordination/workMailboxQueue.js +5 -6
  62. package/dist/core/controllerClient.js +1 -91
  63. package/dist/core/controllerServer.js +3 -6
  64. package/dist/doctor/doctor.js +51 -314
  65. package/dist/execution/candidateConvergence.js +3 -3
  66. package/dist/execution/executionGroup.js +32 -33
  67. package/dist/execution/executionHealth.js +36 -38
  68. package/dist/execution/resourceBroker.js +12 -12
  69. package/dist/executor/agentExecutor.js +30 -265
  70. package/dist/executor/effectiveLaunch.js +9 -19
  71. package/dist/executor/executorRegistry.js +43 -17
  72. package/dist/executor/fileRoleLaunchPlanner.js +56 -56
  73. package/dist/executor/launchPlan.js +3 -3
  74. package/dist/executor/workspacePreflightClassification.js +16 -16
  75. package/dist/grant/capabilityGrant.js +20 -25
  76. package/dist/input/inputRequest.js +12 -12
  77. package/dist/integration/changeSet.js +9 -13
  78. package/dist/integration/changeSetManifest.js +1 -1
  79. package/dist/integration/gitIntegrationService.js +2 -2
  80. package/dist/integration/integrationQueueService.js +5 -21
  81. package/dist/integration/overlapDiagnostics.js +2 -2
  82. package/dist/interaction/operatorPresentation.js +1 -1
  83. package/dist/job/jobRunner.js +1 -1
  84. package/dist/lifecycle/canonicalLifecycleEvent.js +25 -25
  85. package/dist/lifecycle/{exactRunTerminalization.js → exactTurnTerminalization.js} +116 -232
  86. package/dist/message/message.js +6 -6
  87. package/dist/observability/executionAudit.js +74 -86
  88. package/dist/observability/faultClassification.js +7 -24
  89. package/dist/observability/orchestrationMetrics.js +5 -5
  90. package/dist/observability/runtimeIdentity.js +34 -101
  91. package/dist/profile/agentProfile.js +1 -1
  92. package/dist/release/cliHomeReleaseFence.js +3 -3
  93. package/dist/release/releaseWorkflowEngine.js +6 -10
  94. package/dist/release/releaseWorkflowPorts.js +2 -2
  95. package/dist/release/workflowFileLock.js +1 -1
  96. package/dist/repository/checkoutSwap.js +1 -1
  97. package/dist/repository/gitWorkspace.js +0 -13
  98. package/dist/repository/project.js +1 -1
  99. package/dist/repository/projectMaintenanceLock.js +1 -2
  100. package/dist/repository/taskBaseFreshness.js +13 -13
  101. package/dist/repository/taskWorkspaceCoordinator.js +21 -25
  102. package/dist/repository/taskWorkspaceIdentity.js +8 -21
  103. package/dist/repository/taskWorkspacePreparer.js +63 -620
  104. package/dist/resources/autoResourceGc.js +3 -3
  105. package/dist/resources/liveReferences.js +1 -1
  106. package/dist/resources/resourceGc.js +1 -1
  107. package/dist/resources/sqliteResourceRegistry.js +1 -1
  108. package/dist/review/reviewDecision.js +7 -7
  109. package/dist/review/reviewFindingLedger.js +1 -1
  110. package/dist/review/reviewOutcomeClassifier.js +35 -50
  111. package/dist/review/reviewRound.js +14 -25
  112. package/dist/review/reviewerAvailability.js +4 -4
  113. package/dist/review/taskFinalReviewContractResolution.js +41 -0
  114. package/dist/role/role.js +6 -3
  115. package/dist/runtime/agentDriver.js +4 -0
  116. package/dist/runtime/agentHost.js +151 -36
  117. package/dist/runtime/builtinAgentDrivers.js +79 -2
  118. package/dist/runtime/codexAppServerRuntime.js +122 -0
  119. package/dist/runtime/continuationManager.js +3 -5
  120. package/dist/runtime/exactControlPlane.js +17 -42
  121. package/dist/runtime/firstProgressAdvisory.js +1 -1
  122. package/dist/runtime/index.js +2 -2
  123. package/dist/runtime/launchDiagnostics.js +2 -2
  124. package/dist/runtime/lifecycleReservation.js +4 -4
  125. package/dist/runtime/processExitObservation.js +1 -1
  126. package/dist/runtime/processExitOutbox.js +0 -32
  127. package/dist/runtime/promptEnvelope.js +9 -9
  128. package/dist/runtime/providerContinuation.js +2 -2
  129. package/dist/runtime/providerContinuationReconciliationService.js +2 -2
  130. package/dist/runtime/providerRuntimeIdentity.js +76 -24
  131. package/dist/runtime/recentTurnIds.js +38 -0
  132. package/dist/runtime/runtimeContinuationProjection.js +2 -2
  133. package/dist/runtime/runtimeHealthPolicy.js +1 -1
  134. package/dist/runtime/runtimeObservation.js +58 -21
  135. package/dist/runtime/runtimeProjection.js +19 -27
  136. package/dist/runtime/sessionLaunchRequest.js +2 -2
  137. package/dist/runtime/structuredProviderHost.js +177 -12
  138. package/dist/runtime/tmuxAdapters.js +46 -11
  139. package/dist/scheduler/activeRoleTurnDelivery.js +245 -0
  140. package/dist/scheduler/leaderWakeupProcessor.js +147 -445
  141. package/dist/scheduler/operatorEvent.js +4 -6
  142. package/dist/scheduler/resourceQueueProjection.js +71 -0
  143. package/dist/scheduler/{roleRunLiveness.js → roleTurnLiveness.js} +24 -28
  144. package/dist/scheduler/{roleRunStall.js → roleTurnStall.js} +162 -247
  145. package/dist/scheduler/taskExecutionProjection.js +99 -99
  146. package/dist/scheduler/taskObservabilityProjection.js +10 -10
  147. package/dist/scheduler/taskWake.js +4 -4
  148. package/dist/scheduler/wakeReason.js +6 -10
  149. package/dist/scheduler/wakeupQueue.js +0 -9
  150. package/dist/setup/setupCommand.js +2 -2
  151. package/dist/storage/currentTaskStore.js +46 -0
  152. package/dist/storage/persistenceWorker.js +1 -1
  153. package/dist/storage/sqliteSchema.js +81 -485
  154. package/dist/storage/sqliteStore.js +206 -365
  155. package/dist/storage/storageSchema.js +1 -15
  156. package/dist/storage/storageVersions.js +8 -9
  157. package/dist/storage/storeRpc.js +10 -34
  158. package/dist/storage/taskStore.js +63 -3415
  159. package/dist/storage/upgrade/recordVersions.js +55 -149
  160. package/dist/storage/upgrade/upgradeOrchestrator.js +89 -1752
  161. package/dist/task/completionReadiness.js +5 -5
  162. package/dist/task/deliveryGuard.js +10 -10
  163. package/dist/task/nextAction.js +74 -77
  164. package/dist/task/task.js +2 -4
  165. package/dist/task/taskRecordReference.js +3 -3
  166. package/dist/task/taskRecordRetirement.js +1 -1
  167. package/dist/telemetry/sqliteTelemetryStore.js +53 -53
  168. package/dist/telemetry/telemetryConfig.js +14 -14
  169. package/dist/telemetry/telemetryWiring.js +8 -14
  170. package/dist/turn/turn.js +356 -0
  171. package/dist/{run/runIdentity.js → turn/turnIdentity.js} +4 -4
  172. package/dist/verification/gateArtifactStore.js +2 -2
  173. package/dist/web/assets/client/app.js +1 -1
  174. package/dist/web/assets/client/components.js +29 -54
  175. package/dist/web/assets/client/i18n.js +17 -37
  176. package/dist/web/assets/client/view.js +37 -37
  177. package/dist/web/assets/shell.js +1 -1
  178. package/dist/web/assets/styles/cards.js +5 -5
  179. package/dist/web/assets/styles/widgets.js +1 -2
  180. package/dist/web/webSnapshot.js +65 -60
  181. package/dist/workItem/workItem.js +11 -11
  182. package/dist/workspace/workItemChangeSetManager.js +1 -1
  183. package/docs/task-local-identity.md +5 -5
  184. package/i18n/README.zh-CN.md +41 -54
  185. package/package.json +1 -1
  186. package/skills/yui-leader/SKILL.md +85 -83
  187. package/skills/yui-operator/SKILL.md +16 -16
  188. package/skills/yui-reviewer/SKILL.md +10 -10
  189. package/skills/yui-runtime/SKILL.md +17 -18
  190. package/skills/yui-worker/SKILL.md +16 -18
  191. package/dist/context/runContextContract.js +0 -162
  192. package/dist/executor/turnCompletion.js +0 -126
  193. package/dist/review/taskFinalReviewContractEvent.js +0 -1
  194. package/dist/review/taskFinalReviewContractRebind.js +0 -367
  195. package/dist/run/agentRun.js +0 -356
  196. package/dist/run/rejectedYieldAttempt.js +0 -221
  197. package/dist/run/runControlRequest.js +0 -50
  198. package/dist/run/yieldReceipt.js +0 -65
  199. package/dist/runtime/turnCompletion.js +0 -3
  200. package/dist/scheduler/actionability.js +0 -323
  201. package/dist/scheduler/activeRoleRunDelivery.js +0 -868
  202. package/dist/scheduler/activeTaskProgress.js +0 -126
  203. package/dist/storage/compatibleTaskStore.js +0 -248
  204. package/dist/storage/migration/baseline.js +0 -78
  205. package/dist/storage/migration/classifier.js +0 -51
  206. package/dist/storage/migration/compatibleCodec.js +0 -53
  207. package/dist/storage/migration/engine.js +0 -147
  208. package/dist/storage/migration/index.js +0 -33
  209. package/dist/storage/migration/planner.js +0 -154
  210. package/dist/storage/migration/productionRegistry.js +0 -3931
  211. package/dist/storage/migration/registry.js +0 -169
  212. package/dist/storage/migration/report.js +0 -54
  213. package/dist/storage/migration/types.js +0 -31
  214. package/dist/storage/upgrade/homeClassification.js +0 -353
  215. package/dist/storage/upgrade/homeMigrationTarget.js +0 -595
  216. package/dist/storage/upgrade/migrationReceipt.js +0 -67
  217. package/dist/storage/upgrade/offlineUpgradeInventory.js +0 -453
  218. package/dist/storage/upgrade/productionMigrationRegistry.js +0 -6
  219. package/dist/storage/upgrade/pseudoLayoutRepair.js +0 -241
  220. package/dist/storage/upgrade/recordVersionScan.js +0 -176
  221. package/dist/storage/upgrade/sqliteFileSet.js +0 -50
  222. package/dist/storage/upgrade/sqliteMigrationTarget.js +0 -375
  223. package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +0 -305
  224. package/dist/storage/upgrade/sqliteStateMigration.js +0 -918
  225. package/dist/storage/upgrade/switchProgress.js +0 -80
  226. package/dist/storage/upgrade/upgradeReceipt.js +0 -161
  227. package/dist/storage/upgradeCoordination.js +0 -187
  228. package/dist/storage/upgradeFence.js +0 -366
  229. package/dist/telemetry/telemetryCompaction.js +0 -252
@@ -1,1789 +1,126 @@
1
- /**
2
- * The storage upgrade orchestrator.
3
- *
4
- * This composes the generic migration engine (`../migration`) with the real
5
- * Home-bound target and the admission fence into the user-facing `yui upgrade`
6
- * and the storage half of `yui update`:
7
- *
8
- * preflight (classify, read-only)
9
- * -> plan (delegated to the engine; missing/future step = fail-closed)
10
- * -> [offline only] authoritative active Run/Session/lifecycle inventory
11
- * -> [execute] admission fence (new writers refused)
12
- * -> close pre-admitted writers + final offline inventory
13
- * -> [execute] coordination lock (outside Home; shared with inbox publish)
14
- * -> quiesce (record/stop prior Controller; verify no foreign writer /
15
- * unfinished lifecycle)
16
- * -> re-pin revision (under `.state.lock`)
17
- * -> snapshot -> validate gate -> atomic switch + backup
18
- * -> [execute] post-switch health check (fresh FileTaskStore loader)
19
- * -> restore old Controller on pre-switch block, or start the replacement
20
- * only after a committed switch and successful verification
21
- *
22
- * Compatible-old returns before constructing a migration target. The internal
23
- * update preflight returns after classification, compatible-source validation,
24
- * or the authoritative offline inventory appropriate to the classified path, so
25
- * it remains safe while the exact old Controller is still running and never
26
- * claims staged-output validation. User-facing
27
- * `--dry-run` runs the staged validation gate and succeeds only when the engine
28
- * itself returns `dry-run`; every other engine outcome is preserved as a blocker.
29
- *
30
- * Quiesce uses only explicit, deterministic signals — the public
31
- * `controller.stop`/shutdownAndDrain, the real `.state.lock`, unfinished runtime
32
- * lifecycle mailboxes — never a broad process kill and never a TTL/idle
33
- * heuristic. Any signal that is not clear fails closed and leaves the original
34
- * authoritative input byte-for-byte unchanged.
35
- */
36
- import { spawn } from "node:child_process";
37
- import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
38
- import { tmpdir } from "node:os";
39
- import { dirname, join } from "node:path";
40
- import Database from "better-sqlite3";
41
- import { describeReport, runMigration } from "../migration/index.js";
42
- import { validateCompatibleFileTaskStore } from "../compatibleTaskStore.js";
43
- import { stopFileTaskController, ensureFileTaskController, ensureFileTaskControllerIdentity } from "../../controller/clientRuntime.js";
44
- import { callController } from "../../core/controllerClient.js";
45
- import { FileTaskStore, STORAGE_STATE_FILE, withStorageWriteLock } from "../taskStore.js";
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
46
3
  import { SqliteTaskStore } from "../sqliteStore.js";
47
- import { migrateSqliteSchema } from "../sqliteSchema.js";
48
- import { clearUpgradeFence, placeUpgradeFence, readUpgradeFence, UpgradeFenceError } from "../upgradeFence.js";
49
- import { withUpgradeCoordinationLock } from "../upgradeCoordination.js";
50
- import { clearUpgradeReceipt, writeUpgradeReceipt, upgradeReceiptPath } from "./upgradeReceipt.js";
51
- import { switchProgressPath } from "./switchProgress.js";
52
- import { classifyHome } from "./homeClassification.js";
53
- import { createHomeMigrationTarget, describeActiveRuntime, homeRuntimeIsActive, inspectHomeRuntime, inspectSourceVersionState } from "./homeMigrationTarget.js";
54
- import { repairPseudoLayout7 } from "./pseudoLayoutRepair.js";
55
- import { createSqliteMigrationTarget } from "./sqliteMigrationTarget.js";
56
- import { createSqliteRecordMigrationTarget } from "./sqliteRecordMigrationTarget.js";
57
- import { COMMITTED_DATABASE_FILENAME } from "./sqliteStateMigration.js";
58
- import { inspectOfflineUpgradeInventory, inspectSqliteDurableUpgradeInventory } from "./offlineUpgradeInventory.js";
4
+ import { inspectStorageSchema } from "../storageSchema.js";
5
+ const CURRENT_DATABASE_FILENAME = "yui.db";
59
6
  /**
60
- * Run the storage upgrade for one Home. Never throws for an expected blocker;
61
- * it returns a structured `blocked` result instead. It only throws on a truly
62
- * unexpected fault, and even then never after the atomic switch has committed.
7
+ * Current-only storage admission. Historical Homes are preserved byte-for-byte
8
+ * and rejected; Yui never repairs, normalizes, or upgrades them in place.
63
9
  */
64
10
  export async function runStorageUpgrade(options) {
65
- const { home, registry, latest, mode } = options;
66
- const now = options.now ?? (() => new Date());
67
- const callerPid = options.callerPid ?? process.pid;
68
- // 1) Preflight read-only classification.
69
- const classification = classifyHome({ home, registry, latest });
70
- // An uninitialized Home has no storage to migrate. The classifier reports it as
71
- // USABLE (nothing is wrong; doctor may present it as-is), but for the UPGRADE
72
- // path that verdict would collapse into a silent no-op against a Home that was
73
- // never `yui setup`. Return a structured, actionable blocker instead — never a
74
- // false success and never an unclassified runtime error (P2-7).
75
- if (classification.uninitialized === true) {
76
- return withClassification({
77
- outcome: "blocked",
78
- stage: "uninitialized",
79
- message: "Yui storage is not initialized for this Home; there is nothing to upgrade.",
80
- action: "Run `yui setup` to initialize storage, then re-run the upgrade if needed."
81
- }, classification);
82
- }
83
- const verdict = classification.classification.verdict;
84
- if (verdict === "CORRUPTED") {
85
- return withClassification({
86
- outcome: "blocked",
87
- stage: "corruption",
88
- message: `Storage is corrupted: ${classification.classification.detail}`,
89
- action: "Restore from a backup or a healthy Home; upgrade cannot proceed."
90
- }, classification);
91
- }
92
- if (verdict === "NEEDS_NEW_VERSION") {
93
- const blocker = classification.classification.blocker;
94
- return withClassification({
95
- outcome: "blocked",
96
- stage: blocker.reason === "future-version" ? "future-version" : "missing-step",
97
- message: blocker.message,
98
- action: blocker.action
99
- }, classification);
100
- }
101
- if (classification.sqliteMigration !== undefined) {
102
- return runSqliteInPlaceUpgrade(options, classification, now, callerPid);
103
- }
104
- // A pseudo-layout-7 Home (manifest 7, no yui.db, readable state.json) needs
105
- // the deterministic staged state.json→SQLite repair, not the version
106
- // migration engine (Issue 01). The update preflight reports it as
107
- // migration-required so a parent update can stop the old Controller first;
108
- // dry-run stages and verifies without promoting; execute runs the full
109
- // fenced repair with Controller lifecycle management.
110
- if (verdict === "NEEDS_STORAGE_REPAIR") {
111
- if (mode === "update-preflight") {
112
- return {
113
- outcome: "update-preflight",
114
- status: "migration-required",
115
- stepCount: 1,
116
- classification
117
- };
118
- }
119
- if (mode === "dry-run") {
120
- const repairResult = repairPseudoLayout7({
121
- home,
122
- latest,
123
- mode: "dry-run",
124
- ...(options.now === undefined ? {} : { now: options.now })
125
- });
126
- if (repairResult.outcome === "blocked") {
127
- return withClassification({
128
- outcome: "blocked",
129
- stage: repairResult.stage,
130
- message: repairResult.message,
131
- action: repairResult.action
132
- }, classification);
133
- }
134
- const inspected = inspectSourceVersionState(home, latest);
135
- const source = "corruption" in inspected ? latest : inspected.source;
136
- return {
137
- outcome: "dry-run",
138
- classification,
139
- report: {
140
- outcome: "dry-run",
141
- mode: "dry-run",
142
- source,
143
- target: latest,
144
- steps: [],
145
- effects: [],
146
- derived: { rebuiltEffects: [] },
147
- validation: {
148
- checks: [
149
- {
150
- name: "pseudo-layout-7 repair verification",
151
- outcome: "passed",
152
- detail: `verified ${repairResult.verifiedFamilies} record families against an `
153
- + "independent state.json re-read; staged database discarded"
154
- }
155
- ]
156
- }
157
- }
158
- };
159
- }
160
- return executePseudoLayout7Repair(options, classification, now);
11
+ const schema = inspectStorageSchema(options.home);
12
+ const classification = classifyCurrentHome(schema, options.latest);
13
+ if (schema.status === "uninitialized") {
14
+ return blocked(classification, "uninitialized", "Yui storage is not initialized for this Home.", "Run `yui setup` with a new Home.");
161
15
  }
162
- // An all-compatible chain is an online-load contract, not a migration plan.
163
- // Validate the declared old source shape and its in-memory normalization before
164
- // ANY mode reports compatible, including the internal update preflight. This is
165
- // read-only and safe while the exact old Controller is still running; it creates
166
- // no migration target or staged Home and touches no lifecycle state.
167
- if (verdict === "COMPATIBLE") {
168
- try {
169
- validateCompatibleFileTaskStore(home, { registry, latest });
170
- if (mode === "update-preflight") {
171
- return {
172
- outcome: "update-preflight",
173
- status: "compatible",
174
- stepCount: classification.classification.stepCount,
175
- classification
176
- };
177
- }
178
- return { outcome: "compatible", classification };
179
- }
180
- catch (error) {
181
- const detail = `Compatible source validation failed: ${messageOf(error)}`;
182
- const invalid = {
183
- ...classification,
184
- classification: {
185
- verdict: "CORRUPTED",
186
- status: "unsupported",
187
- detail
188
- }
189
- };
190
- return withClassification({
191
- outcome: "blocked",
192
- stage: "corruption",
193
- message: detail,
194
- action: "Do not activate the new binary. The declared old shape did not pass its strict read-only validator."
195
- }, invalid);
196
- }
197
- }
198
- // `yui update` invokes this explicit internal contract while the exact old
199
- // Controller is still running. It performs classification for a current Home,
200
- // compatible-source validation above for a compatible Home, and classification
201
- // plus the authoritative offline inventory for a migration path. It never
202
- // constructs a migration target, so there is no staging copy, backup, fence,
203
- // runtime/Controller lifecycle probe, staged-output loader validation, or switch.
204
- if (mode === "update-preflight") {
205
- if (verdict === "USABLE") {
206
- return {
207
- outcome: "update-preflight",
208
- status: "already-current",
209
- stepCount: 0,
210
- classification
211
- };
212
- }
213
- // verdict === MIGRATABLE: a parent update may stop the old Controller only
214
- // after this authoritative offline inventory is clear.
215
- const inventory = await readOfflineInventory(options, home);
216
- if (inventory.total > 0) {
217
- return withClassification(offlineInventoryBlocker(inventory, true), classification);
218
- }
219
- return {
220
- outcome: "update-preflight",
221
- status: "migration-required",
222
- stepCount: classification.classification.stepCount,
223
- classification
224
- };
225
- }
226
- if (verdict === "MIGRATABLE") {
227
- const inventory = await readOfflineInventory(options, home);
228
- if (inventory.total > 0) {
229
- return withClassification(offlineInventoryBlocker(inventory, true), classification);
230
- }
231
- }
232
- // Select the migration target.
233
- // - A layout-6 Home migrates to the SQLite control-plane layout (7) via the
234
- // staged state.json→SQLite target.
235
- // - A layout-7 Home whose authoritative store is `yui.db` migrates
236
- // record/aggregate versions via the SQLite record target (state.json may
237
- // have been archived by the pseudo-layout-7 repair).
238
- // - A layout-6 Home with record-only migrations (no layout step) uses the
239
- // file-document target.
240
- const usesSqliteLayoutTarget = classification.layoutVersion === 6 && latest.layout === 7;
241
- const usesSqliteRecordTarget = classification.layoutVersion === latest.layout
242
- && latest.layout >= 7;
243
- const target = usesSqliteLayoutTarget
244
- ? createSqliteMigrationTarget({ home, latest, registry, now, callerPid })
245
- : usesSqliteRecordTarget
246
- ? createSqliteRecordMigrationTarget({
247
- home,
248
- latest,
249
- registry,
250
- now,
251
- callerPid,
252
- ...(options.renameImpl === undefined ? {} : { renameImpl: options.renameImpl })
253
- })
254
- : createHomeMigrationTarget({
255
- home,
256
- latest,
257
- now,
258
- callerPid,
259
- ...(options.renameImpl === undefined ? {} : { renameImpl: options.renameImpl }),
260
- ...(options.switchFaultHook === undefined ? {} : { switchFaultHook: options.switchFaultHook })
261
- });
262
- // 2) A USABLE (already-current) Home has nothing to migrate; the engine
263
- // confirms with a no-op and we never fence, drain, or switch.
264
- if (verdict === "USABLE") {
265
- const report = runMigration({ registry, target, latest, mode: "dry-run" });
266
- if (report.outcome === "already-current") {
267
- return { outcome: "already-current", classification, report };
268
- }
269
- // A USABLE verdict with a runnable plan cannot happen (USABLE == no-op), but
270
- // if the registry disagrees we fail closed rather than switch unexpectedly.
271
- return {
272
- ...withClassification({
273
- outcome: "blocked",
274
- stage: "missing-step",
275
- message: "Classifier and planner disagree about whether an upgrade is needed.",
276
- action: "Re-run `yui doctor`; do not force an upgrade."
277
- }, classification),
278
- report
279
- };
280
- }
281
- // verdict === "MIGRATABLE": a complete offline step path exists and the
282
- // authoritative inventory was re-read clear before any lifecycle mutation.
283
- if (mode === "dry-run") {
284
- return dryRun(options, classification, target);
285
- }
286
- return execute(options, classification, target, callerPid, now);
287
- }
288
- function describeOfflineBlockers(inventory) {
289
- const lines = inventory.blockers.map((blocker, index) => {
290
- const identity = [
291
- blocker.taskId === undefined ? undefined : `task=${blocker.taskId}`,
292
- blocker.roleName === undefined ? undefined : `role=${blocker.roleName}`,
293
- blocker.runId === undefined ? undefined : `run=${blocker.runId}`,
294
- blocker.nativeSessionId === undefined
295
- ? undefined
296
- : `nativeSession=${blocker.nativeSessionId}`,
297
- blocker.launchId === undefined ? undefined : `launch=${blocker.launchId}`
298
- ].filter((value) => value !== undefined).join(" ");
299
- const reason = blocker.reason === "pending-inbox"
300
- ? "pending durable inbox"
301
- : blocker.reason === "pending-mailbox"
302
- ? "pending lifecycle mailbox"
303
- : blocker.reason;
304
- return `${index + 1}. ${identity.length === 0 ? "identity=unknown" : identity} ` +
305
- `reason=${reason}`;
306
- });
307
- return `Offline Home migration cannot start while ${inventory.total} runtime obligation(s) remain. ` +
308
- lines.join("; ");
309
- }
310
- async function readOfflineInventory(options, home) {
311
- try {
312
- return await (options.inspectOfflineInventory
313
- ?? ((targetHome) => inspectOfflineUpgradeInventory(targetHome)))(home);
314
- }
315
- catch {
316
- return {
317
- total: 1,
318
- blockers: [{ reason: "native-session-unknown" }]
319
- };
320
- }
321
- }
322
- function offlineInventoryBlocker(inventory, sceneUnchanged) {
323
- const lifecycleOnly = inventory.blockers.every(({ reason }) => (reason === "pending-mailbox" || reason === "pending-inbox"));
324
- const runtimeUnknown = inventory.blockers.some(({ reason }) => (reason === "native-session-unknown"));
325
- const action = lifecycleOnly
326
- ? (sceneUnchanged
327
- ? "No binary, Controller, fence, or Home change was made. Wait for the listed lifecycle "
328
- + "events to settle, then re-run `yui update`."
329
- : "The Home was not switched. Wait for the listed lifecycle events to settle, then "
330
- + "re-run `yui update`.")
331
- : runtimeUnknown
332
- ? (sceneUnchanged
333
- ? "Offline Home migration could not confirm that every native Session is stopped. Run "
334
- + "`yui controller status --verbose` from a normal shell and inspect the reported "
335
- + "runtime ownership. Use `yui controller cleanup` only for resources it classifies "
336
- + "for cleanup, then re-run `yui update`; do not kill unknown processes blindly. No "
337
- + "binary, Controller, fence, or Home change was made."
338
- : "The Home was not switched because native Session state could not be confirmed. Run "
339
- + "`yui controller status --verbose`, inspect the reported ownership, and use "
340
- + "`yui controller cleanup` only for resources it classifies for cleanup before "
341
- + "retrying; do not kill unknown processes blindly.")
342
- : (sceneUnchanged
343
- ? "This version requires an offline Home migration with every managed Agent Session "
344
- + "stopped. Let the listed Turns or Runs finish. If the installed `yui` supports "
345
- + "`session stop --all`, run `yui session stop --all` from a normal shell; otherwise "
346
- + "exit every listed managed Session manually. Then re-run `yui update`. No binary, "
347
- + "Controller, fence, or Home change was made."
348
- : "The Home was not switched. Let the listed Turns or Runs finish. If the installed "
349
- + "`yui` supports `session stop --all`, run `yui session stop --all` from a normal "
350
- + "shell; otherwise exit every listed managed Session manually. Then re-run "
351
- + "`yui update`.");
352
- return {
353
- outcome: "blocked",
354
- stage: lifecycleOnly
355
- ? "drain-incomplete"
356
- : runtimeUnknown
357
- ? "runtime-unknown"
358
- : "active-sessions",
359
- message: describeOfflineBlockers(inventory),
360
- action,
361
- blockers: inventory.blockers,
362
- retryCommand: "yui update",
363
- ...(sceneUnchanged ? { sceneUnchanged: true } : {})
364
- };
365
- }
366
- /**
367
- * A valid SQLite migration prefix advances the authoritative database in
368
- * place. It still uses the offline runtime gate because the old Controller
369
- * cannot safely resume after the schema transaction commits, but it never
370
- * snapshots, copies, rebuilds, or swaps the database.
371
- */
372
- async function runSqliteInPlaceUpgrade(options, classification, now, callerPid) {
373
- const migration = classification.sqliteMigration;
374
- const inventory = await readOfflineInventory(options, options.home);
375
- if (inventory.total > 0) {
376
- return withClassification(offlineInventoryBlocker(inventory, true), classification);
16
+ if (schema.status === "invalid") {
17
+ return blocked(classification, "corruption", `Storage schema is invalid: ${schema.detail}`, "Preserve this Home for diagnosis and initialize a new Home.");
377
18
  }
378
- if (options.mode === "update-preflight") {
379
- return {
380
- outcome: "update-preflight",
381
- status: "in-place-migration",
382
- stepCount: migration.pendingVersions.length,
383
- classification,
384
- sqliteMigration: migration
385
- };
19
+ if (schema.status === "unsupported") {
20
+ return blocked(classification, "unsupported", `Storage contract is ${schema.direction}: ${schema.incompatibleComponent}.`, "Open the old Home only with its original Yui version to export a summary; then let the new Operator create a new Task in a new Home.");
386
21
  }
387
- if (options.mode === "dry-run") {
388
- return validateSqliteInPlaceDryRun(options, classification, now);
22
+ if (!existsSync(join(options.home, CURRENT_DATABASE_FILENAME))) {
23
+ return blocked(corruptedClassification(classification, "The current SQLite database is missing."), "corruption", "The current SQLite Home is incomplete: yui.db is missing.", "Preserve this Home for diagnosis and initialize a new Home.");
389
24
  }
390
- return executeSqliteInPlaceUpgrade(options, classification, now, callerPid);
391
- }
392
- /**
393
- * Exercise every pending SQLite migration against a consistent disposable
394
- * snapshot, then open that snapshot through the current production loader.
395
- * The authoritative database is opened read-only and is never migrated.
396
- */
397
- async function validateSqliteInPlaceDryRun(options, classification, now) {
398
- const migration = classification.sqliteMigration;
399
- const stagingRoot = mkdtempSync(join(tmpdir(), "yui-sqlite-upgrade-dry-run-"));
400
- const stagedDatabasePath = join(stagingRoot, COMMITTED_DATABASE_FILENAME);
401
- let source;
402
- let staged;
403
- let loader;
404
25
  try {
405
- source = new Database(join(options.home, COMMITTED_DATABASE_FILENAME), {
406
- readonly: true,
407
- fileMustExist: true
408
- });
409
- source.pragma("busy_timeout = 5000");
410
- await source.backup(stagedDatabasePath);
411
- source.close();
412
- source = undefined;
413
- staged = new Database(stagedDatabasePath);
414
- staged.pragma("journal_mode = WAL");
415
- staged.pragma("synchronous = FULL");
416
- staged.pragma("foreign_keys = ON");
417
- const applied = migrateSqliteSchema(staged, { mode: "apply" }).applied;
418
- if (applied.join(",") !== migration.pendingVersions.join(",")) {
419
- throw new Error(`staged SQLite migration applied versions ${applied.join(",") || "none"}; `
420
- + `expected ${migration.pendingVersions.join(",")}`);
421
- }
422
- staged.close();
423
- staged = undefined;
424
- loader = new SqliteTaskStore(stagingRoot);
425
- const quickCheck = loader.databaseHandle().pragma("quick_check", { simple: true });
426
- if (quickCheck !== "ok") {
427
- throw new Error(`staged SQLite quick_check returned ${String(quickCheck)}`);
428
- }
429
- loader.close();
430
- loader = undefined;
431
- return {
432
- outcome: "dry-run",
433
- classification,
434
- report: sqliteInPlaceReport(options.latest, migration, "dry-run", now)
435
- };
436
- }
437
- catch (error) {
438
- return withClassification({
439
- outcome: "blocked",
440
- stage: "validate",
441
- message: `SQLite dry-run validation failed: ${messageOf(error)}`,
442
- action: "The authoritative database was not changed. Resolve the reported migration or loader failure, then retry the dry run."
443
- }, classification);
444
- }
445
- finally {
26
+ const store = new SqliteTaskStore(options.home);
446
27
  try {
447
- loader?.close();
448
- }
449
- catch { /* best-effort disposable cleanup */ }
450
- try {
451
- staged?.close();
452
- }
453
- catch { /* best-effort disposable cleanup */ }
454
- try {
455
- source?.close();
28
+ store.getConfig();
456
29
  }
457
- catch { /* best-effort disposable cleanup */ }
458
- rmSync(stagingRoot, { recursive: true, force: true });
459
- }
460
- }
461
- /** Acquire a local write fence or prove the exact parent-owned fence exists. */
462
- function acquireUpgradeAdmission(options, reason, now, callerPid) {
463
- const externalOwnerPid = options.externalUpgradeFenceOwnerPid;
464
- if (externalOwnerPid !== undefined) {
465
- const fence = readUpgradeFence(options.home);
466
- if (!isPositivePid(externalOwnerPid) || fence?.ownerPid !== externalOwnerPid) {
467
- throw new UpgradeFenceError(`expected parent-owned upgrade fence for PID ${String(externalOwnerPid)}`);
30
+ finally {
31
+ store.close();
468
32
  }
469
- return () => { };
470
- }
471
- return placeUpgradeFence(options.home, {
472
- reason,
473
- createdAt: now().toISOString(),
474
- ownerPid: callerPid
475
- });
476
- }
477
- async function executeSqliteInPlaceUpgrade(options, classification, now, callerPid) {
478
- const { home } = options;
479
- const migration = classification.sqliteMigration;
480
- let releaseFence;
481
- try {
482
- releaseFence = acquireUpgradeAdmission(options, "SQLite schema migration in progress", now, callerPid);
483
33
  }
484
34
  catch (error) {
485
- if (!(error instanceof UpgradeFenceError))
486
- throw error;
487
- return withClassification({
488
- outcome: "blocked",
489
- stage: "coordination",
490
- message: `Upgrade coordination could not be acquired: ${error.message}`,
491
- action: "Wait for the current maintenance operation to finish, then retry."
492
- }, classification);
493
- }
494
- const externallyQuiesced = options.controllerLifecycle === "externally-quiesced";
495
- let controllerWasRunning = false;
496
- let controllerStopConfirmed = false;
497
- let controllerIdentity;
498
- let committed = false;
499
- let result;
500
- let unexpected;
501
- try {
502
- if (!externallyQuiesced) {
503
- const controllerStatus = options.controllerStatus
504
- ?? ((targetHome) => defaultControllerStatus(targetHome, options.controllerOptions));
505
- let status;
506
- try {
507
- status = await controllerStatus(home);
508
- }
509
- catch (error) {
510
- result = withClassification(controllerLifecycleBlocker("Controller status could not be verified", error), classification);
511
- }
512
- if (result === undefined && !isControllerLifecycleStatus(status)) {
513
- result = withClassification(controllerLifecycleBlocker("Controller status was malformed", new Error("expected a boolean running field")), classification);
514
- }
515
- if (result === undefined && status.running && !isControllerLaunchIdentity(status.identity)) {
516
- result = withClassification(controllerLifecycleBlocker("Controller launch identity could not be authenticated", new Error("executable/argv/version identity is unavailable")), classification);
517
- }
518
- if (result === undefined && status.running && !isPositivePid(status.pid)) {
519
- result = withClassification(controllerLifecycleBlocker("Controller PID could not be authenticated", new Error("a positive status PID is unavailable for fenced stop")), classification);
520
- }
521
- if (result === undefined) {
522
- controllerWasRunning = status.running;
523
- controllerIdentity = status.running ? status.identity : undefined;
524
- controllerStopConfirmed = !controllerWasRunning;
525
- }
526
- if (result === undefined && controllerWasRunning) {
527
- const stopController = options.stopController
528
- ?? ((targetHome, expectedPid) => defaultStopController(targetHome, expectedPid, options.controllerOptions));
529
- try {
530
- const expectedPid = status.pid;
531
- const stopped = await stopController(home, expectedPid);
532
- if (!confirmedControllerStopped(stopped, expectedPid)) {
533
- result = withClassification(controllerLifecycleBlocker("Controller stop did not confirm a drained process", new Error(`stop did not confirm captured PID ${expectedPid} with stopped:true`)), classification);
534
- }
535
- else {
536
- controllerStopConfirmed = true;
537
- }
538
- }
539
- catch (error) {
540
- result = withClassification(controllerLifecycleBlocker("Controller stop/drain failed", error), classification);
541
- }
542
- }
543
- }
544
- else {
545
- controllerStopConfirmed = true;
546
- }
547
- // The first inventory is a cheap/read-only preflight. Re-read after the
548
- // Controller has fully drained so a lifecycle request that committed while
549
- // the fence was being placed cannot slip through on stale evidence.
550
- if (result === undefined) {
551
- const finalInventory = await readOfflineInventory(options, home);
552
- if (finalInventory.total > 0) {
553
- result = withClassification(offlineInventoryBlocker(finalInventory, true), classification);
554
- }
555
- }
556
- if (result === undefined) {
557
- try {
558
- result = withUpgradeCoordinationLock(home, () => {
559
- const quiesce = verifyQuiesced(home, callerPid);
560
- if (quiesce !== null)
561
- return withClassification(quiesce, classification);
562
- const db = new Database(join(home, COMMITTED_DATABASE_FILENAME));
563
- try {
564
- db.pragma("journal_mode = WAL");
565
- db.pragma("synchronous = FULL");
566
- db.pragma("foreign_keys = ON");
567
- db.pragma("busy_timeout = 5000");
568
- const apply = db.transaction(() => {
569
- // BEGIN IMMEDIATE waits for every older SQLite writer. With the
570
- // upgrade fence still held, the state read here is the final
571
- // authoritative durable gate for the same schema transaction.
572
- const durableInventory = inspectSqliteDurableUpgradeInventory(home, db);
573
- if (durableInventory.total > 0) {
574
- return withClassification(offlineInventoryBlocker(durableInventory, true), classification);
575
- }
576
- migrateSqliteSchema(db, { mode: "apply" });
577
- return {
578
- outcome: "upgraded",
579
- classification,
580
- migrationMode: "in-place",
581
- report: sqliteInPlaceReport(options.latest, migration, "execute", now)
582
- };
583
- });
584
- const applied = apply.immediate();
585
- if (applied.outcome === "upgraded")
586
- committed = true;
587
- return applied;
588
- }
589
- finally {
590
- db.close();
591
- }
592
- }, options.externalUpgradeFenceOwnerPid ?? callerPid);
593
- }
594
- catch (error) {
595
- unexpected = error;
596
- }
597
- }
35
+ const message = error instanceof Error ? error.message : String(error);
36
+ return blocked(corruptedClassification(classification, message), "corruption", `Current storage validation failed: ${message}`, "Preserve this Home for diagnosis and initialize a new Home.");
598
37
  }
599
- finally {
600
- try {
601
- releaseFence();
602
- }
603
- catch (error) {
604
- if (unexpected === undefined)
605
- unexpected = error;
606
- }
607
- }
608
- if (unexpected !== undefined) {
609
- if (committed) {
610
- return withClassification({
611
- outcome: "blocked",
612
- stage: "post-verify",
613
- message: `SQLite migration committed but completion failed: ${messageOf(unexpected)}`,
614
- action: "Do not restore the old Controller. Re-run this Yui version; the SQLite migration ledger is the commit record.",
615
- storageCommitted: true
616
- }, classification);
617
- }
618
- if (controllerWasRunning && controllerStopConfirmed) {
619
- await restoreController(home, options, unexpected, controllerIdentity);
620
- }
621
- throw unexpected;
622
- }
623
- if (result === undefined)
624
- throw new Error("SQLite in-place upgrade did not produce a result.");
625
- if (result.outcome === "upgraded" && !externallyQuiesced && controllerWasRunning) {
626
- try {
627
- await (options.startController
628
- ?? ((targetHome) => defaultStartController(targetHome, options.controllerOptions)))(home);
629
- }
630
- catch (error) {
631
- return withClassification({
632
- outcome: "blocked",
633
- stage: "post-verify",
634
- message: `SQLite migration committed but the replacement Controller did not start: ${messageOf(error)}`,
635
- action: "Do not restore the old Controller. Start the current Yui Controller; the SQLite migration ledger already committed.",
636
- storageCommitted: true
637
- }, classification);
638
- }
639
- return result;
640
- }
641
- if (result.outcome === "blocked"
642
- && !committed
643
- && !externallyQuiesced
644
- && controllerWasRunning
645
- && controllerStopConfirmed) {
646
- await restoreController(home, options, new Error(result.message), controllerIdentity);
647
- }
648
- return result;
649
- }
650
- function sqliteInPlaceReport(latest, migration, mode, now) {
651
- const detail = `SQLite schema ${migration.currentVersion}->${migration.targetVersion}; `
652
- + `pending versions ${migration.pendingVersions.join(", ")}`;
653
- if (mode === "dry-run") {
38
+ if (options.mode === "update-preflight") {
654
39
  return {
655
- outcome: "dry-run",
656
- mode,
657
- source: latest,
658
- target: latest,
659
- steps: [],
660
- effects: [],
661
- derived: { rebuiltEffects: [] },
662
- validation: {
663
- checks: [{
664
- name: "SQLite staged migration and loader gate",
665
- outcome: "passed",
666
- detail: `${detail}; snapshot migrated and reopened by the current SQLite loader`
667
- }]
668
- }
40
+ outcome: "update-preflight",
41
+ status: "already-current",
42
+ stepCount: 0,
43
+ classification
669
44
  };
670
45
  }
671
- return {
672
- outcome: "migrated",
673
- mode,
674
- source: latest,
675
- target: latest,
676
- steps: [],
677
- effects: [],
678
- derived: { rebuiltEffects: [] },
679
- validation: {
680
- checks: [{ name: "SQLite migration transaction", outcome: "passed", detail }]
681
- },
682
- switch: {
683
- status: "switched",
684
- detail: "SQLite schema migrated in place; no database copy or rebuild was created."
685
- },
686
- completedAt: now().toISOString()
46
+ const report = {
47
+ outcome: "already-current",
48
+ mode: options.mode,
49
+ source: options.latest,
50
+ target: options.latest
687
51
  };
52
+ return { outcome: "already-current", classification, report };
688
53
  }
689
- /** Dry run: validate through the staged gate, then discard; never switch. */
690
- function dryRun(options, classification, target) {
691
- // Refuse to reuse a stale staging directory from an interrupted run.
692
- target.discardFreshOutput();
693
- const report = runMigration({
694
- registry: options.registry,
695
- target,
696
- latest: options.latest,
697
- mode: "dry-run"
698
- });
699
- target.discardFreshOutput();
700
- if (report.outcome === "dry-run") {
701
- return { outcome: "dry-run", classification, report };
702
- }
703
- if (report.outcome === "failed") {
704
- return { ...blockedFromFailedReport(report, classification), report };
705
- }
706
- // A dry-run success is proven only by the engine's exact `dry-run` variant,
707
- // which carries staged-output and loader-gate evidence. In particular, a live
708
- // runtime returns before read/transform/write/validate; preserve that outcome
709
- // as a blocker instead of wrapping it in a false outer success.
710
- return { ...blockedFromEngineReport(report, classification), report };
711
- }
712
- /** Execute: fence -> coordination/quiesce -> re-pin -> switch -> post-verify. */
713
- async function execute(options, classification, target, callerPid, now) {
714
- const { home } = options;
715
- // 3) Admission fence — from here, new baseline CLI and Controller writers are
716
- // refused at the storage commit choke point. The fencing process is exempt.
717
- // A live foreign fence is expected coordination contention, not a generic
718
- // runtime failure. Return a structured blocker without removing or retrying
719
- // the other upgrader's fence.
720
- let releaseFence;
721
- try {
722
- releaseFence = acquireUpgradeAdmission(options, "storage upgrade in progress", now, callerPid);
723
- }
724
- catch (error) {
725
- if (!(error instanceof UpgradeFenceError))
726
- throw error;
727
- return withClassification({
728
- outcome: "blocked",
729
- stage: "coordination",
730
- message: `Upgrade coordination could not be acquired: ${error.message}`,
731
- action: "Another upgrade is already coordinating this Home. Wait for it to finish, then retry; "
732
- + "do not remove its live fence or retry blindly."
733
- }, classification);
734
- }
735
- const externallyQuiesced = options.controllerLifecycle === "externally-quiesced";
736
- let controllerWasRunning = false;
737
- let controllerStopConfirmed = false;
738
- let controllerIdentity;
739
- let result;
740
- let unexpected;
741
- const switchState = { committed: false };
742
- try {
743
- if (!externallyQuiesced) {
744
- // Record the lifecycle fact before stopping anything. An unavailable or
745
- // malformed status is unknown, not "stopped"; fail closed without attempting
746
- // a blind stop/retry.
747
- const controllerStatus = options.controllerStatus
748
- ?? ((h) => defaultControllerStatus(h, options.controllerOptions));
749
- let status;
750
- try {
751
- status = await controllerStatus(home);
752
- }
753
- catch (error) {
754
- result = withClassification(controllerLifecycleBlocker("Controller status could not be verified", error), classification);
755
- }
756
- if (result === undefined && !isControllerLifecycleStatus(status)) {
757
- result = withClassification(controllerLifecycleBlocker("Controller status was malformed", new Error("expected a boolean running field")), classification);
758
- }
759
- if (result === undefined && status.running && !isControllerLaunchIdentity(status.identity)) {
760
- result = withClassification(controllerLifecycleBlocker("Controller launch identity could not be authenticated", new Error("executable/argv/version identity is unavailable")), classification);
761
- }
762
- if (result === undefined && status.running && !isPositivePid(status.pid)) {
763
- result = withClassification(controllerLifecycleBlocker("Controller PID could not be authenticated", new Error("a positive status PID is unavailable for fenced stop")), classification);
764
- }
765
- if (result === undefined) {
766
- controllerWasRunning = status.running;
767
- controllerIdentity = status.running ? status.identity : undefined;
768
- controllerStopConfirmed = !controllerWasRunning;
769
- }
770
- // 4) Quiesce — drain the Controller with the public stop, then require that
771
- // no foreign writer, no live Controller, and no unfinished runtime lifecycle
772
- // remain. Any unclear signal fails closed with the source unchanged. A stop
773
- // request is issued at most once; timeout/rejection is a structured blocker,
774
- // never a retry loop.
775
- const stopController = options.stopController
776
- ?? ((h, expectedPid) => defaultStopController(h, expectedPid, options.controllerOptions));
777
- if (result === undefined && controllerWasRunning) {
778
- try {
779
- const expectedPid = status.pid;
780
- const stopped = await stopController(home, expectedPid);
781
- if (!confirmedControllerStopped(stopped, expectedPid)) {
782
- result = withClassification(controllerLifecycleBlocker("Controller stop did not confirm a drained process", new Error(`stop did not confirm captured PID ${expectedPid} with stopped:true`)), classification);
783
- }
784
- else {
785
- controllerStopConfirmed = true;
786
- }
787
- }
788
- catch (error) {
789
- result = withClassification(controllerLifecycleBlocker("Controller stop/drain failed", error), classification);
790
- }
791
- }
792
- }
793
- else {
794
- // The parent update already captured and drained the old Controller. This
795
- // staged child must not inspect or start one from its temporary install.
796
- controllerStopConfirmed = true;
797
- }
798
- if (result === undefined) {
799
- // Close the only admission race left by the read-only preflight. A writer
800
- // which acquired `.state.lock` before the fence must finish before this
801
- // pin; every later commit sees the fence and fails. Re-read the complete
802
- // offline inventory only after that boundary and before staging/switching.
803
- try {
804
- repinRevision(home);
805
- }
806
- catch (error) {
807
- result = withClassification({
808
- outcome: "blocked",
809
- stage: "active-runtime",
810
- message: `The pre-fence writer window is undeterminable and could not be closed safely: ` +
811
- `${messageOf(error)}.`,
812
- action: "The Home was not switched. Inspect the exact storage-lock owner and retry only " +
813
- "after it is settled; do not remove a live lock or retry blindly."
814
- }, classification);
815
- }
816
- }
817
- if (result === undefined) {
818
- const inventory = await readOfflineInventory(options, home);
819
- if (inventory.total > 0) {
820
- result = withClassification(offlineInventoryBlocker(inventory, false), classification);
821
- }
822
- }
823
- if (result === undefined) {
824
- try {
825
- result = executeFenced(options, classification, target, callerPid, now, switchState);
826
- }
827
- catch (error) {
828
- // Any exception after the atomic rename is a post-switch ambiguity. It
829
- // must not cross the generic restore path: the old Controller is unsafe
830
- // against a Home whose authority has already moved.
831
- if (switchState.committed) {
832
- result = withClassification(postSwitchAmbiguity(home, switchState.backupPath, error), classification);
833
- }
834
- else {
835
- unexpected = error;
836
- }
837
- }
838
- }
839
- }
840
- finally {
841
- try {
842
- releaseFence();
843
- }
844
- catch (error) {
845
- if (switchState.committed && result === undefined) {
846
- result = withClassification(postSwitchAmbiguity(home, switchState.backupPath, error), classification);
847
- }
848
- else if (unexpected === undefined) {
849
- unexpected = error;
850
- }
851
- }
852
- }
853
- // Restoring the old Controller must happen after the upgrade fence is
854
- // released, otherwise its startup scheduler would be blocked by our own
855
- // fence. A thrown unexpected fault remains a throw, but the old lifecycle is
856
- // still restored exactly once before it escapes.
857
- if (unexpected !== undefined) {
858
- if (switchState.committed) {
859
- return withClassification(postSwitchAmbiguity(home, switchState.backupPath, unexpected), classification);
860
- }
861
- if (controllerWasRunning && controllerStopConfirmed) {
862
- await restoreController(home, options, unexpected, controllerIdentity);
863
- }
864
- throw unexpected;
865
- }
866
- if (result === undefined) {
867
- throw new Error("Storage upgrade did not produce a result.");
868
- }
869
- if (result.outcome === "upgraded") {
870
- const startController = options.startController
871
- ?? ((h) => defaultStartController(h, options.controllerOptions));
872
- if (!externallyQuiesced && controllerWasRunning) {
873
- try {
874
- // New Controller startup is deliberately after switch + post-verify.
875
- await startController(home);
876
- }
877
- catch (error) {
878
- // Keep the completion receipt and backup as durable evidence when the
879
- // storage switch is verified but the replacement Controller cannot be
880
- // started. The old Controller is not safe to resume on the new Home.
881
- return {
882
- ...withClassification(postSwitchAmbiguity(home, result.backupPath, new Error(`The replacement Controller could not start after the committed switch: ${messageOf(error)}`)), classification),
883
- report: result.report
884
- };
885
- }
886
- }
887
- // No further uncertainty remains once the replacement Controller (when one
888
- // existed) is ready; clear the receipt only at this final boundary.
889
- try {
890
- options.postSwitchFaultHook?.("receipt-clear");
891
- clearUpgradeReceipt(home);
892
- }
893
- catch (error) {
894
- return {
895
- ...withClassification(postSwitchAmbiguity(home, result.backupPath, error), classification),
896
- report: result.report
897
- };
898
- }
899
- return result;
900
- }
901
- if (!externallyQuiesced
902
- &&
903
- controllerWasRunning
904
- && controllerStopConfirmed
905
- && result.outcome === "blocked"
906
- // Once the switch committed (post-verify) or became ambiguous, the old
907
- // Controller is not safe to resume against this Home.
908
- && result.stage !== "post-verify"
909
- && result.stage !== "switch-ambiguous") {
910
- try {
911
- // A blocked/failed pre-switch attempt leaves the old Home authoritative;
912
- // restore the Controller that was running before quiesce, once and only
913
- // once. Ambiguous switch is excluded: its Home authority is unknown.
914
- await restoreController(home, options, new Error(result.message), controllerIdentity);
915
- }
916
- catch (error) {
917
- return {
918
- ...withClassification({
919
- outcome: "blocked",
920
- stage: "active-runtime",
921
- message: `${result.message} The previously running Controller could not be restored: `
922
- + `${messageOf(error)}.`,
923
- action: "Keep the old Home quiesced and resolve the Controller startup failure; "
924
- + "do not resume writes until the Controller and Home are verified."
925
- }, classification),
926
- ...(result.report === undefined ? {} : { report: result.report })
927
- };
928
- }
929
- }
930
- return result;
931
- }
932
- /**
933
- * Execute the pseudo-layout-7 repair with Controller lifecycle management
934
- * (Issue 01). This mirrors the lifecycle of {@link execute} — fence, quiesce,
935
- * repair, post-verify, restart — but runs the dedicated staged state.json→SQLite
936
- * repair instead of the version-migration engine.
937
- */
938
- async function executePseudoLayout7Repair(options, classification, now) {
939
- const { home, latest } = options;
940
- const callerPid = options.callerPid ?? process.pid;
941
- let releaseFence;
942
- try {
943
- releaseFence = acquireUpgradeAdmission(options, "storage repair in progress", now, callerPid);
944
- }
945
- catch (error) {
946
- if (!(error instanceof UpgradeFenceError))
947
- throw error;
948
- return withClassification({
949
- outcome: "blocked",
950
- stage: "coordination",
951
- message: `Repair coordination could not be acquired: ${error.message}`,
952
- action: "Another upgrade is already coordinating this Home. Wait for it to finish, then retry."
953
- }, classification);
954
- }
955
- const externallyQuiesced = options.controllerLifecycle === "externally-quiesced";
956
- let controllerWasRunning = false;
957
- let controllerStopConfirmed = false;
958
- let controllerIdentity;
959
- let result;
960
- let unexpected;
961
- const switchState = { committed: false };
962
- try {
963
- if (!externallyQuiesced) {
964
- const controllerStatus = options.controllerStatus
965
- ?? ((h) => defaultControllerStatus(h, options.controllerOptions));
966
- let status;
967
- try {
968
- status = await controllerStatus(home);
969
- }
970
- catch (error) {
971
- result = withClassification(controllerLifecycleBlocker("Controller status could not be verified", error), classification);
972
- }
973
- if (result === undefined && !isControllerLifecycleStatus(status)) {
974
- result = withClassification(controllerLifecycleBlocker("Controller status was malformed", new Error("expected a boolean running field")), classification);
975
- }
976
- if (result === undefined && status.running && !isControllerLaunchIdentity(status.identity)) {
977
- result = withClassification(controllerLifecycleBlocker("Controller launch identity could not be authenticated", new Error("executable/argv/version identity is unavailable")), classification);
978
- }
979
- if (result === undefined && status.running && !isPositivePid(status.pid)) {
980
- result = withClassification(controllerLifecycleBlocker("Controller PID could not be authenticated", new Error("a positive status PID is unavailable for fenced stop")), classification);
981
- }
982
- if (result === undefined) {
983
- controllerWasRunning = status.running;
984
- controllerIdentity = status.running ? status.identity : undefined;
985
- controllerStopConfirmed = !controllerWasRunning;
986
- }
987
- const stopController = options.stopController
988
- ?? ((h, expectedPid) => defaultStopController(h, expectedPid, options.controllerOptions));
989
- if (result === undefined && controllerWasRunning) {
990
- try {
991
- const expectedPid = status.pid;
992
- const stopped = await stopController(home, expectedPid);
993
- if (!confirmedControllerStopped(stopped, expectedPid)) {
994
- result = withClassification(controllerLifecycleBlocker("Controller stop did not confirm a drained process", new Error(`stop did not confirm captured PID ${expectedPid} with stopped:true`)), classification);
995
- }
996
- else {
997
- controllerStopConfirmed = true;
998
- }
999
- }
1000
- catch (error) {
1001
- result = withClassification(controllerLifecycleBlocker("Controller stop/drain failed", error), classification);
1002
- }
1003
- }
1004
- }
1005
- else {
1006
- controllerStopConfirmed = true;
1007
- }
1008
- if (result === undefined) {
1009
- try {
1010
- repinRevision(home);
1011
- }
1012
- catch (error) {
1013
- result = withClassification({
1014
- outcome: "blocked",
1015
- stage: "active-runtime",
1016
- message: `The pre-fence writer window is undeterminable and could not be closed safely: ` +
1017
- `${messageOf(error)}.`,
1018
- action: "The Home was not repaired. Inspect the exact storage-lock owner and retry only " +
1019
- "after it is settled."
1020
- }, classification);
1021
- }
1022
- }
1023
- if (result === undefined) {
1024
- const inventory = await readOfflineInventory(options, home);
1025
- if (inventory.total > 0) {
1026
- result = withClassification(offlineInventoryBlocker(inventory, false), classification);
1027
- }
1028
- }
1029
- if (result === undefined) {
1030
- try {
1031
- result = executePseudoLayout7RepairFenced(options, classification, now, switchState);
1032
- }
1033
- catch (error) {
1034
- if (switchState.committed) {
1035
- result = withClassification(postSwitchAmbiguity(home, switchState.backupPath, error), classification);
1036
- }
1037
- else {
1038
- unexpected = error;
1039
- }
1040
- }
1041
- }
1042
- }
1043
- finally {
1044
- try {
1045
- releaseFence();
1046
- }
1047
- catch (error) {
1048
- if (switchState.committed && result === undefined) {
1049
- result = withClassification(postSwitchAmbiguity(home, switchState.backupPath, error), classification);
1050
- }
1051
- else if (unexpected === undefined) {
1052
- unexpected = error;
1053
- }
1054
- }
1055
- }
1056
- if (unexpected !== undefined) {
1057
- if (switchState.committed) {
1058
- return withClassification(postSwitchAmbiguity(home, switchState.backupPath, unexpected), classification);
1059
- }
1060
- if (controllerWasRunning && controllerStopConfirmed) {
1061
- await restoreController(home, options, unexpected, controllerIdentity);
1062
- }
1063
- throw unexpected;
1064
- }
1065
- if (result === undefined) {
1066
- throw new Error("Storage repair did not produce a result.");
1067
- }
1068
- if (result.outcome === "upgraded") {
1069
- const startController = options.startController
1070
- ?? ((h) => defaultStartController(h, options.controllerOptions));
1071
- if (!externallyQuiesced && controllerWasRunning) {
1072
- try {
1073
- await startController(home);
1074
- }
1075
- catch (error) {
1076
- return {
1077
- ...withClassification(postSwitchAmbiguity(home, result.backupPath, new Error(`The replacement Controller could not start after the committed repair: ${messageOf(error)}`)), classification),
1078
- report: result.report
1079
- };
1080
- }
1081
- }
1082
- try {
1083
- options.postSwitchFaultHook?.("receipt-clear");
1084
- clearUpgradeReceipt(home);
1085
- }
1086
- catch (error) {
1087
- return {
1088
- ...withClassification(postSwitchAmbiguity(home, result.backupPath, error), classification),
1089
- report: result.report
1090
- };
1091
- }
1092
- return result;
1093
- }
1094
- if (!externallyQuiesced
1095
- && controllerWasRunning
1096
- && controllerStopConfirmed
1097
- && result.outcome === "blocked"
1098
- && result.stage !== "post-verify"
1099
- && result.stage !== "switch-ambiguous") {
1100
- try {
1101
- await restoreController(home, options, new Error(result.message), controllerIdentity);
1102
- }
1103
- catch (error) {
1104
- return {
1105
- ...withClassification({
1106
- outcome: "blocked",
1107
- stage: "active-runtime",
1108
- message: `${result.message} The previously running Controller could not be restored: ` +
1109
- `${messageOf(error)}.`,
1110
- action: "Keep the old Home quiesced and resolve the Controller startup failure; " +
1111
- "do not resume writes until the Controller and Home are verified."
1112
- }, classification),
1113
- ...(result.report === undefined ? {} : { report: result.report })
1114
- };
1115
- }
1116
- }
1117
- return result;
1118
- }
1119
- /** Run the staged state.json→SQLite repair inside the upgrade fence. */
1120
- function executePseudoLayout7RepairFenced(options, classification, now, switchState) {
1121
- const { home, latest } = options;
1122
- const repairResult = repairPseudoLayout7({
1123
- home,
1124
- latest,
1125
- mode: "execute",
1126
- ...(options.now === undefined ? {} : { now: options.now })
1127
- });
1128
- if (repairResult.outcome === "blocked") {
1129
- return withClassification({
1130
- outcome: "blocked",
1131
- stage: repairResult.stage,
1132
- message: repairResult.message,
1133
- action: repairResult.action
1134
- }, classification);
1135
- }
1136
- if (repairResult.outcome !== "repaired") {
1137
- // Execute mode never returns the dry-run variant; fail closed if it does.
1138
- return withClassification({
1139
- outcome: "blocked",
1140
- stage: "validate",
1141
- message: `Unexpected repair outcome: ${repairResult.outcome}`,
1142
- action: "Retry the repair; the Home was not changed."
1143
- }, classification);
1144
- }
1145
- // The database was promoted (atomic rename committed). From this point the
1146
- // old Home is no longer authoritative; a fault is a post-switch ambiguity.
1147
- switchState.committed = true;
1148
- switchState.backupPath = repairResult.stateBackupPath;
1149
- // Post-switch health check: open the promoted database through a fresh
1150
- // SqliteTaskStore and verify the core families (the repair already did a
1151
- // read-back, but this is the orchestrator's independent verification).
1152
- const postVerify = postSwitchHealthCheck(home);
1153
- if (postVerify !== null) {
1154
- return withClassification(postSwitchAmbiguity(home, repairResult.stateBackupPath, new Error(postVerify.message)), classification);
1155
- }
1156
- // Multi-phase (Issue 01 cross-issue handoff): the repair promoted yui.db but
1157
- // the database may still carry older record/aggregate versions. Re-classify
1158
- // the Home and, when MIGRATABLE, run the record-family migration in the same
1159
- // fenced window so one upgrade attempt reaches the current version. The
1160
- // Controller is already stopped and the fence is held, so no writer can
1161
- // observe the intermediate state.
1162
- const postRepairClassification = classifyHome({
1163
- home,
1164
- registry: options.registry,
1165
- latest
1166
- });
1167
- if (postRepairClassification.classification.verdict === "MIGRATABLE") {
1168
- const callerPid = options.callerPid ?? process.pid;
1169
- const target = createSqliteRecordMigrationTarget({
1170
- home,
1171
- latest,
1172
- registry: options.registry,
1173
- now,
1174
- callerPid,
1175
- ...(options.renameImpl === undefined ? {} : { renameImpl: options.renameImpl })
1176
- });
1177
- const report = runMigration({
1178
- registry: options.registry,
1179
- target,
1180
- latest,
1181
- mode: "execute"
1182
- });
1183
- if (report.outcome === "failed") {
1184
- target.discardFreshOutput();
1185
- return {
1186
- ...blockedFromFailedReport(report, postRepairClassification),
1187
- report
1188
- };
1189
- }
1190
- if (report.outcome === "switch-ambiguous") {
1191
- return {
1192
- ...blockedFromSwitchAmbiguous(report, postRepairClassification),
1193
- report
1194
- };
1195
- }
1196
- if (report.outcome !== "migrated") {
1197
- target.discardFreshOutput();
1198
- return {
1199
- ...blockedFromEngineReport(report, postRepairClassification),
1200
- report
1201
- };
1202
- }
1203
- // The record migration committed its own atomic switch. Verify the fully
1204
- // migrated database before writing the completion receipt.
1205
- switchState.backupPath = report.switch.backupPath ?? repairResult.stateBackupPath;
1206
- const postRecordVerify = postSwitchHealthCheck(home);
1207
- if (postRecordVerify !== null) {
1208
- return withClassification(postSwitchAmbiguity(home, report.switch.backupPath, new Error(postRecordVerify.message)), postRepairClassification);
1209
- }
1210
- options.postSwitchFaultHook?.("receipt-write");
1211
- writeUpgradeReceipt(home, {
1212
- switched: true,
1213
- homePath: home,
1214
- completedAt: now().toISOString(),
1215
- targetLayoutVersion: latest.layout,
1216
- targetAggregateVersion: latest.aggregate,
1217
- ...(report.switch.backupPath === undefined
1218
- ? {}
1219
- : { backupPath: report.switch.backupPath })
1220
- });
1221
- return {
1222
- outcome: "upgraded",
1223
- classification: postRepairClassification,
1224
- ...(report.switch.backupPath === undefined
1225
- ? {}
1226
- : { backupPath: report.switch.backupPath }),
1227
- report
1228
- };
1229
- }
1230
- // Fail closed: the repair promoted a healthy yui.db, but the pure version
1231
- // classifier still fences this Home (a future/pre-baseline record axis, or
1232
- // structural damage). Reporting "upgraded" would hide a blocker the old
1233
- // pure-classifier route surfaced before the physical-backend check moved
1234
- // ahead of it; the Controller's strict schema gate would then fail at
1235
- // startup with a less precise diagnosis. The repaired database is healthy,
1236
- // so a newer build (or a restored backup) can still proceed from here.
1237
- const postRepairVerdict = postRepairClassification.classification.verdict;
1238
- if (postRepairVerdict === "NEEDS_NEW_VERSION"
1239
- || postRepairVerdict === "CORRUPTED") {
1240
- const detail = postRepairVerdict === "CORRUPTED"
1241
- ? postRepairClassification.classification.detail
1242
- : postRepairClassification.classification.blocker.message;
1243
- return withClassification({
1244
- outcome: "blocked",
1245
- stage: postRepairVerdict === "CORRUPTED" ? "post-verify" : "missing-step",
1246
- message: postRepairVerdict === "CORRUPTED"
1247
- ? `The repaired database is structurally damaged: ${detail}`
1248
- : `The repaired database still cannot be migrated by this build: ${detail}`,
1249
- action: postRepairVerdict === "CORRUPTED"
1250
- ? "Restore the Home from a backup; do not start a Controller against the repaired database."
1251
- : "Install a newer Yui release and re-run `yui upgrade`; the repaired database is healthy and ready for the record migration."
1252
- }, postRepairClassification);
1253
- }
1254
- // Write the temporary upgrade receipt (for the update flow's ambiguity
1255
- // window); it is cleared by the caller after the Controller restarts.
1256
- options.postSwitchFaultHook?.("receipt-write");
1257
- writeUpgradeReceipt(home, {
1258
- switched: true,
1259
- homePath: home,
1260
- completedAt: now().toISOString(),
1261
- targetLayoutVersion: latest.layout,
1262
- targetAggregateVersion: latest.aggregate,
1263
- backupPath: repairResult.stateBackupPath
1264
- });
1265
- const inspected = inspectSourceVersionState(home, latest);
1266
- const source = "corruption" in inspected ? latest : inspected.source;
1267
- return {
1268
- outcome: "upgraded",
1269
- classification,
1270
- backupPath: repairResult.stateBackupPath,
1271
- report: {
1272
- outcome: "migrated",
1273
- mode: "execute",
1274
- source,
1275
- target: latest,
1276
- steps: [],
1277
- effects: [],
1278
- derived: { rebuiltEffects: [] },
1279
- validation: {
1280
- checks: [
1281
- {
1282
- name: "SQLite staged-database checksum verification",
1283
- outcome: "passed",
1284
- detail: `verified ${repairResult.verifiedFamilies} record families against an independent state.json re-read`
1285
- }
1286
- ]
1287
- },
1288
- switch: {
1289
- status: "switched",
1290
- backupPath: repairResult.stateBackupPath,
1291
- detail: `SQLite database promoted and state.json backed up to ${repairResult.stateBackupPath}.`
1292
- },
1293
- completedAt: now().toISOString()
1294
- }
54
+ function classifyCurrentHome(schema, latest) {
55
+ const base = {
56
+ latestLayoutVersion: latest.layout,
57
+ latestAggregateVersion: latest.aggregate
1295
58
  };
1296
- }
1297
- /** Execute the fenced, coordinated migration after Controller quiesce. */
1298
- function executeFenced(options, classification, target, callerPid, now, switchState) {
1299
- const { home, registry, latest } = options;
1300
- try {
1301
- // The read-only drain proof, revision pin, complete-home copy, and both
1302
- // switch renames share one sibling coordination lock with inbox publish.
1303
- return withUpgradeCoordinationLock(home, () => {
1304
- const quiesce = verifyQuiesced(home, callerPid);
1305
- if (quiesce !== null)
1306
- return withClassification(quiesce, classification);
1307
- // Re-pin the final revision under the write lock, after drain, so the
1308
- // snapshot reflects the last committed state (no check-then-migrate race).
1309
- repinRevision(home);
1310
- // Snapshot -> validate gate -> atomic switch + timestamped backup.
1311
- target.discardFreshOutput();
1312
- const report = runMigration({ registry, target, latest, mode: "execute" });
1313
- if (report.outcome === "failed") {
1314
- target.discardFreshOutput();
1315
- return { ...blockedFromFailedReport(report, classification), report };
1316
- }
1317
- if (report.outcome === "switch-ambiguous") {
1318
- // The switch was left partially applied. Do not write a completion
1319
- // receipt; the interrupted switch-progress marker is the durable signal.
1320
- return { ...blockedFromSwitchAmbiguous(report, classification), report };
1321
- }
1322
- if (report.outcome !== "migrated") {
1323
- target.discardFreshOutput();
1324
- return { ...blockedFromEngineReport(report, classification), report };
1325
- }
1326
- // From this line onward the old Home has moved to its backup and the new
1327
- // Home is authoritative. Set the guard before any receipt/health work so
1328
- // even an injected post-switch failure cannot restore the old Controller.
1329
- switchState.committed = true;
1330
- switchState.backupPath = report.switch.backupPath;
1331
- // The atomic switch has COMMITTED. Keep a durable receipt until the
1332
- // post-switch loader and (when applicable) replacement Controller both
1333
- // succeed; this closes the activation ambiguity window.
1334
- options.postSwitchFaultHook?.("receipt-write");
1335
- writeUpgradeReceipt(home, {
1336
- switched: true,
1337
- homePath: home,
1338
- completedAt: now().toISOString(),
1339
- targetLayoutVersion: latest.layout,
1340
- targetAggregateVersion: latest.aggregate,
1341
- ...(report.switch.backupPath === undefined
1342
- ? {}
1343
- : { backupPath: report.switch.backupPath })
1344
- });
1345
- options.postSwitchFaultHook?.("post-verify");
1346
- const postVerify = postSwitchHealthCheck(home);
1347
- if (postVerify !== null) {
1348
- return {
1349
- ...withClassification(postSwitchAmbiguity(home, report.switch.backupPath, new Error(postVerify.message)), classification),
1350
- report
1351
- };
1352
- }
1353
- return {
1354
- outcome: "upgraded",
1355
- classification,
1356
- report,
1357
- ...(report.switch.backupPath === undefined
1358
- ? {}
1359
- : { backupPath: report.switch.backupPath })
1360
- };
1361
- }, options.externalUpgradeFenceOwnerPid ?? callerPid);
1362
- }
1363
- catch (error) {
1364
- if (!(error instanceof UpgradeFenceError))
1365
- throw error;
1366
- return withClassification({
1367
- outcome: "blocked",
1368
- stage: "coordination",
1369
- message: `Upgrade coordination could not be acquired: ${error.message}`,
1370
- action: "Wait for the other writer or switch recovery to finish, then retry; "
1371
- + "the authoritative Home was not switched by this attempt."
1372
- }, classification);
1373
- }
1374
- }
1375
- /**
1376
- * Verify the Home is quiesced after the Controller drain. Returns `null` when
1377
- * clear, or a fail-closed blocker when any live runtime or unfinished lifecycle
1378
- * obligation remains. Never kills anything.
1379
- *
1380
- * The source may still be at an older schema here, so it reads runtime signals
1381
- * and the raw `state.json` mailboxes directly rather than through the
1382
- * version-gated store.
1383
- */
1384
- function verifyQuiesced(home, callerPid) {
1385
- const signals = inspectHomeRuntime(home, callerPid);
1386
- if (homeRuntimeIsActive(signals)) {
59
+ if (schema.status === "uninitialized") {
1387
60
  return {
1388
- outcome: "blocked",
1389
- stage: "active-runtime",
1390
- message: `Cannot upgrade: ${describeActiveRuntime(signals)}`,
1391
- action: "Stop all Yui activity for this Home (and clear any stale .state.lock / "
1392
- + "runtime/controller.json only after confirming no process owns it), then retry."
61
+ ...base,
62
+ classification: { verdict: "USABLE", status: "current" },
63
+ uninitialized: true
1393
64
  };
1394
65
  }
1395
- // Unfinished runtime lifecycle obligations block. This is TWO independent
1396
- // durable lanes (per task-1 / message-8 §3, either non-empty fails closed):
1397
- // 1. the aggregate `state.json` mailboxes (runtime lifecycle lanes), and
1398
- // 2. the DURABLE runtime inbox `runtime/inbox/*` authoritative, not-yet-
1399
- // applied native-hook events. A healthy Controller drains the inbox, but
1400
- // the no-Controller / stale-event path (fully supported) reaches here with
1401
- // inbox entries still on disk, and those must not be silently discarded by
1402
- // an atomic switch. We prove the inbox empty READ-ONLY (never acknowledging
1403
- // or quarantining as part of the check — that would mutate the source).
1404
- const pendingRuntime = countPendingRuntimeMailboxes(home);
1405
- const pendingInbox = countPendingDurableInbox(home);
1406
- if (pendingRuntime > 0 || pendingInbox > 0) {
1407
- const parts = [];
1408
- if (pendingRuntime > 0)
1409
- parts.push(`${pendingRuntime} pending mailbox(es)`);
1410
- if (pendingInbox > 0)
1411
- parts.push(`${pendingInbox} pending durable inbox entr(ies)`);
1412
- return {
1413
- outcome: "blocked",
1414
- stage: "drain-incomplete",
1415
- message: `Runtime lifecycle work is not drained (${parts.join("; ")}).`,
1416
- action: "Let the Controller finish draining the runtime inbox and mailboxes (or start it so it "
1417
- + "can), then retry the upgrade. The authoritative Home is unchanged."
1418
- };
1419
- }
1420
- return null;
1421
- }
1422
- /**
1423
- * Count runtime lifecycle mailboxes with pending/processing work, read directly
1424
- * from the raw `state.json` so it works on a not-yet-migrated source. A mailbox
1425
- * is a runtime lifecycle lane when its target kind is `role-runtime` or
1426
- * `global-role-runtime`; it has work when `pending` or `processing` is set.
1427
- */
1428
- function countPendingRuntimeMailboxes(home) {
1429
- let raw;
1430
- try {
1431
- raw = readFileSync(join(home, STORAGE_STATE_FILE), "utf8");
1432
- }
1433
- catch {
1434
- return 0;
1435
- }
1436
- let mailboxes;
1437
- try {
1438
- const state = JSON.parse(raw);
1439
- if (typeof state.mailboxes !== "object" || state.mailboxes === null)
1440
- return 0;
1441
- mailboxes = state.mailboxes;
1442
- }
1443
- catch {
1444
- return 0;
1445
- }
1446
- let count = 0;
1447
- for (const value of Object.values(mailboxes)) {
1448
- if (typeof value !== "object" || value === null)
1449
- continue;
1450
- const mailbox = value;
1451
- const kind = mailbox.target?.kind;
1452
- const isRuntimeLane = kind === "role-runtime" || kind === "global-role-runtime";
1453
- const hasWork = mailbox.pending !== null || mailbox.processing !== null;
1454
- if (isRuntimeLane && hasWork)
1455
- count += 1;
1456
- }
1457
- return count;
1458
- }
1459
- /**
1460
- * Count pending entries in the DURABLE runtime inbox, READ-ONLY (R3-F4). This
1461
- * proves the inbox is drained before an atomic switch replaces the whole Home,
1462
- * so authoritative not-yet-applied native-hook events are never silently lost.
1463
- *
1464
- * It deliberately does NOT go through `FileRuntimeEventInbox.list()`, which
1465
- * quarantines malformed entries as a side effect — the quiesce check must not
1466
- * mutate the source. Instead it counts, purely by directory listing:
1467
- * - any committed event file (`*.json`) in `runtime/inbox`,
1468
- * - any in-progress temporary write (`.<id>.tmp-*`) in `runtime/inbox`, and
1469
- * - any quarantined-but-unresolved entry under `runtime/inbox-invalid`.
1470
- * Any of these being non-zero means lifecycle work is not fully drained. A
1471
- * missing inbox directory (or an unreadable one) counts as zero pending here for
1472
- * the inbox itself, but an unreadable directory is surfaced as a conservative
1473
- * single pending entry so an undeterminable inbox fails closed rather than open.
1474
- */
1475
- function countPendingDurableInbox(home) {
1476
- const inboxDir = join(home, "runtime", "inbox");
1477
- const invalidDir = join(home, "runtime", "inbox-invalid");
1478
- let count = 0;
1479
- count += countInboxDirectoryEntries(inboxDir, /* countTemporary */ true);
1480
- count += countInboxDirectoryEntries(invalidDir, /* countTemporary */ true);
1481
- return count;
1482
- }
1483
- /**
1484
- * Count durable entries in one inbox directory. A `.json` file or (when
1485
- * `countTemporary`) a `.tmp-` in-progress write is pending. Returns 0 when the
1486
- * directory is provably absent; returns 1 (fail-closed) when it exists but cannot
1487
- * be listed, so an undeterminable inbox never reads as "empty".
1488
- */
1489
- function countInboxDirectoryEntries(directory, countTemporary) {
1490
- let entries;
1491
- try {
1492
- entries = readdirSync(directory);
1493
- }
1494
- catch (error) {
1495
- if (error instanceof Error && "code" in error && error.code === "ENOENT")
1496
- return 0;
1497
- return 1; // present but unreadable: fail closed.
1498
- }
1499
- let count = 0;
1500
- for (const name of entries) {
1501
- if (name.endsWith(".json"))
1502
- count += 1;
1503
- else if (countTemporary && name.includes(".tmp-"))
1504
- count += 1;
1505
- else if (!name.startsWith("."))
1506
- count += 1; // any other real entry (e.g. quarantined copies).
1507
- }
1508
- return count;
1509
- }
1510
- /**
1511
- * Re-read and pin the committed revision under the write lock, after drain. It
1512
- * takes the same lock the store uses (via {@link withStorageWriteLock}, which is
1513
- * not version-gated) and reads the committed revision without mutating, so it
1514
- * serializes the snapshot against the last committed write without a store.
1515
- */
1516
- function repinRevision(home) {
1517
- return withStorageWriteLock(home, () => readCommittedRevision(home));
1518
- }
1519
- function readCommittedRevision(home) {
1520
- try {
1521
- const raw = readFileSync(join(home, STORAGE_STATE_FILE), "utf8");
1522
- const value = JSON.parse(raw);
1523
- return Number.isInteger(value.revision) ? value.revision : 0;
1524
- }
1525
- catch {
1526
- return 0;
1527
- }
1528
- }
1529
- /** Post-switch health check: a fresh loader must parse the promoted Home. */
1530
- function postSwitchHealthCheck(home) {
1531
- try {
1532
- // A layout-7 Home that went through the SQLite staged migration has
1533
- // yui.db; verify it through the SQLite store. Otherwise fall back to the
1534
- // file-document store (record-only migrations on an existing layout-7 Home).
1535
- if (existsSync(join(home, "yui.db"))) {
1536
- const store = new SqliteTaskStore(home);
1537
- try {
1538
- store.getConfig();
1539
- store.listTasks();
1540
- store.listProjects();
1541
- store.listConfiguredAgents();
1542
- store.listWorkMailboxes();
1543
- }
1544
- finally {
1545
- store.close();
1546
- }
1547
- }
1548
- else {
1549
- const store = new FileTaskStore(home);
1550
- store.getConfig();
1551
- store.listTasks();
1552
- store.listProjects();
1553
- store.listConfiguredAgents();
1554
- store.listWorkMailboxes();
1555
- }
1556
- return null;
1557
- }
1558
- catch (error) {
66
+ if (schema.status === "invalid") {
67
+ return corruptedClassification({
68
+ ...base,
69
+ classification: { verdict: "CORRUPTED", status: "unsupported", detail: schema.detail }
70
+ }, schema.detail);
71
+ }
72
+ if (schema.status === "unsupported") {
73
+ const blocker = schema.direction === "newer"
74
+ ? {
75
+ reason: "future-version",
76
+ axis: schema.incompatibleComponent,
77
+ ...(schema.recordFamily === undefined ? {} : { recordKind: schema.recordFamily }),
78
+ found: schema.currentVersion,
79
+ supported: schema.latestVersion,
80
+ message: "Historical storage contracts are not supported by this release.",
81
+ action: "Initialize a new Home."
82
+ }
83
+ : {
84
+ reason: "missing-step",
85
+ axis: schema.incompatibleComponent,
86
+ ...(schema.recordFamily === undefined ? {} : { recordKind: schema.recordFamily }),
87
+ from: schema.currentVersion,
88
+ to: schema.latestVersion,
89
+ message: "Historical storage contracts are not supported by this release.",
90
+ action: "Initialize a new Home."
91
+ };
1559
92
  return {
1560
- outcome: "blocked",
1561
- stage: "post-verify",
1562
- message: `Post-switch health check failed: ${error instanceof Error ? error.message : String(error)}`,
1563
- action: "The migrated Home did not load. Restore the timestamped backup to recover the "
1564
- + "original Home; do not resume writes until the backup is restored."
93
+ ...base,
94
+ classification: {
95
+ verdict: "NEEDS_NEW_VERSION",
96
+ status: "unsupported",
97
+ blocker
98
+ },
99
+ layoutVersion: schema.currentLayoutVersion,
100
+ aggregateVersion: schema.currentAggregateSchemaVersion,
101
+ incompatibleComponent: schema.incompatibleComponent
1565
102
  };
1566
103
  }
1567
- }
1568
- function blockedFromFailedReport(report, classification) {
1569
- const stage = report.stage === "switch" ? "switch" : "validate";
1570
- return withClassification({
1571
- outcome: "blocked",
1572
- stage,
1573
- message: `Migration failed at ${report.stage}: ${report.error}`,
1574
- action: "The source Home is unchanged. Delete any staged output and retry; if it recurs, "
1575
- + "restore from backup and report the failure."
1576
- }, classification);
1577
- }
1578
- /**
1579
- * Build a blocker for a partially-applied, ambiguous switch: the original was
1580
- * moved to the backup but neither the promotion nor its rollback completed. This
1581
- * is reported at the `switch` stage with the exact backup-restore command and an
1582
- * explicit statement that the Home is NOT unchanged (P1-4).
1583
- */
1584
- function blockedFromSwitchAmbiguous(report, classification) {
1585
- return withClassification({
1586
- outcome: "blocked",
1587
- stage: "switch-ambiguous",
1588
- message: `Storage switch is AMBIGUOUS and partially applied: ${report.error}`,
1589
- action: `Do NOT assume the Home is unchanged. The original Home is at ${report.backupPath}; `
1590
- + `restore it to recover: ${switchAmbiguousRestoreCommand(report)}. The interrupted `
1591
- + `switch marker "${switchProgressPath(report.homePath)}" records this ambiguity; verify `
1592
- + `with "yui doctor" after restoring.`
1593
- }, classification);
1594
- }
1595
- /**
1596
- * The exact restore command for an ambiguous switch. A file-layout backup is a
1597
- * sibling directory moved back over the Home; a SQLite backup is the committed
1598
- * `yui.db` file moved aside *inside* the Home and must be renamed back onto the
1599
- * database path, not onto the Home directory (which would nest the file).
1600
- */
1601
- function switchAmbiguousRestoreCommand(report) {
1602
- const committedDbPath = join(report.homePath, COMMITTED_DATABASE_FILENAME);
1603
- // The degenerate SQLite case: no prior database existed, so there is no
1604
- // backup to move back; the promoted database is already in place and the
1605
- // recovery is to advance schema.json (or restore externally).
1606
- if (report.backupPath === committedDbPath) {
1607
- return `the database is already at ${committedDbPath}; advance schema.json `
1608
- + "record-family versions or restore from an external backup";
1609
- }
1610
- const restoreTarget = dirname(report.backupPath) === report.homePath
1611
- ? committedDbPath
1612
- : report.homePath;
1613
- return `mv "${report.backupPath}" "${restoreTarget}"`;
1614
- }
1615
- /**
1616
- * A structured blocker for failures after the atomic Home switch. The receipt
1617
- * and progress marker are named explicitly so an operator can reconcile the
1618
- * committed Home; the generic old-Controller restore path must never run here.
1619
- */
1620
- function postSwitchAmbiguity(home, backupPath, error) {
1621
- const receiptPath = upgradeReceiptPath(home);
1622
- const progressPath = switchProgressPath(home);
1623
- const backup = backupPath === undefined ? "the timestamped Home backup" : `backup ${backupPath}`;
1624
104
  return {
1625
- outcome: "blocked",
1626
- stage: "post-verify",
1627
- switchCommitted: true,
1628
- ...(backupPath === undefined ? {} : { backupPath }),
1629
- recoveryEvidence: {
1630
- ...(backupPath === undefined ? {} : { backupPath }),
1631
- receiptPath,
1632
- progressPath
1633
- },
1634
- message: `The storage switch committed, but post-switch completion could not be confirmed: `
1635
- + `${messageOf(error)}. The old Controller was not restored.`,
1636
- action: `Do not start the old Controller against the migrated Home. Inspect ${backup}, receipt `
1637
- + `"${receiptPath}", and switch-progress marker "${progressPath}"; verify the Home, then `
1638
- + (backupPath === undefined
1639
- ? "complete or explicitly recover the switch before resuming writes."
1640
- : `restore the backup explicitly with mv "${backupPath}" "${home}" if verification fails.`)
105
+ ...base,
106
+ classification: { verdict: "USABLE", status: "current" },
107
+ layoutVersion: schema.currentLayoutVersion,
108
+ aggregateVersion: schema.currentAggregateSchemaVersion
1641
109
  };
1642
110
  }
1643
- function blockedFromEngineReport(report, classification) {
1644
- return withClassification({
1645
- outcome: "blocked",
1646
- stage: report.outcome === "active-runtime" ? "active-runtime" : "missing-step",
1647
- message: describeReport(report),
1648
- action: "Resolve the reported condition and retry the upgrade."
1649
- }, classification);
1650
- }
1651
- function withClassification(blocker, classification) {
1652
- return { ...blocker, classification };
1653
- }
1654
- async function defaultStopController(home, expectedPid, controllerOptions) {
1655
- return stopFileTaskController(home, {
1656
- ...(controllerOptions ?? {}),
1657
- expectedPid
1658
- });
1659
- }
1660
- async function defaultStartController(home, controllerOptions) {
1661
- return ensureFileTaskController(home, controllerOptions ?? {});
1662
- }
1663
- async function defaultControllerStatus(home, controllerOptions) {
1664
- const call = controllerOptions?.call ?? callController;
1665
- let raw;
1666
- try {
1667
- raw = await call(home, "controller.status", {});
1668
- }
1669
- catch (error) {
1670
- // ENOENT/CONTROLLER_NOT_RUNNING is the only definitive stopped fact. A
1671
- // transport timeout or invalid discovery is unknown-active and must block;
1672
- // never infer a stopped Controller from a stale or malformed artifact.
1673
- if (controllerErrorCode(error) === "CONTROLLER_NOT_RUNNING") {
1674
- return { running: false };
1675
- }
1676
- if (controllerOptions?.call === undefined
1677
- && controllerErrorCode(error) === "CONTROLLER_DISCOVERY_INVALID") {
1678
- return controllerStatusFromRuntime(home, error);
1679
- }
1680
- throw error;
1681
- }
1682
- if (!isRecord(raw) || typeof raw.running !== "boolean") {
1683
- throw new Error("Controller status response is invalid.");
1684
- }
1685
- const identity = raw.running
1686
- ? await authenticatedControllerIdentity(home, call, raw)
1687
- : undefined;
111
+ function corruptedClassification(classification, detail) {
1688
112
  return {
1689
- running: raw.running,
1690
- ...(identity === undefined ? {} : { identity }),
1691
- ...(isPositivePid(raw.pid) ? { pid: raw.pid } : {})
113
+ ...classification,
114
+ classification: { verdict: "CORRUPTED", status: "unsupported", detail }
1692
115
  };
1693
116
  }
1694
- function controllerStatusFromRuntime(home, cause) {
1695
- const runtime = inspectHomeRuntime(home);
1696
- // A malformed/stale artifact is accepted as stopped only when the
1697
- // layout-agnostic runtime probe proves its named process is dead. Unknown or
1698
- // live discovery remains unknown-active; it never receives an inferred
1699
- // executable/argv/version identity.
1700
- if (runtime.liveController === null)
1701
- return { running: false };
1702
- throw new Error(`Controller runtime discovery could not be resolved: ${messageOf(cause)}`, { cause });
1703
- }
1704
- function confirmedControllerStopped(value, expectedPid) {
1705
- // Existing test seams historically returned void after a successful drain;
1706
- // retain that narrow compatibility while requiring explicit confirmation for
1707
- // any structured result.
1708
- return value === undefined || (isRecord(value)
1709
- && value.stopped === true
1710
- && value.pid === expectedPid);
1711
- }
1712
- function isControllerLifecycleStatus(value) {
1713
- return isRecord(value) && typeof value.running === "boolean";
1714
- }
1715
- function controllerLifecycleBlocker(prefix, error) {
117
+ function blocked(classification, stage, message, action) {
1716
118
  return {
1717
119
  outcome: "blocked",
1718
- stage: "active-runtime",
1719
- message: `${prefix}: ${messageOf(error)}.`,
1720
- action: "The Controller may still be active or draining. Do not retry stop blindly; "
1721
- + "inspect the Controller and runtime, then retry once the Home is quiesced."
120
+ stage,
121
+ message,
122
+ action,
123
+ classification,
124
+ sceneUnchanged: true
1722
125
  };
1723
126
  }
1724
- async function restoreController(home, options, cause, identity) {
1725
- if (identity === undefined) {
1726
- throw new Error("The previously running Controller identity was not captured; refusing to restore with a new executable.", { cause });
1727
- }
1728
- const restore = options.restoreController
1729
- ?? ((h, i) => defaultRestoreController(h, i, options.controllerOptions));
1730
- try {
1731
- await restore(home, identity);
1732
- }
1733
- catch (error) {
1734
- throw new Error(`Storage upgrade failed and the previously running Controller could not be restored: `
1735
- + `${messageOf(error)}`, { cause });
1736
- }
1737
- }
1738
- async function authenticatedControllerIdentity(home, call, status) {
1739
- const inline = status.identity;
1740
- if (isControllerLaunchIdentity(inline))
1741
- return inline;
1742
- const raw = await call(home, "controller.identity", {});
1743
- if (!isControllerLaunchIdentity(raw)) {
1744
- throw new Error("Authenticated Controller identity is unavailable; refusing to stop an un-restorable process.");
1745
- }
1746
- if (typeof status.version === "string" && raw.version !== status.version) {
1747
- throw new Error(`Controller identity version ${raw.version} does not match status version ${status.version}.`);
1748
- }
1749
- return raw;
1750
- }
1751
- /** Start the exact captured process command and await an authenticated readiness proof. */
1752
- async function defaultRestoreController(home, identity, controllerOptions) {
1753
- await ensureFileTaskControllerIdentity(home, identity, {
1754
- ...(controllerOptions ?? {}),
1755
- spawnController: (_restoredHome, environment) => {
1756
- const child = spawn(identity.executablePath, [...identity.args], {
1757
- env: environment,
1758
- detached: true,
1759
- stdio: "ignore"
1760
- });
1761
- child.unref();
1762
- }
1763
- });
1764
- }
1765
- function controllerErrorCode(error) {
1766
- return isRecord(error) && typeof error.code === "string" ? error.code : undefined;
1767
- }
1768
- function isPositivePid(value) {
1769
- return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
1770
- }
1771
- function isControllerLaunchIdentity(value) {
1772
- return isRecord(value)
1773
- && typeof value.executablePath === "string"
1774
- && value.executablePath.length > 0
1775
- && Array.isArray(value.args)
1776
- && value.args.every((arg) => typeof arg === "string")
1777
- && typeof value.version === "string"
1778
- && value.version.length > 0;
1779
- }
1780
- function isRecord(value) {
1781
- return typeof value === "object" && value !== null && !Array.isArray(value);
1782
- }
1783
- function messageOf(error) {
1784
- return error instanceof Error ? error.message : String(error);
1785
- }
1786
- /** Read the current fence for a Home (re-exported for command wiring). */
1787
- export { readUpgradeFence, clearUpgradeFence };
1788
- /** Read/locate the completion receipt (re-exported for update orchestration). */
1789
- export { readUpgradeReceipt, correlateUpgradeReceipt, upgradeReceiptPath } from "./upgradeReceipt.js";