@codeyam-editor/codeyam-editor 0.1.6 → 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 +308 -52
- package/npm/compose-conflict.js +59 -0
- package/npm/fleet-add-failure.js +174 -18
- package/npm/fleet-cleanup.js +129 -0
- package/npm/fleet-job-badge.js +28 -12
- package/npm/fleet-memory.js +73 -4
- package/npm/fleet-probe-transport.js +155 -0
- package/npm/fleet-queue.js +141 -0
- package/npm/fleet-revision.js +104 -0
- package/npm/fleet-running-plan.js +37 -7
- package/npm/fleet-vm-access.js +162 -0
- package/npm/ingress-invoke.js +46 -2
- package/npm/launch-config.js +163 -4
- package/npm/scenario-check.js +63 -19
- 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 +114 -22
- package/package.json +5 -5
- package/ui/dist/assets/{ContainerInspector-DgM0pTAx.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-E0NYYiwy.js +1 -0
- package/ui/dist/assets/ScenarioDataPanel-6qJE-0lA.js +15 -0
- package/ui/dist/assets/{ScenarioDataPanelFullPage-BLqRCxQX.js → ScenarioDataPanelFullPage-Cyk6efiI.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-Uyt54vHI.js +21 -0
- package/ui/dist/assets/{index-DCHqRrwO.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-B7Q06uJ_.js +0 -252
- package/ui/dist/assets/EditorShell-DP5UY20G.css +0 -1
- package/ui/dist/assets/ProjectLauncherScreen-BRrEr8Ng.js +0 -1
- package/ui/dist/assets/ScenarioDataPanel-BeQfa28g.js +0 -9
- package/ui/dist/assets/ScenarioRenderer-CIAxfHPh.js +0 -21
- package/ui/dist/assets/index-BygCiJSb.css +0 -1
- package/ui/dist/assets/useServerIdentity-B7rI0u3O.js +0 -1
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
|
|
@@ -784,6 +826,18 @@ function claudeAuthSeeded(mode) {
|
|
|
784
826
|
// the server.js helper so callers don't have to thread one through.
|
|
785
827
|
function _shq(s) { return `'${String(s).replace(/'/g, `'\\''`)}'`; }
|
|
786
828
|
|
|
829
|
+
// Assemble a `pkill -f` whose ERE is ANCHORED at the target's own executable:
|
|
830
|
+
// `^`, an optional directory prefix, then `exe`. Every sweep builder in this
|
|
831
|
+
// module routes through here so the anchor is structural rather than remembered
|
|
832
|
+
// — an unanchored `pkill -f` matches the FULL command line, which for the fleet
|
|
833
|
+
// dashboard's `sh -c '<add script>'` wrapper includes the script text itself.
|
|
834
|
+
// See the "Self-match-safe process sweeps" block below for the full failure
|
|
835
|
+
// mode. `argv` is an ERE fragment matched after the executable; escape any
|
|
836
|
+
// literal dots in it.
|
|
837
|
+
function _anchoredSweep({ exe, argv } = {}) {
|
|
838
|
+
return `pkill -f "^([^ ]*/)?${exe} ${argv}" 2>/dev/null`;
|
|
839
|
+
}
|
|
840
|
+
|
|
787
841
|
/** Build a `docker exec … bash -c` sink that stages stdin to a same-directory
|
|
788
842
|
* `.cytmp` sibling and only `mv`s it onto `target` when the staged file is
|
|
789
843
|
* NON-EMPTY — exiting 1 (so the caller's `… || echo CY_DEGRADED` fires) and
|
|
@@ -978,13 +1032,112 @@ function editorTunnelCmd({ gcloud, inst, zone, editorPort, n } = {}) {
|
|
|
978
1032
|
* from the editor reachability forward (`editorTunnelCmd`): a preview re-wire
|
|
979
1033
|
* pkills + respawns ONLY this process, leaving the editor :14199 forward — and
|
|
980
1034
|
* the reachability probe riding it — untouched. The preview local port is unique
|
|
981
|
-
* per VM and never collides with the editor port
|
|
982
|
-
* `pkill -f "<previewPort>:127.0.0.1:"`
|
|
983
|
-
*
|
|
1035
|
+
* per VM and never collides with the editor port. Sweep it through
|
|
1036
|
+
* `previewForwardSweepCmd` — the bare `pkill -f "<previewPort>:127.0.0.1:"` this
|
|
1037
|
+
* comment used to call "targets exactly this process" did NOT: it also matched
|
|
1038
|
+
* every `sh -c '<add script>'` wrapper carrying that literal. Pure; no I/O. */
|
|
984
1039
|
function previewTunnelCmd({ gcloud, inst, zone, previewPort, appPort, n } = {}) {
|
|
985
1040
|
return `nohup ${gcloud} compute ssh ${inst} --tunnel-through-iap --zone=${zone} -- -N -L ${previewPort}:127.0.0.1:${appPort} > /tmp/ssh-preview-vm-${n}.log 2>&1 &`;
|
|
986
1041
|
}
|
|
987
1042
|
|
|
1043
|
+
/* ── Self-match-safe process sweeps ─────────────────────────────────────────
|
|
1044
|
+
*
|
|
1045
|
+
* Every builder below emits a `pkill -f` whose match expression is ANCHORED at
|
|
1046
|
+
* the TARGET's executable — a leading `^`, an optional directory prefix, then
|
|
1047
|
+
* the program name (`ssh`, `node`, `cloudflared`). That anchor is the entire
|
|
1048
|
+
* point of these builders, not decoration.
|
|
1049
|
+
*
|
|
1050
|
+
* `pkill -f` matches the FULL command line. The fleet dashboard runs an Add as
|
|
1051
|
+
* `/bin/sh -c '<the entire add script text>'`, and that script text EMBEDS these
|
|
1052
|
+
* very sweep lines — so an unanchored pattern like `pkill -f
|
|
1053
|
+
* "14199:127.0.0.1:14199"` is a substring of the wrapper's own argv. It SIGTERMs
|
|
1054
|
+
* the wrapper that is running it, and every concurrent SIBLING add wrapper too.
|
|
1055
|
+
* The wrapper then dies from an uncaught signal, so its EXIT trap never writes
|
|
1056
|
+
* the `add-<n>.exit` marker that `fc.readExitMarker` / `fc.reattachVerdict` exist
|
|
1057
|
+
* to consume; `startJob`'s exec callback reports a red "Add failed" on a VM that
|
|
1058
|
+
* is fine; and the detached `cloud:up` it started — whose own argv does NOT carry
|
|
1059
|
+
* the pattern — is orphaned to init and keeps provisioning, after which
|
|
1060
|
+
* stranded-reconcile declares the VM half-provisioned and stacks a SECOND
|
|
1061
|
+
* `cloud:up` on top of it. That is the 2026-07-31 VM-3..8 batch, whose containers
|
|
1062
|
+
* were Up and answering /api/health 200 throughout.
|
|
1063
|
+
*
|
|
1064
|
+
* Excluding `$$` / `$PPID` does NOT fix this: the processes being wrongly killed
|
|
1065
|
+
* are SIBLINGS, not self or parent. The pattern itself must be incapable of
|
|
1066
|
+
* matching a `sh -c '<script>'` command line, and anchoring at the executable is
|
|
1067
|
+
* what guarantees that — a wrapper's argv always begins with the shell, never
|
|
1068
|
+
* with `ssh` / `node` / `cloudflared`.
|
|
1069
|
+
*
|
|
1070
|
+
* Same trap, same lesson as `npm/cloud.js` bootstrapStatusCommand (:1446) and
|
|
1071
|
+
* projectBootstrapStatusCommand (:1760), which document it explicitly ("the
|
|
1072
|
+
* status command's own argv contains the bootstrap path, so a `pgrep -f` on it
|
|
1073
|
+
* would match this very probe") and dodge it with a PID file + `kill -0`. A PID
|
|
1074
|
+
* file cannot replace THESE sweeps: they deliberately target STRAY processes left
|
|
1075
|
+
* by a prior add, a crashed watchdog respawn, or an earlier container generation
|
|
1076
|
+
* — processes this script never owned and holds no PID for. So the sweep stays
|
|
1077
|
+
* pattern-based; only the pattern's blast radius changes.
|
|
1078
|
+
*
|
|
1079
|
+
* Keep every sweep in this family behind one of these builders. Re-inlining a
|
|
1080
|
+
* bare `pkill -f` at a call site is exactly how the bug got in the first time. */
|
|
1081
|
+
|
|
1082
|
+
/** Sweep a stray EDITOR reachability forward — the `-L <localPort>:127.0.0.1:14199`
|
|
1083
|
+
* ssh process `editorTunnelCmd` opens.
|
|
1084
|
+
*
|
|
1085
|
+
* Targets the `ssh` CHILD, not the `gcloud compute ssh` python parent. The child
|
|
1086
|
+
* is what binds the local listening socket, so killing it is what actually
|
|
1087
|
+
* releases the port; the parent is only waiting on it and exits on its own once
|
|
1088
|
+
* it goes. Only the child's argv starts with `ssh`, which is also what makes the
|
|
1089
|
+
* anchor safe. Pure; no I/O. */
|
|
1090
|
+
function editorForwardSweepCmd({ localPort } = {}) {
|
|
1091
|
+
return _anchoredSweep({ exe: "ssh", argv: `.*-L ${localPort}:127\\.0\\.0\\.1:14199` });
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/** Sweep a stray LIVE-PREVIEW forward — the `-L <localPort>:127.0.0.1:<appPort>`
|
|
1095
|
+
* ssh process `previewTunnelCmd` opens. The remote port is left open because the
|
|
1096
|
+
* app's dev-server port legitimately drifts; the per-VM preview local port is the
|
|
1097
|
+
* identifying half and never collides with the editor port. Same ssh-child
|
|
1098
|
+
* targeting as `editorForwardSweepCmd`. Pure; no I/O. */
|
|
1099
|
+
function previewForwardSweepCmd({ localPort } = {}) {
|
|
1100
|
+
return _anchoredSweep({ exe: "ssh", argv: `.*-L ${localPort}:127\\.0\\.0\\.1:` });
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/** Sweep a wedged `cloud:up` provision for one instance.
|
|
1104
|
+
*
|
|
1105
|
+
* Targets the `node npm/cloud.js up --instance-name <inst>` process, NOT the
|
|
1106
|
+
* `npm run cloud:up` parent — call sites already hold the npm child's PID and
|
|
1107
|
+
* `kill` it directly; this reaps the node grandchild that PID does not cover.
|
|
1108
|
+
* `--instance-name` is the first pair `cloudUpArgs` emits, so the literal stays
|
|
1109
|
+
* adjacent to `up`. Pure; no I/O. */
|
|
1110
|
+
function cloudUpSweepCmd({ inst } = {}) {
|
|
1111
|
+
return _anchoredSweep({ exe: "node", argv: `.*cloud\\.js up --instance-name ${inst}` });
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/** Sweep the cloudflared quick-tunnel fronting a VM's editor port.
|
|
1115
|
+
*
|
|
1116
|
+
* Anchored for the same reason as its siblings, with one extra bite: this sweep
|
|
1117
|
+
* runs inside a `sh -c` compound built by `exec()`, so unanchored it matched its
|
|
1118
|
+
* OWN parent shell — killing the compound before the editor/preview sweeps that
|
|
1119
|
+
* follow it on the same line ever ran. Pure; no I/O. */
|
|
1120
|
+
function cloudflaredTunnelSweepCmd({ localPort } = {}) {
|
|
1121
|
+
return _anchoredSweep({ exe: "cloudflared", argv: `tunnel --url http://localhost:${localPort}` });
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/** Build the destroy-time local reap compound for one VM: free the editor port,
|
|
1125
|
+
* then sweep the cloudflared quick-tunnel and BOTH split ssh forwards.
|
|
1126
|
+
*
|
|
1127
|
+
* Lives here rather than inline in `cleanLocalVmArtifacts` because the ORDERING
|
|
1128
|
+
* is load-bearing and was previously untestable: the whole compound runs as one
|
|
1129
|
+
* `sh -c`, so the unanchored cloudflared sweep used to match that shell's own
|
|
1130
|
+
* argv and kill it BEFORE the two forward sweeps behind it ever ran — meaning a
|
|
1131
|
+
* destroy silently orphaned both forwards. Pure; no I/O. */
|
|
1132
|
+
function localVmReapCmd({ port, previewPort } = {}) {
|
|
1133
|
+
return [
|
|
1134
|
+
`lsof -ti:${port} 2>/dev/null | xargs -r kill`,
|
|
1135
|
+
cloudflaredTunnelSweepCmd({ localPort: port }),
|
|
1136
|
+
editorForwardSweepCmd({ localPort: port }),
|
|
1137
|
+
previewForwardSweepCmd({ localPort: previewPort }),
|
|
1138
|
+
].join("; ");
|
|
1139
|
+
}
|
|
1140
|
+
|
|
988
1141
|
/** Build the shell snippet the Add script runs to AWAIT cloud:up's clean exit at
|
|
989
1142
|
* container-healthy instead of reaping it mid-flight.
|
|
990
1143
|
*
|
|
@@ -1009,7 +1162,7 @@ function awaitWiringCmd({ inst, deadlineSec = 180, pidVar = "UP_PID" } = {}) {
|
|
|
1009
1162
|
`while kill -0 $${pidVar} 2>/dev/null; do`,
|
|
1010
1163
|
` if [ "$__cy_wiring_waited" -ge ${deadlineSec} ]; then`,
|
|
1011
1164
|
` echo "WARN cloud:up still running after ${deadlineSec}s (wedged before the container-healthy early return) — reaping it (the dashboard self-heal will converge any half-wired backend)"`,
|
|
1012
|
-
` kill $${pidVar} 2>/dev/null;
|
|
1165
|
+
` kill $${pidVar} 2>/dev/null; ${cloudUpSweepCmd({ inst })}; break`,
|
|
1013
1166
|
` fi`,
|
|
1014
1167
|
` sleep 5; __cy_wiring_waited=$((__cy_wiring_waited + 5))`,
|
|
1015
1168
|
`done`,
|
|
@@ -1070,6 +1223,7 @@ module.exports = {
|
|
|
1070
1223
|
cloudUpArgs,
|
|
1071
1224
|
classifyContainerWait,
|
|
1072
1225
|
decideAddSlot,
|
|
1226
|
+
heavyChargeCount,
|
|
1073
1227
|
HEAVY_ACTIONS,
|
|
1074
1228
|
queuedStatusForAction,
|
|
1075
1229
|
pushHistory,
|
|
@@ -1097,6 +1251,11 @@ module.exports = {
|
|
|
1097
1251
|
decideStartJob,
|
|
1098
1252
|
editorTunnelCmd,
|
|
1099
1253
|
previewTunnelCmd,
|
|
1254
|
+
editorForwardSweepCmd,
|
|
1255
|
+
previewForwardSweepCmd,
|
|
1256
|
+
cloudUpSweepCmd,
|
|
1257
|
+
cloudflaredTunnelSweepCmd,
|
|
1258
|
+
localVmReapCmd,
|
|
1100
1259
|
awaitWiringCmd,
|
|
1101
1260
|
getAuthRepoUrl,
|
|
1102
1261
|
interpretLsRemote,
|
package/npm/scenario-check.js
CHANGED
|
@@ -191,14 +191,40 @@ const {
|
|
|
191
191
|
// inject it here from the on-box 0600 token file.
|
|
192
192
|
const SESSION_COOKIE = "cy_session";
|
|
193
193
|
|
|
194
|
+
// The loopback names the editor can be addressed by. A cookie is scoped by
|
|
195
|
+
// HOST, and the jar does NOT treat these as interchangeable: a cookie stored for
|
|
196
|
+
// `localhost` is simply not sent to `127.0.0.1`. That matters because the two
|
|
197
|
+
// token-gated capture routes resolve their origin differently — the harness
|
|
198
|
+
// route via `resolveHarnessOrigin()` (typically `localhost`), the preview-proxy
|
|
199
|
+
// route via `PROXY_CAPTURE_LOOPBACK` in handlers.rs, which is deliberately
|
|
200
|
+
// IPv4-pinned to `127.0.0.1` so it matches the forwarder's own pinning. Scoping
|
|
201
|
+
// the cookie to `localhost` alone therefore authenticated the harness route and
|
|
202
|
+
// left every `/__codeyam_preview` capture with no credential, so `preview-verify`
|
|
203
|
+
// / `preview-interact` / `preview-flow` all 401'd on a non-loopback bind.
|
|
204
|
+
// Emitting one cookie per alias is the fix: same token, same on-box editor,
|
|
205
|
+
// whichever name the capture URL happens to use.
|
|
206
|
+
const LOOPBACK_COOKIE_DOMAINS = ["localhost", "127.0.0.1"];
|
|
207
|
+
|
|
208
|
+
// Extract the host from an origin string, or null when it is unparseable.
|
|
209
|
+
function originHost(origin) {
|
|
210
|
+
try {
|
|
211
|
+
return new URL(origin).hostname;
|
|
212
|
+
} catch (_) {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
194
217
|
// Build the Playwright cookie array that authenticates the capture context to
|
|
195
218
|
// the editor's token-gated control-API. Reads the on-box `.codeyam/session-token`
|
|
196
|
-
// (0600) and
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
// `
|
|
219
|
+
// (0600) and emits a `cy_session` cookie for every host the capture might address
|
|
220
|
+
// the editor by (see `LOOPBACK_COOKIE_DOMAINS`); cookies are port-agnostic, so
|
|
221
|
+
// one per host covers the harness origin on the control port, the preview-proxy
|
|
222
|
+
// route, and any `/api` call on the app-proxy port. A non-loopback harness origin
|
|
223
|
+
// (a cloud tunnel domain) additionally gets its own host, since no loopback alias
|
|
224
|
+
// would match it. Returns `[]` — a no-op — when there is no token file (a
|
|
225
|
+
// loopback bind that never persisted one) or no resolvable harness origin, so a
|
|
226
|
+
// laptop capture is completely unaffected. `deps` is injectable so the builder is
|
|
227
|
+
// unit-testable without disk.
|
|
202
228
|
function buildSessionTokenCookies({
|
|
203
229
|
readTokenFile = () => {
|
|
204
230
|
const p = path.join(process.cwd(), ".codeyam", "session-token");
|
|
@@ -209,16 +235,19 @@ function buildSessionTokenCookies({
|
|
|
209
235
|
try {
|
|
210
236
|
const token = readTokenFile();
|
|
211
237
|
if (!token || !harnessOrigin) return [];
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
238
|
+
const host = originHost(harnessOrigin);
|
|
239
|
+
const domains = [...LOOPBACK_COOKIE_DOMAINS];
|
|
240
|
+
// A tunnelled/cloud harness origin is not a loopback alias, so it needs its
|
|
241
|
+
// own entry or the harness navigation goes out uncredentialed.
|
|
242
|
+
if (host && !domains.includes(host)) domains.push(host);
|
|
243
|
+
return domains.map((domain) => ({
|
|
244
|
+
name: SESSION_COOKIE,
|
|
245
|
+
value: token,
|
|
246
|
+
domain,
|
|
247
|
+
path: "/",
|
|
248
|
+
httpOnly: true,
|
|
249
|
+
sameSite: "Lax",
|
|
250
|
+
}));
|
|
222
251
|
} catch (_) {
|
|
223
252
|
// A missing/unreadable token file must never break capture on a loopback
|
|
224
253
|
// bind where the token isn't required anyway — degrade to no cookie.
|
|
@@ -817,9 +846,14 @@ async function runScenarioCheck(
|
|
|
817
846
|
config,
|
|
818
847
|
{ preflight = assertAppPortReachable, harnessOrigin } = {},
|
|
819
848
|
) {
|
|
820
|
-
const resolvedHarnessOrigin =
|
|
821
|
-
harnessOrigin !== undefined ? harnessOrigin : resolveHarnessOrigin();
|
|
822
849
|
const { url, outputPath, width, height, httpMocks = {} } = config;
|
|
850
|
+
// The harness host mirrors the capture target's host so the framed load stays
|
|
851
|
+
// SAME-SITE — see `resolveHarnessOrigin`. A cross-site frame withholds the
|
|
852
|
+
// SameSite=Lax `cy_session` cookie and every token-gated route 401s.
|
|
853
|
+
const resolvedHarnessOrigin =
|
|
854
|
+
harnessOrigin !== undefined
|
|
855
|
+
? harnessOrigin
|
|
856
|
+
: resolveHarnessOrigin({ targetUrl: url });
|
|
823
857
|
// Per-scenario capture-check allowances (see `CaptureAllowances` /
|
|
824
858
|
// `ScenarioDefinition` on the Rust side). Default false so the guards keep
|
|
825
859
|
// their strict behavior for every scenario that does not opt in.
|
|
@@ -932,7 +966,11 @@ async function runScenarioCheck(
|
|
|
932
966
|
// wait out an in-flight fetch that would otherwise be screenshotted as a
|
|
933
967
|
// loading skeleton.
|
|
934
968
|
const networkTracker = createNetworkTracker(page);
|
|
935
|
-
|
|
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 });
|
|
936
974
|
|
|
937
975
|
page.on("pageerror", (error) => {
|
|
938
976
|
pushIssue(issues, handlePageError(error));
|
|
@@ -1344,6 +1382,8 @@ async function runScenarioCheck(
|
|
|
1344
1382
|
outputPath,
|
|
1345
1383
|
url: frame.url() || url,
|
|
1346
1384
|
unmockedRoutes,
|
|
1385
|
+
mockUsage: { used: mockObserver.used, unused: mockObserver.unused },
|
|
1386
|
+
externalRequests: mockObserver.externalRequests,
|
|
1347
1387
|
});
|
|
1348
1388
|
|
|
1349
1389
|
if (config.interaction) {
|
|
@@ -1387,6 +1427,10 @@ async function runScenarioCheck(
|
|
|
1387
1427
|
outputPath,
|
|
1388
1428
|
url,
|
|
1389
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,
|
|
1390
1434
|
});
|
|
1391
1435
|
} finally {
|
|
1392
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
|
|