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