@zq-silk/yui 0.6.0 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -5
- package/dist/agent/managedRuntimeEnvironment.js +2 -1
- package/dist/cli/commandCatalog.js +251 -13
- package/dist/cli/updateOrchestrator.js +8 -0
- package/dist/cli/updatePorts.js +76 -22
- package/dist/cli.js +264 -20
- package/dist/commands/configCommands.js +83 -9
- package/dist/commands/controllerCommands.js +103 -0
- package/dist/commands/deliveryGuardPreflight.js +30 -0
- package/dist/commands/durableJobCommands.js +231 -0
- package/dist/commands/executionAuditCommands.js +193 -0
- package/dist/commands/grantCommands.js +374 -0
- package/dist/commands/projectCommands.js +119 -81
- package/dist/commands/releaseCommands.js +444 -0
- package/dist/commands/resourcesCommands.js +274 -0
- package/dist/commands/sessionCommands.js +104 -0
- package/dist/commands/taskActor.js +117 -0
- package/dist/commands/taskChangeSetCommands.js +60 -0
- package/dist/commands/taskCommands.js +618 -202
- package/dist/commands/taskCompletionGate.js +78 -1
- package/dist/commands/taskContextCommand.js +33 -6
- package/dist/commands/taskInputCommands.js +1 -1
- package/dist/commands/taskIntegrationCommands.js +136 -33
- package/dist/commands/taskIntegrationQueueCommands.js +228 -0
- package/dist/commands/taskNextActionCommand.js +100 -0
- package/dist/commands/taskOverlapCommands.js +120 -0
- package/dist/commands/taskOverviewCommand.js +36 -8
- package/dist/commands/telemetryCommands.js +330 -0
- package/dist/commands/workflowCommands.js +415 -0
- package/dist/config/yuiConfig.js +62 -0
- package/dist/controller/clientRuntime.js +42 -1
- package/dist/controller/controller.js +402 -56
- package/dist/controller/controllerMain.js +25 -2
- package/dist/controller/domainIdentity.js +16 -8
- package/dist/controller/fileSchedulerStoreAdapter.js +423 -31
- package/dist/controller/handoverCandidate.js +168 -0
- package/dist/controller/jobClient.js +102 -0
- package/dist/controller/jobControl.js +613 -0
- package/dist/controller/jobSupervisor.js +498 -0
- package/dist/controller/providerHookRunFence.js +34 -5
- package/dist/controller/resourceCleanupLinux.js +18 -9
- package/dist/controller/resourceInventoryLinux.js +90 -39
- package/dist/controller/runtime.js +165 -15
- package/dist/controller/runtimeEventInbox.js +234 -57
- package/dist/controller/runtimeEventProcessor.js +297 -58
- package/dist/controller/sessionOwnerReconciliation.js +321 -0
- package/dist/core/controllerServer.js +416 -27
- package/dist/core/controllerTelemetry.js +167 -0
- package/dist/doctor/doctor.js +113 -16
- package/dist/domain/validation.js +9 -0
- package/dist/execution/executionGroup.js +40 -3
- package/dist/executor/agentExecutor.js +6 -3
- package/dist/executor/effectiveLaunch.js +52 -0
- package/dist/executor/executorRegistry.js +50 -0
- package/dist/executor/fileRoleLaunchPlanner.js +61 -6
- package/dist/grant/capabilityGrant.js +282 -0
- package/dist/integration/changeSet.js +16 -3
- package/dist/integration/changeSetManifest.js +46 -0
- package/dist/integration/gitIntegrationService.js +528 -147
- package/dist/integration/integrationAttempt.js +54 -5
- package/dist/integration/integrationQueueEntry.js +221 -0
- package/dist/integration/integrationQueueService.js +955 -0
- package/dist/integration/manifestTags.js +99 -0
- package/dist/integration/overlapDiagnostics.js +211 -0
- package/dist/job/durableJob.js +449 -0
- package/dist/job/jobRunner.js +350 -0
- package/dist/lifecycle/exactRunTerminalization.js +24 -2
- package/dist/lifecycle/providerErrorClass.js +126 -0
- package/dist/message/message.js +16 -3
- package/dist/observability/executionAudit.js +545 -0
- package/dist/observability/faultClassification.js +160 -0
- package/dist/observability/runtimeIdentity.js +367 -0
- package/dist/release/fakeReleasePorts.js +55 -0
- package/dist/release/releaseHandover.js +475 -0
- package/dist/release/releaseIdempotencyStore.js +165 -0
- package/dist/release/releaseWorkflow.js +459 -0
- package/dist/release/releaseWorkflowEngine.js +688 -0
- package/dist/release/releaseWorkflowPorts.js +1720 -0
- package/dist/release/runtimeRelease.js +495 -0
- package/dist/release/workflowFileLock.js +218 -0
- package/dist/repository/gitWorkspace.js +177 -1
- package/dist/repository/projectMaintenanceLock.js +315 -0
- package/dist/repository/taskWorkspaceCoordinator.js +87 -17
- package/dist/repository/taskWorkspacePreparer.js +1091 -517
- package/dist/resources/autoResourceGc.js +116 -0
- package/dist/resources/liveReferences.js +574 -0
- package/dist/resources/resourceDiscovery.js +477 -0
- package/dist/resources/resourceGc.js +645 -0
- package/dist/resources/resourceRegistrar.js +256 -0
- package/dist/resources/resourceRegistry.js +150 -0
- package/dist/resources/resourceRegistryStore.js +41 -0
- package/dist/resources/resourceTypes.js +42 -0
- package/dist/resources/sqliteResourceRegistry.js +111 -0
- package/dist/review/reviewConfig.js +10 -0
- package/dist/review/reviewFinding.js +240 -0
- package/dist/review/reviewFindingLedger.js +545 -0
- package/dist/review/reviewOutcomeClassifier.js +61 -0
- package/dist/review/reviewRound.js +56 -4
- package/dist/run/agentRun.js +80 -4
- package/dist/run/providerRetry.js +84 -0
- package/dist/run/providerRetryConfig.js +63 -0
- package/dist/run/yieldReceipt.js +65 -0
- package/dist/runtime/exactControlPlane.js +79 -2
- package/dist/runtime/index.js +4 -0
- package/dist/runtime/sessionOwnerIdentity.js +269 -0
- package/dist/runtime/sessionOwnerRegistry.js +132 -0
- package/dist/runtime/sessionReconciliation.js +93 -0
- package/dist/runtime/sessionTerminationGuard.js +211 -0
- package/dist/runtime/taskRuntimeIsolation.js +13 -0
- package/dist/runtime/tmuxAdapters.js +34 -1
- package/dist/scheduler/actionability.js +155 -0
- package/dist/scheduler/activeRoleRunDelivery.js +14 -5
- package/dist/scheduler/activeTaskProgress.js +60 -0
- package/dist/scheduler/leaderWakeupProcessor.js +22 -11
- package/dist/scheduler/roleRunStall.js +135 -29
- package/dist/scheduler/taskExecutionProjection.js +11 -0
- package/dist/storage/compatibleTaskStore.js +112 -5
- package/dist/storage/migration/productionRegistry.js +736 -1
- package/dist/storage/sqliteSchema.js +264 -3
- package/dist/storage/sqliteStore.js +487 -13
- package/dist/storage/storeRpc.js +21 -0
- package/dist/storage/taskStore.js +974 -21
- package/dist/storage/upgrade/homeClassification.js +120 -2
- package/dist/storage/upgrade/migrationReceipt.js +67 -0
- package/dist/storage/upgrade/pseudoLayoutRepair.js +241 -0
- package/dist/storage/upgrade/recordVersions.js +10 -1
- package/dist/storage/upgrade/sqliteMigrationTarget.js +58 -6
- package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +290 -0
- package/dist/storage/upgrade/sqliteStateMigration.js +258 -2
- package/dist/storage/upgrade/upgradeOrchestrator.js +482 -16
- package/dist/task/deliveryGuard.js +226 -0
- package/dist/task/nextAction.js +738 -0
- package/dist/task/repairWave.js +137 -0
- package/dist/task/taskRecordReference.js +6 -1
- package/dist/telemetry/sqliteTelemetryStore.js +387 -0
- package/dist/telemetry/telemetryCompaction.js +251 -0
- package/dist/telemetry/telemetryConfig.js +64 -0
- package/dist/telemetry/telemetryRouter.js +32 -0
- package/dist/telemetry/telemetryStore.js +19 -0
- package/dist/telemetry/telemetryWiring.js +33 -0
- package/dist/tmux/tmuxManager.js +20 -1
- package/dist/tmux/tmuxSocketEndpoint.js +20 -0
- package/dist/verification/gateArtifact.js +216 -0
- package/dist/verification/gateArtifactStore.js +87 -0
- package/dist/verification/verificationGateService.js +414 -0
- package/dist/verification/verificationPlan.js +308 -0
- package/dist/workspace/gitChangeSetCapture.js +12 -2
- package/dist/workspace/workItemChangeSetManager.js +60 -3
- package/package.json +1 -1
- package/skills/yui-leader/SKILL.md +8 -0
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `yui release` commands: immutable local release installation, listing, and
|
|
3
|
+
* atomic Controller handover activation (Issue 02).
|
|
4
|
+
*/
|
|
5
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
6
|
+
import { chmodSync, cpSync, existsSync, lstatSync, mkdirSync, readdirSync, rmSync, } from "node:fs";
|
|
7
|
+
import { join, resolve } from "node:path";
|
|
8
|
+
import { callController, ControllerClientError } from "../core/controllerClient.js";
|
|
9
|
+
import { readLinuxProcessStartIdentity } from "../controller/domainIdentity.js";
|
|
10
|
+
import { activateRelease } from "../release/releaseHandover.js";
|
|
11
|
+
import { assertReleaseIsNotWorktreeOrLinked, readActiveReleasePointer, readReleaseManifest, releasesDirectory, releaseDirectoryFor, verifyReleaseIntegrity, writeSmokeReceipt } from "../release/runtimeRelease.js";
|
|
12
|
+
/**
|
|
13
|
+
* Installs a runtime package directory into the Home's immutable release
|
|
14
|
+
* tree. The source must carry a release manifest, must not be a Git worktree
|
|
15
|
+
* or contain symlinks, and must verify byte-for-byte after copying.
|
|
16
|
+
*/
|
|
17
|
+
export function runReleaseInstall(home, sourceDir, options = {}) {
|
|
18
|
+
const now = options.now ?? (() => new Date());
|
|
19
|
+
const source = resolve(sourceDir);
|
|
20
|
+
if (!existsSync(join(source, "dist", "cli.js"))) {
|
|
21
|
+
return {
|
|
22
|
+
outcome: "aborted",
|
|
23
|
+
message: `Release source has no dist/cli.js: ${source}.`,
|
|
24
|
+
action: "Assemble the runtime package with scripts/assemble-runtime-package.mjs first."
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
let manifest;
|
|
28
|
+
try {
|
|
29
|
+
manifest = readReleaseManifest(source);
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
return {
|
|
33
|
+
outcome: "aborted",
|
|
34
|
+
message: `Release source manifest is invalid: ${messageOf(error)}.`,
|
|
35
|
+
action: "Re-assemble the runtime package; the manifest is written at assembly time."
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
assertReleaseIsNotWorktreeOrLinked(source);
|
|
40
|
+
verifyReleaseIntegrity(source);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
return {
|
|
44
|
+
outcome: "aborted",
|
|
45
|
+
message: `Release source failed integrity verification: ${messageOf(error)}.`,
|
|
46
|
+
action: "Re-assemble the runtime package from a clean build."
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
const target = releaseDirectoryFor(home, manifest);
|
|
50
|
+
if (existsSync(target)) {
|
|
51
|
+
try {
|
|
52
|
+
const existing = verifyReleaseIntegrity(target);
|
|
53
|
+
if (existing.packageDigest === manifest.packageDigest) {
|
|
54
|
+
return {
|
|
55
|
+
outcome: "already-installed",
|
|
56
|
+
releaseId: `${manifest.version}-${manifest.packageDigest}`,
|
|
57
|
+
releaseDir: target
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// Fall through to the fail-closed diagnosis below.
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
outcome: "aborted",
|
|
66
|
+
message: `Release directory already exists with different content: ${target}.`,
|
|
67
|
+
action: "Remove the drifted directory manually after confirming it is not the active release."
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
mkdirSync(releasesDirectory(home), { recursive: true, mode: 0o700 });
|
|
71
|
+
copyRegularTree(source, target);
|
|
72
|
+
try {
|
|
73
|
+
assertReleaseIsNotWorktreeOrLinked(target);
|
|
74
|
+
verifyReleaseIntegrity(target);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
rmSync(target, { recursive: true, force: true });
|
|
78
|
+
return {
|
|
79
|
+
outcome: "aborted",
|
|
80
|
+
message: `Installed release failed integrity verification: ${messageOf(error)}.`,
|
|
81
|
+
action: "The partial release was removed; retry the installation."
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
const smoke = runInstallSmoke(target, manifest.version);
|
|
85
|
+
if (smoke !== null) {
|
|
86
|
+
rmSync(target, { recursive: true, force: true });
|
|
87
|
+
return {
|
|
88
|
+
outcome: "aborted",
|
|
89
|
+
message: `Release smoke failed: ${smoke}.`,
|
|
90
|
+
action: "The partial release was removed; fix the package and retry."
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
const receipt = Object.freeze({
|
|
94
|
+
schemaVersion: 1,
|
|
95
|
+
version: manifest.version,
|
|
96
|
+
buildId: manifest.buildId,
|
|
97
|
+
packageDigest: manifest.packageDigest,
|
|
98
|
+
checks: Object.freeze(["cli-version", "cli-help"]),
|
|
99
|
+
ranAt: now().toISOString()
|
|
100
|
+
});
|
|
101
|
+
writeSmokeReceipt(target, receipt);
|
|
102
|
+
return {
|
|
103
|
+
outcome: "installed",
|
|
104
|
+
releaseId: `${manifest.version}-${manifest.packageDigest}`,
|
|
105
|
+
buildId: manifest.buildId,
|
|
106
|
+
packageDigest: manifest.packageDigest,
|
|
107
|
+
releaseDir: target
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/** Lists installed releases and the active release pointer. */
|
|
111
|
+
export function runReleaseList(home) {
|
|
112
|
+
const active = readActiveReleasePointer(home);
|
|
113
|
+
const directory = releasesDirectory(home);
|
|
114
|
+
const releases = existsSync(directory)
|
|
115
|
+
? readdirSync(directory, { withFileTypes: true })
|
|
116
|
+
.filter((entry) => entry.isDirectory())
|
|
117
|
+
.map((entry) => {
|
|
118
|
+
const releaseDir = join(directory, entry.name);
|
|
119
|
+
try {
|
|
120
|
+
const manifest = verifyReleaseIntegrity(releaseDir);
|
|
121
|
+
return {
|
|
122
|
+
releaseId: entry.name,
|
|
123
|
+
version: manifest.version,
|
|
124
|
+
buildId: manifest.buildId,
|
|
125
|
+
packageDigest: manifest.packageDigest,
|
|
126
|
+
smoke: existsSync(join(releaseDir, "smoke-receipt.json"))
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return {
|
|
131
|
+
releaseId: entry.name,
|
|
132
|
+
version: "unknown",
|
|
133
|
+
buildId: "unknown",
|
|
134
|
+
packageDigest: "unknown",
|
|
135
|
+
smoke: false
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
: [];
|
|
140
|
+
return { active: active?.releaseId ?? null, releases };
|
|
141
|
+
}
|
|
142
|
+
/** Resolves an installed release by directory name or build ID. */
|
|
143
|
+
export function resolveInstalledRelease(home, releaseId) {
|
|
144
|
+
const directory = releasesDirectory(home);
|
|
145
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
146
|
+
if (!entry.isDirectory())
|
|
147
|
+
continue;
|
|
148
|
+
if (entry.name === releaseId) {
|
|
149
|
+
const releaseDir = join(directory, entry.name);
|
|
150
|
+
try {
|
|
151
|
+
return { releaseDir, manifest: verifyReleaseIntegrity(releaseDir) };
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
throw new Error(`Release ${releaseId} failed integrity verification and may have drifted: `
|
|
155
|
+
+ `${error instanceof Error ? error.message : String(error)}. `
|
|
156
|
+
+ "Reinstall it with `yui release install <source-dir>` before activating.", { cause: error });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
const manifest = readReleaseManifest(join(directory, entry.name));
|
|
161
|
+
if (manifest.buildId === releaseId) {
|
|
162
|
+
return { releaseDir: join(directory, entry.name), manifest };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
/** Runs the atomic Controller handover for one installed release. */
|
|
172
|
+
export async function runReleaseActivate(home, releaseId, options = {}) {
|
|
173
|
+
let resolved;
|
|
174
|
+
try {
|
|
175
|
+
resolved = resolveInstalledRelease(home, releaseId);
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
return {
|
|
179
|
+
outcome: "aborted",
|
|
180
|
+
phase: "resolve",
|
|
181
|
+
message: error instanceof Error ? error.message : String(error),
|
|
182
|
+
action: "Reinstall the release; a drifted release cannot be activated.",
|
|
183
|
+
recoverable: true
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
if (resolved === null) {
|
|
187
|
+
return {
|
|
188
|
+
outcome: "aborted",
|
|
189
|
+
phase: "resolve",
|
|
190
|
+
message: `Release is not installed: ${releaseId}.`,
|
|
191
|
+
action: "Install it with `yui release install <source-dir>` first.",
|
|
192
|
+
recoverable: true
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
if (!existsSync(join(resolved.releaseDir, "smoke-receipt.json"))) {
|
|
196
|
+
return {
|
|
197
|
+
outcome: "aborted",
|
|
198
|
+
phase: "resolve",
|
|
199
|
+
message: `Release has no smoke receipt: ${releaseId}.`,
|
|
200
|
+
action: "Reinstall the release; the smoke receipt is written at install time.",
|
|
201
|
+
recoverable: true
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
const ports = createReleaseActivatePorts(options.ports);
|
|
205
|
+
return activateRelease(ports, {
|
|
206
|
+
home,
|
|
207
|
+
releaseDir: resolved.releaseDir,
|
|
208
|
+
manifest: resolved.manifest,
|
|
209
|
+
...(options.candidateReadyTimeoutMs === undefined
|
|
210
|
+
? {}
|
|
211
|
+
: { candidateReadyTimeoutMs: options.candidateReadyTimeoutMs }),
|
|
212
|
+
...(options.promotionTimeoutMs === undefined
|
|
213
|
+
? {}
|
|
214
|
+
: { promotionTimeoutMs: options.promotionTimeoutMs }),
|
|
215
|
+
...(options.dualOwnerGraceMs === undefined
|
|
216
|
+
? {}
|
|
217
|
+
: { dualOwnerGraceMs: options.dualOwnerGraceMs })
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
/** Renders the install result as concise CLI text. */
|
|
221
|
+
export function renderReleaseInstallResult(result) {
|
|
222
|
+
switch (result.outcome) {
|
|
223
|
+
case "installed":
|
|
224
|
+
return `Installed release ${result.releaseId} (build ${result.buildId}).`;
|
|
225
|
+
case "already-installed":
|
|
226
|
+
return `Release ${result.releaseId} is already installed.`;
|
|
227
|
+
case "aborted":
|
|
228
|
+
return `Release install aborted: ${result.message}\nAction: ${result.action}`;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/** Renders the list result as concise CLI text. */
|
|
232
|
+
export function renderReleaseList(result) {
|
|
233
|
+
if (result.releases.length === 0) {
|
|
234
|
+
return "No releases installed.";
|
|
235
|
+
}
|
|
236
|
+
const lines = result.releases.map((release) => {
|
|
237
|
+
const active = release.releaseId === result.active ? " (active)" : "";
|
|
238
|
+
const smoke = release.smoke ? "" : " [no smoke receipt]";
|
|
239
|
+
return ` ${release.releaseId}${active}${smoke}`;
|
|
240
|
+
});
|
|
241
|
+
return [`Installed releases:`, ...lines].join("\n");
|
|
242
|
+
}
|
|
243
|
+
/** Renders the activate result as concise CLI text. */
|
|
244
|
+
export function renderReleaseActivateResult(result) {
|
|
245
|
+
switch (result.outcome) {
|
|
246
|
+
case "activated":
|
|
247
|
+
return `Activated release ${result.releaseId} via handover ${result.handoverId}.`;
|
|
248
|
+
case "already-active":
|
|
249
|
+
return `Release ${result.releaseId} is already active.`;
|
|
250
|
+
case "dual-owner":
|
|
251
|
+
return [
|
|
252
|
+
`DUAL-OWNER after handover ${result.handoverId}: ${result.message}`,
|
|
253
|
+
`Action: ${result.action}`
|
|
254
|
+
].join("\n");
|
|
255
|
+
case "aborted":
|
|
256
|
+
return [
|
|
257
|
+
`Activation aborted during ${result.phase}: ${result.message}`,
|
|
258
|
+
result.recoverable
|
|
259
|
+
? "The previous Controller and active release pointer remain usable."
|
|
260
|
+
: "Manual recovery is required (see below).",
|
|
261
|
+
`Action: ${result.action}`
|
|
262
|
+
].join("\n");
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// Real ports
|
|
267
|
+
export function createReleaseActivatePorts(overrides = {}) {
|
|
268
|
+
const sleep = overrides.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
269
|
+
return Object.freeze({
|
|
270
|
+
call: overrides.call ?? ((home, method, params) => callController(home, method, params)),
|
|
271
|
+
spawnCandidate: overrides.spawnCandidate ?? ((home, releaseDir, handoverId) => {
|
|
272
|
+
spawnDetachedController(home, releaseDir, {
|
|
273
|
+
[CONTROLLER_CANDIDATE_ENV]: "1",
|
|
274
|
+
[CONTROLLER_HANDOVER_ID_ENV]: handoverId
|
|
275
|
+
});
|
|
276
|
+
}),
|
|
277
|
+
startControllerFromRelease: overrides.startControllerFromRelease
|
|
278
|
+
?? (async (home, releaseDir) => {
|
|
279
|
+
spawnDetachedController(home, releaseDir, {});
|
|
280
|
+
const deadline = Date.now() + 30_000;
|
|
281
|
+
const expectedBuildId = readReleaseManifest(releaseDir).buildId;
|
|
282
|
+
for (;;) {
|
|
283
|
+
try {
|
|
284
|
+
const status = await callController(home, "controller.status", {});
|
|
285
|
+
if (typeof status === "object" && status !== null && status.running === true) {
|
|
286
|
+
// Verify the running Controller is the target release, not a
|
|
287
|
+
// stale/previous Controller that happened to answer.
|
|
288
|
+
const identity = await callController(home, "controller.identity", {});
|
|
289
|
+
if (identity.buildId !== expectedBuildId) {
|
|
290
|
+
throw new Error(`Controller is running build ${String(identity.buildId)}, `
|
|
291
|
+
+ `not the target release build ${expectedBuildId}.`);
|
|
292
|
+
}
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
if (!isUnavailableError(error))
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
if (Date.now() >= deadline) {
|
|
301
|
+
throw new Error("Controller did not become ready within 30000 ms.");
|
|
302
|
+
}
|
|
303
|
+
await sleep(100);
|
|
304
|
+
}
|
|
305
|
+
}),
|
|
306
|
+
runPreflight: overrides.runPreflight ?? ((releaseDir, home) => {
|
|
307
|
+
const cli = join(releaseDir, "dist", "cli.js");
|
|
308
|
+
const result = spawnSync(process.execPath, [cli, "doctor", "--json"], {
|
|
309
|
+
env: { ...process.env, YUI_HOME: home, NO_COLOR: "1" },
|
|
310
|
+
encoding: "utf8",
|
|
311
|
+
timeout: 60_000
|
|
312
|
+
});
|
|
313
|
+
let report;
|
|
314
|
+
try {
|
|
315
|
+
const envelope = JSON.parse(result.stdout);
|
|
316
|
+
report = envelope.data;
|
|
317
|
+
}
|
|
318
|
+
catch (error) {
|
|
319
|
+
throw new Error(`Release preflight produced no JSON (exit ${result.status}): `
|
|
320
|
+
+ `${messageOf(error)} ${result.stderr.trim()}`);
|
|
321
|
+
}
|
|
322
|
+
// The handover preflight is a compatibility check: the new release must
|
|
323
|
+
// understand the Home's storage schema and layout. The four-state
|
|
324
|
+
// `storage compatibility` verdict is authoritative: it is `ok` for
|
|
325
|
+
// current, compatible-old, and migration-required (MIGRATABLE) Homes,
|
|
326
|
+
// and non-ok for needs-new-version or corrupted Homes. The strict
|
|
327
|
+
// `storage schema` check reads `unsupported` for a MIGRATABLE Home even
|
|
328
|
+
// though the Controller opens it through the compatible migration path,
|
|
329
|
+
// so gating on it would reject every older-but-upgradable Home. A
|
|
330
|
+
// missing `storage state` (a Home that never ran `yui setup`) is not a
|
|
331
|
+
// compatibility blocker — the new Controller initializes it.
|
|
332
|
+
const checks = report?.checks;
|
|
333
|
+
if (!Array.isArray(checks)) {
|
|
334
|
+
throw new Error("Release preflight report has no storage checks.");
|
|
335
|
+
}
|
|
336
|
+
const required = ["storage compatibility"];
|
|
337
|
+
const failed = required.filter((name) => {
|
|
338
|
+
const check = checks.find((candidate) => candidate !== null
|
|
339
|
+
&& typeof candidate === "object"
|
|
340
|
+
&& candidate.name === name);
|
|
341
|
+
return check === undefined
|
|
342
|
+
|| check.status !== "ok";
|
|
343
|
+
});
|
|
344
|
+
if (failed.length > 0) {
|
|
345
|
+
throw new Error(`Release preflight storage compatibility checks failed: ${failed.join(", ")}.`);
|
|
346
|
+
}
|
|
347
|
+
}),
|
|
348
|
+
killOwnedProcess: overrides.killOwnedProcess ?? ((owner) => {
|
|
349
|
+
if (readLinuxProcessStartIdentity(owner.pid) !== owner.processStartIdentity)
|
|
350
|
+
return;
|
|
351
|
+
process.kill(owner.pid, "SIGTERM");
|
|
352
|
+
}),
|
|
353
|
+
sleep,
|
|
354
|
+
now: overrides.now ?? (() => new Date())
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
const CONTROLLER_CANDIDATE_ENV = "YUI_CONTROLLER_CANDIDATE";
|
|
358
|
+
const CONTROLLER_HANDOVER_ID_ENV = "YUI_CONTROLLER_HANDOVER_ID";
|
|
359
|
+
const CONTROLLER_OPERATIONAL_ENV = [
|
|
360
|
+
"PATH",
|
|
361
|
+
"HOME",
|
|
362
|
+
"USER",
|
|
363
|
+
"LOGNAME",
|
|
364
|
+
"SHELL",
|
|
365
|
+
"TMPDIR",
|
|
366
|
+
"TMP",
|
|
367
|
+
"TEMP",
|
|
368
|
+
"TZ",
|
|
369
|
+
"LANG",
|
|
370
|
+
"TERM",
|
|
371
|
+
"YUI_TMUX_BIN"
|
|
372
|
+
];
|
|
373
|
+
function spawnDetachedController(home, releaseDir, extraEnv) {
|
|
374
|
+
// Fail closed before launching: a release that drifted from its manifest
|
|
375
|
+
// (corrupted or partially-deleted files) must never spawn a Controller.
|
|
376
|
+
// `runReleaseActivate` verifies at resolve time; this closes the window
|
|
377
|
+
// where the tree drifts between resolve and spawn and covers direct port
|
|
378
|
+
// callers that bypass `resolveInstalledRelease`.
|
|
379
|
+
verifyReleaseIntegrity(releaseDir);
|
|
380
|
+
const environment = {};
|
|
381
|
+
for (const name of CONTROLLER_OPERATIONAL_ENV) {
|
|
382
|
+
const value = process.env[name];
|
|
383
|
+
if (value !== undefined)
|
|
384
|
+
environment[name] = value;
|
|
385
|
+
}
|
|
386
|
+
environment.YUI_HOME = home;
|
|
387
|
+
Object.assign(environment, extraEnv);
|
|
388
|
+
const child = spawn(process.execPath, [join(releaseDir, "dist", "controller", "controllerMain.js")], { env: environment, detached: true, stdio: "ignore" });
|
|
389
|
+
child.unref();
|
|
390
|
+
}
|
|
391
|
+
function runInstallSmoke(releaseDir, expectedVersion) {
|
|
392
|
+
const cli = join(releaseDir, "dist", "cli.js");
|
|
393
|
+
const version = spawnSync(process.execPath, [cli, "version"], {
|
|
394
|
+
env: { ...process.env, NO_COLOR: "1" },
|
|
395
|
+
encoding: "utf8",
|
|
396
|
+
timeout: 30_000
|
|
397
|
+
});
|
|
398
|
+
if (version.status !== 0) {
|
|
399
|
+
return `cli version exited with ${version.status}: ${version.stderr.trim()}`;
|
|
400
|
+
}
|
|
401
|
+
if (version.stdout.trim() !== expectedVersion) {
|
|
402
|
+
return `cli version reported ${version.stdout.trim()}, expected ${expectedVersion}`;
|
|
403
|
+
}
|
|
404
|
+
const help = spawnSync(process.execPath, [cli, "help"], {
|
|
405
|
+
env: { ...process.env, NO_COLOR: "1" },
|
|
406
|
+
encoding: "utf8",
|
|
407
|
+
timeout: 30_000
|
|
408
|
+
});
|
|
409
|
+
if (help.status !== 0 || !help.stdout.includes("Yui")) {
|
|
410
|
+
return "cli help did not render.";
|
|
411
|
+
}
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
function copyRegularTree(source, target) {
|
|
415
|
+
mkdirSync(target, { recursive: true, mode: 0o755 });
|
|
416
|
+
for (const entry of readdirSync(source, { withFileTypes: true })) {
|
|
417
|
+
const sourceChild = join(source, entry.name);
|
|
418
|
+
const targetChild = join(target, entry.name);
|
|
419
|
+
if (entry.isDirectory()) {
|
|
420
|
+
copyRegularTree(sourceChild, targetChild);
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
if (!entry.isFile() || entry.isSymbolicLink()) {
|
|
424
|
+
throw new Error(`Release source contains an unsupported entry: ${sourceChild}.`);
|
|
425
|
+
}
|
|
426
|
+
const metadata = lstatSync(sourceChild);
|
|
427
|
+
cpSync(sourceChild, targetChild);
|
|
428
|
+
// cpSync's `mode` option is the COPYFILE flag bitmask, not a permission
|
|
429
|
+
// mode; apply the source permission bits after the copy.
|
|
430
|
+
chmodSync(targetChild, metadata.mode & 0o777);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
function isUnavailableError(error) {
|
|
434
|
+
return error instanceof ControllerClientError
|
|
435
|
+
|| (typeof error === "object"
|
|
436
|
+
&& error !== null
|
|
437
|
+
&& "code" in error
|
|
438
|
+
&& (error.code === "CONTROLLER_NOT_RUNNING"
|
|
439
|
+
|| error.code === "ECONNREFUSED"
|
|
440
|
+
|| error.code === "ECONNRESET"));
|
|
441
|
+
}
|
|
442
|
+
function messageOf(error) {
|
|
443
|
+
return error instanceof Error ? error.message : String(error);
|
|
444
|
+
}
|