@codeyam-editor/codeyam-editor 0.1.12 → 0.1.13-staging.g1b6c798
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 +61 -15
- package/npm/compose-conflict.js +34 -1
- package/npm/fleet-add-failure.js +99 -6
- package/npm/fleet-api-budget.js +105 -0
- package/npm/fleet-job-badge.js +5 -2
- package/npm/launch-config.js +125 -4
- package/npm/scenario-playwright.js +4 -1
- package/package.json +5 -5
- package/ui/dist/assets/{ContainerInspector-B86bl6-K.js → ContainerInspector-nU0qr8np.js} +1 -1
- package/ui/dist/assets/EditorShell-Nzjasb6I.js +285 -0
- package/ui/dist/assets/{ScenarioDataPanel-BOz-4rZe.js → ScenarioDataPanel-BEslyDRr.js} +2 -2
- package/ui/dist/assets/{ScenarioDataPanelFullPage-Dy8mPj3e.js → ScenarioDataPanelFullPage-DdKVvDwC.js} +1 -1
- package/ui/dist/assets/{ScenarioRenderer-CmcR8ciQ.js → ScenarioRenderer-6CcF5jhy.js} +3 -3
- package/ui/dist/assets/{index-maa9OUlU.js → index-C9TVsqen.js} +2 -2
- package/ui/dist/assets/index-D67Yd3Bo.css +1 -0
- package/ui/dist/assets/useEvents-DNb1olaj.js +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/EditorShell-BLCavSsd.js +0 -284
- package/ui/dist/assets/index-D2tD0_8V.css +0 -1
- package/ui/dist/assets/useEvents-S3Qk1Y_d.js +0 -1
package/npm/cloud.js
CHANGED
|
@@ -114,7 +114,7 @@ const { addToRoster, readRoster } = require("./fleet-roster");
|
|
|
114
114
|
// Tells a docker container-name conflict (a concurrent run already started this
|
|
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
|
-
const { classifyComposeUpFailure } = require("./compose-conflict");
|
|
117
|
+
const { classifyComposeUpFailure, isGcloudTransportFailure } = require("./compose-conflict");
|
|
118
118
|
// The two streamed bootstrap scripts, named once for this file's STREAMED_SCRIPTS
|
|
119
119
|
// and for the advance's provisioning pre-flight, which checks the same set.
|
|
120
120
|
const { BOOTSTRAP_SCRIPT, PROJECT_BOOTSTRAP_SCRIPT } = require("./provisioning-contract");
|
|
@@ -881,8 +881,8 @@ function parseEditorComposeState(stdout) {
|
|
|
881
881
|
}
|
|
882
882
|
|
|
883
883
|
/** Decide whether `cloud:up`'s abort handler may delete the instance it is
|
|
884
|
-
* unwinding. Pure `{ createdThisRun, containerUp, rostered, probeInconclusive
|
|
885
|
-
* → 'delete' | 'keep'`, matching the `reapVerdict` / `reattachVerdict` /
|
|
884
|
+
* unwinding. Pure `{ createdThisRun, containerUp, rostered, probeInconclusive,
|
|
885
|
+
* remoteCommandNeverRan } → 'delete' | 'keep'`, matching the `reapVerdict` / `reattachVerdict` /
|
|
886
886
|
* `reconcileRosteredVm` pattern in npm/fleet-cleanup.js, so the whole matrix is
|
|
887
887
|
* exhaustively testable without a VM.
|
|
888
888
|
*
|
|
@@ -904,9 +904,20 @@ function parseEditorComposeState(stdout) {
|
|
|
904
904
|
* SAFE: a failing probe is how we reach the abort handler in the first place,
|
|
905
905
|
* so it can never count as evidence the VM is dead. An orphan costs one
|
|
906
906
|
* `npm run cloud:destroy`; a wrong delete costs a 15-40 minute rebuild plus
|
|
907
|
-
* any unpushed work on the VM.
|
|
908
|
-
|
|
907
|
+
* any unpushed work on the VM.
|
|
908
|
+
* - `remoteCommandNeverRan` — the error that brought us here was a transport
|
|
909
|
+
* failure: the gcloud client never reached the VM (an OS Login quota 429 on
|
|
910
|
+
* 2026-10-01), so the step it was running never executed. Same class as
|
|
911
|
+
* `probeInconclusive` — absence of evidence — and it must fail SAFE the same
|
|
912
|
+
* way. On that date the probes truthfully reported "container down, not
|
|
913
|
+
* rostered", but only BECAUSE compose-up never ran; deleting on that reading
|
|
914
|
+
* destroyed VM-8 after ~19 minutes of finished build. A run that never managed
|
|
915
|
+
* to execute its step has learned nothing about the VM, so it has not earned
|
|
916
|
+
* the right to delete one. Defense in depth: the compose-up retry should keep
|
|
917
|
+
* this from being reached at all. */
|
|
918
|
+
function abortTeardownDecision({ createdThisRun, containerUp, rostered, probeInconclusive, remoteCommandNeverRan } = {}) {
|
|
909
919
|
if (!createdThisRun) return "keep";
|
|
920
|
+
if (remoteCommandNeverRan) return "keep";
|
|
910
921
|
if (probeInconclusive) return "keep";
|
|
911
922
|
if (containerUp) return "keep";
|
|
912
923
|
if (rostered) return "keep";
|
|
@@ -1268,13 +1279,24 @@ function provisionFailureMessage(verb, instanceName, result) {
|
|
|
1268
1279
|
);
|
|
1269
1280
|
}
|
|
1270
1281
|
|
|
1271
|
-
// A bounded, pure classifier
|
|
1272
|
-
// for an ssh failure)
|
|
1282
|
+
// A bounded, pure classifier with two independent branches:
|
|
1283
|
+
// - status === 255 (gcloud-ssh's exit for an ssh failure) plus a signature
|
|
1284
|
+
// match against known transient SSH-transport strings;
|
|
1285
|
+
// - a gcloud CLIENT-SIDE API failure (an OS Login quota 429), at any status.
|
|
1286
|
+
// gcloud does not exit 255 for these, so the status code is evidence for the
|
|
1287
|
+
// first branch, never a precondition for reading the text — gating on it is
|
|
1288
|
+
// what kept the 2026-10-01 VM-8 quota 429 out of the retry path.
|
|
1273
1289
|
function isTransientSshFailure(result) {
|
|
1274
|
-
if (!result || result.status
|
|
1290
|
+
if (!result || result.status === 0 || result.status == null) {
|
|
1275
1291
|
return false;
|
|
1276
1292
|
}
|
|
1277
1293
|
const output = String(result.stderr || "") + String(result.stdout || "");
|
|
1294
|
+
if (isGcloudTransportFailure(output)) {
|
|
1295
|
+
return true;
|
|
1296
|
+
}
|
|
1297
|
+
if (result.status !== 255) {
|
|
1298
|
+
return false;
|
|
1299
|
+
}
|
|
1278
1300
|
const transientRegex = /kex_exchange_identification|Connection closed|failed to connect to backend|Failed to connect to port 22|Connection refused|Connection reset|Connection timed out|4003|4047/i;
|
|
1279
1301
|
return transientRegex.test(output);
|
|
1280
1302
|
}
|
|
@@ -2471,16 +2493,34 @@ async function ensureEditorContainerUp(cfg) {
|
|
|
2471
2493
|
// stream and capture, so the operator's view is preserved by echoing the
|
|
2472
2494
|
// captured streams immediately below instead of live. Do NOT revert this to
|
|
2473
2495
|
// "inherit"; classification depends on it.
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2496
|
+
//
|
|
2497
|
+
// Through the retry wrapper: a transport failure (an OS Login quota 429 on
|
|
2498
|
+
// 2026-10-01) means `up -d` never ran, so the honest response is to try again.
|
|
2499
|
+
// Retrying is safe without a did-it-run probe — `up -d` is idempotent here, and
|
|
2500
|
+
// a retry that races a container into existence lands on the name-conflict
|
|
2501
|
+
// verdict below, which converges.
|
|
2502
|
+
const up = await runGcloudSshWithRetry(
|
|
2503
|
+
"ssh-probe",
|
|
2504
|
+
cfg,
|
|
2505
|
+
{ command: `cd ~/projects/${repoName} && ${composeCmd} up -d` },
|
|
2506
|
+
{ stdio: ["ignore", "pipe", "pipe"], label: "docker compose up -d" },
|
|
2479
2507
|
);
|
|
2480
2508
|
if (up.stdout) process.stderr.write(up.stdout);
|
|
2481
2509
|
if (up.stderr) process.stderr.write(up.stderr);
|
|
2482
2510
|
if (up.status !== 0) {
|
|
2483
2511
|
const verdict = classifyComposeUpFailure({ status: up.status, stderr: `${up.stderr || ""}\n${up.stdout || ""}` });
|
|
2512
|
+
if (verdict === "transport-error") {
|
|
2513
|
+
// Retries exhausted and gcloud still never reached the VM. Say so, and mark
|
|
2514
|
+
// the error so the abort handler leaves the VM alone: nothing about the VM
|
|
2515
|
+
// has been learned, so it cannot be judged broken.
|
|
2516
|
+
const err = new Error(
|
|
2517
|
+
"could not reach the VM to run docker compose up -d: gcloud failed before connecting " +
|
|
2518
|
+
"(an OS Login API quota 429 — too many concurrent fleet ssh calls). The remote command " +
|
|
2519
|
+
"never ran; the VM itself was not shown to be broken.",
|
|
2520
|
+
);
|
|
2521
|
+
err.remoteCommandNeverRan = true;
|
|
2522
|
+
throw err;
|
|
2523
|
+
}
|
|
2484
2524
|
if (verdict !== "name-conflict") {
|
|
2485
2525
|
throw new Error("docker compose up -d failed on the VM");
|
|
2486
2526
|
}
|
|
@@ -4773,12 +4813,18 @@ async function cmdUp(args, projectDir = rootDir) {
|
|
|
4773
4813
|
// VM is in right now, then let the pure decision rule — see
|
|
4774
4814
|
// abortTeardownDecision's doc comment for why each input is load-bearing.
|
|
4775
4815
|
const state = probeAbortTeardownState(cfg, createdThisRun);
|
|
4776
|
-
|
|
4816
|
+
// An error that never reached the VM proves nothing about it — see the
|
|
4817
|
+
// `remoteCommandNeverRan` paragraph of abortTeardownDecision.
|
|
4818
|
+
const remoteCommandNeverRan =
|
|
4819
|
+
Boolean(err && err.remoteCommandNeverRan) || isGcloudTransportFailure(err && err.message);
|
|
4820
|
+
const verdict = abortTeardownDecision({ createdThisRun, ...state, remoteCommandNeverRan });
|
|
4777
4821
|
if (createdThisRun && verdict === "keep") {
|
|
4778
4822
|
// Deliberately loud and specific. The old blanket "tearing down the VM
|
|
4779
4823
|
// cloud:up created this run (no orphan left behind)" was actively
|
|
4780
4824
|
// misleading here — it asserted no orphan while deleting a working VM.
|
|
4781
|
-
const why =
|
|
4825
|
+
const why = remoteCommandNeverRan
|
|
4826
|
+
? "the failing step never reached the VM (a gcloud transport/quota error), so nothing shows the VM is broken"
|
|
4827
|
+
: state.probeInconclusive
|
|
4782
4828
|
? "its liveness could not be confirmed (probe failed), and an unconfirmed VM is never deleted"
|
|
4783
4829
|
: state.containerUp
|
|
4784
4830
|
? "its editor container is RUNNING — another run brought it up"
|
package/npm/compose-conflict.js
CHANGED
|
@@ -31,12 +31,44 @@
|
|
|
31
31
|
// whole captured stream rather than line- or anchor-based.
|
|
32
32
|
const NAME_CONFLICT_FRAGMENTS = [/\bConflict\./i, /container name/i, /is already in use/i];
|
|
33
33
|
|
|
34
|
+
// The stable fragments of a gcloud CLIENT-SIDE API failure — gcloud's own HTTP
|
|
35
|
+
// client failed before any ssh was attempted, so the remote command never ran.
|
|
36
|
+
// Observed verbatim in the 2026-10-01 VM-8 add log, when an OS Login
|
|
37
|
+
// loginProfile read hit the `ReadRequestsPerMinutePerUser` quota (limit 60):
|
|
38
|
+
//
|
|
39
|
+
// ERROR: (gcloud.compute.ssh) HttpError accessing <https://oslogin.googleapis.com/…
|
|
40
|
+
// /loginProfile?…>: response: <{'status': 429}>, content <{"error": {"code": 429,
|
|
41
|
+
// "message": "Quota exceeded for quota metric …", "status": "RESOURCE_EXHAUSTED",
|
|
42
|
+
// "details": [{"reason": "RATE_LIMIT_EXCEEDED"}]}}
|
|
43
|
+
//
|
|
44
|
+
// Same discipline as the conflict fragments, for the same reason — Google
|
|
45
|
+
// rephrases these: require gcloud's `HttpError accessing` wrapper AND at least
|
|
46
|
+
// one quota token, never a single token, so an app-level message that merely
|
|
47
|
+
// contains `429` is not swept in.
|
|
48
|
+
const GCLOUD_HTTP_ERROR_FRAGMENT = /HttpError accessing/;
|
|
49
|
+
const GCLOUD_QUOTA_FRAGMENTS = [/'status':\s*429\b/, /RESOURCE_EXHAUSTED/, /RATE_LIMIT_EXCEEDED/];
|
|
50
|
+
|
|
51
|
+
/** True when `text` carries a gcloud client-side API quota failure, i.e. the
|
|
52
|
+
* gcloud client never reached the VM. Shared by {@link classifyComposeUpFailure}
|
|
53
|
+
* and `cloud.js`'s transient-ssh predicate and abort handler, so the three
|
|
54
|
+
* cannot drift apart on what counts as "never reached the VM". */
|
|
55
|
+
function isGcloudTransportFailure(text) {
|
|
56
|
+
const s = String(text == null ? '' : text);
|
|
57
|
+
return GCLOUD_HTTP_ERROR_FRAGMENT.test(s) && GCLOUD_QUOTA_FRAGMENTS.some((re) => re.test(s));
|
|
58
|
+
}
|
|
59
|
+
|
|
34
60
|
/** Classify the outcome of `docker compose up -d`.
|
|
35
61
|
*
|
|
36
62
|
* Returns one of:
|
|
37
63
|
* - `'ok'` — the command succeeded; there is no failure to classify. Guards
|
|
38
64
|
* against a caller that classifies unconditionally, so a zero status can
|
|
39
65
|
* never be read as a conflict.
|
|
66
|
+
* - `'transport-error'` — the gcloud ssh client never reached the VM (an OS
|
|
67
|
+
* Login quota 429 on 2026-10-01), so `up -d` did not run and nothing has been
|
|
68
|
+
* learned about the box. Reading this as `'failed'` is what drove the abort
|
|
69
|
+
* handler into deleting VM-8 after it had already built its editor image.
|
|
70
|
+
* The caller retries; it must never treat this as a provisioning failure.
|
|
71
|
+
* Checked first so a transport failure can never be read as a daemon message.
|
|
40
72
|
* - `'name-conflict'` — the daemon refused because a container of that name
|
|
41
73
|
* already exists, i.e. a concurrent run already started it. The caller
|
|
42
74
|
* re-probes for a running container and converges instead of throwing.
|
|
@@ -50,10 +82,11 @@ const NAME_CONFLICT_FRAGMENTS = [/\bConflict\./i, /container name/i, /is already
|
|
|
50
82
|
function classifyComposeUpFailure({ status, stderr } = {}) {
|
|
51
83
|
if (status === 0) return 'ok';
|
|
52
84
|
const text = String(stderr == null ? '' : stderr);
|
|
85
|
+
if (isGcloudTransportFailure(text)) return 'transport-error';
|
|
53
86
|
if (NAME_CONFLICT_FRAGMENTS.every((re) => re.test(text))) {
|
|
54
87
|
return 'name-conflict';
|
|
55
88
|
}
|
|
56
89
|
return 'failed';
|
|
57
90
|
}
|
|
58
91
|
|
|
59
|
-
module.exports = { classifyComposeUpFailure };
|
|
92
|
+
module.exports = { classifyComposeUpFailure, isGcloudTransportFailure };
|
package/npm/fleet-add-failure.js
CHANGED
|
@@ -99,6 +99,15 @@ function lastNonEmptyLines(text, maxLines = 50) {
|
|
|
99
99
|
return lines.join('\n');
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
// How many trailing lines the CLASSIFIER reads — deliberately wider than the
|
|
103
|
+
// 50-line tail the card displays. A verbose error body can crowd the signature
|
|
104
|
+
// that explains it out of a narrow window: VM-8's 2026-10-01 add died on an
|
|
105
|
+
// OS Login 429 whose JSON error body alone ran to 35 lines, pushing cloud:up's
|
|
106
|
+
// own teardown line to the edge of the 50-line tail. Reading more costs nothing
|
|
107
|
+
// and has no false-negative mode, unlike filtering "noisy" lines, which would
|
|
108
|
+
// have to know every error shape in advance.
|
|
109
|
+
const CLASSIFY_TAIL_LINES = 200;
|
|
110
|
+
|
|
102
111
|
// True iff the provision transcript reached the early-roster marker (`CY_ROSTER
|
|
103
112
|
// <n>`) — i.e. the instance exists and the container gate passed, so the VM is
|
|
104
113
|
// rostered. Reuses parseRosterMarkers (npm/fleet-early-roster.js) so the
|
|
@@ -139,14 +148,17 @@ const CALM_ADD_CATEGORIES = new Set(['converging', 'verifying', 'access-pending'
|
|
|
139
148
|
// answering — the box exists but never came up.
|
|
140
149
|
// - `wiringDeadlineBlown` the wire-add-tail gave up (the failed
|
|
141
150
|
// marker is the tail's own honest deadline signal).
|
|
151
|
+
// - `teardownReported` the transcript carries cloud:up's own
|
|
152
|
+
// clean-teardown report (cleanTeardownReported): it deleted the VM it
|
|
153
|
+
// created, so the instance is gone by the provisioner's own account.
|
|
142
154
|
//
|
|
143
155
|
// Every fact is TRI-STATE on purpose: `undefined`/`null` means "not probed" and
|
|
144
156
|
// corroborates nothing. Only an explicit `false` counts, so an unprobed VM can
|
|
145
157
|
// never be escalated to red by absence of evidence.
|
|
146
|
-
function addFailureCorroborated({ exitCode, instanceAlive, containerHealthy, wiringDeadlineBlown } = {}) {
|
|
158
|
+
function addFailureCorroborated({ exitCode, instanceAlive, containerHealthy, wiringDeadlineBlown, teardownReported } = {}) {
|
|
147
159
|
const code = exitCode == null ? NaN : Number(exitCode);
|
|
148
160
|
if (!Number.isInteger(code) || code === 0) return false;
|
|
149
|
-
return instanceAlive === false || containerHealthy === false || !!wiringDeadlineBlown;
|
|
161
|
+
return instanceAlive === false || containerHealthy === false || !!wiringDeadlineBlown || !!teardownReported;
|
|
150
162
|
}
|
|
151
163
|
|
|
152
164
|
// Resolve the two independent health signals into the single tri-state
|
|
@@ -697,6 +709,26 @@ function classifyAddFailure({ logTail, exitCode, instanceAlive, containerHealthy
|
|
|
697
709
|
cloudUpFailedRecovery({ cleanTeardown: cleanTeardownReported(log) }));
|
|
698
710
|
}
|
|
699
711
|
|
|
712
|
+
// 11b. torn-down — cloud:up reported, in its own words, that it deleted the VM it
|
|
713
|
+
// created this run, but no named cause above matched. That report is positive
|
|
714
|
+
// proof of death — the standard addFailureCorroborated demands — so the outcome
|
|
715
|
+
// is PROVEN, not unconfirmed. VM-8, 2026-10-01: an OS Login 429 killed the
|
|
716
|
+
// container step, cloud:up tore the instance down, and the transcript said so
|
|
717
|
+
// plainly; with no arm reading that line as evidence it fell to the `verifying`
|
|
718
|
+
// residue, whose calm hold could never release for a VM that never rostered,
|
|
719
|
+
// and the card spun "Add outcome unconfirmed" for 49 minutes and counting.
|
|
720
|
+
//
|
|
721
|
+
// Terminal and NOT retryable: nothing is left to reconcile or destroy, but the
|
|
722
|
+
// cause may still be present, so re-adding is the operator's call, not a
|
|
723
|
+
// one-click action. Tested after every named-cause arm so a specific cause
|
|
724
|
+
// always wins and keeps its actionable recovery (cloud-up-failed already reads
|
|
725
|
+
// the same teardown fact into its copy), and BEFORE interrupted / converging /
|
|
726
|
+
// verifying, none of which may describe a VM the provisioner reports deleted.
|
|
727
|
+
if (cleanTeardownReported(log)) {
|
|
728
|
+
return buildFailureVerdict('torn-down', 'Provisioning failed — VM deleted',
|
|
729
|
+
cloudUpFailedRecovery({ cleanTeardown: true }));
|
|
730
|
+
}
|
|
731
|
+
|
|
700
732
|
// 12. interrupted — a signal kill (OOM 137, SIGTERM 143, …) or an explicit
|
|
701
733
|
// Killed / out-of-memory marker. The real VM state is unknown. Not retryable.
|
|
702
734
|
if (fc.isSignalKillExit(Number(exitCode)) || /\bKilled\b/.test(log) || /Out of memory/i.test(log)) {
|
|
@@ -766,7 +798,8 @@ function classifyAddFailure({ logTail, exitCode, instanceAlive, containerHealthy
|
|
|
766
798
|
// addFailureCorroborated). The box is provably dead or provably never
|
|
767
799
|
// came up, so the loud, terminal escalation is warranted and unchanged.
|
|
768
800
|
// Still never invites an unproven retry.
|
|
769
|
-
if (addFailureCorroborated({ exitCode, instanceAlive, containerHealthy, wiringDeadlineBlown
|
|
801
|
+
if (addFailureCorroborated({ exitCode, instanceAlive, containerHealthy, wiringDeadlineBlown,
|
|
802
|
+
teardownReported: cleanTeardownReported(log) })) {
|
|
770
803
|
return buildFailureVerdict('unknown', 'Add failed',
|
|
771
804
|
'The add failed for an unclassified reason and the VM is confirmed dead or never came up — inspect the transcript below, then reconcile or destroy this VM before re-adding.');
|
|
772
805
|
}
|
|
@@ -818,8 +851,56 @@ function canRetryAdd({ n, job } = {}) {
|
|
|
818
851
|
// escalation, which this predicate narrows but never softens. Kept pure (like
|
|
819
852
|
// canRetryAdd / canClearBadge) so the badge decision is unit-tested without a
|
|
820
853
|
// live dashboard.
|
|
821
|
-
|
|
822
|
-
|
|
854
|
+
//
|
|
855
|
+
// The hold also has an ABSOLUTE AGE CEILING, because `wiringDeadlineBlown` alone is
|
|
856
|
+
// not a reachable release for every add: the failed marker is dropped by the
|
|
857
|
+
// add's wire tail, and an add that never rostered never had one. VM-8
|
|
858
|
+
// (2026-10-01) sat in `verifying` for 49 minutes with no possible exit. So:
|
|
859
|
+
//
|
|
860
|
+
// ageMs ms since the add settled (job.finishedAt). Past the ceiling the hold
|
|
861
|
+
// releases. Missing / non-finite HOLDS — an unknown age may never
|
|
862
|
+
// escalate a card on its own, the same tri-state rule
|
|
863
|
+
// addFailureCorroborated applies to every fact.
|
|
864
|
+
// rostered is the VM in the roster? An explicit `false` means the add never
|
|
865
|
+
// reached CY_ROSTER, so there is no ingress convergence left to wait
|
|
866
|
+
// for and the shorter ceiling applies. Unknown takes the longer one.
|
|
867
|
+
//
|
|
868
|
+
// `wiringDeadlineBlown` stays the fast path: a genuinely failed wiring still
|
|
869
|
+
// releases immediately rather than waiting out the clock.
|
|
870
|
+
const CALM_HOLD_CEILING_MS = 30 * 60 * 1000; // well past the 8-minute wiring deadline plus LB propagation
|
|
871
|
+
const NEVER_ROSTERED_HOLD_CEILING_MS = 10 * 60 * 1000; // no wiring to wait for — only a late transcript flush
|
|
872
|
+
|
|
873
|
+
function holdConvergingBadge({ category, wiringDeadlineBlown, ageMs, rostered } = {}) {
|
|
874
|
+
if (!CALM_ADD_CATEGORIES.has(category) || wiringDeadlineBlown) return false;
|
|
875
|
+
if (typeof ageMs !== 'number' || !Number.isFinite(ageMs)) return true;
|
|
876
|
+
const ceiling = rostered === false ? NEVER_ROSTERED_HOLD_CEILING_MS : CALM_HOLD_CEILING_MS;
|
|
877
|
+
return ageMs < ceiling;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// Age in ms of a settled job, from its `finishedAt` ISO stamp, for
|
|
881
|
+
// holdConvergingBadge's ceiling. Returns undefined when the stamp is missing or
|
|
882
|
+
// unparseable, which holds — never a fabricated age.
|
|
883
|
+
function settledAgeMs(job, nowMs = Date.now()) {
|
|
884
|
+
const t = job && job.finishedAt ? Date.parse(job.finishedAt) : NaN;
|
|
885
|
+
return Number.isFinite(t) ? Math.max(0, nowMs - t) : undefined;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// The `instanceAlive` fact for addFailureCorroborated, read from a RECORDED GCP
|
|
889
|
+
// existence verdict ({ exists, at } — what the vanish reconcile's describe
|
|
890
|
+
// returned, and when) rather than a fresh probe, so the failure path stays
|
|
891
|
+
// synchronous. Two rules keep it from escalating a live VM:
|
|
892
|
+
//
|
|
893
|
+
// - only a boolean verdict counts; the describe's inconclusive `null`, or no
|
|
894
|
+
// record at all, returns null (corroborates nothing);
|
|
895
|
+
// - a verdict taken BEFORE `sinceIso` (the add's startedAt) describes a
|
|
896
|
+
// previous instance under the same number, not this add's, so it returns
|
|
897
|
+
// null too. An unparseable or missing `sinceIso` cannot date the record
|
|
898
|
+
// against anything, and the verdict is used as-is.
|
|
899
|
+
function instanceAliveSince(record, sinceIso) {
|
|
900
|
+
if (!record || typeof record.exists !== 'boolean') return null;
|
|
901
|
+
const since = sinceIso ? Date.parse(sinceIso) : NaN;
|
|
902
|
+
if (Number.isFinite(since) && !(record.at >= since)) return null;
|
|
903
|
+
return record.exists;
|
|
823
904
|
}
|
|
824
905
|
|
|
825
906
|
// Refusal copy for a Retry on a card whose branch was deleted from origin.
|
|
@@ -884,4 +965,16 @@ function streamedLibraryRetryMessage(n, recovery) {
|
|
|
884
965
|
return `VM-${n} cannot be retried until the code is fixed: ${recovery || 'a streamed bootstrap library was never shipped to the VM.'}`;
|
|
885
966
|
}
|
|
886
967
|
|
|
887
|
-
|
|
968
|
+
// Refusal copy for a one-click "Retry add" on a `torn-down` card. The generic
|
|
969
|
+
// refusal's "may have left a half-provisioned instance — reconcile or destroy it"
|
|
970
|
+
// is false here: cloud:up reported deleting the VM it created. The retry is
|
|
971
|
+
// refused only because the unclassified cause may still be present, so the copy
|
|
972
|
+
// says nothing is left behind and hands the re-add to the operator. Pure; no I/O.
|
|
973
|
+
function tornDownRetryMessage(n) {
|
|
974
|
+
return (
|
|
975
|
+
`VM-${n} was deleted by cloud:up when its add failed, so there is nothing to reconcile or destroy. ` +
|
|
976
|
+
'Read the transcript for the cause, then use Add VM again once it is resolved.'
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
module.exports = { classifyAddFailure, extractDriftFiles, lastNonEmptyLines, canRetryAdd, transcriptRostered, holdConvergingBadge, addFailureCorroborated, containerHealthWitness, stockoutCapacityPhrase, missingBranchRefFacts, missingBranchRefRecovery, deadRefRetryMessage, wedgedInsertFacts, wedgedInsertRecovery, wedgedRetryMessage, iapNotReadyFacts, iapNotReadyRecovery, streamedLibraryMissingFacts, cleanTeardownReported, streamedLibraryMissingRecovery, cloudUpFailedRecovery, streamedLibraryRetryMessage, tornDownRetryMessage, settledAgeMs, instanceAliveSince, CALM_ADD_CATEGORIES, ADD_FAILURE_RETRYABLE, CLASSIFY_TAIL_LINES };
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// npm/fleet-api-budget.js — the RATE dimension of heavy-job admission: how many
|
|
4
|
+
// Google API calls per minute the fleet's in-flight work is projected to spend,
|
|
5
|
+
// and whether admitting one more add would push that past the per-user budget.
|
|
6
|
+
//
|
|
7
|
+
// Background (fleet--a-batch-of-adds-blows-a-per-user-google-api-quota): every
|
|
8
|
+
// `gcloud compute ssh` against an OS-Login VM spends at least one
|
|
9
|
+
// `oslogin.googleapis.com` loginProfile read; the limit is 60 per minute per
|
|
10
|
+
// USER, and all fleet automation runs as one service account — so the whole
|
|
11
|
+
// fleet shares one 60/min budget. On 2026-10-01 it ran out while the job cap of
|
|
12
|
+
// 4 held the entire time: a cap counts jobs, and a quota is a rate. A 429 landed
|
|
13
|
+
// mid-provision and tore down an add that had already built its editor image.
|
|
14
|
+
//
|
|
15
|
+
// This module is the accountant for that rate. It is a PROJECTION from known
|
|
16
|
+
// per-add shapes, not a measured counter: counting real calls would mean
|
|
17
|
+
// intercepting every gcloud invocation across detached shells — the same
|
|
18
|
+
// invisibility that produced this class of bug twice. Every function is pure.
|
|
19
|
+
|
|
20
|
+
/** Defaults for the API-rate dimension of add admission.
|
|
21
|
+
*
|
|
22
|
+
* - `budgetPerMin` — the real limit: `oslogin.googleapis.com` read requests per
|
|
23
|
+
* minute per user.
|
|
24
|
+
* - `reservedPerMin` — headroom kept back for traffic admission cannot see: the
|
|
25
|
+
* dashboard's own monitoring gathers (bounded in concurrency by hostOpLimiter,
|
|
26
|
+
* not in rate) and the tunnel watchdog.
|
|
27
|
+
* - `perAddCallsPerMin` — what one in-flight add is charged. Deliberately its
|
|
28
|
+
* PEAK, not its average: the fast first minute of the container gate (6 reads)
|
|
29
|
+
* overlapping cloud:up's own ssh steps or the post-roster burst. Charging the
|
|
30
|
+
* peak is wrong in the safe direction — it queues a little early rather than
|
|
31
|
+
* 429ing a provision.
|
|
32
|
+
*
|
|
33
|
+
* At these defaults (60 - 20) / 10 = 4 concurrent adds, the same as the job cap,
|
|
34
|
+
* so an operator who raises MAX_JOB_CONCURRENCY meets this budget instead of the
|
|
35
|
+
* quota. */
|
|
36
|
+
const API_BUDGET_DEFAULTS = Object.freeze({ budgetPerMin: 60, reservedPerMin: 20, perAddCallsPerMin: 10 });
|
|
37
|
+
|
|
38
|
+
// A positive finite number, else 0 — so garbage degrades toward admitting work,
|
|
39
|
+
// never toward a phantom load that would freeze admission forever.
|
|
40
|
+
function nonNegative(value) {
|
|
41
|
+
const n = Number(value);
|
|
42
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Project the fleet's Google API call rate (calls/min) from the heavy work in
|
|
46
|
+
* flight. `inFlight` is `heavyChargeCount`'s answer — running adds PLUS detached
|
|
47
|
+
* wiring tails — so this dimension inherits the tail visibility the job cap
|
|
48
|
+
* already has instead of re-deriving it. */
|
|
49
|
+
function projectApiCallsPerMin({
|
|
50
|
+
inFlight = 0,
|
|
51
|
+
perAddCallsPerMin = API_BUDGET_DEFAULTS.perAddCallsPerMin,
|
|
52
|
+
reservedPerMin = API_BUDGET_DEFAULTS.reservedPerMin,
|
|
53
|
+
} = {}) {
|
|
54
|
+
return nonNegative(reservedPerMin) + nonNegative(inFlight) * nonNegative(perAddCallsPerMin);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** True when admitting one more add (costing `apiCallsPerAdd`) would project past
|
|
58
|
+
* the budget. False whenever the budget or the projection is absent or
|
|
59
|
+
* non-numeric, or the budget is non-positive — an unset or misconfigured budget
|
|
60
|
+
* degrades to "no rate gate", the same rule `decideAddSlot` applies to a bad
|
|
61
|
+
* cap. */
|
|
62
|
+
function wouldExceedApiBudget({
|
|
63
|
+
apiCallBudgetPerMin,
|
|
64
|
+
projectedApiCallsPerMin,
|
|
65
|
+
apiCallsPerAdd = API_BUDGET_DEFAULTS.perAddCallsPerMin,
|
|
66
|
+
} = {}) {
|
|
67
|
+
if (apiCallBudgetPerMin == null || projectedApiCallsPerMin == null) return false;
|
|
68
|
+
const budget = Number(apiCallBudgetPerMin);
|
|
69
|
+
const projected = Number(projectedApiCallsPerMin);
|
|
70
|
+
if (!Number.isFinite(budget) || budget <= 0 || !Number.isFinite(projected)) return false;
|
|
71
|
+
return projected + nonNegative(apiCallsPerAdd) > budget;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Whether the RATE dimension, not the job cap, is what holds the next add back
|
|
75
|
+
* — so a queue paced by the API budget can say so instead of reading as an
|
|
76
|
+
* unexplained stall. Takes the same inputs as `decideAddSlot`. A non-positive
|
|
77
|
+
* cap means "no job cap", so the slot is free and only the rate can hold. */
|
|
78
|
+
function heldByApiBudget(inputs = {}) {
|
|
79
|
+
const cap = Number(inputs.maxConcurrency);
|
|
80
|
+
const capFree = !(cap > 0) || Number(inputs.activeCount) < cap;
|
|
81
|
+
return capFree && wouldExceedApiBudget(inputs);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The text Google returns when a per-user API quota is spent. `RESOURCE_EXHAUSTED`
|
|
85
|
+
* is the gRPC status name, `Quota exceeded` / `rateLimitExceeded` the REST
|
|
86
|
+
* wording. A bare "429" is deliberately NOT matched alone, since port numbers
|
|
87
|
+
* and VM indexes contain it — only an HTTP-status shape of it is. */
|
|
88
|
+
const API_QUOTA_PATTERN = /RESOURCE_EXHAUSTED|Quota exceeded|rateLimitExceeded|RATE_LIMIT_EXCEEDED|HTTP(?:Error)? 429|status(?:Code)?[ =:]+429/i;
|
|
89
|
+
|
|
90
|
+
/** First line of `text` naming a Google API quota exhaustion, trimmed and capped
|
|
91
|
+
* at 300 chars, or null. Matched against job output so a 429 reads as a named
|
|
92
|
+
* condition instead of an "unconfirmed" card with the cause in a transcript. */
|
|
93
|
+
function detectApiQuotaExhaustion(text) {
|
|
94
|
+
if (typeof text !== 'string' || !text) return null;
|
|
95
|
+
const line = text.split(/\r?\n/).find((l) => API_QUOTA_PATTERN.test(l));
|
|
96
|
+
return line ? line.trim().slice(0, 300) : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = {
|
|
100
|
+
API_BUDGET_DEFAULTS,
|
|
101
|
+
projectApiCallsPerMin,
|
|
102
|
+
wouldExceedApiBudget,
|
|
103
|
+
heldByApiBudget,
|
|
104
|
+
detectApiQuotaExhaustion,
|
|
105
|
+
};
|
package/npm/fleet-job-badge.js
CHANGED
|
@@ -117,10 +117,13 @@ function canClearBadge({ rostered, job } = {}) {
|
|
|
117
117
|
//
|
|
118
118
|
// Only the LABEL changes. `job.state` stays 'error', so canRetryAdd,
|
|
119
119
|
// canClearBadge and shouldReconcileFailedBadge keep gating on it unchanged.
|
|
120
|
-
|
|
120
|
+
//
|
|
121
|
+
// `ageMs` / `rostered` are holdConvergingBadge's age-ceiling inputs, passed
|
|
122
|
+
// through unchanged for the same reason.
|
|
123
|
+
function jobBadgeLabel({ job, wiringDeadlineBlown, ageMs, rostered } = {}) {
|
|
121
124
|
if (!job) return '';
|
|
122
125
|
if (job.action === 'add' && job.state === 'error') {
|
|
123
|
-
return faf.holdConvergingBadge({ category: job.category, wiringDeadlineBlown })
|
|
126
|
+
return faf.holdConvergingBadge({ category: job.category, wiringDeadlineBlown, ageMs, rostered })
|
|
124
127
|
? 'add unconfirmed'
|
|
125
128
|
: 'add failed';
|
|
126
129
|
}
|
package/npm/launch-config.js
CHANGED
|
@@ -12,6 +12,7 @@ const { CANONICAL_EDITOR_REPO, EDITOR_VARIANTS, repoBasename, AGENT_ENV_VARS } =
|
|
|
12
12
|
const { normalizeVmSlug } = require("./vm-urls");
|
|
13
13
|
const { normalizeAccessList } = require("./fleet-vm-access");
|
|
14
14
|
const { normalizeRepoUrl } = require("./fleet-env");
|
|
15
|
+
const { API_BUDGET_DEFAULTS, wouldExceedApiBudget } = require("./fleet-api-budget");
|
|
15
16
|
const { posix: posixPath } = require("node:path");
|
|
16
17
|
|
|
17
18
|
/** The build agents a VM's editor terminal can run, as the keys of cloud.js's
|
|
@@ -396,11 +397,26 @@ function classifyContainerWait({ everSeenUp = false, instanceGone = false, elaps
|
|
|
396
397
|
* queue + posting a `queued` job entry on "queue", or starting addVm on
|
|
397
398
|
* "start". Decoupling the decision from the I/O makes it unit-testable
|
|
398
399
|
* against arbitrary (cap, active) tuples — the test signal the OOM bug
|
|
399
|
-
* needed and the queue never had.
|
|
400
|
-
|
|
400
|
+
* needed and the queue never had.
|
|
401
|
+
*
|
|
402
|
+
* A second, optional dimension is a RATE, not a count: the per-user Google API
|
|
403
|
+
* budget (oslogin loginProfile reads, 60/min for the one service account the
|
|
404
|
+
* whole fleet runs as). When `apiCallBudgetPerMin` AND `projectedApiCallsPerMin`
|
|
405
|
+
* are both supplied, an add that would push the projection past the budget by
|
|
406
|
+
* its own `apiCallsPerAdd` is queued even with job slots free — the 2026-10-01
|
|
407
|
+
* shape, where a cap of 4 held while the fleet 429'd a provision mid-flight.
|
|
408
|
+
* Omit either and the decision is exactly the count-only one above. */
|
|
409
|
+
function decideAddSlot({
|
|
410
|
+
maxConcurrency,
|
|
411
|
+
activeCount = 0,
|
|
412
|
+
apiCallBudgetPerMin,
|
|
413
|
+
projectedApiCallsPerMin,
|
|
414
|
+
apiCallsPerAdd = API_BUDGET_DEFAULTS.perAddCallsPerMin,
|
|
415
|
+
} = {}) {
|
|
401
416
|
const cap = Number(maxConcurrency);
|
|
402
|
-
|
|
403
|
-
|
|
417
|
+
const underCap = !Number.isFinite(cap) || cap <= 0 || Number(activeCount) < cap;
|
|
418
|
+
if (!underCap) return "queue";
|
|
419
|
+
return wouldExceedApiBudget({ apiCallBudgetPerMin, projectedApiCallsPerMin, apiCallsPerAdd }) ? "queue" : "start";
|
|
404
420
|
}
|
|
405
421
|
|
|
406
422
|
/** How many concurrency slots the fleet's heavy work is ACTUALLY occupying —
|
|
@@ -1479,6 +1495,109 @@ function localVmReapCmd({ port, previewPort } = {}) {
|
|
|
1479
1495
|
].join("; ");
|
|
1480
1496
|
}
|
|
1481
1497
|
|
|
1498
|
+
/** Poll cadence for the Add's container gate, as `[{ untilSec, everySec }, …]`
|
|
1499
|
+
* bands over elapsed time; the last band (no `untilSec`) runs to the deadline.
|
|
1500
|
+
* A cold image build takes 15-25 min, so the old flat 10s cadence asked "is it
|
|
1501
|
+
* up yet?" far finer than the event it waits for — and each ask is a
|
|
1502
|
+
* `gcloud compute ssh`, i.e. an oslogin read against the fleet-wide 60/min
|
|
1503
|
+
* per-user quota. Fast for the first minute (a warm image is up in seconds),
|
|
1504
|
+
* then widening: ~6 reads in minute one, 2/min to minute five, 1/min after —
|
|
1505
|
+
* roughly 32 reads over a full 28-min wait instead of 168. */
|
|
1506
|
+
const CONTAINER_GATE_SCHEDULE = Object.freeze([
|
|
1507
|
+
Object.freeze({ untilSec: 60, everySec: 10 }),
|
|
1508
|
+
Object.freeze({ untilSec: 300, everySec: 30 }),
|
|
1509
|
+
Object.freeze({ everySec: 60 }),
|
|
1510
|
+
]);
|
|
1511
|
+
|
|
1512
|
+
/** Build the Add script's container gate: wait for the editor container to report
|
|
1513
|
+
* `Up`, up to `deadlineSec` of REAL elapsed time (default 1680s, the historical
|
|
1514
|
+
* ~28 min), mirroring `classifyContainerWait`:
|
|
1515
|
+
* - any single `Up` is terminal success (`st=1`);
|
|
1516
|
+
* - a clean cloud:up exit is the normal `--no-tunnel` success path — keep
|
|
1517
|
+
* probing; a NON-ZERO exit gets one final container re-check and otherwise
|
|
1518
|
+
* fails fast with the cloud:up log tail instead of probing a dead provision;
|
|
1519
|
+
* - an empty/errored probe (transient gcloud-ssh blip) is retried, never read
|
|
1520
|
+
* as "not up";
|
|
1521
|
+
* - past the deadline: a final re-check, then a vanished instance is told
|
|
1522
|
+
* apart from a build that never came up, and cloud:up is reaped.
|
|
1523
|
+
*
|
|
1524
|
+
* Only the POLLING COST differs from the inline loop this replaced: probes follow
|
|
1525
|
+
* `schedule`, and the waits between them are 5s `kill -0` slices — free, no API
|
|
1526
|
+
* call — so a cloud:up exit (success under `--no-tunnel`, or a failure) is still
|
|
1527
|
+
* noticed within ~5s rather than a full band interval later. Elapsed time is read
|
|
1528
|
+
* from the clock, not counted in ticks, so a wider cadence cannot move the
|
|
1529
|
+
* deadline.
|
|
1530
|
+
*
|
|
1531
|
+
* Each probe's stderr is kept (it used to be discarded) and scanned for a Google
|
|
1532
|
+
* API quota refusal; a hit prints one `CY_API_QUOTA` line on the job's stdout so
|
|
1533
|
+
* the dashboard can name it. `probeCmd` must already be shell-quoted. Pure. */
|
|
1534
|
+
function containerGateCmd({
|
|
1535
|
+
gcloud = "gcloud",
|
|
1536
|
+
inst,
|
|
1537
|
+
zone,
|
|
1538
|
+
probeCmd,
|
|
1539
|
+
logPath,
|
|
1540
|
+
deadlineSec = 1680,
|
|
1541
|
+
schedule = CONTAINER_GATE_SCHEDULE,
|
|
1542
|
+
pidVar = "UP_PID",
|
|
1543
|
+
} = {}) {
|
|
1544
|
+
const errPath = `${logPath}.gate-err`;
|
|
1545
|
+
const bands = (Array.isArray(schedule) && schedule.length ? schedule : CONTAINER_GATE_SCHEDULE);
|
|
1546
|
+
// The last band is the fallback whatever its `untilSec` says, so the chain
|
|
1547
|
+
// always ends in an `else` and every elapsed value gets a cadence.
|
|
1548
|
+
const cadence = bands.map((b, i) => {
|
|
1549
|
+
if (i === bands.length - 1) return i === 0 ? ` __cy_gate_every=${b.everySec}` : ` else __cy_gate_every=${b.everySec}`;
|
|
1550
|
+
return ` ${i === 0 ? "if" : "elif"} [ "$__cy_gate_elapsed" -lt ${b.untilSec} ]; then __cy_gate_every=${b.everySec}`;
|
|
1551
|
+
});
|
|
1552
|
+
if (bands.length > 1) cadence.push(` fi`);
|
|
1553
|
+
const minutes = Math.round(deadlineSec / 60);
|
|
1554
|
+
// cloud:up's own ssh steps can be the call that 429s; its log is private, so on
|
|
1555
|
+
// every failure exit republish the first quota line onto the job's stdout.
|
|
1556
|
+
const quotaFromLog = `grep -m1 -iE 'RESOURCE_EXHAUSTED|Quota exceeded|rateLimitExceeded' ${logPath} 2>/dev/null | sed 's/^/CY_API_QUOTA cloud:up: /' || true`;
|
|
1557
|
+
return [
|
|
1558
|
+
`__cy_gate_start=$(date +%s); st=0; cloud_up_exited=0`,
|
|
1559
|
+
`__cy_gate_probe() {`,
|
|
1560
|
+
` up=$(${gcloud} compute ssh ${inst} --tunnel-through-iap --zone=${zone} --command=${probeCmd} 2>${errPath} | grep -c Up || true)`,
|
|
1561
|
+
` __cy_quota=$(grep -m1 -iE 'RESOURCE_EXHAUSTED|Quota exceeded|rateLimitExceeded' ${errPath} 2>/dev/null || true)`,
|
|
1562
|
+
` if [ -n "$__cy_quota" ]; then echo "CY_API_QUOTA container gate probe for ${inst}: $__cy_quota"; fi`,
|
|
1563
|
+
`}`,
|
|
1564
|
+
`while :; do`,
|
|
1565
|
+
` __cy_gate_probe`,
|
|
1566
|
+
` if [ "$up" = "1" ]; then st=1; break; fi`,
|
|
1567
|
+
` if [ "$cloud_up_exited" = "0" ] && ! kill -0 $${pidVar} 2>/dev/null; then`,
|
|
1568
|
+
` cloud_up_exited=1; wait $${pidVar} 2>/dev/null; up_rc=$?`,
|
|
1569
|
+
` if [ "$up_rc" != "0" ]; then`,
|
|
1570
|
+
` __cy_gate_probe`,
|
|
1571
|
+
` if [ "$up" != "1" ]; then`,
|
|
1572
|
+
` echo "cloud:up FAILED (exit $up_rc) before ${inst}'s container came up — provisioning aborted (see ${logPath}):"`,
|
|
1573
|
+
` tail -20 ${logPath} 2>/dev/null | grep -vE 'WARNING|NumPy|cloud\\.google\\.com/iap' || true`,
|
|
1574
|
+
` ${quotaFromLog}`,
|
|
1575
|
+
` exit 1`,
|
|
1576
|
+
` fi`,
|
|
1577
|
+
` st=1; break`,
|
|
1578
|
+
` fi`,
|
|
1579
|
+
` echo "cloud:up exited cleanly (expected under --no-tunnel once the container is up); continuing to probe ${inst} until it reports Up or the deadline..."`,
|
|
1580
|
+
` fi`,
|
|
1581
|
+
` __cy_gate_elapsed=$(( $(date +%s) - __cy_gate_start ))`,
|
|
1582
|
+
` if [ "$__cy_gate_elapsed" -ge ${deadlineSec} ]; then break; fi`,
|
|
1583
|
+
...cadence,
|
|
1584
|
+
` __cy_gate_left=$(( ${deadlineSec} - __cy_gate_elapsed ))`,
|
|
1585
|
+
` if [ "$__cy_gate_every" -gt "$__cy_gate_left" ]; then __cy_gate_every=$__cy_gate_left; fi`,
|
|
1586
|
+
` __cy_gate_slept=0`,
|
|
1587
|
+
` while [ "$__cy_gate_slept" -lt "$__cy_gate_every" ]; do`,
|
|
1588
|
+
` if [ "$cloud_up_exited" = "0" ] && ! kill -0 $${pidVar} 2>/dev/null; then break; fi`,
|
|
1589
|
+
` sleep 5; __cy_gate_slept=$((__cy_gate_slept + 5))`,
|
|
1590
|
+
` done`,
|
|
1591
|
+
`done`,
|
|
1592
|
+
`if [ "$st" != "1" ]; then __cy_gate_probe; [ "$up" = "1" ] && st=1; fi`,
|
|
1593
|
+
`if [ "$st" != "1" ]; then`,
|
|
1594
|
+
` if ! ${gcloud} compute instances describe ${inst} --zone=${zone} --format='value(name)' >/dev/null 2>&1; then echo "instance ${inst} is gone — provisioning failed before the container started (see ${logPath})"; else echo "container never came up for ${inst} within ~${minutes} min (see ${logPath})"; fi`,
|
|
1595
|
+
` ${quotaFromLog}`,
|
|
1596
|
+
` kill $${pidVar} 2>/dev/null; ${cloudUpSweepCmd({ inst })}; exit 1`,
|
|
1597
|
+
`fi`,
|
|
1598
|
+
].join("\n");
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1482
1601
|
/** Build the shell snippet the Add script runs to AWAIT cloud:up's clean exit at
|
|
1483
1602
|
* container-healthy instead of reaping it mid-flight.
|
|
1484
1603
|
*
|
|
@@ -1568,6 +1687,8 @@ module.exports = {
|
|
|
1568
1687
|
classifyContainerWait,
|
|
1569
1688
|
decideAddSlot,
|
|
1570
1689
|
heavyChargeCount,
|
|
1690
|
+
CONTAINER_GATE_SCHEDULE,
|
|
1691
|
+
containerGateCmd,
|
|
1571
1692
|
HEAVY_ACTIONS,
|
|
1572
1693
|
queuedStatusForAction,
|
|
1573
1694
|
pushHistory,
|
|
@@ -98,6 +98,9 @@ function escapeHtmlAttribute(value) {
|
|
|
98
98
|
// Preview. Callers (via scenario-check.js) pass a concrete color when the
|
|
99
99
|
// UI has detected a background it wants the capture to paint behind the
|
|
100
100
|
// iframe, e.g. `var(--bg-deep)` from the editor shell.
|
|
101
|
+
// The iframe is named `codeyam-preview` (CODEYAM_PREVIEW_FRAME_NAME in
|
|
102
|
+
// ui/src/helpers/previewMockPolicy.ts) so a framed editor page knows it is
|
|
103
|
+
// inside codeyam's own capture, not a hosting IDE's pane.
|
|
101
104
|
function buildIframeHarness(url, { background = "transparent" } = {}) {
|
|
102
105
|
const escapedUrl = escapeHtmlAttribute(url);
|
|
103
106
|
const bg = String(background);
|
|
@@ -124,7 +127,7 @@ function buildIframeHarness(url, { background = "transparent" } = {}) {
|
|
|
124
127
|
</style>
|
|
125
128
|
</head>
|
|
126
129
|
<body>
|
|
127
|
-
<iframe id="scenario-frame" title="Scenario Preview" src="${escapedUrl}"></iframe>
|
|
130
|
+
<iframe id="scenario-frame" name="codeyam-preview" title="Scenario Preview" src="${escapedUrl}"></iframe>
|
|
128
131
|
</body>
|
|
129
132
|
</html>`;
|
|
130
133
|
}
|