@codeyam-editor/codeyam-editor 0.1.7 → 0.1.9

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.
Files changed (45) hide show
  1. package/npm/cloud.js +477 -41
  2. package/npm/editor-dev.js +24 -2
  3. package/npm/editor.js +15 -5
  4. package/npm/fleet-add-failure.js +406 -25
  5. package/npm/fleet-branch.js +124 -1
  6. package/npm/fleet-build-data.js +12 -1
  7. package/npm/fleet-cleanup.js +84 -3
  8. package/npm/fleet-datastore-health.js +107 -0
  9. package/npm/fleet-memory.js +29 -4
  10. package/npm/fleet-queue.js +141 -0
  11. package/npm/fleet-revision.js +108 -0
  12. package/npm/fleet-run-setup.js +66 -0
  13. package/npm/fleet-session-activity.js +162 -0
  14. package/npm/fleet-vm-access.js +162 -0
  15. package/npm/fleet-vm-readiness.js +57 -16
  16. package/npm/ingress-invoke.js +46 -2
  17. package/npm/launch-config.js +169 -2
  18. package/npm/scenario-check.js +708 -35
  19. package/npm/scenario-handlers.js +18 -0
  20. package/npm/scenario-interactivity.js +477 -23
  21. package/npm/scenario-issues.js +72 -0
  22. package/npm/scenario-mocks.js +245 -39
  23. package/npm/scenario-playwright.js +137 -26
  24. package/npm/utils.js +262 -22
  25. package/package.json +5 -5
  26. package/ui/dist/assets/{ContainerInspector-Cu1tSN-o.js → ContainerInspector-C7I9wctz.js} +1 -1
  27. package/ui/dist/assets/EditorShell-9aYMeOdZ.js +330 -0
  28. package/ui/dist/assets/EditorShell-CE8iG_EM.css +1 -0
  29. package/ui/dist/assets/{ProjectLauncherScreen-Dmm6Z0mv.js → ProjectLauncherScreen-E0NYYiwy.js} +1 -1
  30. package/ui/dist/assets/ScenarioDataPanel-CLwkrex0.js +18 -0
  31. package/ui/dist/assets/{ScenarioDataPanelFullPage-BqMxWVKW.js → ScenarioDataPanelFullPage-DT0TZC4M.js} +1 -1
  32. package/ui/dist/assets/ScenarioRenderer-Bsarhcrl.js +21 -0
  33. package/ui/dist/assets/index-BRoAenc1.css +1 -0
  34. package/ui/dist/assets/index-iMR5siiG.js +27 -0
  35. package/ui/dist/assets/useServerIdentity-CVuHTzjg.js +1 -0
  36. package/ui/dist/index.html +2 -2
  37. package/ui/dist/preview-subdir-demo/index.html +62 -0
  38. package/ui/dist/preview-subdir-demo/sibling.html +48 -0
  39. package/ui/dist/assets/EditorShell--V2ExYQ6.js +0 -260
  40. package/ui/dist/assets/EditorShell-DP5UY20G.css +0 -1
  41. package/ui/dist/assets/ScenarioDataPanel-DjprZq2L.js +0 -9
  42. package/ui/dist/assets/ScenarioRenderer-DqLVVMYS.js +0 -21
  43. package/ui/dist/assets/index-CjxWhkSy.css +0 -1
  44. package/ui/dist/assets/index-VuyqBbGJ.js +0 -27
  45. package/ui/dist/assets/useServerIdentity-BdXy8TQv.js +0 -1
@@ -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(/\/+$/, '')}/cy-ingress-inflight-vm-${n}`;
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,
@@ -119,6 +119,79 @@ function validateLaunchConfig(cfg) {
119
119
  return { ok: true };
120
120
  }
121
121
 
122
+ /** The branch refs a launch config asks git to resolve, as
123
+ * `[{ field, repo, branch }]` — one entry per branch-carrying field that is
124
+ * actually populated. This is the input to a ref-existence preflight; it lives
125
+ * beside normalizeLaunchConfig (rather than in a second parser at the add
126
+ * endpoint or in cloud.js) because it reads the SAME canonical field names that
127
+ * normalizer produces, so a field rename can never leave the two disagreeing.
128
+ *
129
+ * BOTH fields are reported, deliberately. In the 2026-08-25 incident the dead
130
+ * ref sat in `clientBranch` AND `editorBranch`, and it was `clientBranch` that
131
+ * actually killed the bootstrap — an editor-only check would have missed it.
132
+ *
133
+ * A greenfield add (`newProjectName`, no `clientRepo`) has no client repo to
134
+ * resolve a branch against, so its clientBranch is skipped rather than probed
135
+ * against a repo URL that does not exist. `editorRepo` is passed in because the
136
+ * launch config carries an editor BRANCH but not an editor REPO — the repo is
137
+ * the caller's (cloud:up resolves it via resolveEditorConfig; the dashboard
138
+ * holds it in REPO). Pure; no I/O. */
139
+ function branchRefTargets(cfg, { editorRepo } = {}) {
140
+ const c = cfg || {};
141
+ const targets = [];
142
+ if (c.clientRepo && c.clientBranch) {
143
+ targets.push({ field: "clientBranch", repo: c.clientRepo, branch: c.clientBranch });
144
+ }
145
+ if (editorRepo && c.editorBranch) {
146
+ targets.push({ field: "editorBranch", repo: editorRepo, branch: c.editorBranch });
147
+ }
148
+ return targets;
149
+ }
150
+
151
+ /** Partition branchRefTargets by whether each ref still resolves, using an
152
+ * INJECTED probe so the decision logic is unit-tested with no network and no
153
+ * git. `refExists({ repo, branch })` returns true when the ref is present,
154
+ * false when it is provably gone.
155
+ *
156
+ * A probe that cannot reach the remote at all must NOT report false — a network
157
+ * or credential fault is not evidence a branch was deleted, and treating it as
158
+ * such would refuse every add the moment GitHub blipped. The probe signals that
159
+ * by returning `null`/`undefined`, which this treats as "unknown, let it
160
+ * through": the classifier arm downstream still catches a genuinely dead ref,
161
+ * at the cost this preflight normally saves. Fail-open is the right default
162
+ * here precisely because the preflight is an OPTIMIZATION over a failure that
163
+ * is already handled, never the only line of defence. Pure; no I/O. */
164
+ function missingBranchRefs(targets, refExists) {
165
+ const missing = [];
166
+ for (const t of targets || []) {
167
+ if (refExists(t) === false) missing.push(t);
168
+ }
169
+ return missing;
170
+ }
171
+
172
+ /** Operator-facing copy for a failed branch-ref preflight. States the OUTCOME
173
+ * first (nothing was created), then the mechanism, then the remedy — and never
174
+ * the "a partial instance may exist — reconcile or destroy it" language the
175
+ * generic provisioning failure uses, which is what made the real incident
176
+ * confusing: at preflight time no instance, disk, NEG or backend service has
177
+ * been created, so there is nothing to reconcile and nothing to destroy.
178
+ *
179
+ * Names which FIELD carried the dead ref, because the two are configured in
180
+ * different places and an operator who only corrects one re-issues a request
181
+ * that fails identically. Pure; no I/O. */
182
+ function missingBranchRefMessage(missing) {
183
+ const list = (missing || [])
184
+ .map((m) => `${m.field}: branch '${m.branch}' is not on ${m.repo}`)
185
+ .join("; ");
186
+ return (
187
+ `Branch no longer exists on origin — ${list}. ` +
188
+ "Nothing was provisioned: this is checked before any instance is created, so " +
189
+ "there is no VM, disk or backend service to clean up. This repo prunes branches " +
190
+ "when they merge, so a stored branch name expires silently. Re-add on a live " +
191
+ "branch (the current fleet branch, or any branch that still exists on origin)."
192
+ );
193
+ }
194
+
122
195
  /** Pre-flight the operator-host CONFIG a dashboard Add depends on, BEFORE
123
196
  * spawning the ~20-min cloud:up. Pure classifier: it takes the ALREADY-resolved
124
197
  * zone, project, and GitHub token (the side-effecting resolution — env lookups,
@@ -328,6 +401,48 @@ function decideAddSlot({ maxConcurrency, activeCount = 0 } = {}) {
328
401
  return Number(activeCount) < cap ? "start" : "queue";
329
402
  }
330
403
 
404
+ /** How many concurrency slots the fleet's heavy work is ACTUALLY occupying —
405
+ * the `activeCount` `decideAddSlot` above should be judging against.
406
+ *
407
+ * Two populations, not one. `runningVms` are the VMs with a heavy job in the
408
+ * `running` state; `wiringVms` are the VMs whose Add has already settled at
409
+ * container-healthy but whose DETACHED wire-add-tail is still spawning gcloud
410
+ * children for another 5-10 minutes. Charging only the first is what let a
411
+ * six-VM batch put 32 `gcloud.py compute` children on a 16-vCPU host at load
412
+ * 38.23 against a cap of 4: each Add released its slot the instant the job
413
+ * settled, while its real work ran on invisibly.
414
+ *
415
+ * A VM present in BOTH lists is charged ONCE, and that is the subtle part.
416
+ * `cmdUp` writes the in-flight marker BEFORE the Add settles, so every add has
417
+ * a window where its job is still `running` and its marker already exists;
418
+ * summing the two lists would charge that Add two of the four slots for the
419
+ * overlap. Counting the union instead makes an Add hold exactly one slot
420
+ * continuously across the job -> tail handoff, which is the property the cap
421
+ * needs in both directions — never over-charging during the overlap, never
422
+ * releasing early after it.
423
+ *
424
+ * Non-integer, non-positive, and duplicate entries are dropped from either
425
+ * list, so a caller's readdir glitch or a malformed job key degrades toward
426
+ * under-counting (admitting work) rather than manufacturing a phantom slot
427
+ * that would freeze admission forever.
428
+ *
429
+ * Pure: pulls no globals, has no side effects. */
430
+ function heavyChargeCount({ runningVms = [], wiringVms = [] } = {}) {
431
+ const numbers = (list) => {
432
+ const out = new Set();
433
+ for (const raw of Array.isArray(list) ? list : []) {
434
+ const n = Number(raw);
435
+ if (Number.isInteger(n) && n > 0) out.add(n);
436
+ }
437
+ return out;
438
+ };
439
+ const running = numbers(runningVms);
440
+ const wiring = numbers(wiringVms);
441
+ let untrackedTails = 0;
442
+ for (const n of wiring) if (!running.has(n)) untrackedTails += 1;
443
+ return running.size + untrackedTails;
444
+ }
445
+
331
446
  /** Closed set of "heavy" action names — those that invoke `npm run cloud:up`
332
447
  * (over gcloud-ssh) and so contend for the shared spawn budget that prevents
333
448
  * the dashboard's launchd-managed node process from OOMing under burst. The
@@ -480,11 +595,42 @@ function recentlyLaunchedWithin(ts, nowMs, graceMs) {
480
595
  * variant(@branch) fallback.
481
596
  * - source "live" when editorIdentity drove editorLabel, else
482
597
  * "history" — the affordance the card uses to mark inferred
483
- * data. Pure; no I/O. */
598
+ * data.
599
+ * - deployedBranch the branch the fleet ADVANCED this VM to, from the editor's
600
+ * `editorDeployment` (which reads the record the refresh script
601
+ * writes at install time); null when the editor reports none.
602
+ * - deployedTag the full artifact tag behind that branch, for the hover.
603
+ * - deployedSource "live" when the editor reported a deployment, else
604
+ * "unknown".
605
+ * - deployedState the card's three-way presentation verdict, resolved HERE
606
+ * rather than in the browser so the rule is unit-tested in one
607
+ * place and index.html carries no copy of it (the compromise
608
+ * groupVmsByProject's own comment apologizes for, avoidable
609
+ * here because this value rides in the /data payload):
610
+ * "agrees" deployment and provenance name the same branch
611
+ * "differs" they disagree — the EXPECTED plan-only-retag
612
+ * state, to be shown as a pair, never badged
613
+ * "unknown" no deployment reported
614
+ *
615
+ * editorLabel and deployedBranch answer DIFFERENT questions and are routinely
616
+ * expected to disagree. editorLabel is build provenance — what source the
617
+ * binary was compiled from — and stays exactly as it was; it is the right
618
+ * answer to "what code is this actually running?" and is what fragility #33's
619
+ * fix was built for. deployedBranch is the deployment target. After a plan-only
620
+ * advance, where fleet-advance-to.sh correctly retags the base branch's CI
621
+ * binary, provenance `main` + deployment `editor-improvements-78` is the
622
+ * CORRECT state, so the card must present the pair rather than badge it.
623
+ *
624
+ * A missing deployment resolves to null / "unknown" and is NEVER filled in from
625
+ * cfg.editorBranch, though that value is right there: it is written at
626
+ * reconfigure and is not updated by a binary refresh, so it is stale for
627
+ * precisely this question — the same live-over-history discipline the rest of
628
+ * this function applies, and the same `source` honesty. Pure; no I/O. */
484
629
  function resolveCardIdentity(live, historyCfg) {
485
630
  const cfg = historyCfg || {};
486
631
  const lcp = live && live.clientProject;
487
632
  const lei = live && live.editorIdentity;
633
+ const led = live && live.editorDeployment;
488
634
 
489
635
  const historyName = cfg.clientRepo ? repoBasename(cfg.clientRepo) : (cfg.newProjectName || "");
490
636
  const project = (lcp && lcp.name) || historyName || null;
@@ -505,7 +651,24 @@ function resolveCardIdentity(live, historyCfg) {
505
651
  source = "history";
506
652
  }
507
653
 
508
- return { project, clientBranch, editorLabel, source };
654
+ const deployedBranch = (led && led.branch) || null;
655
+ const deployedTag = (led && led.tag) || null;
656
+ const deployedSource = deployedBranch || deployedTag ? "live" : "unknown";
657
+ let deployedState;
658
+ if (!deployedBranch) deployedState = "unknown";
659
+ else if (deployedBranch === editorLabel) deployedState = "agrees";
660
+ else deployedState = "differs";
661
+
662
+ return {
663
+ project,
664
+ clientBranch,
665
+ editorLabel,
666
+ source,
667
+ deployedBranch,
668
+ deployedTag,
669
+ deployedSource,
670
+ deployedState,
671
+ };
509
672
  }
510
673
 
511
674
  /** Sticky-merge a VM poll's `clientProject` against the last-known-good value
@@ -1173,6 +1336,9 @@ module.exports = {
1173
1336
  EMPTY_CONFIG,
1174
1337
  normalizeLaunchConfig,
1175
1338
  validateLaunchConfig,
1339
+ branchRefTargets,
1340
+ missingBranchRefs,
1341
+ missingBranchRefMessage,
1176
1342
  addPreflight,
1177
1343
  isVariantProvisionable,
1178
1344
  isVariantRebuildable,
@@ -1181,6 +1347,7 @@ module.exports = {
1181
1347
  cloudUpArgs,
1182
1348
  classifyContainerWait,
1183
1349
  decideAddSlot,
1350
+ heavyChargeCount,
1184
1351
  HEAVY_ACTIONS,
1185
1352
  queuedStatusForAction,
1186
1353
  pushHistory,