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