@codeyam-editor/codeyam-editor 0.1.13 → 0.1.14-staging.g3274a6f
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 +223 -89
- package/npm/fleet-api-budget.js +21 -8
- package/npm/fleet-build-data.js +22 -2
- package/npm/fleet-dev-server-health.js +86 -0
- package/npm/fleet-host-busy.js +52 -0
- package/npm/fleet-invoked-by.js +20 -0
- package/npm/fleet-queue.js +12 -2
- package/npm/fleet-status-refs.js +47 -0
- package/npm/google-ingress.js +84 -0
- package/npm/scenario-check.js +831 -31
- package/npm/scenario-metrics.js +64 -0
- package/npm/scenario-mocks.js +4 -0
- package/npm/scenario-playwright.js +313 -10
- package/npm/utils.js +2 -0
- package/package.json +5 -5
- package/ui/dist/assets/{ContainerInspector-DKH4LjcY.js → ContainerInspector-9w8TlRwl.js} +2 -2
- package/ui/dist/assets/CyLogo-BIbFVvgW.js +1 -0
- package/ui/dist/assets/EditorShell-CmEoMxKh.js +366 -0
- package/ui/dist/assets/EditorShell-DTyhLLc8.css +1 -0
- package/ui/dist/assets/Markdown-BaLq9qkt.js +1 -0
- package/ui/dist/assets/ProjectLauncherScreen-DP25uSQm.js +1 -0
- package/ui/dist/assets/ScenarioDataPanel-BM7COEfv.js +21 -0
- package/ui/dist/assets/{ScenarioDataPanelFullPage-DrhY8Okl.js → ScenarioDataPanelFullPage-DkysGs6X.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-M3HRiuEn.js +26 -0
- package/ui/dist/assets/{StepsSimulator-CymGgfbu.js → StepsSimulator-CcD_CR_X.js} +1 -1
- package/ui/dist/assets/index-Bk0fHYF3.css +1 -0
- package/ui/dist/assets/index-CqKB9WVm.js +27 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/EditorShell-BwNMzsnF.js +0 -286
- package/ui/dist/assets/EditorShell-lndJDLqT.css +0 -1
- package/ui/dist/assets/Markdown-Chgdf3PW.js +0 -1
- package/ui/dist/assets/ProjectLauncherScreen-E0NYYiwy.js +0 -1
- package/ui/dist/assets/ScenarioDataPanel-CtYXcbIr.js +0 -21
- package/ui/dist/assets/ScenarioRenderer-kBIhG1qo.js +0 -26
- package/ui/dist/assets/index-4TZsSjHA.css +0 -1
- package/ui/dist/assets/index-mgNPDFlH.js +0 -27
package/npm/cloud.js
CHANGED
|
@@ -935,6 +935,62 @@ function abortTeardownDecision({ createdThisRun, containerUp, rostered, probeInc
|
|
|
935
935
|
return "delete";
|
|
936
936
|
}
|
|
937
937
|
|
|
938
|
+
/** Fold what the abort-causing error proves into the probed VM state, giving
|
|
939
|
+
* the full {@link abortTeardownDecision} input (minus `createdThisRun`).
|
|
940
|
+
*
|
|
941
|
+
* - `remoteCommandNeverRan` — set by a fatal ssh step that classified a
|
|
942
|
+
* transport failure, or sniffed from a message that carries the quota text.
|
|
943
|
+
* - `sshCauseUnknown` — a fatal ssh step could not establish whether its
|
|
944
|
+
* command ran. That is absence of evidence too, so it also raises
|
|
945
|
+
* `probeInconclusive` (fail SAFE) rather than adding a parallel verdict input.
|
|
946
|
+
* Pure: `(err, state) → state`, so the err → verdict path is testable without
|
|
947
|
+
* running cloud:up. */
|
|
948
|
+
function abortEvidence(err, state = {}) {
|
|
949
|
+
const remoteCommandNeverRan =
|
|
950
|
+
Boolean(err && err.remoteCommandNeverRan) || isGcloudTransportFailure(err && err.message);
|
|
951
|
+
const sshCauseUnknown = Boolean(err && err.sshCauseUnknown);
|
|
952
|
+
return {
|
|
953
|
+
...state,
|
|
954
|
+
probeInconclusive: Boolean(state.probeInconclusive) || sshCauseUnknown,
|
|
955
|
+
remoteCommandNeverRan,
|
|
956
|
+
sshCauseUnknown,
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/** The operator-facing line for a VM this run created but the abort handler
|
|
961
|
+
* KEPT. Deliberately loud and specific: the old blanket "tearing down the VM
|
|
962
|
+
* cloud:up created this run (no orphan left behind)" was actively misleading —
|
|
963
|
+
* it asserted no orphan while deleting a working VM. When nothing was learned
|
|
964
|
+
* about the VM (the step never ran, or its cause could not be established), the
|
|
965
|
+
* likely-right next move is to finish provisioning it in place, so the line
|
|
966
|
+
* names that recovery (`converge-vm.sh <n>` for a fleet VM). `state` is
|
|
967
|
+
* {@link abortEvidence}'s output. Pure. */
|
|
968
|
+
function abortKeptMessage({ instanceName, vmNumber, state = {} } = {}) {
|
|
969
|
+
const why = state.remoteCommandNeverRan
|
|
970
|
+
? "the failing step never reached the VM (a gcloud transport/quota error), so nothing shows the VM is broken"
|
|
971
|
+
: state.sshCauseUnknown
|
|
972
|
+
? "the failing ssh step could not establish its cause (the command may never have run), so nothing shows the VM is broken"
|
|
973
|
+
: state.probeInconclusive
|
|
974
|
+
? "its liveness could not be confirmed (probe failed), and an unconfirmed VM is never deleted"
|
|
975
|
+
: state.containerUp
|
|
976
|
+
? "its editor container is RUNNING — another run brought it up"
|
|
977
|
+
: "it is in the fleet roster and visible to the dashboard";
|
|
978
|
+
const nothingLearned = state.remoteCommandNeverRan || state.sshCauseUnknown;
|
|
979
|
+
const recover = nothingLearned
|
|
980
|
+
? ` To finish provisioning it in place: ${
|
|
981
|
+
vmNumber != null
|
|
982
|
+
? `scripts/operator/converge-vm.sh ${vmNumber}`
|
|
983
|
+
: `npm run cloud:up -- --instance-name ${instanceName}`
|
|
984
|
+
}\n`
|
|
985
|
+
: "";
|
|
986
|
+
return (
|
|
987
|
+
`✗ provisioning aborted — the VM ${instanceName} was LEFT IN PLACE because ${why}.\n` +
|
|
988
|
+
` This run created the instance, but that alone does not authorize deleting a VM that is in service.\n` +
|
|
989
|
+
recover +
|
|
990
|
+
` If it really should go, remove it deliberately: npm run cloud:destroy`
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
|
|
938
994
|
/** Gather the CURRENT-STATE inputs {@link abortTeardownDecision} needs, so the
|
|
939
995
|
* abort handler reads gather → decide → act instead of inlining probes among
|
|
940
996
|
* the delete calls.
|
|
@@ -1340,6 +1396,80 @@ async function runGcloudSshWithRetry(verb, cfg, extras, { stdio, retries = 5, la
|
|
|
1340
1396
|
return lastResult;
|
|
1341
1397
|
}
|
|
1342
1398
|
|
|
1399
|
+
/** What a failed ssh step proves about the VM, from its captured result:
|
|
1400
|
+
* - `'transport'` — the gcloud client or the ssh connection failed before the
|
|
1401
|
+
* remote command ran (an OS Login quota 429, a refused/reset connection), so
|
|
1402
|
+
* nothing about the VM has been learned.
|
|
1403
|
+
* - `'unknown'` — ssh itself exited 255 with no recognized signature, the call
|
|
1404
|
+
* was killed/never spawned (no status), or gcloud reported an error of its
|
|
1405
|
+
* own (`ERROR: (gcloud.…)` — an auth or IAP refusal exits 1 like a remote
|
|
1406
|
+
* command would). Whether the remote command ran cannot be established.
|
|
1407
|
+
* - `'remote'` — the remote command ran and exited non-zero: real evidence.
|
|
1408
|
+
* Pure, so the matrix is testable without a VM. Only meaningful for a non-zero
|
|
1409
|
+
* result; the caller has already branched on success. */
|
|
1410
|
+
function classifySshStepFailure(result) {
|
|
1411
|
+
if (isTransientSshFailure(result)) return "transport";
|
|
1412
|
+
if (!result || result.status == null || result.status === 255) return "unknown";
|
|
1413
|
+
if (/ERROR: \(gcloud\./.test(String(result.stderr || ""))) return "unknown";
|
|
1414
|
+
return "remote";
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
/** Run a provisioning ssh step whose failure is FATAL — it throws, and cloud:up's
|
|
1418
|
+
* abort handler then decides whether to delete the VM. Two guards keep that
|
|
1419
|
+
* decision honest, and every fatal step needs both (VM-8 on 2026-10-01 and VM-37
|
|
1420
|
+
* on 2026-10-08 were the same OS Login 429 at two different steps, a week apart,
|
|
1421
|
+
* each at a call site that had neither):
|
|
1422
|
+
*
|
|
1423
|
+
* 1. The retry wrapper, so a transient quota 429 is retried instead of being
|
|
1424
|
+
* fatal on first occurrence.
|
|
1425
|
+
* 2. CAPTURED stderr, never `"inherit"` — a step that cannot see why it failed
|
|
1426
|
+
* cannot classify the failure. The streams are echoed immediately so the
|
|
1427
|
+
* operator's view is kept (`echoStdout` for steps whose stdout used to go to
|
|
1428
|
+
* the terminal; steps that parse stdout leave it off).
|
|
1429
|
+
*
|
|
1430
|
+
* On failure the thrown error carries the classification for
|
|
1431
|
+
* abortTeardownDecision: `remoteCommandNeverRan` for a transport failure,
|
|
1432
|
+
* `sshCauseUnknown` when it cannot be established, and nothing for a genuine
|
|
1433
|
+
* remote failure — so a really broken VM still takes the destructive path. A
|
|
1434
|
+
* remote failure keeps `failureMessage` verbatim. */
|
|
1435
|
+
async function runFatalSshStep(cfg, command, { label, failureMessage, echoStdout = false, sleepFn } = {}) {
|
|
1436
|
+
const result = await runGcloudSshWithRetry(
|
|
1437
|
+
"ssh-probe",
|
|
1438
|
+
cfg,
|
|
1439
|
+
{ command },
|
|
1440
|
+
{ stdio: ["ignore", "pipe", "pipe"], label, sleepFn },
|
|
1441
|
+
);
|
|
1442
|
+
// Both echoes go to OUR stderr, as at the compose-up site: this process's
|
|
1443
|
+
// stdout carries the machine-read `CY_DEGRADED` lines the dashboard scans.
|
|
1444
|
+
if (echoStdout && result.stdout) process.stderr.write(result.stdout);
|
|
1445
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
1446
|
+
if (result.status === 0) return result;
|
|
1447
|
+
throw fatalSshStepError(result, failureMessage);
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
/** The error a failed fatal ssh step throws, carrying its
|
|
1451
|
+
* {@link classifySshStepFailure} verdict as the flag abortTeardownDecision
|
|
1452
|
+
* reads. A transport failure names its cause in the message (the quota text
|
|
1453
|
+
* itself was echoed to stderr, not carried here); a remote failure keeps
|
|
1454
|
+
* `failureMessage` verbatim. Pure: result in, Error out. */
|
|
1455
|
+
function fatalSshStepError(result, failureMessage) {
|
|
1456
|
+
const cause = classifySshStepFailure(result);
|
|
1457
|
+
if (cause === "transport") {
|
|
1458
|
+
const why = isGcloudTransportFailure(`${result.stderr || ""}\n${result.stdout || ""}`)
|
|
1459
|
+
? "an OS Login API quota 429 — too many concurrent fleet ssh calls"
|
|
1460
|
+
: "the ssh connection to the VM could not be established";
|
|
1461
|
+
const err = new Error(
|
|
1462
|
+
`${failureMessage}: gcloud failed before reaching the VM (${why}), after retrying. ` +
|
|
1463
|
+
"The remote command never ran; the VM itself was not shown to be broken.",
|
|
1464
|
+
);
|
|
1465
|
+
err.remoteCommandNeverRan = true;
|
|
1466
|
+
return err;
|
|
1467
|
+
}
|
|
1468
|
+
const err = new Error(failureMessage);
|
|
1469
|
+
if (cause === "unknown") err.sshCauseUnknown = true;
|
|
1470
|
+
return err;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1343
1473
|
function ensureGcloud() {
|
|
1344
1474
|
const result = spawnSync("gcloud", ["version"], { stdio: "ignore" });
|
|
1345
1475
|
if (result.error || result.status !== 0) {
|
|
@@ -1579,6 +1709,7 @@ async function waitForIapReady(cfg) {
|
|
|
1579
1709
|
const probeStderrs = [];
|
|
1580
1710
|
while (Date.now() < deadline) {
|
|
1581
1711
|
attempts++;
|
|
1712
|
+
// Tolerant ssh step: this loop is its own retry, so no wrapper.
|
|
1582
1713
|
const probe = runGcloud(
|
|
1583
1714
|
buildGcloudArgs("ssh-probe", cfg, { command: "true" }),
|
|
1584
1715
|
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
@@ -1836,6 +1967,7 @@ function sshScriptCommand(scriptBody, scriptArgs = [], envVars = {}) {
|
|
|
1836
1967
|
|
|
1837
1968
|
async function ensureBootstrapped(cfg, { sleepFn } = {}) {
|
|
1838
1969
|
console.error("Checking VM bootstrap state...");
|
|
1970
|
+
// Tolerant ssh step: a failed probe falls through to the idempotent bootstrap.
|
|
1839
1971
|
const probe = runGcloud(
|
|
1840
1972
|
buildGcloudArgs("ssh-probe", cfg, {
|
|
1841
1973
|
command: "command -v docker >/dev/null && docker image inspect codeyam-editor:local >/dev/null 2>&1",
|
|
@@ -1946,6 +2078,7 @@ async function ensureBootstrapped(cfg, { sleepFn } = {}) {
|
|
|
1946
2078
|
// single outcome string ("success" | "failed" | "running" | "absent"). Used
|
|
1947
2079
|
// both for the idempotent re-attach decision and after each follow session.
|
|
1948
2080
|
function probeBootstrapOutcome(cfg) {
|
|
2081
|
+
// Tolerant ssh step: unparseable output reads as "absent", which re-attaches.
|
|
1949
2082
|
const r = runGcloud(
|
|
1950
2083
|
buildGcloudArgs("ssh-probe", cfg, { command: bootstrapStatusCommand() }),
|
|
1951
2084
|
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
@@ -1956,6 +2089,7 @@ function probeBootstrapOutcome(cfg) {
|
|
|
1956
2089
|
// Fetch the tail of the VM-side bootstrap log for an actionable failure
|
|
1957
2090
|
// message — the operator sees WHY the build failed, not a bare exit code.
|
|
1958
2091
|
function bootstrapLogTail(cfg, lines = 40) {
|
|
2092
|
+
// Tolerant ssh step: diagnostic only, falls back to "(log unavailable)".
|
|
1959
2093
|
const r = runGcloud(
|
|
1960
2094
|
buildGcloudArgs("ssh-probe", cfg, {
|
|
1961
2095
|
command: `tail -n ${lines} ${BOOTSTRAP_LOG_PATH} 2>/dev/null`,
|
|
@@ -2179,6 +2313,7 @@ function decideProjectBootstrapFollowAction({ outcome, now, deadline } = {}) {
|
|
|
2179
2313
|
// to a single outcome string ("success" | "failed" | "running" | "absent").
|
|
2180
2314
|
// Used both for the idempotent re-attach decision and after each follow session.
|
|
2181
2315
|
function probeProjectBootstrapOutcome(cfg, repoName) {
|
|
2316
|
+
// Tolerant ssh step: unparseable output reads as "absent", which re-attaches.
|
|
2182
2317
|
const r = runGcloud(
|
|
2183
2318
|
buildGcloudArgs("ssh-probe", cfg, { command: projectBootstrapStatusCommand(repoName) }),
|
|
2184
2319
|
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
@@ -2232,6 +2367,7 @@ function projectBootstrapEnv(cfg = {}, { env = process.env, githubToken, gitIden
|
|
|
2232
2367
|
// Fetch the tail of the VM-side project-bootstrap log for an actionable failure
|
|
2233
2368
|
// message — the operator sees WHY the install failed, not a bare exit code.
|
|
2234
2369
|
function projectBootstrapLogTail(cfg, repoName, lines = 40) {
|
|
2370
|
+
// Tolerant ssh step: diagnostic only, falls back to "(log unavailable)".
|
|
2235
2371
|
const r = runGcloud(
|
|
2236
2372
|
buildGcloudArgs("ssh-probe", cfg, {
|
|
2237
2373
|
command: `tail -n ${lines} ${projectBootstrapLogPath(repoName)} 2>/dev/null`,
|
|
@@ -2308,6 +2444,7 @@ async function ensureProjectBootstrapped(cfg, { sleepFn } = {}) {
|
|
|
2308
2444
|
// correctly re-run (the re-run is idempotent — skip-on-exists per step). The
|
|
2309
2445
|
// two structural checks stay as belt-and-suspenders for a legacy VM whose
|
|
2310
2446
|
// marker predates this change but is otherwise wired.
|
|
2447
|
+
// Tolerant ssh step: a failed probe falls through to the idempotent bootstrap.
|
|
2311
2448
|
const probe = runGcloud(
|
|
2312
2449
|
buildGcloudArgs("ssh-probe", cfg, {
|
|
2313
2450
|
command: projectBootstrapDoneProbeCommand(repoName),
|
|
@@ -2446,6 +2583,8 @@ async function ensureProjectBootstrapped(cfg, { sleepFn } = {}) {
|
|
|
2446
2583
|
function probeEditorComposeState(cfg) {
|
|
2447
2584
|
const repoName = resolveProject(cfg).name;
|
|
2448
2585
|
const composeCmd = `docker compose -f docker-compose.cloud.yml`;
|
|
2586
|
+
// Tolerant ssh step: a failure reports `inconclusive`, which every caller
|
|
2587
|
+
// treats as fail-safe.
|
|
2449
2588
|
const probe = runGcloud(
|
|
2450
2589
|
buildGcloudArgs("ssh-probe", cfg, {
|
|
2451
2590
|
command: `cd ~/projects/${repoName} 2>/dev/null && ${composeCmd} ps --format json 2>/dev/null || true`,
|
|
@@ -2989,13 +3128,13 @@ async function seedGithubCredentials(cfg, opts = {}) {
|
|
|
2989
3128
|
`echo ${b64} | base64 -d > "$t" && chmod 600 "$t" && ` +
|
|
2990
3129
|
`if [ -s "$t" ]; then cat "$t" > "$f" && chmod 600 "$f" && rm -f "$t" && echo OK; ` +
|
|
2991
3130
|
`else rm -f "$t"; echo EMPTY-WRITE-REFUSED >&2; exit 1; fi`;
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
}
|
|
3131
|
+
// Fatal ssh step — see runFatalSshStep. stdout stays unechoed, as it always
|
|
3132
|
+
// was: it carries only the `OK` marker.
|
|
3133
|
+
await runFatalSshStep(cfg, remoteCmd, {
|
|
3134
|
+
label: "write git credentials",
|
|
3135
|
+
failureMessage: "Failed to write ~/.codeyam-cloud-git-credentials on the VM",
|
|
3136
|
+
sleepFn: opts.sleepFn,
|
|
3137
|
+
});
|
|
2999
3138
|
}
|
|
3000
3139
|
|
|
3001
3140
|
/** Sentinel the remote read prints when it cannot find an `editor.json` under
|
|
@@ -3070,6 +3209,8 @@ async function readVmAppPort(cfg) {
|
|
|
3070
3209
|
* the human description of the miss for the caller's own warning. */
|
|
3071
3210
|
async function probeVmAppPort(cfg) {
|
|
3072
3211
|
const repoName = resolveProject(cfg).name;
|
|
3212
|
+
// Tolerant ssh step: a failure is returned as `{ kind: "ssh" }` and the
|
|
3213
|
+
// caller falls back to VITE_PORT with a loud warning.
|
|
3073
3214
|
const read = runGcloud(
|
|
3074
3215
|
buildGcloudArgs("ssh-probe", cfg, {
|
|
3075
3216
|
command: buildVmAppPortReadCommand(repoName),
|
|
@@ -3122,6 +3263,7 @@ const VM_PUBLISHED_PORT_MARKER = "<<<PUBLISHED>>>";
|
|
|
3122
3263
|
* publishes (live `docker ps` bindings) in ONE SSH round-trip, so the roster
|
|
3123
3264
|
* pass costs one extra probe per VM. Either side is null when unreadable. */
|
|
3124
3265
|
function readVmAppPortsForDrift(cfg) {
|
|
3266
|
+
// Tolerant ssh step: a failure reads as "unknown" (nulls), never as drift.
|
|
3125
3267
|
const read = runGcloud(
|
|
3126
3268
|
buildGcloudArgs("ssh-probe", cfg, {
|
|
3127
3269
|
command:
|
|
@@ -3154,6 +3296,7 @@ function parseVmAppPortsForDrift(stdout) {
|
|
|
3154
3296
|
* Unknown — including a VM whose checkout predates the script — is null, and
|
|
3155
3297
|
* null is never treated as idle. */
|
|
3156
3298
|
function readVmIdleVerdict(cfg) {
|
|
3299
|
+
// Tolerant ssh step: a failure returns null (no verdict).
|
|
3157
3300
|
const read = runGcloud(
|
|
3158
3301
|
buildGcloudArgs("ssh-probe", cfg, {
|
|
3159
3302
|
command:
|
|
@@ -3300,7 +3443,7 @@ async function seedPreviewOrigin(
|
|
|
3300
3443
|
// One round-trip reads both the current editor.local.json (for the
|
|
3301
3444
|
// skip-on-already-set guard) and stack.json (for the sameOriginSafe flag that
|
|
3302
3445
|
// decides same-origin subpath vs dedicated origin). Mirrors readVmAppPort's
|
|
3303
|
-
// separator-delimited cat.
|
|
3446
|
+
// separator-delimited cat. Tolerant ssh step: a failed read parses as `{}`.
|
|
3304
3447
|
const read = runGcloud(
|
|
3305
3448
|
buildGcloudArgs("ssh-probe", cfg, {
|
|
3306
3449
|
command:
|
|
@@ -3406,15 +3549,12 @@ async function seedPreviewOrigin(
|
|
|
3406
3549
|
const merged = deepMergeJson(current, overlay);
|
|
3407
3550
|
const mergedJson = JSON.stringify(merged, null, 2);
|
|
3408
3551
|
const b64 = Buffer.from(mergedJson, "utf8").toString("base64");
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
}
|
|
3413
|
-
|
|
3414
|
-
);
|
|
3415
|
-
if (write.status !== 0) {
|
|
3416
|
-
throw new Error(`Failed to write ${remotePath} on VM`);
|
|
3417
|
-
}
|
|
3552
|
+
// Fatal ssh step — see runFatalSshStep.
|
|
3553
|
+
await runFatalSshStep(cfg, buildEditorLocalSeedWriteCommand({ repoName, remotePath, b64 }), {
|
|
3554
|
+
label: "seed editor.local.json (previewOrigin)",
|
|
3555
|
+
failureMessage: `Failed to write ${remotePath} on VM`,
|
|
3556
|
+
echoStdout: true,
|
|
3557
|
+
});
|
|
3418
3558
|
}
|
|
3419
3559
|
|
|
3420
3560
|
/** READY-probe auto-heal: after the editor container is verifiably Up, ask the
|
|
@@ -3448,6 +3588,8 @@ async function seedPreviewOrigin(
|
|
|
3448
3588
|
* `cmdReconcileIngress`, the ~15-minute roster sweep that genuinely does run,
|
|
3449
3589
|
* which is what makes a mid-life flip self-heal without an Add. */
|
|
3450
3590
|
async function healPreviewOriginAtReady(cfg, appPort = VITE_PORT, { resolveAppPort = null } = {}) {
|
|
3591
|
+
// Tolerant ssh steps (this probe and the reload below): the heal is
|
|
3592
|
+
// best-effort and never fails the add.
|
|
3451
3593
|
const probe = runGcloud(
|
|
3452
3594
|
buildGcloudArgs("ssh-probe", cfg, {
|
|
3453
3595
|
command: `curl -fsS --max-time 5 http://localhost:${EDITOR_PORT}/api/config 2>/dev/null || echo '{}'`,
|
|
@@ -3516,8 +3658,9 @@ async function healPreviewOriginAtReady(cfg, appPort = VITE_PORT, { resolveAppPo
|
|
|
3516
3658
|
* the provider seed MUST run before ensureEditorContainerUp, because the editor
|
|
3517
3659
|
* caches config.provider at startup and reports it as currentProvider; a post-start
|
|
3518
3660
|
* seed leaves the running editor (and therefore the dashboard badge) on the default
|
|
3519
|
-
* "claude" until a config reload.
|
|
3520
|
-
|
|
3661
|
+
* "claude" until a config reload. `sleepFn` is the retry backoff seam, injectable
|
|
3662
|
+
* as on ensureBootstrapped. */
|
|
3663
|
+
async function seedAgentApiKey(cfg, { sleepFn } = {}) {
|
|
3521
3664
|
// The provider seed is unconditional — `buildEditorLocalEnvPatch` always
|
|
3522
3665
|
// returns a patch carrying `provider`, so the chosen agent always lands on
|
|
3523
3666
|
// the VM. The API-key seed is opportunistic: when the matching key isn't set
|
|
@@ -3544,6 +3687,8 @@ async function seedAgentApiKey(cfg) {
|
|
|
3544
3687
|
: `Seeding provider '${cfg.agent}' into ${remotePath}...`;
|
|
3545
3688
|
console.error(seedLabel);
|
|
3546
3689
|
|
|
3690
|
+
// Tolerant ssh step: a failed read falls back to `{}` below, so it needs no
|
|
3691
|
+
// retry wrapper. The write it feeds is the fatal one.
|
|
3547
3692
|
const read = runGcloud(
|
|
3548
3693
|
buildGcloudArgs("ssh-probe", cfg, {
|
|
3549
3694
|
command: `cat ${remotePath} 2>/dev/null || echo '{}'`,
|
|
@@ -3566,15 +3711,15 @@ async function seedAgentApiKey(cfg) {
|
|
|
3566
3711
|
const merged = deepMergeJson(current, patch);
|
|
3567
3712
|
const mergedJson = JSON.stringify(merged, null, 2);
|
|
3568
3713
|
const b64 = Buffer.from(mergedJson, "utf8").toString("base64");
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
}
|
|
3714
|
+
// Fatal ssh step — see runFatalSshStep. This exact write, then a bare
|
|
3715
|
+
// `stdio: "inherit"` runGcloud, is the step whose oslogin 429 deleted VM-37 on
|
|
3716
|
+
// 2026-10-08: no retry, and the quota text never reached the thrown message.
|
|
3717
|
+
await runFatalSshStep(cfg, buildEditorLocalSeedWriteCommand({ repoName, remotePath, b64 }), {
|
|
3718
|
+
label: "seed editor.local.json (provider)",
|
|
3719
|
+
failureMessage: `Failed to write ${remotePath} on VM`,
|
|
3720
|
+
echoStdout: true,
|
|
3721
|
+
sleepFn,
|
|
3722
|
+
});
|
|
3578
3723
|
// NOTE: seedAgentApiKey deliberately does NOT install the build-agent CLI.
|
|
3579
3724
|
// The CLI install is a POST-container-start step owned solely by
|
|
3580
3725
|
// ensureEditorContainerUp (which runs `docker compose up -d` first). Seeding
|
|
@@ -3627,6 +3772,7 @@ async function seedGreenfieldProjectTitle(cfg) {
|
|
|
3627
3772
|
const remotePath = `~/projects/${repoName}/.codeyam/editor.local.json`;
|
|
3628
3773
|
console.error(`Seeding projectTitle='${patch.projectTitle}' into ${remotePath}...`);
|
|
3629
3774
|
|
|
3775
|
+
// Tolerant ssh step: mergeEditorLocalPatchB64 treats a failed read as `{}`.
|
|
3630
3776
|
const read = runGcloud(
|
|
3631
3777
|
buildGcloudArgs("ssh-probe", cfg, {
|
|
3632
3778
|
command: `cat ${remotePath} 2>/dev/null || echo '{}'`,
|
|
@@ -3634,15 +3780,12 @@ async function seedGreenfieldProjectTitle(cfg) {
|
|
|
3634
3780
|
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
3635
3781
|
);
|
|
3636
3782
|
const b64 = mergeEditorLocalPatchB64(read.stdout, patch);
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
}
|
|
3641
|
-
|
|
3642
|
-
);
|
|
3643
|
-
if (write.status !== 0) {
|
|
3644
|
-
throw new Error(`Failed to write ${remotePath} on VM`);
|
|
3645
|
-
}
|
|
3783
|
+
// Fatal ssh step — see runFatalSshStep.
|
|
3784
|
+
await runFatalSshStep(cfg, buildEditorLocalSeedWriteCommand({ repoName, remotePath, b64 }), {
|
|
3785
|
+
label: "seed editor.local.json (projectTitle)",
|
|
3786
|
+
failureMessage: `Failed to write ${remotePath} on VM`,
|
|
3787
|
+
echoStdout: true,
|
|
3788
|
+
});
|
|
3646
3789
|
}
|
|
3647
3790
|
|
|
3648
3791
|
/** Tri-state probe of the editor container's running state on the VM host.
|
|
@@ -3663,6 +3806,7 @@ async function seedGreenfieldProjectTitle(cfg) {
|
|
|
3663
3806
|
* which — pre-fix — made every probe report a non-running container regardless
|
|
3664
3807
|
* of its real state and took the whole fleet to zero on 2026-06-30. */
|
|
3665
3808
|
function probeContainerState(cfg, container) {
|
|
3809
|
+
// Tolerant ssh step: a failure is `"unknown"`, which callers do not count as down.
|
|
3666
3810
|
const checkProbe = runGcloud(
|
|
3667
3811
|
buildGcloudArgs("ssh-probe", cfg, {
|
|
3668
3812
|
command: `docker ps --filter name=^/${container}$ --filter status=running --format '{{.Names}}'`,
|
|
@@ -3685,6 +3829,8 @@ function probeContainerRunning(cfg, container) {
|
|
|
3685
3829
|
|
|
3686
3830
|
/** Probe whether `binary` resolves on PATH inside the running container. */
|
|
3687
3831
|
function probeAgentBinaryInstalled(cfg, container, binary) {
|
|
3832
|
+
// Tolerant ssh step: a failure reads as "not installed", which leads to the
|
|
3833
|
+
// idempotent install (a fatal step) rather than a throw of its own.
|
|
3688
3834
|
const probe = runGcloud(
|
|
3689
3835
|
buildGcloudArgs("ssh-probe", cfg, {
|
|
3690
3836
|
command: `docker exec ${container} bash -lc 'command -v ${binary}'`,
|
|
@@ -3835,16 +3981,13 @@ async function ensureAgentCliInstalled(cfg, waitOpts = {}) {
|
|
|
3835
3981
|
|
|
3836
3982
|
const hostCommand = `docker exec -i ${container} bash -s <<'EOF'\n${containerScript}\nEOF`;
|
|
3837
3983
|
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
if (installCmd.status !== 0) {
|
|
3846
|
-
throw new Error(`Failed to install build-agent CLI in container for agent: ${agent}`);
|
|
3847
|
-
}
|
|
3984
|
+
// Fatal ssh step — see runFatalSshStep. Safe to retry: the script short-circuits
|
|
3985
|
+
// when the binary is already on PATH.
|
|
3986
|
+
await runFatalSshStep(cfg, hostCommand, {
|
|
3987
|
+
label: "install build-agent CLI",
|
|
3988
|
+
failureMessage: `Failed to install build-agent CLI in container for agent: ${agent}`,
|
|
3989
|
+
echoStdout: true,
|
|
3990
|
+
});
|
|
3848
3991
|
}
|
|
3849
3992
|
|
|
3850
3993
|
async function openTunnel(cfg, appPort = VITE_PORT) {
|
|
@@ -4966,27 +5109,15 @@ async function cmdUp(args, projectDir = rootDir) {
|
|
|
4966
5109
|
// `createdThisRun` alone MUST NOT authorize deletion. Gather what state the
|
|
4967
5110
|
// VM is in right now, then let the pure decision rule — see
|
|
4968
5111
|
// abortTeardownDecision's doc comment for why each input is load-bearing.
|
|
4969
|
-
const state = probeAbortTeardownState(cfg, createdThisRun);
|
|
4970
|
-
|
|
4971
|
-
// `remoteCommandNeverRan` paragraph of abortTeardownDecision.
|
|
4972
|
-
const remoteCommandNeverRan =
|
|
4973
|
-
Boolean(err && err.remoteCommandNeverRan) || isGcloudTransportFailure(err && err.message);
|
|
4974
|
-
const verdict = abortTeardownDecision({ createdThisRun, ...state, remoteCommandNeverRan });
|
|
5112
|
+
const state = abortEvidence(err, probeAbortTeardownState(cfg, createdThisRun));
|
|
5113
|
+
const verdict = abortTeardownDecision({ createdThisRun, ...state });
|
|
4975
5114
|
if (createdThisRun && verdict === "keep") {
|
|
4976
|
-
// Deliberately loud and specific. The old blanket "tearing down the VM
|
|
4977
|
-
// cloud:up created this run (no orphan left behind)" was actively
|
|
4978
|
-
// misleading here — it asserted no orphan while deleting a working VM.
|
|
4979
|
-
const why = remoteCommandNeverRan
|
|
4980
|
-
? "the failing step never reached the VM (a gcloud transport/quota error), so nothing shows the VM is broken"
|
|
4981
|
-
: state.probeInconclusive
|
|
4982
|
-
? "its liveness could not be confirmed (probe failed), and an unconfirmed VM is never deleted"
|
|
4983
|
-
: state.containerUp
|
|
4984
|
-
? "its editor container is RUNNING — another run brought it up"
|
|
4985
|
-
: "it is in the fleet roster and visible to the dashboard";
|
|
4986
5115
|
console.error(
|
|
4987
|
-
|
|
4988
|
-
|
|
4989
|
-
|
|
5116
|
+
abortKeptMessage({
|
|
5117
|
+
instanceName: cfg.instanceName,
|
|
5118
|
+
vmNumber: resolveFleetVmNumber(cfg.instanceName),
|
|
5119
|
+
state,
|
|
5120
|
+
}),
|
|
4990
5121
|
);
|
|
4991
5122
|
}
|
|
4992
5123
|
if (verdict === "delete") {
|
|
@@ -5161,6 +5292,7 @@ async function cmdStatus(args, projectDir = rootDir) {
|
|
|
5161
5292
|
return;
|
|
5162
5293
|
}
|
|
5163
5294
|
|
|
5295
|
+
// Tolerant ssh step: status display only; a failure prints "?".
|
|
5164
5296
|
const imgProbe = runGcloud(
|
|
5165
5297
|
buildGcloudArgs("ssh-probe", cfg, {
|
|
5166
5298
|
command:
|
|
@@ -5698,12 +5830,12 @@ async function probeGitCredentials(cfg) {
|
|
|
5698
5830
|
`${projectCd} && if ${composeExec} git -C /workspace ls-remote origin -h HEAD ` +
|
|
5699
5831
|
`>/dev/null 2>/tmp/cy-lsr.err; then echo LSREMOTE=ok; ` +
|
|
5700
5832
|
`else echo LSREMOTE=fail; sed 's/^/LSRERR:/' /tmp/cy-lsr.err; fi`;
|
|
5701
|
-
|
|
5702
|
-
|
|
5703
|
-
|
|
5704
|
-
);
|
|
5705
|
-
if (
|
|
5706
|
-
throw new Error(
|
|
5833
|
+
// Fatal ssh step — see runFatalSshStep. It runs on the add path too, via
|
|
5834
|
+
// assertGitPushCredentials. stdout is parsed below, so it is not echoed.
|
|
5835
|
+
const failureMessage = `Failed to probe credentials on ${cfg.instanceName} (SSH error).`;
|
|
5836
|
+
const probe = await runFatalSshStep(cfg, probeCmd, { label: "probe git credentials", failureMessage });
|
|
5837
|
+
if (typeof probe.stdout !== "string") {
|
|
5838
|
+
throw new Error(failureMessage);
|
|
5707
5839
|
}
|
|
5708
5840
|
const out = probe.stdout;
|
|
5709
5841
|
return {
|
|
@@ -5747,14 +5879,12 @@ async function seedLiveCredentialCache(cfg, token) {
|
|
|
5747
5879
|
`echo ${storeB64} | base64 -d | ${composeExec} ` +
|
|
5748
5880
|
`bash -c 'cat > ${store} && chmod 600 ${store}' && ` +
|
|
5749
5881
|
`echo APPROVED`;
|
|
5750
|
-
|
|
5751
|
-
|
|
5752
|
-
|
|
5753
|
-
);
|
|
5754
|
-
if (
|
|
5755
|
-
throw new Error(
|
|
5756
|
-
`Failed to seed the live credential cache on ${cfg.instanceName}.`,
|
|
5757
|
-
);
|
|
5882
|
+
// Fatal ssh step — see runFatalSshStep. Safe to retry: `credential approve`
|
|
5883
|
+
// and the in-place store truncate are both idempotent.
|
|
5884
|
+
const failureMessage = `Failed to seed the live credential cache on ${cfg.instanceName}.`;
|
|
5885
|
+
const seed = await runFatalSshStep(cfg, seedCmd, { label: "seed live credential cache", failureMessage });
|
|
5886
|
+
if (!/APPROVED/.test(String(seed.stdout || ""))) {
|
|
5887
|
+
throw new Error(failureMessage);
|
|
5758
5888
|
}
|
|
5759
5889
|
}
|
|
5760
5890
|
|
|
@@ -5804,15 +5934,14 @@ async function cmdReconcileAdmin(args, projectDir = rootDir) {
|
|
|
5804
5934
|
console.error(
|
|
5805
5935
|
`Reconciling admin mode to ${desired === "1" ? "ON" : "off"} on ${cfg.instanceName}...`,
|
|
5806
5936
|
);
|
|
5807
|
-
|
|
5808
|
-
|
|
5809
|
-
|
|
5810
|
-
|
|
5811
|
-
|
|
5812
|
-
|
|
5813
|
-
|
|
5814
|
-
|
|
5815
|
-
}
|
|
5937
|
+
// Fatal ssh step — see runFatalSshStep. No teardown follows here, but a 429 on
|
|
5938
|
+
// an operator command is still a retry, not a failure. Safe to retry: the
|
|
5939
|
+
// compose line rewrite and `up -d editor` are both idempotent.
|
|
5940
|
+
await runFatalSshStep(cfg, buildReconcileAdminCommand(repoName, desired), {
|
|
5941
|
+
label: "reconcile admin mode",
|
|
5942
|
+
failureMessage: `Failed to reconcile admin mode on ${cfg.instanceName}.`,
|
|
5943
|
+
echoStdout: true,
|
|
5944
|
+
});
|
|
5816
5945
|
console.error(
|
|
5817
5946
|
` ✓ editor service recreated; /api/config now reports admin=${desired === "1" ? "true" : "false"}.`,
|
|
5818
5947
|
);
|
|
@@ -5990,6 +6119,8 @@ module.exports = {
|
|
|
5990
6119
|
probeContainerState,
|
|
5991
6120
|
waitForEditorComposeRunning,
|
|
5992
6121
|
abortTeardownDecision,
|
|
6122
|
+
abortEvidence,
|
|
6123
|
+
abortKeptMessage,
|
|
5993
6124
|
probeAbortTeardownState,
|
|
5994
6125
|
probeAgentBinaryInstalled,
|
|
5995
6126
|
waitForContainerRunning,
|
|
@@ -6080,6 +6211,9 @@ module.exports = {
|
|
|
6080
6211
|
formatGcloudFailure,
|
|
6081
6212
|
isTransientSshFailure,
|
|
6082
6213
|
runGcloudSshWithRetry,
|
|
6214
|
+
classifySshStepFailure,
|
|
6215
|
+
runFatalSshStep,
|
|
6216
|
+
fatalSshStepError,
|
|
6083
6217
|
ensureInstance,
|
|
6084
6218
|
ensureBootstrapped,
|
|
6085
6219
|
assertEditorContainerRunning,
|
package/npm/fleet-api-budget.js
CHANGED
|
@@ -25,15 +25,28 @@
|
|
|
25
25
|
* dashboard's own monitoring gathers (bounded in concurrency by hostOpLimiter,
|
|
26
26
|
* not in rate) and the tunnel watchdog.
|
|
27
27
|
* - `perAddCallsPerMin` — what one in-flight add is charged. Deliberately its
|
|
28
|
-
* PEAK, not its average
|
|
29
|
-
*
|
|
30
|
-
* peak is wrong in the safe direction — it queues a little early rather than
|
|
31
|
-
* 429ing a provision.
|
|
28
|
+
* PEAK, not its average. Charging the peak is wrong in the safe direction — it
|
|
29
|
+
* queues a little early rather than 429ing a provision.
|
|
32
30
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
|
|
31
|
+
* Provenance of 15 (2026-10-09, re-derived after VM-37 429'd on 2026-10-08
|
|
32
|
+
* under the old 10): the peak is cloud:up's seed → container-gate window,
|
|
33
|
+
* where each `gcloud compute ssh` (≥1 loginProfile read) runs back to back.
|
|
34
|
+
* Counted from npm/cloud.js on the clone path: app-port probe 1, previewOrigin
|
|
35
|
+
* read+write 2, git-credentials write 1, provider seed read+write 2, compose
|
|
36
|
+
* state probe + `up -d` 2, container probe + CLI install 2, git-auth probe 1,
|
|
37
|
+
* final container assert 1, CLI probe 1 = 13; a greenfield add adds the
|
|
38
|
+
* projectTitle read+write = 15. At ~4s per IAP ssh round trip that is ~15
|
|
39
|
+
* reads inside one minute. The old 10 was the container gate's first minute
|
|
40
|
+
* (6 reads) plus overlap — it did not count the seed burst VM-37 died in.
|
|
41
|
+
* This is a STATIC count, not a measured counter; replace it if a measured
|
|
42
|
+
* per-add rate is ever taken. The batch `dash_add` path is NOT the gap:
|
|
43
|
+
* server.js admits each reserved VM separately through tryStartOrQueue, and
|
|
44
|
+
* addVm reaches startJob synchronously, so the job cap holds per VM.
|
|
45
|
+
*
|
|
46
|
+
* At these defaults an add is admitted while 20 + 15·inFlight + 15 ≤ 60, i.e.
|
|
47
|
+
* 2 concurrent adds — below the job cap of 4, so this budget, not the cap, is
|
|
48
|
+
* what paces a batch. API_CALLS_PER_ADD_PER_MIN overrides it on the dashboard. */
|
|
49
|
+
const API_BUDGET_DEFAULTS = Object.freeze({ budgetPerMin: 60, reservedPerMin: 20, perAddCallsPerMin: 15 });
|
|
37
50
|
|
|
38
51
|
// A positive finite number, else 0 — so garbage degrades toward admitting work,
|
|
39
52
|
// never toward a phantom load that would freeze admission forever.
|
package/npm/fleet-build-data.js
CHANGED
|
@@ -55,6 +55,24 @@ function buildVmEnvelope(v, ctx) {
|
|
|
55
55
|
// a definite `false` that no probe had ever established.
|
|
56
56
|
...fr.normalizeReachability(v),
|
|
57
57
|
hasSession: !!v.hasSession,
|
|
58
|
+
// The three session-age signals the codeyam-fleet-latest Phase 1b idle
|
|
59
|
+
// triage reads by name. server.js computes all three per poll onto `v`, but
|
|
60
|
+
// they were never copied here, so /data carried none of them and the
|
|
61
|
+
// procedure's ACTIVE/ENDED table fell through to UNCLEAR on every HOLD VM.
|
|
62
|
+
// `sessionIdleSeconds`: seconds since the session last did anything, or null
|
|
63
|
+
// when the editor is too old to report it — never coerced to 0, which would
|
|
64
|
+
// read as "just active".
|
|
65
|
+
sessionIdleSeconds:
|
|
66
|
+
typeof v.sessionIdleSeconds === 'number' && Number.isFinite(v.sessionIdleSeconds)
|
|
67
|
+
? v.sessionIdleSeconds
|
|
68
|
+
: null,
|
|
69
|
+
// 'none' | 'active' | 'stale' | 'unknown' (npm/fleet-session-activity.js).
|
|
70
|
+
// An unpolled VM has no verdict at all; it reads 'unknown', which blocks
|
|
71
|
+
// like 'active', so an absent signal can never clear a VM as stale.
|
|
72
|
+
sessionActivity: v.sessionActivity || 'unknown',
|
|
73
|
+
// Boolean twin of the `session-wedged:` prose in degradedReasons, so a
|
|
74
|
+
// program can read it without parsing English.
|
|
75
|
+
sessionWedged: v.sessionWedged === true,
|
|
58
76
|
// Three-state: true = broker confirmed up, false = confirmed down, null =
|
|
59
77
|
// editor unreachable so broker state is unknown. Never conflate "down" with
|
|
60
78
|
// "unreachable" — that mismatch is what made the card show "broker down"
|
|
@@ -183,14 +201,16 @@ function buildVmEnvelope(v, ctx) {
|
|
|
183
201
|
};
|
|
184
202
|
}
|
|
185
203
|
|
|
186
|
-
// The fields the card UI
|
|
204
|
+
// The fields the card UI — and the codeyam-fleet-latest idle triage, which reads
|
|
205
|
+
// /data directly — depend on being present in every /data VM entry. This
|
|
187
206
|
// is the contract `buildVmEnvelope` must satisfy: the regression that prompted
|
|
188
207
|
// this module was a field the card read but the envelope didn't emit, so the
|
|
189
208
|
// test asserts the envelope keys are a superset of this list. Adding a field the
|
|
190
209
|
// card renders means adding it here AND in buildVmEnvelope — the test fails loud
|
|
191
210
|
// if you do one without the other.
|
|
192
211
|
const REQUIRED_CARD_FIELDS = [
|
|
193
|
-
'n', 'url', 'previewUrl', 'appPort', 'previewServing', 'previewIngress', 'reachable', 'reachableSource', 'hasSession',
|
|
212
|
+
'n', 'url', 'previewUrl', 'appPort', 'previewServing', 'previewIngress', 'reachable', 'reachableSource', 'hasSession',
|
|
213
|
+
'sessionIdleSeconds', 'sessionActivity', 'sessionWedged', 'brokerLive', 'brokerStatus',
|
|
194
214
|
'awaitingInput', 'needsCleanup', 'feature', 'selectedPlan', 'step', 'mode', 'label', 'cycleClosed', 'status', 'orphan',
|
|
195
215
|
'degradedReasons', 'setupSteps', 'attentionReasons',
|
|
196
216
|
'isHead', 'headHolderUnreachable', 'queuePos', 'queueLen',
|