@codeyam-editor/codeyam-editor 0.1.10 → 0.1.11-staging.g91da934
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/npm/cloud.js +68 -16
- package/npm/fleet-add-failure.js +156 -11
- package/npm/fleet-auth.js +41 -1
- package/npm/fleet-reconcile.js +31 -5
- package/npm/fleet-setup-steps.js +27 -2
- package/npm/fleet-vm-readiness.js +27 -0
- package/npm/launch-config.js +111 -12
- package/npm/provisioning-contract.js +148 -0
- package/npm/scenario-check.js +7 -4
- package/package.json +5 -5
- package/ui/dist/assets/{ContainerInspector-DiN91foX.js → ContainerInspector-DDpjRqCQ.js} +1 -1
- package/ui/dist/assets/EditorShell-CPGtQxxJ.js +330 -0
- package/ui/dist/assets/EditorShell-lndJDLqT.css +1 -0
- package/ui/dist/assets/ScenarioDataPanel-B-z3Cnjs.js +21 -0
- package/ui/dist/assets/{ScenarioDataPanelFullPage-CHIWip3n.js → ScenarioDataPanelFullPage-CYc2qM-E.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-BW7R0yhZ.js +21 -0
- package/ui/dist/assets/index-DvkmhQJ8.css +1 -0
- package/ui/dist/assets/{index-sW8wcg7v.js → index-wh9iIy0M.js} +7 -7
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/EditorShell-BEIdHnHd.css +0 -1
- package/ui/dist/assets/EditorShell-CPV5vhjo.js +0 -330
- package/ui/dist/assets/ScenarioDataPanel-DHAHyraq.js +0 -18
- package/ui/dist/assets/ScenarioRenderer-D9a91iwS.js +0 -21
- package/ui/dist/assets/index--cE0hUJw.css +0 -1
package/npm/cloud.js
CHANGED
|
@@ -115,6 +115,9 @@ const { addToRoster, readRoster } = require("./fleet-roster");
|
|
|
115
115
|
// container) apart from a real `docker compose up -d` failure, so the abort path
|
|
116
116
|
// can converge instead of escalating to instance deletion.
|
|
117
117
|
const { classifyComposeUpFailure } = require("./compose-conflict");
|
|
118
|
+
// The two streamed bootstrap scripts, named once for this file's STREAMED_SCRIPTS
|
|
119
|
+
// and for the advance's provisioning pre-flight, which checks the same set.
|
|
120
|
+
const { BOOTSTRAP_SCRIPT, PROJECT_BOOTSTRAP_SCRIPT } = require("./provisioning-contract");
|
|
118
121
|
|
|
119
122
|
// =============================================================================
|
|
120
123
|
// Pure helpers — every export here is unit-tested in cloud.test.js without
|
|
@@ -1540,6 +1543,7 @@ async function waitForIapReady(cfg) {
|
|
|
1540
1543
|
const deadline = Date.now() + 180_000;
|
|
1541
1544
|
let attempts = 0;
|
|
1542
1545
|
let lastErr = "";
|
|
1546
|
+
const probeStderrs = [];
|
|
1543
1547
|
while (Date.now() < deadline) {
|
|
1544
1548
|
attempts++;
|
|
1545
1549
|
const probe = runGcloud(
|
|
@@ -1550,17 +1554,42 @@ async function waitForIapReady(cfg) {
|
|
|
1550
1554
|
console.error(` ✓ IAP ready (${attempts} ${attempts === 1 ? "probe" : "probes"})`);
|
|
1551
1555
|
return;
|
|
1552
1556
|
}
|
|
1553
|
-
|
|
1557
|
+
const stderr = String(probe.stderr || "").trim();
|
|
1558
|
+
probeStderrs.push(stderr);
|
|
1559
|
+
lastErr = stderr.split("\n").slice(-1)[0] || "";
|
|
1554
1560
|
// Exponential-ish backoff: 2s, 3s, 5s, 8s, then cap at 8s.
|
|
1555
1561
|
const delay = Math.min(2000 * Math.pow(1.5, attempts - 1), 8000);
|
|
1556
1562
|
await new Promise((r) => setTimeout(r, delay));
|
|
1557
1563
|
}
|
|
1564
|
+
const cause = iapProbeCause(probeStderrs);
|
|
1558
1565
|
throw new Error(
|
|
1559
1566
|
`IAP did not become ready for ${cfg.instanceName} within 180s ` +
|
|
1560
|
-
`(${attempts} probes;
|
|
1567
|
+
`(${attempts} probes; ${cause ? `likely cause: ${cause}; ` : ""}` +
|
|
1568
|
+
`last error: ${lastErr || "unknown"})`,
|
|
1561
1569
|
);
|
|
1562
1570
|
}
|
|
1563
1571
|
|
|
1572
|
+
/** Name the likely cause behind a run of failed IAP SSH probes. The last stderr
|
|
1573
|
+
* line alone is almost always `ssh exited with return code [255]`, which
|
|
1574
|
+
* collapses three different waits — each with its own expected duration — into
|
|
1575
|
+
* one unactionable string. The full stderr of each probe still distinguishes
|
|
1576
|
+
* them. Scans newest-first so the label describes where the wait ENDED (IAP lag
|
|
1577
|
+
* often gives way to key propagation on a fresh box). Returns null when no
|
|
1578
|
+
* signature matches; the raw last line is kept beside it either way. */
|
|
1579
|
+
function iapProbeCause(probeStderrs) {
|
|
1580
|
+
const signatures = [
|
|
1581
|
+
[/4047|Failed to lookup instance/i, "IAP has not registered the instance yet"],
|
|
1582
|
+
[/Permission denied \(publickey/i, "OS Login SSH key not propagated yet"],
|
|
1583
|
+
[/Connection (refused|closed|reset)/i, "sshd on the instance not accepting connections yet"],
|
|
1584
|
+
];
|
|
1585
|
+
for (const text of [...(probeStderrs || [])].reverse()) {
|
|
1586
|
+
for (const [re, label] of signatures) {
|
|
1587
|
+
if (re.test(text)) return label;
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
return null;
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1564
1593
|
/** Read `process.env[FORWARD_ENV_KEYS_ENV]` (comma-separated key list set by
|
|
1565
1594
|
* the fleet dashboard) and return the subset of `process.env` that should
|
|
1566
1595
|
* ride along into the VM's project-bootstrap shell. Whitelisted to the
|
|
@@ -2216,6 +2245,20 @@ function buildReconcileAdminCommand(repoName, value) {
|
|
|
2216
2245
|
);
|
|
2217
2246
|
}
|
|
2218
2247
|
|
|
2248
|
+
// Every library `scripts/gcp-project-bootstrap.sh` sources, keyed by the path it
|
|
2249
|
+
// sources it AS — i.e. relative to the script's own directory, which on the VM is
|
|
2250
|
+
// `$HOME`. A streamed script is a SINGLE FILE, so each of these must be shipped
|
|
2251
|
+
// beside it or the script dies at its source line and cloud:up tears the
|
|
2252
|
+
// freshly-created VM back down. Adding a `source` line to that script without
|
|
2253
|
+
// adding it here has now broken every add on the fleet twice (`lib/npm-pin.sh`
|
|
2254
|
+
// 2026-09-04, `lib/render-cloud-compose.sh` 2026-09-18), so this list is the one
|
|
2255
|
+
// place to register it: STREAMED_SCRIPTS derives its library entries from this
|
|
2256
|
+
// constant rather than repeating them, and a unit test reads the shipped script
|
|
2257
|
+
// and fails on any source line missing from it. Keep it a ONE-LINE array literal:
|
|
2258
|
+
// the advance's provisioning pre-flight reads it out of a branch's cloud.js TEXT
|
|
2259
|
+
// (provisioning-contract.js → parseProjectBootstrapLibs).
|
|
2260
|
+
const PROJECT_BOOTSTRAP_LIBS = ["lib/npm-pin.sh", "lib/render-cloud-compose.sh"];
|
|
2261
|
+
|
|
2219
2262
|
async function ensureProjectBootstrapped(cfg, { sleepFn } = {}) {
|
|
2220
2263
|
const repoName = resolveProject(cfg).name;
|
|
2221
2264
|
console.error(`Checking project bootstrap state for ${repoName}...`);
|
|
@@ -2245,13 +2288,16 @@ async function ensureProjectBootstrapped(cfg, { sleepFn } = {}) {
|
|
|
2245
2288
|
console.error(` streaming scripts/gcp-project-bootstrap.sh to VM for ${cfg.repo}...`);
|
|
2246
2289
|
const scriptPath = path.join(rootDir, "scripts", "gcp-project-bootstrap.sh");
|
|
2247
2290
|
const scriptBody = readStreamedScript(scriptPath, cfg);
|
|
2248
|
-
// gcp-project-bootstrap.sh sources
|
|
2249
|
-
// directory. Read
|
|
2250
|
-
// to the same drift guard as the script that sources it — streaming a
|
|
2251
|
-
// remote script beside a stale on-disk library would defeat the guard.
|
|
2252
|
-
const bootstrapLibs =
|
|
2253
|
-
|
|
2254
|
-
|
|
2291
|
+
// gcp-project-bootstrap.sh sources its libraries relative to its own
|
|
2292
|
+
// directory. Read them through readStreamedScript too, so each library is
|
|
2293
|
+
// subject to the same drift guard as the script that sources it — streaming a
|
|
2294
|
+
// reviewed remote script beside a stale on-disk library would defeat the guard.
|
|
2295
|
+
const bootstrapLibs = Object.fromEntries(
|
|
2296
|
+
PROJECT_BOOTSTRAP_LIBS.map((rel) => [
|
|
2297
|
+
rel,
|
|
2298
|
+
readStreamedScript(path.join(rootDir, "scripts", ...rel.split("/")), cfg),
|
|
2299
|
+
]),
|
|
2300
|
+
);
|
|
2255
2301
|
const scriptArgs = clientProjectBootstrapArgs(cfg);
|
|
2256
2302
|
// Git identity, forwarded launch-form keys, fleet-domain ingress and the admin
|
|
2257
2303
|
// toggle — see projectBootstrapEnv for what each key is for and when it is
|
|
@@ -3635,14 +3681,18 @@ function promptForInstanceName(expected) {
|
|
|
3635
3681
|
}
|
|
3636
3682
|
|
|
3637
3683
|
// Every file streamed to a VM, and therefore every file the drift guard must
|
|
3638
|
-
// compare against the reviewed remote tip.
|
|
3639
|
-
//
|
|
3640
|
-
//
|
|
3641
|
-
//
|
|
3684
|
+
// compare against the reviewed remote tip. The libraries are here because
|
|
3685
|
+
// gcp-project-bootstrap.sh SOURCES them: a library that reaches the VM is as
|
|
3686
|
+
// much streamed content as the script that sources it, and leaving one out meant
|
|
3687
|
+
// the drift guard silently vouched for only half of what was shipped. They are
|
|
3688
|
+
// DERIVED from PROJECT_BOOTSTRAP_LIBS rather than listed again — when the two
|
|
3689
|
+
// were maintained separately, a library added to neither took the whole fleet's
|
|
3690
|
+
// provisioning down, and a library added to only one would have been shipped
|
|
3691
|
+
// unvouched-for.
|
|
3642
3692
|
const STREAMED_SCRIPTS = [
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
3693
|
+
BOOTSTRAP_SCRIPT,
|
|
3694
|
+
PROJECT_BOOTSTRAP_SCRIPT,
|
|
3695
|
+
...PROJECT_BOOTSTRAP_LIBS.map((rel) => `scripts/${rel}`),
|
|
3646
3696
|
];
|
|
3647
3697
|
|
|
3648
3698
|
/** The configured editor branch to compare/stream streamed scripts against.
|
|
@@ -5647,6 +5697,7 @@ async function cloudMain() {
|
|
|
5647
5697
|
module.exports = {
|
|
5648
5698
|
// pure helpers
|
|
5649
5699
|
isSpawnTimeout,
|
|
5700
|
+
iapProbeCause,
|
|
5650
5701
|
gcloudCallTimeoutMs,
|
|
5651
5702
|
provisionFailureMessage,
|
|
5652
5703
|
GCLOUD_PROVISION_TIMEOUT_MS,
|
|
@@ -5760,6 +5811,7 @@ module.exports = {
|
|
|
5760
5811
|
gatherScriptDriftFacts,
|
|
5761
5812
|
readStreamedScript,
|
|
5762
5813
|
STREAMED_SCRIPTS,
|
|
5814
|
+
PROJECT_BOOTSTRAP_LIBS,
|
|
5763
5815
|
// ingress wiring (pure decision + spawn wrappers)
|
|
5764
5816
|
envPosInt,
|
|
5765
5817
|
ingressWiringPlan,
|
package/npm/fleet-add-failure.js
CHANGED
|
@@ -47,6 +47,13 @@ const { parseRosterMarkers } = require('./fleet-early-roster');
|
|
|
47
47
|
// `ensureBootstrapped` skips on the present image, and
|
|
48
48
|
// `ensureProjectBootstrapped` re-attaches to the in-flight install rather
|
|
49
49
|
// than starting a second `npm install` over the first.
|
|
50
|
+
// `iap-not-ready` is the same ground in its strongest form: the throw comes
|
|
51
|
+
// from waitForIapReady, which ensureInstance calls BEFORE ensureBootstrapped,
|
|
52
|
+
// ensureProjectBootstrapped or ensureEditorContainerUp — so the provable state
|
|
53
|
+
// is "an instance exists and nothing else has run". The retry reuses that
|
|
54
|
+
// live instance and every later step runs for the first time. (VM-4,
|
|
55
|
+
// 2026-09-17: destroy + an identical re-add succeeded in 10 minutes; the
|
|
56
|
+
// failure was a transient IAP/SSH race, not a broken box.)
|
|
50
57
|
//
|
|
51
58
|
// Ground 2 is deliberately narrow — it is NOT "the VM probably still works".
|
|
52
59
|
// `container-stuck` and `incomplete-workspace` also leave a live VM, but their
|
|
@@ -56,6 +63,7 @@ const ADD_FAILURE_RETRYABLE = new Set([
|
|
|
56
63
|
'instance-vanished',
|
|
57
64
|
'capacity-stockout',
|
|
58
65
|
'project-bootstrap-timeout',
|
|
66
|
+
'iap-not-ready',
|
|
59
67
|
]);
|
|
60
68
|
|
|
61
69
|
// Collect the ` - <path>` bullets the drift guard prints AFTER the "following
|
|
@@ -350,6 +358,92 @@ function wedgedInsertRecovery({ code, instance } = {}) {
|
|
|
350
358
|
);
|
|
351
359
|
}
|
|
352
360
|
|
|
361
|
+
// Facts for the iap-not-ready arm: waitForIapReady (npm/cloud.js) threw its
|
|
362
|
+
// readiness timeout. Returns { timedOut, instance, budgetSecs, cause } — `cause` is
|
|
363
|
+
// the label waitForIapReady derived from its probe stderr (null when none of its
|
|
364
|
+
// signatures matched, or on a transcript from before the label existed), so the
|
|
365
|
+
// recovery copy can name WHICH wait was blown instead of only `return code [255]`.
|
|
366
|
+
//
|
|
367
|
+
// The signature is emitted from exactly one place, and that place runs before any
|
|
368
|
+
// bootstrap work, which is what lets this arm assert the instance is bare.
|
|
369
|
+
const IAP_NOT_READY = /IAP did not become ready for (\S+) within (\d+)s/;
|
|
370
|
+
|
|
371
|
+
function iapNotReadyFacts(logTail) {
|
|
372
|
+
const m = String(logTail || '').match(IAP_NOT_READY);
|
|
373
|
+
if (!m) return { timedOut: false, instance: null, budgetSecs: null, cause: null };
|
|
374
|
+
const cause = (String(logTail).match(/likely cause: ([^;)]+)/) || [])[1] || null;
|
|
375
|
+
return { timedOut: true, instance: m[1], budgetSecs: Number(m[2]), cause: cause && cause.trim() };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Operator-facing recovery copy for the iap-not-ready arm. It states what the
|
|
379
|
+
// generic arm cannot: the instance EXISTS and is BARE. "A partial instance may
|
|
380
|
+
// exist" is what steered VM-4's operator toward destroy + re-add (15-25 minutes)
|
|
381
|
+
// when Retry resumes on the same box in seconds — and it left nothing on the card
|
|
382
|
+
// saying a RUNNING, billing instance was sitting there.
|
|
383
|
+
function iapNotReadyRecovery({ instance, budgetSecs, cause } = {}) {
|
|
384
|
+
const named = instance || 'The instance';
|
|
385
|
+
const within = budgetSecs ? ` within ${budgetSecs}s` : '';
|
|
386
|
+
const why = cause ? ` (likely cause: ${cause})` : '';
|
|
387
|
+
return (
|
|
388
|
+
`${named} was created and is RUNNING, but SSH over IAP did not answer${within}${why}. This is usually a transient ` +
|
|
389
|
+
'control-plane race. Nothing was installed on it — the wait runs before any bootstrap step — so it is a bare box and ' +
|
|
390
|
+
'there is nothing partial to reconcile. Retry add resumes on this same instance rather than creating a second one. ' +
|
|
391
|
+
'Until you retry or destroy it, the instance keeps running and billing.'
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Facts for the streamed-library-missing arm: a streamed bootstrap script sourced
|
|
396
|
+
// a `$HOME`-rooted `lib/…` file that was never shipped to the VM, and bash
|
|
397
|
+
// reported `<script>: line N: <path>: No such file or directory`. This took ALL
|
|
398
|
+
// fleet provisioning down twice (lib/npm-pin.sh 2026-09-04, lib/render-cloud-
|
|
399
|
+
// compose.sh 2026-09-18). Returns { missing, script, library } — `script` is the
|
|
400
|
+
// basename of the aborting script, `library` the `lib/…` path relative to $HOME,
|
|
401
|
+
// which is how PROJECT_BOOTSTRAP_LIBS (npm/cloud.js) names its entries.
|
|
402
|
+
const STREAMED_LIBRARY_MISSING = /(\S+): line \d+: \/(?:home\/[^/\s]+|root)\/(lib\/[^\s:]+): No such file or directory/;
|
|
403
|
+
|
|
404
|
+
function streamedLibraryMissingFacts(logTail) {
|
|
405
|
+
const m = String(logTail || '').match(STREAMED_LIBRARY_MISSING);
|
|
406
|
+
if (!m) return { missing: false, script: null, library: null };
|
|
407
|
+
return { missing: true, script: m[1].split('/').pop(), library: m[2] };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Did cloud:up report that it deleted the instance it created this run? The abort
|
|
411
|
+
// path (npm/cloud.js) prints the teardown marker BEFORE it issues the delete, and
|
|
412
|
+
// prints a `during abort teardown` warning when that delete fails — so the marker
|
|
413
|
+
// alone is a claim of intent, and only the marker WITHOUT the warning is evidence
|
|
414
|
+
// that no orphan was left behind.
|
|
415
|
+
function cleanTeardownReported(logTail) {
|
|
416
|
+
const log = String(logTail || '');
|
|
417
|
+
return /tearing down the VM cloud:up created this run \(no orphan left behind\)/.test(log)
|
|
418
|
+
&& !/during abort teardown/.test(log);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Operator-facing recovery copy for the streamed-library-missing arm. It names
|
|
422
|
+
// what to fix — register the library so it is streamed — and, when the transcript
|
|
423
|
+
// proves a clean teardown, says there is no orphan to hunt for. Every add on this
|
|
424
|
+
// branch fails identically until the registry is fixed, so it never offers Retry.
|
|
425
|
+
function streamedLibraryMissingRecovery({ script, library, cleanTeardown } = {}) {
|
|
426
|
+
const lib = library || 'a lib/ file';
|
|
427
|
+
const from = script ? ` sourced by ${script}` : '';
|
|
428
|
+
const orphan = cleanTeardown
|
|
429
|
+
? 'cloud:up already deleted the VM it created, so there is nothing to reconcile or destroy.'
|
|
430
|
+
: 'The transcript does not confirm the VM was deleted — check whether a partial instance exists before re-adding.';
|
|
431
|
+
return (
|
|
432
|
+
`${lib}${from} was never shipped to the VM, so the bootstrap aborted. Add it to PROJECT_BOOTSTRAP_LIBS in npm/cloud.js, ` +
|
|
433
|
+
`push, and re-add — every add from this branch fails the same way until then. ${orphan}`
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Operator-facing recovery copy for the generic cloud-up-failed arm. "A partial
|
|
438
|
+
// instance may exist" is only true when the transcript does not show cloud:up
|
|
439
|
+
// deleting what it created; saying it regardless sends the operator hunting for
|
|
440
|
+
// an orphan that is not there.
|
|
441
|
+
function cloudUpFailedRecovery({ cleanTeardown } = {}) {
|
|
442
|
+
return cleanTeardown
|
|
443
|
+
? 'cloud:up failed during provisioning and deleted the VM it created, so nothing was left behind. Read the transcript for the cause before re-adding.'
|
|
444
|
+
: 'cloud:up failed during provisioning, so a partial instance may exist. Reconcile or destroy this VM before re-adding.';
|
|
445
|
+
}
|
|
446
|
+
|
|
353
447
|
// Build a verdict, stamping `retryable` from the single-source-of-truth Set so it
|
|
354
448
|
// can never drift from the category.
|
|
355
449
|
function buildFailureVerdict(category, headline, recovery, extra) {
|
|
@@ -392,7 +486,12 @@ function buildFailureVerdict(category, headline, recovery, extra) {
|
|
|
392
486
|
// instance. `wedged-insert` (arm 8) is the third: it sits directly above the
|
|
393
487
|
// generic arm so a create GCE accepted-and-abandoned cannot report as an ordinary
|
|
394
488
|
// provisioning failure whose advice is to destroy the box — a destroy that, on a
|
|
395
|
-
// wedged slot, queues behind the wedge instead of clearing it.
|
|
489
|
+
// wedged slot, queues behind the wedge instead of clearing it. `iap-not-ready`
|
|
490
|
+
// (arm 9) is the fourth: it keeps a bare, retry-safe instance from reporting as a
|
|
491
|
+
// possibly-partial one whose only recovery is destroy + re-add.
|
|
492
|
+
// `streamed-library-missing` (arm 10) is the fifth: it names the unshipped library
|
|
493
|
+
// that broke every add, instead of advising a hunt for an orphan cloud:up already
|
|
494
|
+
// deleted.
|
|
396
495
|
function classifyAddFailure({ logTail, exitCode, instanceAlive, containerHealthy, wiringDeadlineBlown, wiringInFlight } = {}) {
|
|
397
496
|
const log = String(logTail || '');
|
|
398
497
|
|
|
@@ -554,28 +653,64 @@ function classifyAddFailure({ logTail, exitCode, instanceAlive, containerHealthy
|
|
|
554
653
|
wedgedInsertRecovery(wedgedInsert));
|
|
555
654
|
}
|
|
556
655
|
|
|
557
|
-
// 9.
|
|
558
|
-
//
|
|
559
|
-
//
|
|
656
|
+
// 9. iap-not-ready — the instance reached RUNNING but IAP-tunneled SSH never
|
|
657
|
+
// answered inside waitForIapReady's budget. That wait is called from
|
|
658
|
+
// ensureInstance before any bootstrap work, so the box exists and is bare.
|
|
659
|
+
// Retryable on the re-entrancy ground (see ADD_FAILURE_RETRYABLE); the budget
|
|
660
|
+
// was already raised 60s → 180s for this race, and VM-4 still blew it, so the
|
|
661
|
+
// answer is a cheap retry rather than a longer wait for every dead add.
|
|
662
|
+
//
|
|
663
|
+
// Tested BEFORE cloud-up-failed for the reason arm 6 documents: the provision
|
|
664
|
+
// shell echoes cloud:up's tail into every transcript, so matching the generic
|
|
665
|
+
// line first refuses the safe retry — exactly the VM-4 failure.
|
|
666
|
+
const iapNotReady = iapNotReadyFacts(log);
|
|
667
|
+
if (iapNotReady.timedOut) {
|
|
668
|
+
return buildFailureVerdict('iap-not-ready', 'SSH never became reachable',
|
|
669
|
+
iapNotReadyRecovery(iapNotReady));
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// 10. streamed-library-missing — a streamed bootstrap script sourced a `lib/…`
|
|
673
|
+
// file that was never shipped to the VM. The failure that has taken ALL fleet
|
|
674
|
+
// provisioning down twice (2026-09-04, 2026-09-18); both times it fell through
|
|
675
|
+
// to cloud-up-failed, whose recovery told six operators to hunt for a partial
|
|
676
|
+
// instance cloud:up had already deleted, and said nothing about the cause.
|
|
677
|
+
//
|
|
678
|
+
// NOT retryable: re-issuing the same add streams the same scripts without the
|
|
679
|
+
// library and fails identically — the missing-branch-ref reasoning. The fix is
|
|
680
|
+
// a code change (register the library), which is what the recovery names.
|
|
681
|
+
//
|
|
682
|
+
// Tested BEFORE cloud-up-failed for the reason arm 6 documents: the provision
|
|
683
|
+
// shell echoes cloud:up's tail into every transcript, so matching the generic
|
|
684
|
+
// line first is precisely the bug being fixed.
|
|
685
|
+
const missingLib = streamedLibraryMissingFacts(log);
|
|
686
|
+
if (missingLib.missing) {
|
|
687
|
+
return buildFailureVerdict('streamed-library-missing', `Bootstrap library never shipped: ${missingLib.library}`,
|
|
688
|
+
streamedLibraryMissingRecovery({ ...missingLib, cleanTeardown: cleanTeardownReported(log) }));
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// 11. cloud-up-failed — a generic cloud:up failure, reached ONLY when the drift /
|
|
692
|
+
// vanished / stuck / stockout / missing-ref / wedged-insert / iap-not-ready /
|
|
693
|
+
// streamed-library signatures above did not match. Not retryable. Whether a
|
|
694
|
+
// partial instance may exist is read off the transcript's teardown evidence.
|
|
560
695
|
if (/cloud:up FAILED \(exit/i.test(log)) {
|
|
561
696
|
return buildFailureVerdict('cloud-up-failed', 'Provisioning failed',
|
|
562
|
-
|
|
697
|
+
cloudUpFailedRecovery({ cleanTeardown: cleanTeardownReported(log) }));
|
|
563
698
|
}
|
|
564
699
|
|
|
565
|
-
//
|
|
700
|
+
// 12. interrupted — a signal kill (OOM 137, SIGTERM 143, …) or an explicit
|
|
566
701
|
// Killed / out-of-memory marker. The real VM state is unknown. Not retryable.
|
|
567
702
|
if (fc.isSignalKillExit(Number(exitCode)) || /\bKilled\b/.test(log) || /Out of memory/i.test(log)) {
|
|
568
703
|
return buildFailureVerdict('interrupted', 'Provisioning interrupted',
|
|
569
704
|
'The provision was interrupted (killed or out of memory), so the VM state is unknown. Reconcile or destroy this VM before re-adding.');
|
|
570
705
|
}
|
|
571
706
|
|
|
572
|
-
//
|
|
707
|
+
// 13. The post-add IAP read-back verdicts. Unlike every signature above, this one
|
|
573
708
|
// is not produced by the provision transcript at all — it is the BLOCKED
|
|
574
709
|
// reason iapReadbackOutcome hands to annotateAddFailure, which is what
|
|
575
710
|
// stops a read-back failure from being the one add failure that reaches the
|
|
576
711
|
// card with no category at all (and so no differentiated headline, no
|
|
577
712
|
// recovery, no guarded Retry, and no chance for holdConvergingBadge to hold it
|
|
578
|
-
// calm). Tested AFTER
|
|
713
|
+
// calm). Tested AFTER every hard-failure signature so a proven failure
|
|
579
714
|
// always wins if both texts are ever present, and BEFORE converging/verifying
|
|
580
715
|
// because it is strictly more specific than either.
|
|
581
716
|
if (/IAP policy authorizes no human/i.test(log)) {
|
|
@@ -599,7 +734,7 @@ function classifyAddFailure({ logTail, exitCode, instanceAlive, containerHealthy
|
|
|
599
734
|
`The VM is up but its IAP policy authorizes no human, so the public URL returns "Error code 51" for everyone. Its background wiring is no longer running, so this will not resolve on its own — grant access to fix it. Do NOT destroy or re-add this VM.${remedy ? `\n\n${remedy}` : ''}`);
|
|
600
735
|
}
|
|
601
736
|
|
|
602
|
-
//
|
|
737
|
+
// 14. converging — the add reached the early-roster marker (`CY_ROSTER`): the
|
|
603
738
|
// container gate passed, the instance is real, and the VM is rostered, yet it
|
|
604
739
|
// exited non-zero WITHOUT matching any specific hard-failure signature above.
|
|
605
740
|
// By construction of the provision script, every genuine hard failure (drift,
|
|
@@ -617,7 +752,7 @@ function classifyAddFailure({ logTail, exitCode, instanceAlive, containerHealthy
|
|
|
617
752
|
'The container is up and the VM is rostered; the LB ingress is still wiring in the background. This resolves on its own once the editor is reachable over the LB — no action needed unless it persists past the wiring deadline.');
|
|
618
753
|
}
|
|
619
754
|
|
|
620
|
-
//
|
|
755
|
+
// 15. The residue: no failure signature above matched. The polarity here is
|
|
621
756
|
// INVERTED from the original design. It used to read "no recognized success
|
|
622
757
|
// signature ⇒ failed", which made red the DEFAULT for "we don't know" — so a
|
|
623
758
|
// VM whose container was Up and whose editor answered /api/health 200 got a
|
|
@@ -739,4 +874,14 @@ function wedgedRetryMessage(n) {
|
|
|
739
874
|
);
|
|
740
875
|
}
|
|
741
876
|
|
|
742
|
-
|
|
877
|
+
// Refusal copy for a one-click "Retry add" after a streamed-library abort,
|
|
878
|
+
// extracted for the same reason wedgedRetryMessage was. The retry is refused
|
|
879
|
+
// because every add from this branch fails identically until the library is
|
|
880
|
+
// registered, and the generic refusal would again send the operator after an
|
|
881
|
+
// orphan cloud:up already deleted. Repeats the classified recovery, which names
|
|
882
|
+
// the fix and says whether anything was left behind. Pure; no I/O.
|
|
883
|
+
function streamedLibraryRetryMessage(n, recovery) {
|
|
884
|
+
return `VM-${n} cannot be retried until the code is fixed: ${recovery || 'a streamed bootstrap library was never shipped to the VM.'}`;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
module.exports = { classifyAddFailure, extractDriftFiles, lastNonEmptyLines, canRetryAdd, transcriptRostered, holdConvergingBadge, addFailureCorroborated, containerHealthWitness, stockoutCapacityPhrase, missingBranchRefFacts, missingBranchRefRecovery, deadRefRetryMessage, wedgedInsertFacts, wedgedInsertRecovery, wedgedRetryMessage, iapNotReadyFacts, iapNotReadyRecovery, streamedLibraryMissingFacts, cleanTeardownReported, streamedLibraryMissingRecovery, cloudUpFailedRecovery, streamedLibraryRetryMessage, CALM_ADD_CATEGORIES, ADD_FAILURE_RETRYABLE };
|
package/npm/fleet-auth.js
CHANGED
|
@@ -133,18 +133,56 @@ function listAvailableAccounts(vms) {
|
|
|
133
133
|
// Pick the credential donor for a chosen account: the lowest-numbered VM in
|
|
134
134
|
// `reachableDonors` whose authenticated account is `email`, or null when no
|
|
135
135
|
// reachable VM holds it (or no email was asked for). `vms` is the per-VM poll
|
|
136
|
-
// state keyed by VM number (`state.vms`).
|
|
136
|
+
// state keyed by VM number (`state.vms`). A donor whose credential failed the
|
|
137
|
+
// integrity probe (`providerAuth.credentialIntact === false`) is skipped as
|
|
138
|
+
// though it were not authenticated — its credential cannot be copied, so the
|
|
139
|
+
// sink would refuse it after an add had already committed to it. An unprobed
|
|
140
|
+
// verdict (null/absent) is usable: a probe failure is not evidence. Pure.
|
|
137
141
|
function selectDonorForAccount(reachableDonors, vms, email) {
|
|
138
142
|
if (!email) return null;
|
|
139
143
|
const want = String(email).toLowerCase();
|
|
140
144
|
for (const n of [...(reachableDonors || [])].sort((a, b) => a - b)) {
|
|
141
145
|
const pa = vms && vms[n] && vms[n].providerAuth;
|
|
146
|
+
if (pa && pa.credentialIntact === false) continue;
|
|
142
147
|
const got = pa && pa.state === 'authenticated' && pa.account && pa.account.email;
|
|
143
148
|
if (got && String(got).toLowerCase() === want) return n;
|
|
144
149
|
}
|
|
145
150
|
return null;
|
|
146
151
|
}
|
|
147
152
|
|
|
153
|
+
// Split the reachable donor candidates into those whose credential can be
|
|
154
|
+
// copied and those the integrity probe failed, so the add can drop the failed
|
|
155
|
+
// ones BEFORE resolveCredsSource's first-donor fallback could pick one, and name
|
|
156
|
+
// each dropped donor in the add log. Order is preserved; unprobed is usable.
|
|
157
|
+
// Returns { usable: number[], skipped: number[] }. Pure.
|
|
158
|
+
function partitionDonorsByIntegrity(reachableDonors, vms) {
|
|
159
|
+
const usable = [];
|
|
160
|
+
const skipped = [];
|
|
161
|
+
for (const n of reachableDonors || []) {
|
|
162
|
+
const pa = vms && vms[n] && vms[n].providerAuth;
|
|
163
|
+
if (pa && pa.credentialIntact === false) skipped.push(n);
|
|
164
|
+
else usable.push(n);
|
|
165
|
+
}
|
|
166
|
+
return { usable, skipped };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Fold the dashboard's credential-integrity verdict into a VM's editor-reported
|
|
170
|
+
// providerAuth record. The editor derives `state` from whether the access token
|
|
171
|
+
// works NOW, so a credential with no refresh token still reads `authenticated`
|
|
172
|
+
// (VM-11, 2026-09-17) — a VM that will strand at its next token expiry and that
|
|
173
|
+
// no add can copy from. When the probe says the credential is incomplete, the
|
|
174
|
+
// card must say what the remedy is: `needs-login`. `credentialIntact` rides on
|
|
175
|
+
// the record either way (null = unprobed) so donor selection reads one place.
|
|
176
|
+
// Returns null for a null auth (editor didn't answer). Pure.
|
|
177
|
+
function applyCredentialIntegrity(auth, credentialIntact) {
|
|
178
|
+
if (!auth) return null;
|
|
179
|
+
const intact = credentialIntact === true || credentialIntact === false ? credentialIntact : null;
|
|
180
|
+
if (intact === false && auth.state === 'authenticated') {
|
|
181
|
+
return { ...auth, state: 'needs-login', credentialIntact: false };
|
|
182
|
+
}
|
|
183
|
+
return { ...auth, credentialIntact: intact };
|
|
184
|
+
}
|
|
185
|
+
|
|
148
186
|
// A plausible Claude account email: one "@", a dotted domain, no whitespace or
|
|
149
187
|
// shell-significant characters. The shared guard for every place an operator
|
|
150
188
|
// can name an account (the Add form's per-launch override, the set-default
|
|
@@ -178,6 +216,8 @@ module.exports = {
|
|
|
178
216
|
deriveDefaultAuthAccount,
|
|
179
217
|
listAvailableAccounts,
|
|
180
218
|
selectDonorForAccount,
|
|
219
|
+
partitionDonorsByIntegrity,
|
|
220
|
+
applyCredentialIntegrity,
|
|
181
221
|
isAccountEmail,
|
|
182
222
|
resolveChosenAccount,
|
|
183
223
|
isAuthStartable,
|
package/npm/fleet-reconcile.js
CHANGED
|
@@ -72,7 +72,21 @@ function strandedAdoptDecision({ stranded, streaks, threshold } = {}) {
|
|
|
72
72
|
// only would be the opposite mistake: reasons like `claude auth copy failed on
|
|
73
73
|
// VM-2 (…)` are already recorded in live snapshots and carry no prefix at all,
|
|
74
74
|
// so both shapes have to match.
|
|
75
|
+
//
|
|
76
|
+
// FIRST MATCH WINS, so the anchored structured prefixes come before the loose
|
|
77
|
+
// free-text patterns: a `binary-stale:` reason names a branch, and a branch
|
|
78
|
+
// called `fix-login` must not route it to the auth witness.
|
|
79
|
+
//
|
|
80
|
+
// `binary-stale:` (gcp-bootstrap.sh's report_binary_staleness) describes the
|
|
81
|
+
// binary installed AT ADD TIME. Its witness is that binary having since been
|
|
82
|
+
// replaced: a deployment record installed after the add finished. Every path
|
|
83
|
+
// that writes that record after provisioning — the operator refresh, and so any
|
|
84
|
+
// fleet advance — refuses to install an artifact behind its branch tip, so a
|
|
85
|
+
// later install is positive evidence the stale binary is gone. A record from
|
|
86
|
+
// the provision itself predates `finishedAt` and proves nothing, and no record
|
|
87
|
+
// at all is silence, which never clears a reason.
|
|
75
88
|
const REASON_WITNESSES = [
|
|
89
|
+
{ match: /^\s*binary-stale\b/i, witness: 'binaryReplaced' },
|
|
76
90
|
{ match: /^\s*claude-auth-not-seeded\b/i, witness: 'authed' },
|
|
77
91
|
{ match: /claude auth|claude\.json|login/i, witness: 'authed' },
|
|
78
92
|
{ match: /container|never came up|provisioning failed/i, witness: 'reachableServing' },
|
|
@@ -81,11 +95,21 @@ const REASON_WITNESSES = [
|
|
|
81
95
|
// Map a single degraded reason to the live health signal that resolves it.
|
|
82
96
|
// Returns true only when that signal now passes; a reason with no mapped signal
|
|
83
97
|
// returns false (retained — a genuine config problem, never a false-green).
|
|
84
|
-
function reasonClearedByHealth(reason,
|
|
98
|
+
function reasonClearedByHealth(reason, witnesses) {
|
|
85
99
|
const r = String(reason || '');
|
|
86
100
|
const entry = REASON_WITNESSES.find((e) => e.match.test(r));
|
|
87
101
|
if (!entry) return false;
|
|
88
|
-
return entry.witness
|
|
102
|
+
return !!witnesses[entry.witness];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// True only when the VM's live deployment record was installed strictly after
|
|
106
|
+
// the add job finished. Either timestamp missing or unparseable → false: an
|
|
107
|
+
// unprovable replacement is not a replacement.
|
|
108
|
+
function binaryReplacedAfter(installedAt, finishedAt) {
|
|
109
|
+
const installed = Date.parse(installedAt || '');
|
|
110
|
+
const finished = Date.parse(finishedAt || '');
|
|
111
|
+
if (Number.isNaN(installed) || Number.isNaN(finished)) return false;
|
|
112
|
+
return installed > finished;
|
|
89
113
|
}
|
|
90
114
|
|
|
91
115
|
// Given a terminal add-job badge and the VM's live health, return the corrected
|
|
@@ -94,8 +118,8 @@ function reasonClearedByHealth(reason, { reachableServing, authed }) {
|
|
|
94
118
|
// `error` add to `done` when the VM is reachable + serving; leaves any reason
|
|
95
119
|
// with no health signal intact (no false-green). The poll loop supplies the live
|
|
96
120
|
// facts (I/O at the edge) and applies the result; the verdict itself is pure.
|
|
97
|
-
// job: { state: 'done'|'error', degraded, degradedReasons }
|
|
98
|
-
// health: { reachable, editorHttpOk, providerAuthState }
|
|
121
|
+
// job: { state: 'done'|'error', degraded, degradedReasons, finishedAt }
|
|
122
|
+
// health: { reachable, editorHttpOk, providerAuthState, deploymentInstalledAt }
|
|
99
123
|
// → null | { state, degraded, degradedReasons, retiredReasons, transition }
|
|
100
124
|
//
|
|
101
125
|
// `retiredReasons` names the reasons this verdict just dropped. Without it a
|
|
@@ -108,6 +132,7 @@ function reconcileTerminalAddBadge({ job, health } = {}) {
|
|
|
108
132
|
if (!job || !health) return null;
|
|
109
133
|
const reachableServing = !!(health.reachable && health.editorHttpOk);
|
|
110
134
|
const authed = health.providerAuthState === 'authenticated';
|
|
135
|
+
const binaryReplaced = binaryReplacedAfter(health.deploymentInstalledAt, job.finishedAt);
|
|
111
136
|
|
|
112
137
|
// Bare `error` add (no degraded reasons): flip to `done` once the VM is
|
|
113
138
|
// reachable + serving — the VM3 case (a manual `cloud:up` recovered the editor
|
|
@@ -133,7 +158,8 @@ function reconcileTerminalAddBadge({ job, health } = {}) {
|
|
|
133
158
|
const remaining = [];
|
|
134
159
|
const retired = [];
|
|
135
160
|
for (const reason of reasons) {
|
|
136
|
-
const
|
|
161
|
+
const cleared = reasonClearedByHealth(reason, { reachableServing, authed, binaryReplaced });
|
|
162
|
+
const bucket = cleared ? retired : remaining;
|
|
137
163
|
bucket.push(reason);
|
|
138
164
|
}
|
|
139
165
|
if (retired.length === 0) return null; // nothing cleared → no-op
|
package/npm/fleet-setup-steps.js
CHANGED
|
@@ -39,6 +39,31 @@ function parseDriftReason(reason) {
|
|
|
39
39
|
return m ? { expected: m[1].trim(), running: m[2].trim() } : { expected: null, running: null };
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
// The Claude-auth reason shapes that classify as the calm `auth` setup step.
|
|
43
|
+
// The structured `claude-auth-not-seeded` prefix is what every producer emits
|
|
44
|
+
// now; the two anchored free-text shapes are what `buildCredsSteps`
|
|
45
|
+
// (npm/launch-config.js) emitted before it carried the prefix, and they are
|
|
46
|
+
// already persisted in live add-job snapshots, so they must keep classifying.
|
|
47
|
+
//
|
|
48
|
+
// NARROW AND ANCHORED, ON PURPOSE — do not "unify" these with the broad
|
|
49
|
+
// `/claude auth|claude\.json|login/i` in npm/fleet-reconcile.js. Over-matching
|
|
50
|
+
// there only retires a reason once a live witness proves the VM authenticated,
|
|
51
|
+
// so a false positive costs nothing. Here it is inverted: over-matching routes a
|
|
52
|
+
// genuine problem into the calm checklist and hides it.
|
|
53
|
+
const AUTH_REASON_PATTERNS = [
|
|
54
|
+
/^claude-auth-not-seeded/,
|
|
55
|
+
/^claude auth copy failed\b/,
|
|
56
|
+
/^claude\.json copy failed\b/,
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
// True when a degraded reason means "this VM's only gap is Claude login". The
|
|
60
|
+
// single match rule for the `auth` step — server.js's auto-kick and login
|
|
61
|
+
// follow-up gates call this rather than hand-rolling their own prefix test.
|
|
62
|
+
function isAuthReason(reason) {
|
|
63
|
+
const r = String(reason);
|
|
64
|
+
return AUTH_REASON_PATTERNS.some((re) => re.test(r));
|
|
65
|
+
}
|
|
66
|
+
|
|
42
67
|
// Partition degradedReasons into { setupSteps, attentionReasons }.
|
|
43
68
|
//
|
|
44
69
|
// setupSteps ordered [{ code, label, detail, action? }] — expected
|
|
@@ -54,7 +79,7 @@ function classifyReasons(degradedReasons = []) {
|
|
|
54
79
|
const list = Array.isArray(degradedReasons) ? degradedReasons : [];
|
|
55
80
|
for (const raw of list) {
|
|
56
81
|
const reason = String(raw);
|
|
57
|
-
if (reason
|
|
82
|
+
if (isAuthReason(reason)) {
|
|
58
83
|
setupSteps.push({ code: 'auth', label: 'Claude login', detail: reason });
|
|
59
84
|
} else if (reason.startsWith('ingress-unwired')) {
|
|
60
85
|
// System-handled: the dashboard reconcile wires the public URL
|
|
@@ -109,4 +134,4 @@ function onlySetupCode(degradedReasons = [], code) {
|
|
|
109
134
|
&& setupSteps.every((s) => s.code === code);
|
|
110
135
|
}
|
|
111
136
|
|
|
112
|
-
module.exports = { classifyReasons, hasOnlySetup, onlySetupCode, SETUP_CODES, SCAFFOLD_PROJECT_DIR };
|
|
137
|
+
module.exports = { classifyReasons, isAuthReason, hasOnlySetup, onlySetupCode, SETUP_CODES, SCAFFOLD_PROJECT_DIR };
|
|
@@ -33,6 +33,11 @@
|
|
|
33
33
|
// 'unreachable' — the editor did not answer over the tunnel. Nothing
|
|
34
34
|
// downstream (broker, LB health) is meaningful; the link is
|
|
35
35
|
// gated exactly as today.
|
|
36
|
+
// 'access-locked' — reachable, but the editor's /api/health reports
|
|
37
|
+
// `accessModeUndeclared: true`: no access mode is declared on
|
|
38
|
+
// its public bind, so it issues no session and serves only the
|
|
39
|
+
// "configure an access mode" page (a 200). Remedy is a config
|
|
40
|
+
// declaration — never a restart, rebuild, or re-wire.
|
|
36
41
|
// 'half-wired' — LB mode, the editor backend service is ABSENT (never
|
|
37
42
|
// created). The permanent 503 case — the public URL will fail
|
|
38
43
|
// until re-wired. The state that had NO signal before.
|
|
@@ -88,6 +93,7 @@
|
|
|
88
93
|
const READINESS_STATES = [
|
|
89
94
|
'provisioning',
|
|
90
95
|
'unreachable',
|
|
96
|
+
'access-locked',
|
|
91
97
|
'half-wired',
|
|
92
98
|
'iap-access-missing',
|
|
93
99
|
'preview-iap-access-missing',
|
|
@@ -176,6 +182,19 @@ function agentRosterReady(verdict) {
|
|
|
176
182
|
return null;
|
|
177
183
|
}
|
|
178
184
|
|
|
185
|
+
// Read the access-mode lockout off a parsed /api/health body as a tri-state:
|
|
186
|
+
// true — the editor reports it issues a session to no one (no access mode
|
|
187
|
+
// declared on a public bind). Feeds the 'access-locked' readiness.
|
|
188
|
+
// false — the editor reports it is NOT locked out.
|
|
189
|
+
// null — no body (probe failed) or a body without a boolean field (a binary
|
|
190
|
+
// that predates it). Absence of evidence, never a verdict either way,
|
|
191
|
+
// so an older VM keeps today's card instead of reading as locked.
|
|
192
|
+
function readAccessModeUndeclared(healthBody) {
|
|
193
|
+
if (!healthBody || typeof healthBody !== 'object') return null;
|
|
194
|
+
const flag = healthBody.accessModeUndeclared;
|
|
195
|
+
return typeof flag === 'boolean' ? flag : null;
|
|
196
|
+
}
|
|
197
|
+
|
|
179
198
|
// A VM mid-provision: an Add (incl. its recovering re-run) that is queued
|
|
180
199
|
// (number reserved, cloud:up not started) or running. Its public URL has no
|
|
181
200
|
// meaningful readiness yet, so this takes precedence over every other signal —
|
|
@@ -201,6 +220,13 @@ function deriveVmReadiness(v, ctx) {
|
|
|
201
220
|
// 2. Editor unreachable over the tunnel: we polled nothing, so broker / LB
|
|
202
221
|
// health are unknowable. Link gated, exactly as the pre-readiness card did.
|
|
203
222
|
if (!v || !v.reachable) return 'unreachable';
|
|
223
|
+
// 2b. Reachable, but the editor itself reports it serves no one: no access
|
|
224
|
+
// mode is declared on its public bind, so `/` is the "configure an access
|
|
225
|
+
// mode" page — a 200, which is why every HTTP check read it as healthy.
|
|
226
|
+
// Ahead of the LB / broker / CLI checks because none of their remedies
|
|
227
|
+
// help: only declaring a mode does. Only an EXPLICIT `true` fires; a
|
|
228
|
+
// binary predating the field leaves it null and falls through.
|
|
229
|
+
if (v.accessModeUndeclared === true) return 'access-locked';
|
|
204
230
|
// 3. LB mode only: the public URL's fate is the editor backend's LB health.
|
|
205
231
|
// Skipped entirely in legacy mode (no LB) or before the first probe lands
|
|
206
232
|
// (no backendHealth) — both fall through to the reachability/broker path.
|
|
@@ -275,4 +301,5 @@ module.exports = {
|
|
|
275
301
|
isProvisioning,
|
|
276
302
|
classifyAgentUsability,
|
|
277
303
|
agentRosterReady,
|
|
304
|
+
readAccessModeUndeclared,
|
|
278
305
|
};
|