@zq-silk/yui 0.6.6 → 0.6.7
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.
|
@@ -849,7 +849,7 @@ export const ROOT_COMMAND = buildNode({
|
|
|
849
849
|
sections: [{
|
|
850
850
|
id: "lifecycle",
|
|
851
851
|
title: "Commands",
|
|
852
|
-
entries: ["status", "cleanup", "identity", "stop", "restart"]
|
|
852
|
+
entries: ["status", "cleanup", "identity", "live-identity", "stop", "restart"]
|
|
853
853
|
}],
|
|
854
854
|
children: [
|
|
855
855
|
{
|
|
@@ -869,6 +869,11 @@ export const ROOT_COMMAND = buildNode({
|
|
|
869
869
|
summary: "Read the stable runtime identity receipt (build, backend, worker).",
|
|
870
870
|
hidden: true
|
|
871
871
|
},
|
|
872
|
+
{
|
|
873
|
+
name: "live-identity",
|
|
874
|
+
summary: "Read the authenticated live Controller launch identity.",
|
|
875
|
+
hidden: true
|
|
876
|
+
},
|
|
872
877
|
{ name: "stop", summary: "Stop the Controller." },
|
|
873
878
|
{ name: "restart", summary: "Restart internal services without stopping tmux sessions." }
|
|
874
879
|
]
|
|
@@ -33,7 +33,8 @@ function resolveExecutionPath(args) {
|
|
|
33
33
|
const internalExecutable = child !== undefined && ((node === ROOT_COMMAND && child.name === "internal")
|
|
34
34
|
|| (node.path.join(" ") === "yui completion" && child.name === "candidates")
|
|
35
35
|
|| (node.path.join(" ") === "yui task run" && child.name === "checkpoint")
|
|
36
|
-
|| (node.path.join(" ") === "yui controller"
|
|
36
|
+
|| (node.path.join(" ") === "yui controller"
|
|
37
|
+
&& (child.name === "identity" || child.name === "live-identity")));
|
|
37
38
|
if (child === undefined || (child.hidden && !internalExecutable)) {
|
|
38
39
|
if (node.kind === "hybrid" && node.acceptsArguments)
|
|
39
40
|
break;
|
package/dist/cli/updatePorts.js
CHANGED
|
@@ -261,10 +261,12 @@ export function createUpdatePorts(environment, spawn = spawnSync, stagingRoot =
|
|
|
261
261
|
}
|
|
262
262
|
},
|
|
263
263
|
// `runUpdate` is intentionally synchronous because the npm/staged-binary
|
|
264
|
-
// ports use spawnSync.
|
|
265
|
-
//
|
|
266
|
-
//
|
|
264
|
+
// ports use spawnSync. A short-lived child owns the async, exactly fenced
|
|
265
|
+
// reconciliation first; lifecycle capture then uses structured live
|
|
266
|
+
// Controller commands. Tests can replace these seams with deterministic
|
|
267
|
+
// fakes without touching a real Controller.
|
|
267
268
|
controllerStatus(home) {
|
|
269
|
+
reconcileControllerResourcesForUpdate(home, environment, spawn);
|
|
268
270
|
return readControllerLifecycle(home, environment, spawn);
|
|
269
271
|
},
|
|
270
272
|
stopController(home, expectedPid) {
|
|
@@ -285,6 +287,7 @@ export function createUpdatePorts(environment, spawn = spawnSync, stagingRoot =
|
|
|
285
287
|
}
|
|
286
288
|
const UPDATE_CLI_PATH = fileURLToPath(new URL("../cli.js", import.meta.url));
|
|
287
289
|
const UPDATE_CLIENT_RUNTIME_PATH = fileURLToPath(new URL("../controller/clientRuntime.js", import.meta.url));
|
|
290
|
+
const UPDATE_CONTROLLER_RECONCILIATION_PATH = fileURLToPath(new URL("../controller/updateReconciliation.js", import.meta.url));
|
|
288
291
|
/**
|
|
289
292
|
* Capture running state plus an authenticated exact process identity before
|
|
290
293
|
* stopping. Public `controller status` is an inventory and intentionally
|
|
@@ -330,18 +333,18 @@ function readControllerLifecycle(home, environment, spawn, cliBinary) {
|
|
|
330
333
|
if (current !== undefined) {
|
|
331
334
|
throw new Error("Controller inventory is current but has no process proof; treating ownership as unknown-active.");
|
|
332
335
|
}
|
|
333
|
-
return proveControllerAbsent(home, environment, spawn);
|
|
336
|
+
return proveControllerAbsent(home, environment, spawn, cliBinary);
|
|
334
337
|
}
|
|
335
|
-
const identity = parseControllerIdentity(runControllerCommand(home, environment, spawn, "identity", cliBinary));
|
|
338
|
+
const identity = parseControllerIdentity(runControllerCommand(home, environment, spawn, "live-identity", cliBinary));
|
|
336
339
|
return {
|
|
337
340
|
running: true,
|
|
338
341
|
...(isPositivePid(processInfo.pid) ? { pid: processInfo.pid } : {}),
|
|
339
342
|
identity
|
|
340
343
|
};
|
|
341
344
|
}
|
|
342
|
-
function proveControllerAbsent(home, environment, spawn) {
|
|
345
|
+
function proveControllerAbsent(home, environment, spawn, cliBinary) {
|
|
343
346
|
try {
|
|
344
|
-
runControllerCommand(home, environment, spawn, "identity");
|
|
347
|
+
runControllerCommand(home, environment, spawn, "live-identity", cliBinary);
|
|
345
348
|
}
|
|
346
349
|
catch (error) {
|
|
347
350
|
if (controllerErrorCode(error) === "CONTROLLER_NOT_RUNNING") {
|
|
@@ -349,7 +352,7 @@ function proveControllerAbsent(home, environment, spawn) {
|
|
|
349
352
|
}
|
|
350
353
|
throw new Error(`Controller absence could not be authenticated: ${messageOf(error)}`, { cause: error });
|
|
351
354
|
}
|
|
352
|
-
throw new Error("Controller identity is reachable but inventory is currentless; treating ownership as unknown-active.");
|
|
355
|
+
throw new Error("A live Controller identity is reachable but inventory is currentless; treating ownership as unknown-active.");
|
|
353
356
|
}
|
|
354
357
|
function stopControllerForUpdate(home, expectedPid, environment, spawn) {
|
|
355
358
|
// Parent update owns this lifecycle boundary. Invoke the runtime client
|
|
@@ -464,7 +467,7 @@ function restartControllerForUpdate(home, environment, spawn, activatedBinary, a
|
|
|
464
467
|
const replacementPid = parseReplacementPid(restart.data);
|
|
465
468
|
let identityFailure;
|
|
466
469
|
try {
|
|
467
|
-
const identity = parseControllerIdentity(runControllerCommand(home, environment, spawn, "identity", activatedBinary));
|
|
470
|
+
const identity = parseControllerIdentity(runControllerCommand(home, environment, spawn, "live-identity", activatedBinary));
|
|
468
471
|
assertActivatedControllerIdentity(identity, activatedBinary, activatedVersion);
|
|
469
472
|
}
|
|
470
473
|
catch (error) {
|
|
@@ -586,6 +589,57 @@ function runControllerCommand(home, environment, spawn, method, cliBinary) {
|
|
|
586
589
|
}
|
|
587
590
|
return parsed.data;
|
|
588
591
|
}
|
|
592
|
+
function reconcileControllerResourcesForUpdate(home, environment, spawn) {
|
|
593
|
+
const helper = [
|
|
594
|
+
"const values = process.argv.slice(1);",
|
|
595
|
+
"const home = values.pop();",
|
|
596
|
+
"const reconciliationModule = values.pop();",
|
|
597
|
+
"(async () => {",
|
|
598
|
+
" const { reconcileControllerResourcesForUpdate } = await import(reconciliationModule);",
|
|
599
|
+
" const data = await reconcileControllerResourcesForUpdate(home, process.env);",
|
|
600
|
+
" process.stdout.write(JSON.stringify({ ok: true, data }));",
|
|
601
|
+
"})().catch((error) => {",
|
|
602
|
+
" const message = error instanceof Error ? error.message : String(error);",
|
|
603
|
+
" process.stderr.write(JSON.stringify({ ok: false, code: 'RUNTIME_ERROR', message }));",
|
|
604
|
+
" process.exitCode = 5;",
|
|
605
|
+
"});"
|
|
606
|
+
].join(" ");
|
|
607
|
+
const result = spawn(process.execPath, ["-e", helper, UPDATE_CONTROLLER_RECONCILIATION_PATH, home], { cwd: process.cwd(), env: { ...environment, YUI_HOME: home }, shell: false });
|
|
608
|
+
if (result.error !== undefined || result.status !== 0) {
|
|
609
|
+
const detail = structuredErrorMessage(result) ?? result.stderr.toString("utf8").trim();
|
|
610
|
+
throw new Error(`Controller reconciliation failed (exit ${result.status ?? "null"})${detail.length === 0 ? "." : `: ${detail}`}`);
|
|
611
|
+
}
|
|
612
|
+
let parsed;
|
|
613
|
+
try {
|
|
614
|
+
parsed = JSON.parse(result.stdout.toString("utf8"));
|
|
615
|
+
}
|
|
616
|
+
catch (error) {
|
|
617
|
+
throw new Error("Controller reconciliation returned an invalid structured result.", {
|
|
618
|
+
cause: error
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
if (!isRecord(parsed)
|
|
622
|
+
|| parsed.ok !== true
|
|
623
|
+
|| !isRecord(parsed.data)
|
|
624
|
+
|| !Array.isArray(parsed.data.cleaned)
|
|
625
|
+
|| parsed.data.cleaned.some((id) => typeof id !== "string")) {
|
|
626
|
+
throw new Error("Controller reconciliation returned an invalid structured result.");
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
function structuredErrorMessage(result) {
|
|
630
|
+
for (const buffer of [result.stderr, result.stdout]) {
|
|
631
|
+
try {
|
|
632
|
+
const value = JSON.parse(buffer.toString("utf8"));
|
|
633
|
+
if (isRecord(value) && typeof value.message === "string" && value.message.length > 0) {
|
|
634
|
+
return value.message;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
catch {
|
|
638
|
+
// Fall back to the child's raw stderr below.
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return undefined;
|
|
642
|
+
}
|
|
589
643
|
function parseControllerIdentity(value) {
|
|
590
644
|
if (typeof value.executablePath !== "string"
|
|
591
645
|
|| value.executablePath.length === 0
|
package/dist/cli.js
CHANGED
|
@@ -301,6 +301,29 @@ export async function main() {
|
|
|
301
301
|
}
|
|
302
302
|
if (args[0] === "controller") {
|
|
303
303
|
const method = args[1];
|
|
304
|
+
if (method === "live-identity" && args.length === 2) {
|
|
305
|
+
try {
|
|
306
|
+
const identity = await callController(home, "controller.identity", {});
|
|
307
|
+
emit("", false, identity);
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
if (!(error instanceof ControllerClientError))
|
|
311
|
+
throw error;
|
|
312
|
+
if (jsonOutput) {
|
|
313
|
+
process.stderr.write(`${JSON.stringify({
|
|
314
|
+
ok: false,
|
|
315
|
+
code: error.code,
|
|
316
|
+
message: error.message,
|
|
317
|
+
details: {}
|
|
318
|
+
})}\n`);
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
process.stderr.write(`RUNTIME_ERROR: ${error.message}\n`);
|
|
322
|
+
}
|
|
323
|
+
process.exitCode = 5;
|
|
324
|
+
}
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
304
327
|
if (method === "identity" && args.length === 2) {
|
|
305
328
|
// Issue 02: the stable, read-only runtime identity receipt. It survives
|
|
306
329
|
// a Controller stop and answers build ID, package digest, backend, and
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { acquireHomeLifecycleLock } from "../core/controllerServer.js";
|
|
3
|
+
import { cleanControllerResource } from "./resourceCleanupLinux.js";
|
|
4
|
+
import { scanControllerResourceInventory } from "./resourceInventoryLinux.js";
|
|
5
|
+
const MAX_RECONCILIATION_PASSES = 4;
|
|
6
|
+
/**
|
|
7
|
+
* Reconcile only Controller-owned resources for the Home being updated.
|
|
8
|
+
*
|
|
9
|
+
* A current Controller is preserved for the update orchestrator's exact
|
|
10
|
+
* capture/stop handoff. Superseded/orphaned Controller processes and stale
|
|
11
|
+
* discovery/socket artifacts are cleaned using their existing process-start
|
|
12
|
+
* and inode fingerprints. Agent, tmux, app, and foreign-Home resources are
|
|
13
|
+
* deliberately outside this operation.
|
|
14
|
+
*/
|
|
15
|
+
export async function reconcileControllerResourcesForUpdate(home, environment = process.env) {
|
|
16
|
+
const resolvedHome = resolve(home);
|
|
17
|
+
const releaseLock = await acquireHomeLifecycleLock(resolvedHome, {
|
|
18
|
+
removeStaleOwner: true
|
|
19
|
+
});
|
|
20
|
+
const cleaned = new Set();
|
|
21
|
+
try {
|
|
22
|
+
for (let pass = 0; pass < MAX_RECONCILIATION_PASSES; pass += 1) {
|
|
23
|
+
const snapshot = await scanControllerResourceInventory({
|
|
24
|
+
currentHome: resolvedHome,
|
|
25
|
+
scope: "current",
|
|
26
|
+
environment
|
|
27
|
+
});
|
|
28
|
+
assertCertainSnapshot(snapshot, resolvedHome);
|
|
29
|
+
const resources = controllerResources(snapshot, resolvedHome);
|
|
30
|
+
const controllers = resources.filter(({ kind }) => kind === "controller");
|
|
31
|
+
const current = controllers.filter(({ state }) => state === "current");
|
|
32
|
+
if (current.length > 1) {
|
|
33
|
+
throw reconciliationBlocked(`multiple current Controllers were reported (${resourceLabels(current)})`);
|
|
34
|
+
}
|
|
35
|
+
const historical = controllers.filter(({ state }) => state !== "current");
|
|
36
|
+
const historicalCleanup = historical.filter(isCleanupEligible);
|
|
37
|
+
const unsafeHistorical = historical.filter((resource) => !isCleanupEligible(resource));
|
|
38
|
+
if (unsafeHistorical.length > 0) {
|
|
39
|
+
throw reconciliationBlocked(`historical Controller ownership is not safely cleanable (${resourceLabels(unsafeHistorical)})`);
|
|
40
|
+
}
|
|
41
|
+
const artifacts = resources.filter(isControllerArtifact);
|
|
42
|
+
const staleArtifactCleanup = artifacts.filter((resource) => (resource.state === "stale" && isCleanupEligible(resource)));
|
|
43
|
+
const unresolvedArtifacts = artifacts.filter((resource) => (!staleArtifactCleanup.includes(resource)));
|
|
44
|
+
// A corrupt discovery is conservatively marked active while an orphan
|
|
45
|
+
// Controller still exists. Remove the exactly fenced historical process
|
|
46
|
+
// first; the next scan can then reclassify and remove the stale artifact.
|
|
47
|
+
if (unresolvedArtifacts.length > 0 && historicalCleanup.length === 0) {
|
|
48
|
+
throw reconciliationBlocked(`a Controller artifact is active or ownership is unknown (${resourceLabels(unresolvedArtifacts)})`);
|
|
49
|
+
}
|
|
50
|
+
const candidates = [...historicalCleanup, ...staleArtifactCleanup];
|
|
51
|
+
if (candidates.length === 0) {
|
|
52
|
+
return { cleaned: [...cleaned] };
|
|
53
|
+
}
|
|
54
|
+
for (const candidate of candidates) {
|
|
55
|
+
try {
|
|
56
|
+
await cleanControllerResource(candidate, { environment });
|
|
57
|
+
cleaned.add(candidate.id);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
// A concurrent exact cleanup that already reached the desired state
|
|
61
|
+
// is harmless. Anything still present or reclassified is a real
|
|
62
|
+
// ownership change and must remain a user-visible blocker.
|
|
63
|
+
const afterFailure = await scanControllerResourceInventory({
|
|
64
|
+
currentHome: resolvedHome,
|
|
65
|
+
scope: "current",
|
|
66
|
+
environment
|
|
67
|
+
});
|
|
68
|
+
assertCertainSnapshot(afterFailure, resolvedHome);
|
|
69
|
+
if (!controllerResources(afterFailure, resolvedHome).some(({ id }) => id === candidate.id)) {
|
|
70
|
+
cleaned.add(candidate.id);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
throw reconciliationBlocked(`resource ${candidate.id} changed or could not be cleaned: ${messageOf(error)}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
throw reconciliationBlocked("Controller resources did not converge after bounded cleanup");
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
await releaseLock();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function assertCertainSnapshot(snapshot, resolvedHome) {
|
|
84
|
+
if (snapshot.scope !== "current"
|
|
85
|
+
|| resolve(snapshot.currentHome) !== resolvedHome) {
|
|
86
|
+
throw reconciliationBlocked("the Controller inventory returned a mismatched Home or scope");
|
|
87
|
+
}
|
|
88
|
+
if (snapshot.warnings.length > 0) {
|
|
89
|
+
throw reconciliationBlocked(`the Controller inventory is uncertain: ${snapshot.warnings.join("; ")}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function controllerResources(snapshot, resolvedHome) {
|
|
93
|
+
return snapshot.resources.filter((resource) => (resource.yuiHome === resolvedHome
|
|
94
|
+
&& (resource.kind === "controller" || isControllerArtifact(resource))));
|
|
95
|
+
}
|
|
96
|
+
function isControllerArtifact(resource) {
|
|
97
|
+
return resource.kind === "artifact"
|
|
98
|
+
&& (resource.artifact?.artifactKind === "controller-discovery"
|
|
99
|
+
|| resource.artifact?.artifactKind === "controller-socket");
|
|
100
|
+
}
|
|
101
|
+
function isCleanupEligible(resource) {
|
|
102
|
+
return resource.disposition === "safe" || resource.disposition === "review";
|
|
103
|
+
}
|
|
104
|
+
function resourceLabels(resources) {
|
|
105
|
+
return resources.map((resource) => `${resource.id}:${resource.reasonCode}`).join(", ");
|
|
106
|
+
}
|
|
107
|
+
function reconciliationBlocked(reason) {
|
|
108
|
+
return new Error(`Automatic Controller reconciliation is blocked because ${reason}. `
|
|
109
|
+
+ "Run `yui controller status --verbose` and resolve only the reported current-Home resource before retrying.");
|
|
110
|
+
}
|
|
111
|
+
function messageOf(error) {
|
|
112
|
+
return error instanceof Error ? error.message : String(error);
|
|
113
|
+
}
|
|
@@ -763,7 +763,7 @@ function safeErrorMessage(message) {
|
|
|
763
763
|
.slice(0, 512);
|
|
764
764
|
return safe.length === 0 ? undefined : safe;
|
|
765
765
|
}
|
|
766
|
-
async function acquireHomeLifecycleLock(home) {
|
|
766
|
+
export async function acquireHomeLifecycleLock(home, options = {}) {
|
|
767
767
|
const lockPath = homeLifecycleLockPath(home);
|
|
768
768
|
await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 });
|
|
769
769
|
const owner = Object.freeze({
|
|
@@ -796,6 +796,12 @@ async function acquireHomeLifecycleLock(home) {
|
|
|
796
796
|
if (isProcessAlive(existing.pid)) {
|
|
797
797
|
throw new Error(`Another Yui home lifecycle operation is already running (${ownerDescription}): ${lockPath}`);
|
|
798
798
|
}
|
|
799
|
+
if (options.removeStaleOwner === true) {
|
|
800
|
+
// The token comparison in releaseHomeLifecycleLock is the CAS fence: a
|
|
801
|
+
// replacement owner that appeared after the read is never removed.
|
|
802
|
+
await releaseHomeLifecycleLock(lockPath, existing);
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
799
805
|
throw new Error(`A previous Yui home lifecycle operation left a stale lock `
|
|
800
806
|
+ `(${ownerDescription}): ${lockPath}. `
|
|
801
807
|
+ "If no Controller startup or development reset is running, "
|