@zq-silk/yui 0.2.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/ARCHITECTURE.md +603 -133
  2. package/README.md +806 -31
  3. package/dist/agent/agent.js +2 -1
  4. package/dist/agent/argumentPolicy.js +3 -1
  5. package/dist/agent/launchEnvironment.js +106 -0
  6. package/dist/agent/managedRuntimeEnvironment.js +34 -0
  7. package/dist/brief/taskBrief.js +11 -1
  8. package/dist/cli/agentConfigurationPicker.js +287 -0
  9. package/dist/cli/commandCatalog.js +488 -60
  10. package/dist/cli/completion.js +146 -22
  11. package/dist/cli/helpRenderer.js +3 -1
  12. package/dist/cli/interactionCandidates.js +53 -15
  13. package/dist/cli/interactionPolicy.js +267 -30
  14. package/dist/cli/interactiveSelection.js +6 -2
  15. package/dist/cli/invocationRouter.js +5 -1
  16. package/dist/cli/operatorWizard.js +87 -0
  17. package/dist/cli/roleOptionCatalog.js +1 -0
  18. package/dist/cli/roleWizard.js +185 -21
  19. package/dist/cli/updateCommand.js +62 -19
  20. package/dist/cli/updateOrchestrator.js +539 -0
  21. package/dist/cli/updatePorts.js +1119 -0
  22. package/dist/cli/upgradeCommand.js +112 -0
  23. package/dist/cli.js +1420 -86
  24. package/dist/commands/agentCommands.js +146 -3
  25. package/dist/commands/configCommands.js +126 -0
  26. package/dist/commands/controllerCommands.js +365 -0
  27. package/dist/commands/globalRoleCommands.js +168 -126
  28. package/dist/commands/jobCommands.js +18 -8
  29. package/dist/commands/operatorCommands.js +159 -9
  30. package/dist/commands/profileCommands.js +203 -0
  31. package/dist/commands/projectCommands.js +650 -0
  32. package/dist/commands/roleConfiguration.js +85 -24
  33. package/dist/commands/roleRuntimeGuard.js +12 -0
  34. package/dist/commands/roleSkillValidation.js +47 -0
  35. package/dist/commands/taskActor.js +127 -0
  36. package/dist/commands/taskCommands.js +4201 -313
  37. package/dist/commands/taskCompletionGate.js +131 -0
  38. package/dist/commands/taskContextCommand.js +244 -30
  39. package/dist/commands/taskInputCommands.js +177 -59
  40. package/dist/commands/taskIntegrationCommands.js +303 -0
  41. package/dist/commands/taskOverviewCommand.js +363 -0
  42. package/dist/commands/taskRoleRuntimeStatus.js +125 -19
  43. package/dist/commands/textInput.js +15 -0
  44. package/dist/completion/completionInstaller.js +26 -22
  45. package/dist/config/yuiConfig.js +4 -3
  46. package/dist/context/dispatchContext.js +90 -38
  47. package/dist/context/roleSessionContext.js +119 -0
  48. package/dist/controller/claudeLifecycleHook.js +203 -0
  49. package/dist/controller/clientRuntime.js +408 -56
  50. package/dist/controller/codexLifecycleHook.js +108 -0
  51. package/dist/controller/controller.js +1089 -32
  52. package/dist/controller/domainIdentity.js +505 -0
  53. package/dist/controller/ephemeralResourceReaper.js +131 -0
  54. package/dist/controller/fileSchedulerStoreAdapter.js +2153 -103
  55. package/dist/controller/providerHookRunFence.js +127 -0
  56. package/dist/controller/resourceCleanupLinux.js +286 -0
  57. package/dist/controller/resourceInventory.js +531 -0
  58. package/dist/controller/resourceInventoryLinux.js +610 -0
  59. package/dist/controller/runtime.js +629 -10
  60. package/dist/controller/runtimeEventInbox.js +564 -0
  61. package/dist/controller/runtimeEventProcessor.js +248 -0
  62. package/dist/controller/runtimeLaunchCoordinator.js +477 -0
  63. package/dist/controller/sessionNotify.js +121 -78
  64. package/dist/coordination/deadlineScheduler.js +15 -0
  65. package/dist/coordination/mailboxScheduler.js +108 -0
  66. package/dist/coordination/workMailbox.js +329 -0
  67. package/dist/coordination/workMailboxQueue.js +86 -0
  68. package/dist/core/controllerClient.js +19 -5
  69. package/dist/core/controllerEndpoint.js +37 -0
  70. package/dist/core/controllerServer.js +218 -10
  71. package/dist/core/protocol.js +6 -2
  72. package/dist/decision/decision.js +2 -1
  73. package/dist/doctor/doctor.js +681 -32
  74. package/dist/domain/validation.js +53 -0
  75. package/dist/errors/cliError.js +5 -3
  76. package/dist/event/taskEvent.js +7 -3
  77. package/dist/execution/codexThreadNaming.js +160 -0
  78. package/dist/execution/executionGroup.js +579 -0
  79. package/dist/executor/agentAdapter.js +255 -40
  80. package/dist/executor/agentConfigurationCatalog.js +326 -0
  81. package/dist/executor/agentConfigurationProbe.js +506 -0
  82. package/dist/executor/agentExecutor.js +625 -10
  83. package/dist/executor/codexConfigConflict.js +290 -0
  84. package/dist/executor/effectiveLaunch.js +340 -0
  85. package/dist/executor/executorRegistry.js +238 -36
  86. package/dist/executor/fileRoleLaunchPlanner.js +550 -40
  87. package/dist/executor/turnCompletion.js +126 -0
  88. package/dist/input/inputRequest.js +30 -9
  89. package/dist/integration/changeSet.js +36 -0
  90. package/dist/integration/checkResult.js +24 -0
  91. package/dist/integration/gitIntegrationService.js +695 -0
  92. package/dist/integration/integrationAttempt.js +142 -0
  93. package/dist/interaction/operatorPresentation.js +96 -0
  94. package/dist/lifecycle/canonicalLifecycleEvent.js +342 -0
  95. package/dist/lifecycle/exactRunTerminalization.js +572 -0
  96. package/dist/lifecycle/providerLifecycleMapping.js +190 -0
  97. package/dist/lifecycle/taskRoleSessionReset.js +124 -0
  98. package/dist/message/message.js +23 -7
  99. package/dist/milestone/milestone.js +2 -1
  100. package/dist/operator/operatorSessionHistory.js +124 -0
  101. package/dist/output/agentConfigurationPresentation.js +43 -0
  102. package/dist/output/rolePresentation.js +34 -10
  103. package/dist/output/terminal.js +8 -0
  104. package/dist/output/timePresentation.js +55 -0
  105. package/dist/profile/agentProfile.js +128 -0
  106. package/dist/repository/gitWorkspace.js +578 -24
  107. package/dist/repository/project.js +213 -0
  108. package/dist/repository/taskWorkspaceCoordinator.js +392 -0
  109. package/dist/repository/taskWorkspacePreparer.js +1688 -191
  110. package/dist/review/reviewConfig.js +11 -0
  111. package/dist/review/reviewRound.js +399 -0
  112. package/dist/review/taskFinalReviewContract.js +90 -0
  113. package/dist/role/role.js +124 -23
  114. package/dist/run/agentRun.js +155 -12
  115. package/dist/run/runIdentity.js +82 -0
  116. package/dist/runtime/exactControlPlane.js +472 -0
  117. package/dist/runtime/index.js +8 -0
  118. package/dist/runtime/lifecycleReservation.js +38 -0
  119. package/dist/runtime/ports.js +11 -0
  120. package/dist/runtime/preallocatedNativeSession.js +13 -0
  121. package/dist/runtime/promptEnvelope.js +30 -0
  122. package/dist/runtime/runtimeBinding.js +31 -0
  123. package/dist/runtime/runtimeOwner.js +14 -0
  124. package/dist/runtime/sessionLaunchRequest.js +62 -0
  125. package/dist/runtime/sessionTitle.js +54 -0
  126. package/dist/runtime/taskRuntimeIsolation.js +643 -0
  127. package/dist/runtime/tmuxAdapters.js +315 -0
  128. package/dist/runtime/turnCompletion.js +3 -0
  129. package/dist/runtime/validation.js +23 -0
  130. package/dist/scheduler/activeRoleRunDelivery.js +342 -32
  131. package/dist/scheduler/activeTaskProgress.js +63 -0
  132. package/dist/scheduler/leaderFailure.js +2 -1
  133. package/dist/scheduler/leaderWakeupProcessor.js +307 -66
  134. package/dist/scheduler/operatorInputNotificationProcessor.js +109 -46
  135. package/dist/scheduler/operatorNotification.js +44 -2
  136. package/dist/scheduler/ports.js +28 -1
  137. package/dist/scheduler/roleRunLiveness.js +131 -25
  138. package/dist/scheduler/roleRunStall.js +951 -0
  139. package/dist/scheduler/taskExecutionProjection.js +544 -0
  140. package/dist/scheduler/wakeupQueue.js +3 -0
  141. package/dist/setup/setupCommand.js +302 -52
  142. package/dist/storage/compatibleTaskStore.js +102 -0
  143. package/dist/storage/migration/baseline.js +78 -0
  144. package/dist/storage/migration/classifier.js +51 -0
  145. package/dist/storage/migration/compatibleCodec.js +53 -0
  146. package/dist/storage/migration/engine.js +147 -0
  147. package/dist/storage/migration/index.js +33 -0
  148. package/dist/storage/migration/planner.js +154 -0
  149. package/dist/storage/migration/productionRegistry.js +486 -0
  150. package/dist/storage/migration/registry.js +169 -0
  151. package/dist/storage/migration/report.js +54 -0
  152. package/dist/storage/migration/types.js +31 -0
  153. package/dist/storage/storageSchema.js +147 -123
  154. package/dist/storage/storageVersions.js +11 -0
  155. package/dist/storage/taskStore.js +1793 -197
  156. package/dist/storage/upgrade/homeClassification.js +156 -0
  157. package/dist/storage/upgrade/homeMigrationTarget.js +595 -0
  158. package/dist/storage/upgrade/offlineUpgradeInventory.js +315 -0
  159. package/dist/storage/upgrade/productionMigrationRegistry.js +6 -0
  160. package/dist/storage/upgrade/recordVersionScan.js +176 -0
  161. package/dist/storage/upgrade/recordVersions.js +159 -0
  162. package/dist/storage/upgrade/switchProgress.js +80 -0
  163. package/dist/storage/upgrade/upgradeOrchestrator.js +948 -0
  164. package/dist/storage/upgrade/upgradeReceipt.js +161 -0
  165. package/dist/storage/upgradeCoordination.js +186 -0
  166. package/dist/storage/upgradeFence.js +366 -0
  167. package/dist/task/task.js +132 -26
  168. package/dist/task/taskRecordReference.js +66 -0
  169. package/dist/tmux/commandExecutor.js +75 -2
  170. package/dist/tmux/tmuxManager.js +747 -49
  171. package/dist/version.js +23 -0
  172. package/dist/web/assets/assetManifest.js +62 -0
  173. package/dist/web/assets/client/app.js +631 -0
  174. package/dist/web/assets/client/components.js +605 -0
  175. package/dist/web/assets/client/dom.js +14 -0
  176. package/dist/web/assets/client/format.js +28 -0
  177. package/dist/web/assets/client/i18n.js +494 -0
  178. package/dist/web/assets/client/markdown.js +114 -0
  179. package/dist/web/assets/client/theme.js +32 -0
  180. package/dist/web/assets/client/view.js +458 -0
  181. package/dist/web/assets/fontData.js +12 -0
  182. package/dist/web/assets/fonts.js +12 -0
  183. package/dist/web/assets/shell.js +114 -0
  184. package/dist/web/assets/styles/cards.js +135 -0
  185. package/dist/web/assets/styles/layout.js +47 -0
  186. package/dist/web/assets/styles/markdown.js +29 -0
  187. package/dist/web/assets/styles/responsive.js +39 -0
  188. package/dist/web/assets/styles/tokens.js +101 -0
  189. package/dist/web/assets/styles/widgets.js +147 -0
  190. package/dist/web/tmuxWebTerminal.js +158 -0
  191. package/dist/web/webServer.js +463 -0
  192. package/dist/web/webSnapshot.js +148 -0
  193. package/dist/workItem/workItem.js +642 -23
  194. package/dist/workspace/gitChangeSetCapture.js +86 -0
  195. package/dist/workspace/workItemChangeSetManager.js +445 -0
  196. package/dist/worktree/managedWorkspace.js +202 -0
  197. package/docs/task-local-identity.md +62 -0
  198. package/i18n/README.zh-CN.md +406 -31
  199. package/package.json +10 -2
  200. package/skills/yui-leader/SKILL.md +601 -39
  201. package/skills/yui-operator/SKILL.md +255 -34
  202. package/skills/yui-reviewer/SKILL.md +57 -0
  203. package/skills/yui-worker/SKILL.md +214 -17
  204. package/dist/commands/repositoryCommands.js +0 -86
  205. package/dist/operator/operatorContext.js +0 -66
  206. package/dist/repository/repository.js +0 -55
  207. package/dist/scheduler/archivedTaskRuntime.js +0 -12
  208. package/dist/worktree/roleWorkspace.js +0 -62
@@ -0,0 +1,595 @@
1
+ /**
2
+ * The real, canonical {@link MigrationTarget} bound to a Yui Home.
3
+ *
4
+ * This is the single seam where the generic, domain-free migration engine
5
+ * (`../migration`) meets a real authoritative store. The engine holds no list of
6
+ * Yui records or derived-state fields; all of that domain knowledge lives here,
7
+ * behind the injected boundary:
8
+ *
9
+ * - `inspectVersions` reads the on-disk versions read-only via
10
+ * `inspectStorageSchema` plus the current record map.
11
+ * - `readSource` reads `schema.json` + `state.json` read-only; it never
12
+ * writes the source.
13
+ * - `writeFreshOutput` stages the migrated snapshot into a fresh sibling
14
+ * directory and REFUSES to overwrite an existing staged output.
15
+ * - `rebuildDerivedState` is the canonical derived-state rebuild; Yui's
16
+ * authoritative state is self-contained in `state.json`, so it rebuilds only
17
+ * the effects a step declared (none for the current production step).
18
+ * - `validateCurrentState` constructs `new FileTaskStore(outputHome)` and reads
19
+ * it once, which forces the strict `parseState` gate (record validation,
20
+ * id counters, and the full reference graph). Any failure throws and the
21
+ * engine aborts before switching.
22
+ * - `atomicSwitchWithBackup` promotes the staged output into the same logical
23
+ * Home path, backing up the original under a timestamped sibling, using the
24
+ * durable temp+rename+fsync discipline.
25
+ *
26
+ * The production registry authorizes only explicit adjacent steps. Aggregate
27
+ * 16→17 and the execution record-family upgrades exercise this target against
28
+ * historical Homes; all other missing paths remain fail-closed.
29
+ */
30
+ import { closeSync, constants, cpSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync } from "node:fs";
31
+ import { basename, dirname, join, relative, resolve } from "node:path";
32
+ import { writeTextFileAtomically } from "../durableFile.js";
33
+ import { inspectStorageSchema, parseStorageSchemaManifest, readStorageSchemaManifest, STORAGE_SCHEMA_FILE } from "../storageSchema.js";
34
+ import { FileTaskStore, STORAGE_STATE_FILE } from "../taskStore.js";
35
+ import { readUpgradeFence } from "../upgradeFence.js";
36
+ import { scanRecordVersionsFromState, scanSourceRecordVersions } from "./recordVersionScan.js";
37
+ import { clearSwitchProgress, writeSwitchProgress } from "./switchProgress.js";
38
+ import { AmbiguousSwitchError } from "../migration/index.js";
39
+ const STATE_LOCK_DIRECTORY = ".state.lock";
40
+ const CONTROLLER_DISCOVERY_FILE = "runtime/controller.json";
41
+ /**
42
+ * Inspect the layout-agnostic runtime signals of a Home. These files
43
+ * (`.state.lock`, `runtime/controller.json`) do not depend on the storage
44
+ * version, so this is safe to call against an older, not-yet-migrated Home.
45
+ */
46
+ export function inspectHomeRuntime(home, callerPid = process.pid) {
47
+ return {
48
+ foreignWriteLock: inspectForeignWriteLock(home, callerPid),
49
+ liveController: inspectLiveController(home),
50
+ fence: readUpgradeFence(home)
51
+ };
52
+ }
53
+ /**
54
+ * True when any live-or-undeterminable runtime is holding the Home. A holder we
55
+ * cannot positively clear (`unknown`) counts as active — fail closed.
56
+ */
57
+ export function homeRuntimeIsActive(signals) {
58
+ return signals.foreignWriteLock !== null || signals.liveController !== null;
59
+ }
60
+ /** A human-readable description of why the runtime is considered active. */
61
+ export function describeActiveRuntime(signals) {
62
+ if (signals.liveController !== null) {
63
+ return signals.liveController.state === "live"
64
+ ? `Controller pid ${signals.liveController.pid} is running.`
65
+ : `A ${CONTROLLER_DISCOVERY_FILE} exists but is malformed; a live Controller cannot be ruled out.`;
66
+ }
67
+ if (signals.foreignWriteLock !== null) {
68
+ return signals.foreignWriteLock.state === "live"
69
+ ? `Another writer holds ${STATE_LOCK_DIRECTORY} (pid ${signals.foreignWriteLock.ownerPid}).`
70
+ : `${STATE_LOCK_DIRECTORY} exists but its owner is undeterminable; another writer cannot be ruled out.`;
71
+ }
72
+ return "A live runtime is holding the Home.";
73
+ }
74
+ /** Build the real Home-bound migration target. */
75
+ export function createHomeMigrationTarget(options) {
76
+ const home = options.home;
77
+ const latest = options.latest;
78
+ const now = options.now ?? (() => new Date());
79
+ const callerPid = options.callerPid ?? process.pid;
80
+ const stagingPath = options.stagingPath ?? `${home}.upgrade-staging`;
81
+ // The staging directory must live OUTSIDE the source Home so the complete-copy
82
+ // contract (F1) never has to copy the staging tree into itself, and so a
83
+ // legitimately-named Home entry is never misclassified as "the staging dir".
84
+ // A staging path nested inside the Home is refused outright rather than papered
85
+ // over with a fragile exclusion.
86
+ if (isPathInside(home, stagingPath)) {
87
+ throw new Error(`Staging path must not be inside the Home being migrated: ${stagingPath} is under ${home}. `
88
+ + "Use a sibling staging directory.");
89
+ }
90
+ // The promote (staging -> home) and rollback (backup -> home) renames share the
91
+ // injectable seam: a fault that blocks the forward rename (e.g. a read-only
92
+ // parent) would block the reverse too, so an always-failing `renameImpl` drives
93
+ // the genuine interrupted/ambiguous path. Default is the real fs.
94
+ const promoteRename = options.renameImpl ?? renameSync;
95
+ // Fault seam for the fsync/marker steps around the renames (F2). A no-op unless
96
+ // a test injects it; production leaves it undefined.
97
+ const faultHook = options.switchFaultHook ?? (() => { });
98
+ return {
99
+ stagingPath,
100
+ inspectVersions() {
101
+ const inspected = inspectSourceVersionState(home, latest);
102
+ if ("corruption" in inspected) {
103
+ // A structurally-damaged source cannot yield trustworthy versions. The
104
+ // engine has no CORRUPTED outcome, so surface it as a hard error rather
105
+ // than pretend the record axis is current (the classifier turns the same
106
+ // structural damage into a CORRUPTED verdict for doctor/upgrade).
107
+ throw new Error(inspected.corruption.detail);
108
+ }
109
+ return inspected.source;
110
+ },
111
+ detectLiveRuntime() {
112
+ const signals = inspectHomeRuntime(home, callerPid);
113
+ if (!homeRuntimeIsActive(signals))
114
+ return { active: false };
115
+ return { active: true, detail: describeActiveRuntime(signals) };
116
+ },
117
+ readSource() {
118
+ const manifestRaw = readFileSync(join(home, STORAGE_SCHEMA_FILE), "utf8");
119
+ const schemaManifest = parseJsonObject(manifestRaw, STORAGE_SCHEMA_FILE);
120
+ const statePath = join(home, STORAGE_STATE_FILE);
121
+ const state = existsSync(statePath)
122
+ ? parseJsonObject(readFileSync(statePath, "utf8"), STORAGE_STATE_FILE)
123
+ : null;
124
+ return Object.freeze({ schemaManifest, state });
125
+ },
126
+ writeFreshOutput(snapshot) {
127
+ if (existsSync(stagingPath)) {
128
+ throw new Error(`Refusing to overwrite an existing staged upgrade output: ${stagingPath}. `
129
+ + "Delete it and retry.");
130
+ }
131
+ mkdirSync(stagingPath, { recursive: true, mode: 0o700 });
132
+ // COMPLETE HOME CONTENT PRESERVATION CONTRACT (P1-1)
133
+ // -------------------------------------------------
134
+ // The migration only TRANSFORMS schema.json + state.json, but the atomic
135
+ // switch replaces the WHOLE Home directory (home -> backup, staging ->
136
+ // home). If staging held only those two files, everything else the real
137
+ // Home persists — runtime/inbox/* (AUTHORITATIVE, not-yet-applied events),
138
+ // runtime/ discovery, cache/ and artifacts/ (rebuildable) — would be
139
+ // silently lost on the first real migration.
140
+ //
141
+ // Chosen contract: staging carries a COMPLETE copy of the Home; only
142
+ // schema.json + state.json are overwritten with their migrated bytes.
143
+ // Every other entry (of any depth: dirs, files, symlinks) is copied
144
+ // verbatim, so the switch preserves all authoritative and rebuildable
145
+ // content. The two migrated files are written LAST so they always win over
146
+ // any copied original. The transient `.state.lock` is NOT promoted (a lock
147
+ // is per-instance coordination state, never authoritative Home content);
148
+ // the upgrade fence under runtime/ is copied but cleared post-switch by
149
+ // this process's fence release. See ARCHITECTURE.md "Complete Home content
150
+ // preservation".
151
+ //
152
+ // Only schema.json / state.json / .state.lock are excluded — NOT the staging
153
+ // basename (F1). The staging directory is guaranteed to be OUTSIDE the Home
154
+ // (enforced at target construction), so a Home entry that happens to share
155
+ // the staging basename (e.g. a real `home.upgrade-staging` file) is genuine
156
+ // content and is copied like anything else, never silently dropped.
157
+ copyHomeContentsExcept(home, stagingPath, new Set([STORAGE_SCHEMA_FILE, STORAGE_STATE_FILE, STATE_LOCK_DIRECTORY]));
158
+ writeTextFileAtomically(join(stagingPath, STORAGE_SCHEMA_FILE), `${JSON.stringify(snapshot.schemaManifest, null, 2)}\n`);
159
+ if (snapshot.state !== null) {
160
+ writeTextFileAtomically(join(stagingPath, STORAGE_STATE_FILE), `${JSON.stringify(snapshot.state, null, 2)}\n`);
161
+ }
162
+ },
163
+ rebuildDerivedState(effects) {
164
+ // Yui's authoritative data is fully contained in state.json; id counters
165
+ // and the reference graph are validated by parseState in the next step.
166
+ // There is no separate on-disk derived index to rebuild, so this echoes
167
+ // the effects a step declared (none for the current production step). New
168
+ // record families that introduce out-of-band derived state extend here.
169
+ return { rebuiltEffects: [...effects] };
170
+ },
171
+ validateCurrentState() {
172
+ // The real post-migration loader gate: constructing the store runs the
173
+ // strict schema check, and a single read forces parseState across every
174
+ // record family plus the full reference graph. Any failure throws.
175
+ const store = new FileTaskStore(stagingPath);
176
+ store.getConfig();
177
+ const tasks = store.listTasks();
178
+ store.listProjects();
179
+ store.listConfiguredAgents();
180
+ store.listWorkMailboxes();
181
+ return {
182
+ checks: [
183
+ {
184
+ name: "FileTaskStore loader",
185
+ outcome: "passed",
186
+ detail: `parsed state, reference graph, and ${tasks.length} task(s)`
187
+ }
188
+ ]
189
+ };
190
+ },
191
+ atomicSwitchWithBackup() {
192
+ if (!existsSync(stagingPath)) {
193
+ throw new Error(`No staged output to promote: ${stagingPath}.`);
194
+ }
195
+ const stamp = now().toISOString().replace(/[:.]/g, "-");
196
+ const backupPath = join(dirname(home), `${basename(home)}.backup-${stamp}`);
197
+ if (existsSync(backupPath)) {
198
+ throw new Error(`Refusing to overwrite an existing backup: ${backupPath}.`);
199
+ }
200
+ // The switch is two atomic renames with one non-atomic window between them.
201
+ // A durable sibling marker records the phase (backing-up / promoting /
202
+ // interrupted) so a reader — or `yui update`'s crash recovery — can tell how
203
+ // far the switch got even if this process dies mid-way (P1-4 / F2 / F3).
204
+ //
205
+ // The invariant that drives error handling: BEFORE step 1's rename commits,
206
+ // the Home is byte-for-byte intact and any failure is a clean pre-switch
207
+ // failure (plain throw -> engine `failed` -> "source unchanged", which is
208
+ // TRUE). AFTER step 1 commits, the original no longer lives at `home`, so
209
+ // EVERY subsequent operation — the post-rename fsync, the `promoting` marker
210
+ // write, the promote rename, and the post-promote fsync/marker-clear — is
211
+ // handled phase-aware: on any error we attempt rollback, and if rollback
212
+ // fails we persist `interrupted` and raise AmbiguousSwitchError. A
213
+ // post-switch exception is NEVER allowed to escape as a plain error that the
214
+ // engine would render as "source unchanged" (F2).
215
+ // Phase 0 (Home intact): write the backing-up marker, then move aside.
216
+ // Any failure here leaves the Home untouched — clear the marker, plain throw.
217
+ try {
218
+ writeSwitchProgress(home, {
219
+ phase: "backing-up",
220
+ homePath: home,
221
+ backupPath,
222
+ stagingPath,
223
+ updatedAt: now().toISOString()
224
+ });
225
+ renameSync(home, backupPath);
226
+ }
227
+ catch (error) {
228
+ // Nothing committed (or the marker write failed before the rename): the
229
+ // Home is intact. Best-effort clear the marker and surface a clean error.
230
+ try {
231
+ clearSwitchProgress(home);
232
+ }
233
+ catch { /* best effort */ }
234
+ throw error;
235
+ }
236
+ // ---- From here the original lives at `backupPath`, NOT at `home`. ----
237
+ // Every step is guarded: on failure, roll back to restore the original; if
238
+ // the rollback fails, the switch is genuinely interrupted (ambiguous).
239
+ try {
240
+ faultHook("post-backup-fsync");
241
+ fsyncDirectory(dirname(home));
242
+ faultHook("promoting-marker");
243
+ writeSwitchProgress(home, {
244
+ phase: "promoting",
245
+ homePath: home,
246
+ backupPath,
247
+ stagingPath,
248
+ updatedAt: now().toISOString()
249
+ });
250
+ // Step 2: promote the staged output into the Home path.
251
+ promoteRename(stagingPath, home);
252
+ // Post-promotion durability. If these throw, the new Home is ALREADY in
253
+ // place and correct; fsync/marker-clear are best-effort durability, not
254
+ // correctness, so a failure here must NOT fail the switch or trigger a
255
+ // rollback (that would destroy a good migrated Home). Swallow them.
256
+ try {
257
+ faultHook("post-promote-fsync");
258
+ fsyncDirectory(dirname(home));
259
+ }
260
+ catch { /* durability best effort */ }
261
+ try {
262
+ faultHook("post-promote-clear");
263
+ clearSwitchProgress(home);
264
+ }
265
+ catch { /* marker cleared lazily later */ }
266
+ return {
267
+ status: "switched",
268
+ backupPath,
269
+ detail: `Original Home backed up at ${backupPath}.`
270
+ };
271
+ }
272
+ catch (switchError) {
273
+ // A failure BEFORE promotion committed (post-rename fsync, `promoting`
274
+ // marker write, or the promote rename itself). The original is at the
275
+ // backup and `home` is missing. Attempt rollback to restore it — the
276
+ // `return` above is the only path once promotion has committed, so here
277
+ // promotion has NOT committed and rollback is always the right recovery.
278
+ return rollbackOrInterrupt(switchError);
279
+ }
280
+ /**
281
+ * Restore the original from the backup after a pre-promotion failure. On
282
+ * success the Home is intact again and we re-throw the original error as a
283
+ * clean pre-switch failure (engine `failed`, truthfully "source unchanged").
284
+ * If the rollback itself fails, persist `interrupted` and raise
285
+ * AmbiguousSwitchError with the exact manual recovery.
286
+ */
287
+ function rollbackOrInterrupt(switchError) {
288
+ try {
289
+ faultHook("rollback-rename");
290
+ promoteRename(backupPath, home);
291
+ try {
292
+ fsyncDirectory(dirname(home));
293
+ }
294
+ catch { /* durability best effort */ }
295
+ try {
296
+ clearSwitchProgress(home);
297
+ }
298
+ catch { /* best effort */ }
299
+ }
300
+ catch (rollbackError) {
301
+ try {
302
+ writeSwitchProgress(home, {
303
+ phase: "interrupted",
304
+ homePath: home,
305
+ backupPath,
306
+ stagingPath,
307
+ updatedAt: now().toISOString()
308
+ });
309
+ }
310
+ catch { /* marker best effort; fs evidence + backup still recover it */ }
311
+ throw new AmbiguousSwitchError({
312
+ homePath: home,
313
+ backupPath,
314
+ stagingPath,
315
+ detail: `Storage switch is in an AMBIGUOUS, partially-applied state: the original Home was `
316
+ + `moved to ${backupPath} but the switch failed (${messageOf(switchError)}), and the `
317
+ + `automatic rollback also failed (${messageOf(rollbackError)}). The Home path ${home} `
318
+ + `may be missing. Recover manually by restoring the backup: mv "${backupPath}" "${home}".`
319
+ });
320
+ }
321
+ // Rollback succeeded: the original is back in place, Home unchanged. Bubble
322
+ // the original error so the engine reports a failed switch with the source
323
+ // intact (the engine's failure path never claims a switch committed).
324
+ throw switchError instanceof Error
325
+ ? switchError
326
+ : new Error(String(switchError));
327
+ }
328
+ },
329
+ discardFreshOutput() {
330
+ rmSync(stagingPath, { recursive: true, force: true });
331
+ }
332
+ };
333
+ }
334
+ /**
335
+ * Read a source Home's full three-axis {@link StorageVersionState} read-only.
336
+ *
337
+ * All three axes come from the durable `schema.json` contract. Raw `state.json`
338
+ * is traversed structurally only to cross-check non-empty families against the
339
+ * declared record versions (never through strict `parseState`, which would
340
+ * conflate an older record family with corruption). A family missing from the
341
+ * manifest is explicit version 0, so the planner must resolve its introduction.
342
+ *
343
+ * Returns `corruption` when the manifest is invalid, storage is uninitialized, or
344
+ * `state.json` is structurally damaged; callers map that to a hard error (engine)
345
+ * or a `CORRUPTED` verdict (classifier).
346
+ */
347
+ export function inspectSourceVersionState(home, latest) {
348
+ const schema = inspectStorageSchema(home);
349
+ if (schema.status === "uninitialized") {
350
+ return {
351
+ corruption: {
352
+ corrupted: true,
353
+ detail: "Yui storage is not initialized. Run `yui setup`."
354
+ }
355
+ };
356
+ }
357
+ if (schema.status === "invalid") {
358
+ return {
359
+ corruption: {
360
+ corrupted: true,
361
+ detail: `Storage schema manifest is invalid: ${schema.detail}`
362
+ }
363
+ };
364
+ }
365
+ // schema.status is "current" or "unsupported": the manifest is structurally
366
+ // valid and supplies the authoritative persisted record-family versions.
367
+ const manifest = readStorageSchemaManifest(home);
368
+ const scan = scanSourceRecordVersions(home, latest.record);
369
+ if ("corruption" in scan) {
370
+ return { corruption: scan.corruption };
371
+ }
372
+ return combineManifestAndState(manifest, scan, latest);
373
+ }
374
+ /** Inspect an in-memory compatible/offline snapshot through the same contract. */
375
+ export function inspectSnapshotVersionState(snapshot, latest) {
376
+ let manifest;
377
+ try {
378
+ manifest = parseStorageSchemaManifest(snapshot.schemaManifest);
379
+ }
380
+ catch (error) {
381
+ return {
382
+ corruption: {
383
+ corrupted: true,
384
+ detail: `Storage schema manifest is invalid: ${messageOf(error)}`
385
+ }
386
+ };
387
+ }
388
+ const scan = snapshot.state === null
389
+ ? {
390
+ record: Object.fromEntries(Object.entries(latest.record).map(([kind, entry]) => [kind, { ...entry }])),
391
+ counts: Object.fromEntries(Object.keys(latest.record).map((kind) => [kind, 0]))
392
+ }
393
+ : scanRecordVersionsFromState(snapshot.state, latest.record);
394
+ if ("corruption" in scan)
395
+ return { corruption: scan.corruption };
396
+ return combineManifestAndState(manifest, scan, latest);
397
+ }
398
+ function combineManifestAndState(manifest, scan, latest) {
399
+ const record = {};
400
+ if (manifest.recordVersions === undefined) {
401
+ for (const [kind, entry] of Object.entries(latest.record)) {
402
+ record[kind] = { version: 0, path: entry.path };
403
+ }
404
+ }
405
+ else {
406
+ for (const [kind, latestEntry] of Object.entries(latest.record)) {
407
+ const persisted = manifest.recordVersions[kind];
408
+ const count = scan.counts[kind] ?? 0;
409
+ const scanned = scan.record[kind];
410
+ if (persisted === undefined) {
411
+ if (count > 0) {
412
+ return {
413
+ corruption: {
414
+ corrupted: true,
415
+ detail: `Manifest omits record family '${kind}' but state.json contains ${count} record(s).`
416
+ }
417
+ };
418
+ }
419
+ record[kind] = { version: 0, path: latestEntry.path };
420
+ continue;
421
+ }
422
+ if (count > 0 && scanned?.version !== persisted) {
423
+ return {
424
+ corruption: {
425
+ corrupted: true,
426
+ detail: `Manifest declares record family '${kind}' at version ${persisted}, `
427
+ + `but state.json contains version ${String(scanned?.version)}.`
428
+ }
429
+ };
430
+ }
431
+ record[kind] = { version: persisted, path: latestEntry.path };
432
+ }
433
+ }
434
+ return {
435
+ source: {
436
+ layout: manifest.storageVersion,
437
+ aggregate: manifest.aggregateSchemaVersion,
438
+ record: Object.freeze(record)
439
+ }
440
+ };
441
+ }
442
+ /**
443
+ * Inspect the `.state.lock` holder, fail-closed.
444
+ *
445
+ * Returns `null` ONLY when the lock directory is provably absent (no writer).
446
+ * When the directory exists, the owner file is read: a clearly-live foreign pid
447
+ * is `live`; a dead owner is reclaimable (`null`); but a missing, empty,
448
+ * non-integer, or unreadable owner is `{ state: "unknown" }` — the lock is
449
+ * acquired mkdir-first with the owner written a moment later, so an
450
+ * undeterminable owner may be a writer mid-acquisition or a live process, and we
451
+ * must not treat it as absent.
452
+ */
453
+ function inspectForeignWriteLock(home, callerPid) {
454
+ const lockDir = join(home, STATE_LOCK_DIRECTORY);
455
+ if (!existsSync(lockDir))
456
+ return null; // provably no lock: no writer.
457
+ let raw;
458
+ try {
459
+ raw = readFileSync(join(lockDir, "owner"), "utf8");
460
+ }
461
+ catch (error) {
462
+ // Lock dir exists but the owner file is missing/unreadable: a writer may be
463
+ // mid-acquisition (mkdir done, owner not yet written). Fail closed.
464
+ if (isEnoent(error))
465
+ return { state: "unknown" };
466
+ return { state: "unknown" };
467
+ }
468
+ const trimmed = raw.trim();
469
+ if (trimmed.length === 0)
470
+ return { state: "unknown" }; // empty/partial write.
471
+ const ownerPid = Number.parseInt(trimmed, 10);
472
+ if (!Number.isInteger(ownerPid) || String(ownerPid) !== trimmed) {
473
+ // Non-integer or malformed owner: undeterminable. Fail closed.
474
+ return { state: "unknown" };
475
+ }
476
+ // Our own fence-exempt process re-pins under this lock; not a foreign writer.
477
+ if (ownerPid === callerPid)
478
+ return null;
479
+ // Only a provably-dead owner is reclaimable (no writer); a live one is active.
480
+ return processIsAlive(ownerPid) ? { state: "live", ownerPid } : null;
481
+ }
482
+ /**
483
+ * Inspect the per-home Controller discovery, fail-closed.
484
+ *
485
+ * Returns `null` ONLY when there is provably no discovery file. A file naming a
486
+ * live pid is `live`; a file naming a dead pid is `null` (no live Controller);
487
+ * but a present-but-malformed/unparseable file is `{ state: "unknown" }` — we
488
+ * cannot rule out a live Controller from unreadable discovery, so it fails
489
+ * closed rather than being treated as "no controller".
490
+ */
491
+ function inspectLiveController(home) {
492
+ const discoveryPath = join(home, CONTROLLER_DISCOVERY_FILE);
493
+ let raw;
494
+ try {
495
+ raw = readFileSync(discoveryPath, "utf8");
496
+ }
497
+ catch (error) {
498
+ if (isEnoent(error))
499
+ return null; // provably no discovery file.
500
+ return { state: "unknown" }; // present but unreadable: fail closed.
501
+ }
502
+ let value;
503
+ try {
504
+ value = JSON.parse(raw);
505
+ }
506
+ catch {
507
+ return { state: "unknown" }; // malformed JSON: cannot rule out a controller.
508
+ }
509
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
510
+ return { state: "unknown" };
511
+ }
512
+ const pid = value.pid;
513
+ if (!Number.isInteger(pid))
514
+ return { state: "unknown" }; // no usable pid.
515
+ // A named pid that is dead means no live Controller; alive means active.
516
+ return processIsAlive(pid) ? { state: "live", pid: pid } : null;
517
+ }
518
+ function parseJsonObject(raw, label) {
519
+ const value = JSON.parse(raw);
520
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
521
+ throw new Error(`${label} must be a JSON object.`);
522
+ }
523
+ return value;
524
+ }
525
+ function fsyncDirectory(directory) {
526
+ const descriptor = openSync(directory, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0));
527
+ try {
528
+ fsyncSync(descriptor);
529
+ }
530
+ finally {
531
+ closeSync(descriptor);
532
+ }
533
+ }
534
+ function processIsAlive(pid) {
535
+ try {
536
+ process.kill(pid, 0);
537
+ return true;
538
+ }
539
+ catch (error) {
540
+ return error instanceof Error && "code" in error && error.code === "EPERM";
541
+ }
542
+ }
543
+ function isEnoent(error) {
544
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
545
+ }
546
+ function messageOf(error) {
547
+ return error instanceof Error ? error.message : String(error);
548
+ }
549
+ /**
550
+ * True when `candidate` is the same path as `base` or nested anywhere inside it.
551
+ * Uses resolved absolute paths and a relative-path check so it is robust to
552
+ * `.`/`..`/trailing-slash forms and never treats a sibling that merely shares a
553
+ * name prefix (e.g. `home.upgrade-staging` vs `home`) as "inside" (F1).
554
+ */
555
+ function isPathInside(base, candidate) {
556
+ const rel = relative(resolve(base), resolve(candidate));
557
+ if (rel === "")
558
+ return true; // identical path.
559
+ // Inside iff the relative path does not escape upward and is not absolute.
560
+ return !rel.startsWith("..") && !isAbsolutePath(rel);
561
+ }
562
+ function isAbsolutePath(p) {
563
+ // resolve() yields platform-absolute paths; a relative() result that is
564
+ // absolute means the two paths share no common base (different roots).
565
+ return resolve(p) === p;
566
+ }
567
+ /**
568
+ * Copy every top-level entry of `sourceHome` into `destDir`, except the names in
569
+ * `exclude`, preserving nested directories, files, symlinks, timestamps, and
570
+ * modes. This implements the complete Home content preservation contract (P1-1):
571
+ * the migrated schema.json/state.json are written by the caller afterward, and
572
+ * everything else (runtime/inbox authoritative events, runtime/ discovery,
573
+ * cache/, artifacts/) is carried verbatim so the atomic switch never drops it.
574
+ *
575
+ * Symlinks are copied as links (not dereferenced), so an in-Home symlink keeps
576
+ * its target rather than duplicating content or escaping the Home. Copy failures
577
+ * propagate: the engine aborts before the switch, leaving the source untouched.
578
+ */
579
+ function copyHomeContentsExcept(sourceHome, destDir, exclude) {
580
+ for (const entry of readdirSync(sourceHome, { withFileTypes: true })) {
581
+ if (exclude.has(entry.name))
582
+ continue;
583
+ cpSync(join(sourceHome, entry.name), join(destDir, entry.name), {
584
+ recursive: true,
585
+ // Copy symlinks as links rather than dereferencing them.
586
+ dereference: false,
587
+ // Preserve timestamps/mode so authoritative/rebuildable content is intact.
588
+ preserveTimestamps: true,
589
+ // The staging dir is fresh, so nothing should already exist; force keeps
590
+ // the copy robust if a partial retry left a remnant.
591
+ force: true,
592
+ errorOnExist: false
593
+ });
594
+ }
595
+ }