@codeyam-editor/codeyam-editor 0.1.7 → 0.1.8
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 +116 -23
- package/npm/fleet-add-failure.js +59 -12
- package/npm/fleet-memory.js +29 -4
- package/npm/fleet-queue.js +141 -0
- package/npm/fleet-vm-access.js +162 -0
- package/npm/ingress-invoke.js +46 -2
- package/npm/launch-config.js +43 -0
- package/npm/scenario-check.js +11 -1
- package/npm/scenario-handlers.js +18 -0
- package/npm/scenario-issues.js +11 -0
- package/npm/scenario-mocks.js +245 -39
- package/npm/scenario-playwright.js +70 -12
- package/package.json +5 -5
- package/ui/dist/assets/{ContainerInspector-Cu1tSN-o.js → ContainerInspector-DSVZvEkB.js} +1 -1
- package/ui/dist/assets/EditorShell-DnXdHrar.js +256 -0
- package/ui/dist/assets/EditorShell-FvbXyqqD.css +1 -0
- package/ui/dist/assets/{ProjectLauncherScreen-Dmm6Z0mv.js → ProjectLauncherScreen-E0NYYiwy.js} +1 -1
- package/ui/dist/assets/ScenarioDataPanel-6qJE-0lA.js +15 -0
- package/ui/dist/assets/{ScenarioDataPanelFullPage-BqMxWVKW.js → ScenarioDataPanelFullPage-Cyk6efiI.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-Uyt54vHI.js +21 -0
- package/ui/dist/assets/{index-VuyqBbGJ.js → index-BPLKgwon.js} +9 -9
- package/ui/dist/assets/index-DKpgJLo3.css +1 -0
- package/ui/dist/assets/useServerIdentity-CVuHTzjg.js +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/EditorShell--V2ExYQ6.js +0 -260
- package/ui/dist/assets/EditorShell-DP5UY20G.css +0 -1
- package/ui/dist/assets/ScenarioDataPanel-DjprZq2L.js +0 -9
- package/ui/dist/assets/ScenarioRenderer-DqLVVMYS.js +0 -21
- package/ui/dist/assets/index-CjxWhkSy.css +0 -1
- package/ui/dist/assets/useServerIdentity-BdXy8TQv.js +0 -1
package/npm/fleet-vm-access.js
CHANGED
|
@@ -215,6 +215,161 @@ function noDefaultOperatorDegradedReasons({ ok, action, lbMode, resolvedOperator
|
|
|
215
215
|
];
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
/** The BLOCKED/Next-valid-action refusal for an Add that would create an
|
|
219
|
+
* IAP-gated VM authorizing NOBODY — the front-door counterpart to
|
|
220
|
+
* `noDefaultOperatorDegradedReasons`, which can only warn AFTER the VM exists.
|
|
221
|
+
* Fires only in LB mode (`lbMode` — a fleet domain is configured, so the VM's
|
|
222
|
+
* public URL is IAP-gated) on a numbered fleet VM (`vmNumber != null`) when the
|
|
223
|
+
* intended principal set is empty (`intendedCount === 0`): no `--vm-access`
|
|
224
|
+
* collaborators AND no default operator from any of the three tiers. The check
|
|
225
|
+
* depends only on env + config, never on GCP state, so the caller can run it in
|
|
226
|
+
* the add's preflight and refuse before an instance exists — provisioning a
|
|
227
|
+
* machine nobody can open, then revoking the slot's prior tenant, is what
|
|
228
|
+
* happened to VMs 4/5/6 on 2026-08-09.
|
|
229
|
+
*
|
|
230
|
+
* Names all THREE resolution tiers because which one to populate depends on the
|
|
231
|
+
* host: an automation-SA session (the dashboard) can only be fixed by env or
|
|
232
|
+
* cloud.json, while a human gcloud session resolves tier 3 on its own and never
|
|
233
|
+
* reaches this refusal. Returns null when the Add is fine (so the call site is a
|
|
234
|
+
* bare `if (msg) throw`), else the two-line recovery pair. Pure — the resolve
|
|
235
|
+
* happens at the call site and only its COUNT is passed in. */
|
|
236
|
+
function unauthorizableAddRefusal({ lbMode, vmNumber, intendedCount } = {}) {
|
|
237
|
+
if (!lbMode || vmNumber == null || intendedCount !== 0) return null;
|
|
238
|
+
return (
|
|
239
|
+
`BLOCKED: this Add would create an IAP-gated VM (VM-${vmNumber}) that authorizes NOBODY.\n`
|
|
240
|
+
+ ` ${DEFAULT_OPERATOR_ENV} is unset, .codeyam/cloud.json has no \`vmAccessDefault\`,\n`
|
|
241
|
+
+ ' and the active gcloud identity is a service account (skipped by design).\n'
|
|
242
|
+
+ 'Next valid action: add `"vmAccessDefault": "user:you@example.com"` to\n'
|
|
243
|
+
+ ' .codeyam/cloud.json (durable, no env re-bake), or pass --vm-access.'
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Is this IAM principal a service account — i.e. authorizes no HUMAN? Matches
|
|
248
|
+
* both the typed form gcloud returns on a policy read (`serviceAccount:x@…`) and
|
|
249
|
+
* a bare `*.gserviceaccount.com` address, so the read-back below cannot be
|
|
250
|
+
* satisfied by the automation SA that `iap-access.sh` unions into every grant.
|
|
251
|
+
* Read-path semantics, like parseProjectMembers: it inspects whatever IAM
|
|
252
|
+
* already holds rather than validating a write, so it never throws. */
|
|
253
|
+
function isServiceAccountPrincipal(principal) {
|
|
254
|
+
const p = String(principal == null ? '' : principal).trim().toLowerCase();
|
|
255
|
+
if (p === '') return false;
|
|
256
|
+
return p.startsWith('serviceaccount:') || /gserviceaccount\.com$/.test(p);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Does a finished job warrant the post-add IAP read-back at all? Only a SUCCESSFUL
|
|
260
|
+
* `add` in LB mode has an IAP-gated public URL whose reachability is worth
|
|
261
|
+
* proving — a failed job, another action, or legacy quick-tunnel mode each have
|
|
262
|
+
* nothing to read back, and flagging them would bury a real failure under a wrong
|
|
263
|
+
* one. Split out so the caller can skip the (gcloud) policy read entirely on the
|
|
264
|
+
* cheap gates, and so that decision is stated in exactly one place instead of
|
|
265
|
+
* being duplicated at the call site and inside the verdict below.
|
|
266
|
+
*
|
|
267
|
+
* `wiringInFlight` is the fourth gate and the one that fixes the 2026-08-11 race:
|
|
268
|
+
* the IAP grant is the LAST step of the detached `wire-add-tail` that the add path
|
|
269
|
+
* spawns, and that tail runs for another 5–10 minutes after the Add settles at
|
|
270
|
+
* container-healthy. A read-back fired while the tail is alive is therefore racing
|
|
271
|
+
* the very step it checks for — it is GUARANTEED to observe zero accessor bindings
|
|
272
|
+
* on a perfectly healthy VM, which is exactly how healthy VM-4 and VM-7 were failed
|
|
273
|
+
* with "authorizes no human". A live tail means the grant provably has not run yet,
|
|
274
|
+
* so there is nothing to read back. Defaults false, so an omitted fact behaves
|
|
275
|
+
* exactly as before this input existed. */
|
|
276
|
+
function shouldReadBackIapAccess({ ok, action, lbMode, wiringInFlight } = {}) {
|
|
277
|
+
return !!ok && action === 'add' && !!lbMode && !wiringInFlight;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Is `job` still the job a post-add read-back was started for? The policy read is
|
|
281
|
+
* async, so a newer job can replace it mid-flight; downgrading THAT job would
|
|
282
|
+
* report a stale verdict against work it never describes. */
|
|
283
|
+
function readbackAppliesToJob(job) {
|
|
284
|
+
return !!job && job.action === 'add' && job.state === 'done';
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Post-add read-back verdict: after a successful LB-mode Add, does VM-N's LIVE
|
|
288
|
+
* IAP policy authorize at least one non-service-account principal? This is the
|
|
289
|
+
* only thing that PROVES the end state is reachable — every other signal in the
|
|
290
|
+
* add path reports intent (what we meant to grant), and on 2026-08-09 intent and
|
|
291
|
+
* outcome disagreed: the job reported `done` while the URL authorized nobody.
|
|
292
|
+
*
|
|
293
|
+
* `iapMembers` is the union of accessor members across surfaces
|
|
294
|
+
* (`aggregateIapMembers(...).all`); `null` means a surface's read ERRORED, the
|
|
295
|
+
* same absence-of-evidence posture aggregateIapMembers itself takes.
|
|
296
|
+
*
|
|
297
|
+
* Reported as a discriminated outcome, so a caller can tell the four cases apart
|
|
298
|
+
* instead of collapsing three of them onto `null`:
|
|
299
|
+
*
|
|
300
|
+
* 'skipped' the check does not apply (not an ok LB-mode add, or the add's
|
|
301
|
+
* detached wiring is still in flight). Nothing to do.
|
|
302
|
+
* 'undetermined' a surface's policy read ERRORED (`iapMembers` is not an array).
|
|
303
|
+
* This is absence of evidence, NOT a pass: the caller must
|
|
304
|
+
* re-arm and re-check rather than concluding. Collapsing it onto
|
|
305
|
+
* "authorized" is how VM-5 settled `done` on 2026-08-11 with its
|
|
306
|
+
* access never actually proven — a flaky gcloud call under load
|
|
307
|
+
* silently skipped the only check that proves reachability.
|
|
308
|
+
* 'authorized' at least one non-service-account principal holds an accessor
|
|
309
|
+
* binding. The end state is provably reachable by a human.
|
|
310
|
+
* 'unauthorized' the policy was read and authorizes no human. `reason` carries
|
|
311
|
+
* the BLOCKED / `Next valid action:` remedy.
|
|
312
|
+
*
|
|
313
|
+
* `reason` is non-null only for 'unauthorized'. Pure — the gcloud read is the
|
|
314
|
+
* caller's. */
|
|
315
|
+
function iapReadbackOutcome({ ok, action, lbMode, wiringInFlight, iapMembers, vmNumber } = {}) {
|
|
316
|
+
if (!shouldReadBackIapAccess({ ok, action, lbMode, wiringInFlight })) {
|
|
317
|
+
return { outcome: 'skipped', reason: null };
|
|
318
|
+
}
|
|
319
|
+
if (!Array.isArray(iapMembers)) return { outcome: 'undetermined', reason: null };
|
|
320
|
+
if (iapMembers.some((p) => !isServiceAccountPrincipal(p))) return { outcome: 'authorized', reason: null };
|
|
321
|
+
const held = iapMembers.length > 0 ? iapMembers.join(', ') : '(none)';
|
|
322
|
+
return {
|
|
323
|
+
outcome: 'unauthorized',
|
|
324
|
+
reason: (
|
|
325
|
+
`BLOCKED: VM-${vmNumber} is up but its IAP policy authorizes no human — the public URL\n`
|
|
326
|
+
+ ` returns "Error code 51" for everyone. Accessor bindings held: ${held}.\n`
|
|
327
|
+
+ `Next valid action: configure a default operator (${DEFAULT_OPERATOR_ENV} or\n`
|
|
328
|
+
+ ' .codeyam/cloud.json `vmAccessDefault`), then run\n'
|
|
329
|
+
+ ` VM_ACCESS=user:you@example.com bash scripts/gcp/iap-access.sh ${vmNumber} grant`
|
|
330
|
+
),
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Timing policy for the DEFERRED post-add read-back: on this attempt, is there
|
|
335
|
+
* anything worth reading yet? Separate from shouldReadBackIapAccess, which asks
|
|
336
|
+
* whether the check applies AT ALL — this asks whether now is the moment.
|
|
337
|
+
*
|
|
338
|
+
* wiringInFlight the add's detached wiring tail is still running, and the IAP
|
|
339
|
+
* grant is its LAST step. Reading now can only observe the
|
|
340
|
+
* grant not having happened yet, so wait.
|
|
341
|
+
* wiringFailed the tail dropped its failed marker. A blown deadline is
|
|
342
|
+
* EVIDENCE, not a reason to keep waiting: read now and let a
|
|
343
|
+
* genuinely unauthorized VM escalate instead of waiting forever.
|
|
344
|
+
* budgetSpent the caller's total wait has elapsed. Read regardless — a
|
|
345
|
+
* crashed tail that never unlinked its marker must not defer the
|
|
346
|
+
* check indefinitely, and the classifier still renders the card
|
|
347
|
+
* calm while the wiring reads live.
|
|
348
|
+
*
|
|
349
|
+
* Returns 'defer' (re-arm and ask again later) or 'read' (do the policy read). */
|
|
350
|
+
function iapReadbackDeferral({ wiringInFlight, wiringFailed, budgetSpent } = {}) {
|
|
351
|
+
if (budgetSpent || wiringFailed) return 'read';
|
|
352
|
+
return wiringInFlight ? 'defer' : 'read';
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** What should the caller DO with an iapReadbackOutcome? Pure so the one rule that
|
|
356
|
+
* cost us a silently-skipped check is stated and tested in one place:
|
|
357
|
+
*
|
|
358
|
+
* An 'undetermined' read (a gcloud call that errored under load) must DEFER, never
|
|
359
|
+
* settle. Treating it as a pass is how VM-5 reached `done` on 2026-08-11 with its
|
|
360
|
+
* reachability never actually proven — the check that exists to catch an
|
|
361
|
+
* unreachable VM was skipped entirely by one flaky call. It settles only once the
|
|
362
|
+
* wait budget is spent, and even then it settles rather than failing: absence of
|
|
363
|
+
* evidence must not manufacture a failed Add either.
|
|
364
|
+
*
|
|
365
|
+
* Returns 'fail' (downgrade the job with the reason), 'defer' (re-arm and
|
|
366
|
+
* re-check), or 'settle' (leave the job alone). */
|
|
367
|
+
function iapReadbackNextAction({ outcome, budgetSpent } = {}) {
|
|
368
|
+
if (outcome === 'unauthorized') return 'fail';
|
|
369
|
+
if (outcome === 'undetermined') return budgetSpent ? 'settle' : 'defer';
|
|
370
|
+
return 'settle';
|
|
371
|
+
}
|
|
372
|
+
|
|
218
373
|
/** Union the default-operator principal(s) into an access list, deduped and
|
|
219
374
|
* order-preserving (explicit collaborators first, the operator appended if not
|
|
220
375
|
* already present). Both inputs are normalized. The result is what gets GRANTED;
|
|
@@ -391,6 +546,13 @@ module.exports = {
|
|
|
391
546
|
resolveDefaultOperator,
|
|
392
547
|
defaultCloudConfigRead,
|
|
393
548
|
noDefaultOperatorDegradedReasons,
|
|
549
|
+
unauthorizableAddRefusal,
|
|
550
|
+
isServiceAccountPrincipal,
|
|
551
|
+
shouldReadBackIapAccess,
|
|
552
|
+
readbackAppliesToJob,
|
|
553
|
+
iapReadbackOutcome,
|
|
554
|
+
iapReadbackDeferral,
|
|
555
|
+
iapReadbackNextAction,
|
|
394
556
|
withDefaultOperator,
|
|
395
557
|
parseProjectMembers,
|
|
396
558
|
reconcileVmAccess,
|
package/npm/ingress-invoke.js
CHANGED
|
@@ -78,7 +78,15 @@ function regionFromZone(zone) {
|
|
|
78
78
|
* stand. An optional `slug` becomes `VM_SLUG`, which makes the script route the
|
|
79
79
|
* VM's custom `<slug>.<surface>.<domain>` host (the same slug must be passed on
|
|
80
80
|
* `down` so the matching host rule is removed); omit it to keep the numeric
|
|
81
|
-
* `vm-<N>` host.
|
|
81
|
+
* `vm-<N>` host.
|
|
82
|
+
*
|
|
83
|
+
* `vmAccess` (an array or comma-separated string, `up` only) becomes VM_ACCESS —
|
|
84
|
+
* the Add's INTENDED principal set, which the script's `apply_default_iap_access`
|
|
85
|
+
* grants instead of reading the per-VM sidecar. Passing it is what stops the
|
|
86
|
+
* ingress path granting a reused slot's prior tenant that the very next step then
|
|
87
|
+
* revokes as stale (VMs 4/5/6, 2026-08-09). Omit it (or pass an empty list) to
|
|
88
|
+
* leave the script's sidecar-read fallback in place — the standalone/reconcile
|
|
89
|
+
* path, where no Add is deciding the intent. */
|
|
82
90
|
function vmIngressInvocation({
|
|
83
91
|
scriptPath,
|
|
84
92
|
vmNumber,
|
|
@@ -89,6 +97,7 @@ function vmIngressInvocation({
|
|
|
89
97
|
appPort,
|
|
90
98
|
instancePrefix,
|
|
91
99
|
slug,
|
|
100
|
+
vmAccess,
|
|
92
101
|
} = {}) {
|
|
93
102
|
if (!scriptPath) throw new Error('vmIngressInvocation requires scriptPath');
|
|
94
103
|
if (action !== 'up' && action !== 'down') {
|
|
@@ -105,6 +114,8 @@ function vmIngressInvocation({
|
|
|
105
114
|
if (appPort != null && String(appPort).trim() !== '') env.APP_PORT = String(appPort);
|
|
106
115
|
if (instancePrefix) env.INSTANCE_PREFIX = instancePrefix;
|
|
107
116
|
if (slug != null && String(slug).trim() !== '') env.VM_SLUG = String(slug).trim();
|
|
117
|
+
const access = (Array.isArray(vmAccess) ? vmAccess.join(',') : String(vmAccess == null ? '' : vmAccess)).trim();
|
|
118
|
+
if (action === 'up' && access !== '') env.VM_ACCESS = access;
|
|
108
119
|
return { command: 'bash', args: [scriptPath, String(n), action], env };
|
|
109
120
|
}
|
|
110
121
|
|
|
@@ -427,6 +438,13 @@ function detachedAddTailInvocation({ nodePath, scriptPath, cfg, appPort } = {})
|
|
|
427
438
|
return { command: nodePath, args: [scriptPath, 'wire-add-tail'], env };
|
|
428
439
|
}
|
|
429
440
|
|
|
441
|
+
/** Basename prefix of the in-flight marker, shared by the path BUILDER below and
|
|
442
|
+
* the `inflightMarkerVmNumber` PARSER beneath it so the naming has exactly one
|
|
443
|
+
* owner. The two must agree exactly: the parser is how the dashboard counts live
|
|
444
|
+
* tails fleet-wide, and a prefix that drifted on one side only would silently
|
|
445
|
+
* count zero — which reads identically to a quiet fleet. */
|
|
446
|
+
const INFLIGHT_MARKER_BASENAME_PREFIX = 'cy-ingress-inflight-vm-';
|
|
447
|
+
|
|
430
448
|
/** The per-VM marker file the detached add-tail holds while its wiring is still
|
|
431
449
|
* running, so the dashboard's auto-rewire gather skips a VM whose FIRST wiring
|
|
432
450
|
* attempt is in flight (never firing a competing `vm-ingress.sh up`). Pure path
|
|
@@ -438,7 +456,32 @@ function ingressInflightMarkerPath(dir, vmNumber) {
|
|
|
438
456
|
if (!Number.isInteger(n) || n <= 0) {
|
|
439
457
|
throw new Error(`ingressInflightMarkerPath requires a positive VM number, got: ${vmNumber}`);
|
|
440
458
|
}
|
|
441
|
-
return `${dir.replace(/\/+$/, '')}
|
|
459
|
+
return `${dir.replace(/\/+$/, '')}/${INFLIGHT_MARKER_BASENAME_PREFIX}${n}`;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Inverse of `ingressInflightMarkerPath`'s basename: given ONE directory entry
|
|
463
|
+
* name, the VM number it is an in-flight marker for, or null if it is not one.
|
|
464
|
+
*
|
|
465
|
+
* This is what lets the dashboard count live wiring tails FLEET-WIDE by scanning
|
|
466
|
+
* the marker directory, rather than per-VM against a roster — the population that
|
|
467
|
+
* matters is exactly the one a roster lags behind, since a batch of adds is when
|
|
468
|
+
* VMs appear and their tails outlive their jobs. That count feeds both the
|
|
469
|
+
* heavy-job concurrency budget and the memory guard's restart refusal.
|
|
470
|
+
*
|
|
471
|
+
* Deliberately strict, because the marker directory is `os.tmpdir()` and the
|
|
472
|
+
* add's own siblings live there too: `cy-ingress-failed-vm-3` (a different
|
|
473
|
+
* marker), `cy-wire-add-vm-5.log` (the tail's log), and any `<marker>.tmp`.
|
|
474
|
+
* Counting one of those as a live tail would charge a phantom slot against a cap
|
|
475
|
+
* of 4 and, worse, hold the memory self-restart off forever. Accepts exactly what
|
|
476
|
+
* the builder emits: a positive integer with no leading zeros and nothing after.
|
|
477
|
+
*
|
|
478
|
+
* Pure; no I/O — the caller does the readdir. */
|
|
479
|
+
function inflightMarkerVmNumber(fileName) {
|
|
480
|
+
if (typeof fileName !== 'string') return null;
|
|
481
|
+
if (!fileName.startsWith(INFLIGHT_MARKER_BASENAME_PREFIX)) return null;
|
|
482
|
+
const rest = fileName.slice(INFLIGHT_MARKER_BASENAME_PREFIX.length);
|
|
483
|
+
if (!/^[1-9][0-9]*$/.test(rest)) return null;
|
|
484
|
+
return Number(rest);
|
|
442
485
|
}
|
|
443
486
|
|
|
444
487
|
/** The per-VM marker file the detached add-tail drops when its converge loop
|
|
@@ -762,6 +805,7 @@ module.exports = {
|
|
|
762
805
|
ingressConvergeDecision,
|
|
763
806
|
detachedAddTailInvocation,
|
|
764
807
|
ingressInflightMarkerPath,
|
|
808
|
+
inflightMarkerVmNumber,
|
|
765
809
|
ingressFailedMarkerPath,
|
|
766
810
|
isWiringInflight,
|
|
767
811
|
iapAccessInvocation,
|
package/npm/launch-config.js
CHANGED
|
@@ -328,6 +328,48 @@ function decideAddSlot({ maxConcurrency, activeCount = 0 } = {}) {
|
|
|
328
328
|
return Number(activeCount) < cap ? "start" : "queue";
|
|
329
329
|
}
|
|
330
330
|
|
|
331
|
+
/** How many concurrency slots the fleet's heavy work is ACTUALLY occupying —
|
|
332
|
+
* the `activeCount` `decideAddSlot` above should be judging against.
|
|
333
|
+
*
|
|
334
|
+
* Two populations, not one. `runningVms` are the VMs with a heavy job in the
|
|
335
|
+
* `running` state; `wiringVms` are the VMs whose Add has already settled at
|
|
336
|
+
* container-healthy but whose DETACHED wire-add-tail is still spawning gcloud
|
|
337
|
+
* children for another 5-10 minutes. Charging only the first is what let a
|
|
338
|
+
* six-VM batch put 32 `gcloud.py compute` children on a 16-vCPU host at load
|
|
339
|
+
* 38.23 against a cap of 4: each Add released its slot the instant the job
|
|
340
|
+
* settled, while its real work ran on invisibly.
|
|
341
|
+
*
|
|
342
|
+
* A VM present in BOTH lists is charged ONCE, and that is the subtle part.
|
|
343
|
+
* `cmdUp` writes the in-flight marker BEFORE the Add settles, so every add has
|
|
344
|
+
* a window where its job is still `running` and its marker already exists;
|
|
345
|
+
* summing the two lists would charge that Add two of the four slots for the
|
|
346
|
+
* overlap. Counting the union instead makes an Add hold exactly one slot
|
|
347
|
+
* continuously across the job -> tail handoff, which is the property the cap
|
|
348
|
+
* needs in both directions — never over-charging during the overlap, never
|
|
349
|
+
* releasing early after it.
|
|
350
|
+
*
|
|
351
|
+
* Non-integer, non-positive, and duplicate entries are dropped from either
|
|
352
|
+
* list, so a caller's readdir glitch or a malformed job key degrades toward
|
|
353
|
+
* under-counting (admitting work) rather than manufacturing a phantom slot
|
|
354
|
+
* that would freeze admission forever.
|
|
355
|
+
*
|
|
356
|
+
* Pure: pulls no globals, has no side effects. */
|
|
357
|
+
function heavyChargeCount({ runningVms = [], wiringVms = [] } = {}) {
|
|
358
|
+
const numbers = (list) => {
|
|
359
|
+
const out = new Set();
|
|
360
|
+
for (const raw of Array.isArray(list) ? list : []) {
|
|
361
|
+
const n = Number(raw);
|
|
362
|
+
if (Number.isInteger(n) && n > 0) out.add(n);
|
|
363
|
+
}
|
|
364
|
+
return out;
|
|
365
|
+
};
|
|
366
|
+
const running = numbers(runningVms);
|
|
367
|
+
const wiring = numbers(wiringVms);
|
|
368
|
+
let untrackedTails = 0;
|
|
369
|
+
for (const n of wiring) if (!running.has(n)) untrackedTails += 1;
|
|
370
|
+
return running.size + untrackedTails;
|
|
371
|
+
}
|
|
372
|
+
|
|
331
373
|
/** Closed set of "heavy" action names — those that invoke `npm run cloud:up`
|
|
332
374
|
* (over gcloud-ssh) and so contend for the shared spawn budget that prevents
|
|
333
375
|
* the dashboard's launchd-managed node process from OOMing under burst. The
|
|
@@ -1181,6 +1223,7 @@ module.exports = {
|
|
|
1181
1223
|
cloudUpArgs,
|
|
1182
1224
|
classifyContainerWait,
|
|
1183
1225
|
decideAddSlot,
|
|
1226
|
+
heavyChargeCount,
|
|
1184
1227
|
HEAVY_ACTIONS,
|
|
1185
1228
|
queuedStatusForAction,
|
|
1186
1229
|
pushHistory,
|
package/npm/scenario-check.js
CHANGED
|
@@ -966,7 +966,11 @@ async function runScenarioCheck(
|
|
|
966
966
|
// wait out an in-flight fetch that would otherwise be screenshotted as a
|
|
967
967
|
// loading skeleton.
|
|
968
968
|
const networkTracker = createNetworkTracker(page);
|
|
969
|
-
|
|
969
|
+
// The observer tallies which declared mocks actually fulfilled and inventories
|
|
970
|
+
// every request the page made, grouped by origin. `appOrigin` lets it split
|
|
971
|
+
// same-origin from cross-origin — a third-party host the scenario has no
|
|
972
|
+
// opinion about becomes visible rather than inferred.
|
|
973
|
+
const mockObserver = await attachHttpMocks(page, httpMocks, { appOrigin });
|
|
970
974
|
|
|
971
975
|
page.on("pageerror", (error) => {
|
|
972
976
|
pushIssue(issues, handlePageError(error));
|
|
@@ -1378,6 +1382,8 @@ async function runScenarioCheck(
|
|
|
1378
1382
|
outputPath,
|
|
1379
1383
|
url: frame.url() || url,
|
|
1380
1384
|
unmockedRoutes,
|
|
1385
|
+
mockUsage: { used: mockObserver.used, unused: mockObserver.unused },
|
|
1386
|
+
externalRequests: mockObserver.externalRequests,
|
|
1381
1387
|
});
|
|
1382
1388
|
|
|
1383
1389
|
if (config.interaction) {
|
|
@@ -1421,6 +1427,10 @@ async function runScenarioCheck(
|
|
|
1421
1427
|
outputPath,
|
|
1422
1428
|
url,
|
|
1423
1429
|
unmockedRoutes,
|
|
1430
|
+
// A capture that threw still made requests; the inventory it collected up
|
|
1431
|
+
// to the failure is often exactly what explains the failure.
|
|
1432
|
+
mockUsage: { used: mockObserver.used, unused: mockObserver.unused },
|
|
1433
|
+
externalRequests: mockObserver.externalRequests,
|
|
1424
1434
|
});
|
|
1425
1435
|
} finally {
|
|
1426
1436
|
await browser.close();
|
package/npm/scenario-handlers.js
CHANGED
|
@@ -135,6 +135,15 @@ ${webSocketStub}
|
|
|
135
135
|
`;
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
// Third mirror of SUPPRESSION_PATTERN in ui/src/hooks/useViteHmrAutoReload.ts
|
|
139
|
+
// and VITE_WS_NOISE_PATTERN in crates/proxy-http/src/error_capture.js. All
|
|
140
|
+
// three surfaces silence the same harness-owned Vite HMR reconnect noise; keep
|
|
141
|
+
// in sync. The capture path is the one that was missing it, which made every
|
|
142
|
+
// preview-flow / preview-interact against a proxy-wrapped route fail its
|
|
143
|
+
// console check on noise the editor itself produces.
|
|
144
|
+
const VITE_WS_NOISE_PATTERN =
|
|
145
|
+
/vite.*failed to connect to websocket|WebSocket closed without opened/i;
|
|
146
|
+
|
|
138
147
|
function handleConsoleMessage(message) {
|
|
139
148
|
if (message.type() !== "error") return null;
|
|
140
149
|
const text = message.text();
|
|
@@ -144,6 +153,14 @@ function handleConsoleMessage(message) {
|
|
|
144
153
|
const advisory = insecureContextAdvisory(text);
|
|
145
154
|
if (advisory) return advisory;
|
|
146
155
|
|
|
156
|
+
// Vite's reconnect banner ("[vite] failed to connect to websocket." plus the
|
|
157
|
+
// `(browser) … <--[WebSocket (failing)]--> … (server)` diagram) contains none
|
|
158
|
+
// of the substrings the ignore list below matches, so it fell through and
|
|
159
|
+
// vetoed the capture. Matched narrowly — the `vite`-anchored alternative and
|
|
160
|
+
// the exact-phrase second alternative both leave a genuine app-authored error
|
|
161
|
+
// mentioning websockets surfacing as a real issue.
|
|
162
|
+
if (VITE_WS_NOISE_PATTERN.test(text)) return null;
|
|
163
|
+
|
|
147
164
|
// Ignore known dev-server WebSocket/HMR errors from Vite proxy, plus the
|
|
148
165
|
// crxjs/Vite dynamic-import reload race: dev loaders `import()` the app entry
|
|
149
166
|
// with a `?t=<timestamp>` cache-buster, and a rapid scenario-reactivation
|
|
@@ -219,4 +236,5 @@ module.exports = {
|
|
|
219
236
|
insecureContextAdvisory,
|
|
220
237
|
INSECURE_CONTEXT_SIGNATURES,
|
|
221
238
|
INSECURE_HOST_ADVISORY_MESSAGE,
|
|
239
|
+
VITE_WS_NOISE_PATTERN,
|
|
222
240
|
};
|
package/npm/scenario-issues.js
CHANGED
|
@@ -24,6 +24,8 @@ function buildResult({
|
|
|
24
24
|
outputPath,
|
|
25
25
|
url,
|
|
26
26
|
unmockedRoutes = [],
|
|
27
|
+
mockUsage = { used: [], unused: [] },
|
|
28
|
+
externalRequests = [],
|
|
27
29
|
}) {
|
|
28
30
|
return {
|
|
29
31
|
ok: loaded && hasContent && issues.length === 0,
|
|
@@ -37,6 +39,15 @@ function buildResult({
|
|
|
37
39
|
// the actionable route list the failure message and `stub-unmocked-routes`
|
|
38
40
|
// consume. Defaults to `[]` so callers that omit it are unchanged.
|
|
39
41
|
unmockedRoutes,
|
|
42
|
+
// Diagnostic-only, and deliberately NOT part of `ok`: an unused mock is not
|
|
43
|
+
// automatically an error (a scenario may legitimately declare a mock for a
|
|
44
|
+
// request the page makes only on interaction). Reporting it turns a silent
|
|
45
|
+
// inertness into a visible fact; failing on it would break working scenarios
|
|
46
|
+
// for a heuristic.
|
|
47
|
+
mockUsage,
|
|
48
|
+
// Requests grouped by origin, split mocked/unmocked and same/cross-origin.
|
|
49
|
+
// The line that ends a "why is this page blank" misdiagnosis.
|
|
50
|
+
externalRequests,
|
|
40
51
|
};
|
|
41
52
|
}
|
|
42
53
|
|