@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.
Files changed (30) hide show
  1. package/npm/cloud.js +116 -23
  2. package/npm/fleet-add-failure.js +59 -12
  3. package/npm/fleet-memory.js +29 -4
  4. package/npm/fleet-queue.js +141 -0
  5. package/npm/fleet-vm-access.js +162 -0
  6. package/npm/ingress-invoke.js +46 -2
  7. package/npm/launch-config.js +43 -0
  8. package/npm/scenario-check.js +11 -1
  9. package/npm/scenario-handlers.js +18 -0
  10. package/npm/scenario-issues.js +11 -0
  11. package/npm/scenario-mocks.js +245 -39
  12. package/npm/scenario-playwright.js +70 -12
  13. package/package.json +5 -5
  14. package/ui/dist/assets/{ContainerInspector-Cu1tSN-o.js → ContainerInspector-DSVZvEkB.js} +1 -1
  15. package/ui/dist/assets/EditorShell-DnXdHrar.js +256 -0
  16. package/ui/dist/assets/EditorShell-FvbXyqqD.css +1 -0
  17. package/ui/dist/assets/{ProjectLauncherScreen-Dmm6Z0mv.js → ProjectLauncherScreen-E0NYYiwy.js} +1 -1
  18. package/ui/dist/assets/ScenarioDataPanel-6qJE-0lA.js +15 -0
  19. package/ui/dist/assets/{ScenarioDataPanelFullPage-BqMxWVKW.js → ScenarioDataPanelFullPage-Cyk6efiI.js} +1 -1
  20. package/ui/dist/assets/ScenarioRenderer-Uyt54vHI.js +21 -0
  21. package/ui/dist/assets/{index-VuyqBbGJ.js → index-BPLKgwon.js} +9 -9
  22. package/ui/dist/assets/index-DKpgJLo3.css +1 -0
  23. package/ui/dist/assets/useServerIdentity-CVuHTzjg.js +1 -0
  24. package/ui/dist/index.html +2 -2
  25. package/ui/dist/assets/EditorShell--V2ExYQ6.js +0 -260
  26. package/ui/dist/assets/EditorShell-DP5UY20G.css +0 -1
  27. package/ui/dist/assets/ScenarioDataPanel-DjprZq2L.js +0 -9
  28. package/ui/dist/assets/ScenarioRenderer-DqLVVMYS.js +0 -21
  29. package/ui/dist/assets/index-CjxWhkSy.css +0 -1
  30. package/ui/dist/assets/useServerIdentity-BdXy8TQv.js +0 -1
package/npm/cloud.js CHANGED
@@ -59,6 +59,7 @@ const {
59
59
  normalizePrincipal,
60
60
  resolveDefaultOperator,
61
61
  withDefaultOperator,
62
+ unauthorizableAddRefusal,
62
63
  accessFor,
63
64
  setAccess,
64
65
  addAccess,
@@ -3608,8 +3609,9 @@ function fleetInstancePrefix() {
3608
3609
  * scripts/gcp/vm-ingress.sh with the resolved coordinates. Returns the spawnSync
3609
3610
  * result so callers decide fatality — Add treats a non-zero as a loud warning
3610
3611
  * (the VM is provisioned; ingress can be re-wired), Destroy likewise warns so a
3611
- * teardown still removes the instance. */
3612
- function runVmIngress(cfg, action, vmNumber, domain, appPort, slug) {
3612
+ * teardown still removes the instance. `vmAccess` (up only) is the Add's intended
3613
+ * principal set, which the script's grant step uses in place of its sidecar read. */
3614
+ function runVmIngress(cfg, action, vmNumber, domain, appPort, slug, vmAccess) {
3613
3615
  const scriptPath = path.join(rootDir, "scripts", "gcp", "vm-ingress.sh");
3614
3616
  const inv = vmIngressInvocation({
3615
3617
  scriptPath,
@@ -3621,6 +3623,7 @@ function runVmIngress(cfg, action, vmNumber, domain, appPort, slug) {
3621
3623
  appPort,
3622
3624
  instancePrefix: fleetInstancePrefix(),
3623
3625
  slug,
3626
+ vmAccess,
3624
3627
  });
3625
3628
  return spawnSync(inv.command, inv.args, {
3626
3629
  stdio: "inherit",
@@ -3651,17 +3654,77 @@ function runVmIngressGrantAccess(cfg, vmNumber, domain, slug) {
3651
3654
  });
3652
3655
  }
3653
3656
 
3657
+ /** THE principal set that must have IAP access to this VM after the Add: the
3658
+ * explicit `cfg.vmAccess` collaborators unioned with the resolved default
3659
+ * operator. Both add-time access paths consume this ONE function —
3660
+ * `wireVmIngressOnAdd` (which hands it to vm-ingress.sh's grant) and
3661
+ * `applyVmAccessOnAdd` (which grants it and subtracts it from the stale-revoke
3662
+ * set). They used to decide independently, and that is precisely how one path
3663
+ * granted `user:jared@codeyam.com` at 00:09:31 and the other revoked the same
3664
+ * principal 18 seconds later as a "stale prior tenant" (VMs 4/5/6, 2026-08-09):
3665
+ * the ingress path read the SIDECAR (still holding the prior tenant) while
3666
+ * `applyVmAccessOnAdd` resolved an empty operator. One source of truth removes
3667
+ * the whole class of disagreement. `env` is injectable for tests; the only I/O
3668
+ * is `resolveDefaultOperator`'s config/gcloud read. */
3669
+ function intendedVmPrincipals(cfg = {}, env = process.env) {
3670
+ return withDefaultOperator(
3671
+ normalizeAccessList(cfg.vmAccess || []),
3672
+ resolveDefaultOperator({ env }),
3673
+ );
3674
+ }
3675
+
3676
+ /** Add preflight: throw — BEFORE any GCE instance exists — when this Add would
3677
+ * come up on an IAP-gated public URL that authorizes NOBODY. The whole condition
3678
+ * is env + config (`intendedVmPrincipals` needs no GCP state), which is why it
3679
+ * belongs beside cmdUp's other cheap boundary validations rather than after a
3680
+ * machine has been provisioned: checking later means a half-provisioned VM to
3681
+ * clean up. On 2026-08-09 the un-refused version of this Add reported `done` for
3682
+ * VMs 4/5/6, left three unreachable URLs, and revoked each slot's prior tenant on
3683
+ * the way.
3684
+ *
3685
+ * Only fires in LB mode on a numbered fleet VM with nothing to grant; an explicit
3686
+ * `--vm-access` satisfies it with no default operator configured, so the
3687
+ * collaborator flow is unaffected, as is every legacy quick-tunnel Add. The
3688
+ * refusal text (and its `Next valid action:` remedy naming all three config
3689
+ * tiers) is the pure `unauthorizableAddRefusal`; this function is the composition
3690
+ * and the throw. */
3691
+ function assertAddCanAuthorizeSomeone(cfg, env = process.env) {
3692
+ let plan;
3693
+ try {
3694
+ plan = ingressWiringPlan(cfg, env);
3695
+ } catch {
3696
+ // A malformed domain is `resolveIngressDomain`'s error to raise on the wiring
3697
+ // path; it must not masquerade here as an authorization refusal.
3698
+ plan = { wire: false };
3699
+ }
3700
+ const refusal = unauthorizableAddRefusal({
3701
+ lbMode: !!plan.wire,
3702
+ vmNumber: plan.wire ? plan.vmNumber : null,
3703
+ intendedCount: plan.wire ? intendedVmPrincipals(cfg, env).length : null,
3704
+ });
3705
+ if (refusal) throw new Error(refusal);
3706
+ }
3707
+
3654
3708
  /** Add-time hook: wire VM-N onto the shared LB when a fleet domain is set.
3655
3709
  * Best-effort + loud — a wiring hiccup must never abort an otherwise-green Add
3656
3710
  * (the VM is reachable operator-side over SSH regardless), so on failure it
3657
3711
  * prints the exact re-run command rather than throwing. No-op (and silent) in
3658
- * legacy quick-tunnel mode or for a non-fleet client project. */
3712
+ * legacy quick-tunnel mode or for a non-fleet client project.
3713
+ *
3714
+ * RETURNS the principals this call granted (`intendedVmPrincipals`), or [] when
3715
+ * it did not wire — the explicit dataflow `applyVmAccessOnAdd` needs to know what
3716
+ * the Add has already granted before it computes a stale-revoke. Returned on the
3717
+ * FAILURE path too: a non-zero `vm-ingress.sh up` may still have completed its
3718
+ * grant step, and a principal that was possibly granted must never be revoked. */
3659
3719
  function wireVmIngressOnAdd(cfg, appPort, env = process.env) {
3660
3720
  const plan = ingressWiringPlan(cfg, env);
3661
- if (!plan.wire) return;
3721
+ if (!plan.wire) return [];
3662
3722
  const label = plan.slug || `vm-${plan.vmNumber}`;
3723
+ // Hand the intended set to the script so its `apply_default_iap_access` grants
3724
+ // THIS Add's principals instead of reading the sidecar's prior tenant.
3725
+ const vmAccess = intendedVmPrincipals(cfg, env);
3663
3726
  console.error(` wiring LB ingress for VM-${plan.vmNumber} on ${plan.domain}...`);
3664
- const res = runVmIngress(cfg, "up", plan.vmNumber, plan.domain, appPort, plan.slug);
3727
+ const res = runVmIngress(cfg, "up", plan.vmNumber, plan.domain, appPort, plan.slug, vmAccess);
3665
3728
  if (res.status === 0) {
3666
3729
  console.error(` ✓ LB ingress wired: https://${label}.editor.${plan.domain}`);
3667
3730
  } else {
@@ -3683,6 +3746,7 @@ function wireVmIngressOnAdd(cfg, appPort, env = process.env) {
3683
3746
  `(vm-ingress.sh up exited ${res.status == null ? "abnormally" : res.status}). Re-wire: ${rewire}`,
3684
3747
  );
3685
3748
  }
3749
+ return vmAccess;
3686
3750
  }
3687
3751
 
3688
3752
  /** Destroy-time hook: unwire VM-N's LB backends before the instance is deleted,
@@ -3751,8 +3815,15 @@ function runIapAccess(cfg, vmNumber, action, principals) {
3751
3815
  * a revoke/grant hiccup (e.g. the SA missing IAP-admin) prints the exact re-run
3752
3816
  * command rather than aborting an otherwise-green Add. `cfg.vmAccess` is the
3753
3817
  * array (or comma-separated string) cmdUp validated; absent ⇒ an empty desired
3754
- * list (the clean-slate case). */
3755
- function applyVmAccessOnAdd(cfg, env = process.env) {
3818
+ * list (the clean-slate case).
3819
+ *
3820
+ * `grantedThisRun` is what an EARLIER step of this same Add already granted —
3821
+ * `wireVmIngressOnAdd`'s return value. It is subtracted from the stale-revoke
3822
+ * set, so a principal this Add just authorized can never be revoked moments later
3823
+ * as a prior tenant. That is a second, cheaper guard than the shared
3824
+ * `intendedVmPrincipals` resolver above: it holds even if the two paths drift
3825
+ * apart again, which is exactly how VMs 4/5/6 broke on 2026-08-09. */
3826
+ function applyVmAccessOnAdd(cfg, env = process.env, { grantedThisRun = [] } = {}) {
3756
3827
  const desired = normalizeAccessList(cfg.vmAccess || []);
3757
3828
  let plan;
3758
3829
  try {
@@ -3769,11 +3840,15 @@ function applyVmAccessOnAdd(cfg, env = process.env) {
3769
3840
  // never inaccessible-by-default (the Error-51 footgun). The default operator is
3770
3841
  // NOT persisted to the sidecar (it's a standing grant, not a per-VM
3771
3842
  // collaborator), so `desired` alone drives the sidecar + stale-revoke below.
3772
- const toGrant = withDefaultOperator(desired, resolveDefaultOperator({ env }));
3843
+ const toGrant = intendedVmPrincipals(cfg, env);
3773
3844
  const stale = accessFor(vmNumber);
3774
3845
  // Never revoke a principal we're about to (re-)grant — a prior tenant who was
3775
- // also the default operator must not be revoked-then-regranted for nothing.
3776
- const staleOnly = stale.filter((p) => !toGrant.includes(p));
3846
+ // also the default operator must not be revoked-then-regranted for nothing —
3847
+ // NOR one an earlier step of this same Add already granted. `toGrant` alone
3848
+ // protected nothing when it was empty: the ingress step had granted the operator
3849
+ // seconds earlier and this revoke took it straight back off (VMs 4/5/6).
3850
+ const grantedAlready = new Set([...toGrant, ...normalizeAccessList(grantedThisRun || [])]);
3851
+ const staleOnly = stale.filter((p) => !grantedAlready.has(p));
3777
3852
 
3778
3853
  if (plan.wire) {
3779
3854
  if (staleOnly.length > 0) {
@@ -3890,12 +3965,23 @@ async function convergeVmIngressOnAdd(cfg, appPort, env = process.env, deps = {}
3890
3965
  }
3891
3966
  if (!plan.wire) {
3892
3967
  // Nothing to converge — the no-op wire keeps the clean-slate invariant.
3893
- wireVmIngressOnAdd(cfg, appPort, env);
3894
- return { wired: true, reason: "no-wire" };
3895
- }
3896
-
3968
+ const granted = wireVmIngressOnAdd(cfg, appPort, env);
3969
+ return { wired: true, reason: "no-wire", grantedThisRun: granted };
3970
+ }
3971
+
3972
+ // The intended set, resolved ONCE and handed to every `up` attempt so each
3973
+ // converge iteration grants the same principals — and reported back to the
3974
+ // caller as `grantedThisRun` so the access reconcile that follows cannot revoke
3975
+ // them. Every `up` runs the script's grant step, so these are granted whether
3976
+ // the loop converges or hits its deadline. Injectable via `deps` like the rest
3977
+ // of this loop's slow I/O: the resolve spawns `gcloud config get-value account`
3978
+ // on its last tier, and a unit test must never depend on the gcloud identity
3979
+ // that happens to be active on the machine running it.
3980
+ const grantedThisRun =
3981
+ deps.grantedThisRun != null ? deps.grantedThisRun : intendedVmPrincipals(cfg, env);
3897
3982
  const runUp =
3898
- deps.runUp || ((p) => runVmIngress(cfg, "up", p.vmNumber, p.domain, appPort, p.slug));
3983
+ deps.runUp
3984
+ || ((p) => runVmIngress(cfg, "up", p.vmNumber, p.domain, appPort, p.slug, grantedThisRun));
3899
3985
  const probeVerdict = deps.probeVerdict || ((p) => probeVmIngressVerdict(cfg, p));
3900
3986
  const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
3901
3987
  const now = deps.now || (() => Date.now());
@@ -3938,7 +4024,7 @@ async function convergeVmIngressOnAdd(cfg, appPort, env = process.env, deps = {}
3938
4024
  if (decision.done) {
3939
4025
  if (decision.wired) {
3940
4026
  console.error(` ✓ LB ingress wired: https://${label}.editor.${plan.domain}`);
3941
- return { wired: true, reason: "wired", attempts: attempt + 1 };
4027
+ return { wired: true, reason: "wired", attempts: attempt + 1, grantedThisRun };
3942
4028
  }
3943
4029
  const rewire =
3944
4030
  `FLEET_DOMAIN=${plan.domain}${plan.slug ? ` VM_SLUG=${plan.slug}` : ""} ` +
@@ -3955,7 +4041,7 @@ async function convergeVmIngressOnAdd(cfg, appPort, env = process.env, deps = {}
3955
4041
  `CY_DEGRADED ingress-wiring-failed: VM-${plan.vmNumber} public URL did not wire ` +
3956
4042
  `within the add window (last verdict=${verdict || "unknown"}). Re-wire: ${rewire}`,
3957
4043
  );
3958
- return { wired: false, reason: "deadline", attempts: attempt + 1 };
4044
+ return { wired: false, reason: "deadline", attempts: attempt + 1, grantedThisRun };
3959
4045
  }
3960
4046
  attempt += 1;
3961
4047
  await sleep(decision.delayMs);
@@ -3984,8 +4070,8 @@ function startDetachedAddWiring(cfg, appPort, env = process.env) {
3984
4070
  if (!plan.wire) {
3985
4071
  // Nothing slow to detach: wiring is a no-op and the access reconcile is a cheap
3986
4072
  // sidecar write. Run them inline so the clean-slate invariant still holds.
3987
- wireVmIngressOnAdd(cfg, appPort, env);
3988
- applyVmAccessOnAdd(cfg, env);
4073
+ const grantedThisRun = wireVmIngressOnAdd(cfg, appPort, env);
4074
+ applyVmAccessOnAdd(cfg, env, { grantedThisRun });
3989
4075
  return;
3990
4076
  }
3991
4077
  const marker = ingressInflightMarkerPath(INGRESS_INFLIGHT_DIR, plan.vmNumber);
@@ -4017,8 +4103,8 @@ function startDetachedAddWiring(cfg, appPort, env = process.env) {
4017
4103
  `running it inline instead.`,
4018
4104
  );
4019
4105
  try { fs.unlinkSync(marker); } catch { /* marker may not exist */ }
4020
- wireVmIngressOnAdd(cfg, appPort, env);
4021
- applyVmAccessOnAdd(cfg, env);
4106
+ const grantedThisRun = wireVmIngressOnAdd(cfg, appPort, env);
4107
+ applyVmAccessOnAdd(cfg, env, { grantedThisRun });
4022
4108
  }
4023
4109
  }
4024
4110
 
@@ -4050,8 +4136,11 @@ async function cmdWireAddTail() {
4050
4136
  // Converge the URL to `verdict=wired` (retry-until-wired), not a single-shot
4051
4137
  // `up` left to the slow reconcile sweep — then the IAP access grant, in the
4052
4138
  // SAME order cmdUp ran them inline.
4053
- await convergeVmIngressOnAdd(cfg, appPort);
4054
- applyVmAccessOnAdd(cfg);
4139
+ // Carry what the converge granted into the access reconcile, so the revoke
4140
+ // step cannot take back a principal this same Add just authorized — the exact
4141
+ // grant-then-revoke-18s-later sequence `/tmp/cy-wire-add-vm-4.log` recorded.
4142
+ const { grantedThisRun } = await convergeVmIngressOnAdd(cfg, appPort);
4143
+ applyVmAccessOnAdd(cfg, process.env, { grantedThisRun });
4055
4144
  } finally {
4056
4145
  if (marker) {
4057
4146
  try { fs.unlinkSync(marker); } catch { /* already gone — fine */ }
@@ -4163,6 +4252,8 @@ async function cmdUp(args, projectDir = rootDir) {
4163
4252
  }
4164
4253
  }
4165
4254
 
4255
+ assertAddCanAuthorizeSomeone(cfg);
4256
+
4166
4257
  checkScriptDrift(cfg);
4167
4258
 
4168
4259
  await ensureFirewallRule(cfg);
@@ -5320,6 +5411,8 @@ module.exports = {
5320
5411
  ingressWiringPlan,
5321
5412
  fleetInstancePrefix,
5322
5413
  runVmIngress,
5414
+ intendedVmPrincipals,
5415
+ assertAddCanAuthorizeSomeone,
5323
5416
  wireVmIngressOnAdd,
5324
5417
  probeVmIngressVerdict,
5325
5418
  convergeVmIngressOnAdd,
@@ -99,10 +99,13 @@ function transcriptRostered(logTail) {
99
99
  // The categories that describe an add whose outcome is NOT yet proven bad — the
100
100
  // calm, in-flight side of the classifier. `converging` is a rostered box whose
101
101
  // ingress is still wiring; `verifying` is the residue: no failure signature
102
- // matched and nothing durable proves the VM is dead. Both render a calm card and
103
- // neither may use the word "failed". Consumed by holdConvergingBadge and by
104
- // deriveStatus (server.js) to pick the matching calm status.
105
- const CALM_ADD_CATEGORIES = new Set(['converging', 'verifying']);
102
+ // matched and nothing durable proves the VM is dead; `access-pending` is a VM that
103
+ // is up and rostered but whose IAP policy has no accessors YET, because its
104
+ // detached wiring tail — which applies the grant last — is still running. All
105
+ // three render a calm card and none may use the word "failed". Consumed by
106
+ // holdConvergingBadge and by deriveStatus (server.js) to pick the matching calm
107
+ // status.
108
+ const CALM_ADD_CATEGORIES = new Set(['converging', 'verifying', 'access-pending']);
106
109
 
107
110
  // Is a non-zero add outcome CORROBORATED by a durable fact — i.e. do we have
108
111
  // positive proof of death, rather than merely an absence of proof of life?
@@ -157,6 +160,17 @@ function containerHealthWitness({ reachable, inVmHealthy } = {}) {
157
160
  return null;
158
161
  }
159
162
 
163
+ // Lift the `Next valid action:` remedy out of a BLOCKED reason so a classified
164
+ // card's recovery line stays the SAME text the producing check already emitted,
165
+ // instead of a second copy of the same instructions that can silently drift from
166
+ // it. Returns '' when the text carries no remedy, in which case the caller's
167
+ // static copy stands on its own.
168
+ function nextValidActionRemedy(text) {
169
+ const lines = String(text || '').split('\n');
170
+ const i = lines.findIndex((l) => /^\s*Next valid action:/.test(l));
171
+ return i === -1 ? '' : lines.slice(i).join('\n').trim();
172
+ }
173
+
160
174
  // Build a verdict, stamping `retryable` from the single-source-of-truth Set so it
161
175
  // can never drift from the category.
162
176
  function buildFailureVerdict(category, headline, recovery, extra) {
@@ -179,6 +193,9 @@ function buildFailureVerdict(category, headline, recovery, extra) {
179
193
  // instanceAlive does GCP still list the instance?
180
194
  // containerHealthy did the in-VM editor answer /api/health?
181
195
  // wiringDeadlineBlown did the add-time wire tail drop its failed marker?
196
+ // wiringInFlight is that tail still RUNNING? Only the IAP read-back arm
197
+ // reads it, to tell "no accessors yet" from "no accessors,
198
+ // ever" — the grant is the tail's last step.
182
199
  //
183
200
  // Returns { category, headline, recovery, retryable, files? }.
184
201
  //
@@ -188,7 +205,7 @@ function buildFailureVerdict(category, headline, recovery, extra) {
188
205
  // signature is therefore tested BEFORE the generic cloud-up-failed signature, or
189
206
  // a retryable drift would misclassify as a non-retryable cloud:up failure and the
190
207
  // operator would lose the safe one-click recovery.
191
- function classifyAddFailure({ logTail, exitCode, instanceAlive, containerHealthy, wiringDeadlineBlown } = {}) {
208
+ function classifyAddFailure({ logTail, exitCode, instanceAlive, containerHealthy, wiringDeadlineBlown, wiringInFlight } = {}) {
192
209
  const log = String(logTail || '');
193
210
 
194
211
  // 1. preflight-drift — the streamed-script drift guard aborted BEFORE any
@@ -270,7 +287,37 @@ function classifyAddFailure({ logTail, exitCode, instanceAlive, containerHealthy
270
287
  'The provision was interrupted (killed or out of memory), so the VM state is unknown. Reconcile or destroy this VM before re-adding.');
271
288
  }
272
289
 
273
- // 8. converging — the add reached the early-roster marker (`CY_ROSTER`): the
290
+ // 8. The post-add IAP read-back verdicts. Unlike every signature above, this one
291
+ // is not produced by the provision transcript at all — it is the BLOCKED
292
+ // reason iapReadbackOutcome hands to annotateAddFailure, which is what
293
+ // stops a read-back failure from being the one add failure that reaches the
294
+ // card with no category at all (and so no differentiated headline, no
295
+ // recovery, no guarded Retry, and no chance for holdConvergingBadge to hold it
296
+ // calm). Tested AFTER all seven hard-failure signatures so a proven failure
297
+ // always wins if both texts are ever present, and BEFORE converging/verifying
298
+ // because it is strictly more specific than either.
299
+ if (/IAP policy authorizes no human/i.test(log)) {
300
+ const remedy = nextValidActionRemedy(log);
301
+ // 8a. access-pending — no accessors YET. The grant is the LAST step of the
302
+ // detached wire-add-tail, so while that tail is alive an empty policy is
303
+ // the expected mid-flight reading, not a verdict. Calm: this is the state
304
+ // that used to render a red "Add failed" on healthy VM-4/VM-7 minutes
305
+ // before their grant step had even run (2026-08-11).
306
+ if (wiringInFlight) {
307
+ return buildFailureVerdict('access-pending', 'Access not granted yet',
308
+ 'The VM is up and rostered, but its IAP policy has no human accessors yet — the add\'s background wiring applies the grant as its last step and is still running. This resolves on its own once that tail finishes; no action is needed unless it persists past the wiring deadline.');
309
+ }
310
+ // 8b. iap-unauthorized — the wiring is done and the policy STILL authorizes
311
+ // nobody, so the public URL returns "Error code 51" for every human. This
312
+ // is the 2026-08-09 case the read-back exists to catch (one step granted
313
+ // the operator, the next revoked him) and it is a genuine hard failure.
314
+ // Not retryable: the VM exists and is fine — re-adding it would duplicate
315
+ // a live box when what is missing is one grant.
316
+ return buildFailureVerdict('iap-unauthorized', 'No one can reach this VM',
317
+ `The VM is up but its IAP policy authorizes no human, so the public URL returns "Error code 51" for everyone. Its background wiring is no longer running, so this will not resolve on its own — grant access to fix it. Do NOT destroy or re-add this VM.${remedy ? `\n\n${remedy}` : ''}`);
318
+ }
319
+
320
+ // 9. converging — the add reached the early-roster marker (`CY_ROSTER`): the
274
321
  // container gate passed, the instance is real, and the VM is rostered, yet it
275
322
  // exited non-zero WITHOUT matching any specific hard-failure signature above.
276
323
  // By construction of the provision script, every genuine hard failure (drift,
@@ -280,15 +327,15 @@ function classifyAddFailure({ logTail, exitCode, instanceAlive, containerHealthy
280
327
  // wire-add-tail / reconcile-ingress — NOT an add failure. Surface it as an
281
328
  // honest in-flight state instead of the scary red `unknown`. Not retryable:
282
329
  // the VM exists and its detached wiring owns finishing it, so a one-click
283
- // retry could duplicate/orphan a live box. Tested LAST so a hard-failure
284
- // signature (all printed pre-roster) always wins — `converging` can never
285
- // shadow a proven failure.
330
+ // retry could duplicate/orphan a live box. Tested after every hard-failure
331
+ // signature (all printed pre-roster) so a proven failure always wins —
332
+ // `converging` can never shadow one.
286
333
  if (transcriptRostered(log)) {
287
334
  return buildFailureVerdict('converging', 'Add still converging',
288
335
  'The container is up and the VM is rostered; the LB ingress is still wiring in the background. This resolves on its own once the editor is reachable over the LB — no action needed unless it persists past the wiring deadline.');
289
336
  }
290
337
 
291
- // 9. The residue: no failure signature above matched. The polarity here is
338
+ // 10. The residue: no failure signature above matched. The polarity here is
292
339
  // INVERTED from the original design. It used to read "no recognized success
293
340
  // signature ⇒ failed", which made red the DEFAULT for "we don't know" — so a
294
341
  // VM whose container was Up and whose editor answered /api/health 200 got a
@@ -298,7 +345,7 @@ function classifyAddFailure({ logTail, exitCode, instanceAlive, containerHealthy
298
345
  //
299
346
  // Red now requires positive proof of death, and everything else is in-flight:
300
347
  //
301
- // 9a. unknown — a non-zero exit CORROBORATED by a durable fact (see
348
+ // 10a. unknown — a non-zero exit CORROBORATED by a durable fact (see
302
349
  // addFailureCorroborated). The box is provably dead or provably never
303
350
  // came up, so the loud, terminal escalation is warranted and unchanged.
304
351
  // Still never invites an unproven retry.
@@ -307,7 +354,7 @@ function classifyAddFailure({ logTail, exitCode, instanceAlive, containerHealthy
307
354
  '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.');
308
355
  }
309
356
 
310
- // 9b. verifying — the honest default. No failure signature, and nothing
357
+ // 10b. verifying — the honest default. No failure signature, and nothing
311
358
  // durable proves this VM is dead: it may still be provisioning or
312
359
  // wiring, and the ingress can keep propagating for minutes after the
313
360
  // wrapper is gone. Calm copy, transcript still one click away. NOT
@@ -290,8 +290,16 @@ function restoreFromSnapshot({ snapshot, maxAgeMs, nowIso, sidecarKeys } = {}) {
290
290
  * Degrades to 'cgroup=unavailable' rather than printing zeros off-cgroup, so a
291
291
  * laptop log cannot be misread as a cgroup at 0MB.
292
292
  *
293
+ * `detachedWiringCount` — when supplied — appends `tails=N`, which is what makes
294
+ * the RSS/cgroup gap ATTRIBUTABLE rather than merely visible. On 2026-08-11 the
295
+ * cgroup read 2154MB against a 96MB RSS and the whole diagnosis was "those ~2GB
296
+ * are this process's own transient add children, not a leak". `RSS=96MB
297
+ * cgroup=2154MB tails=4` reads as a batch draining on its own; `RSS=1300MB
298
+ * cgroup=1400MB tails=0` reads as a genuine leak. Omitted (the pre-existing
299
+ * callers, and any host with no tail accounting) prints exactly as before.
300
+ *
293
301
  * Pure; no I/O. */
294
- function formatMemoryText({ rssBytes, memoryCurrent, memoryHigh } = {}) {
302
+ function formatMemoryText({ rssBytes, memoryCurrent, memoryHigh, detachedWiringCount } = {}) {
295
303
  const mb = (b) => (Number(b) / 1024 / 1024).toFixed(0);
296
304
  const current = Number(memoryCurrent);
297
305
  const high = Number(memoryHigh);
@@ -308,6 +316,8 @@ function formatMemoryText({ rssBytes, memoryCurrent, memoryHigh } = {}) {
308
316
  } else {
309
317
  parts.push('cgroup=unavailable');
310
318
  }
319
+ const tails = Number(detachedWiringCount);
320
+ if (Number.isFinite(tails) && tails >= 0) parts.push(`tails=${tails}`);
311
321
  return parts.join(' ');
312
322
  }
313
323
 
@@ -323,11 +333,26 @@ function formatMemoryText({ rssBytes, memoryCurrent, memoryHigh } = {}) {
323
333
  * protecting. The dashboard's own self-update counts, being a heavy job by any
324
334
  * other name.
325
335
  *
326
- * Pure; no I/O — the caller passes state.jobs / state.dashboardUpdate. */
327
- function hasJobInFlight({ jobs, dashboardUpdate } = {}) {
336
+ * `detachedWiringCount` is the THIRD source of busy, and the one this guard was
337
+ * blind to: an Add settles at container-healthy while its detached wire-add-tail
338
+ * keeps spawning gcloud children for another 5-10 min in the SAME cgroup. Judging
339
+ * "safe to restart" off `state.jobs` alone reported no job in flight while that
340
+ * work was live — on 2026-08-11 the escalation crossed the restart threshold at
341
+ * 86% of memory.high and the only thing that held it back was an unrelated job
342
+ * happening to still be `running`. Had it settled thirty seconds earlier the
343
+ * dashboard would have restarted itself mid-batch and orphaned four adds, which
344
+ * is precisely the failure class the detached-provision work exists to prevent.
345
+ * Omitting the key behaves exactly as before, so every current caller is
346
+ * unaffected.
347
+ *
348
+ * Pure; no I/O — the caller passes state.jobs / state.dashboardUpdate and the
349
+ * live-tail count. */
350
+ function hasJobInFlight({ jobs, dashboardUpdate, detachedWiringCount } = {}) {
328
351
  const busy = (j) => !!j && (j.state === 'running' || j.state === 'recovering');
329
352
  const anyJob = Object.values((jobs && typeof jobs === 'object') ? jobs : {}).some(busy);
330
- return anyJob || !!(dashboardUpdate && dashboardUpdate.state === 'running');
353
+ const tails = Number(detachedWiringCount);
354
+ const anyTail = Number.isFinite(tails) && tails > 0;
355
+ return anyJob || anyTail || !!(dashboardUpdate && dashboardUpdate.state === 'running');
331
356
  }
332
357
 
333
358
  /** Should this escalation level be announced to the journal?
@@ -126,9 +126,150 @@ function reconcileQueueDraft(draftOrder, liveOrder) {
126
126
  return out;
127
127
  }
128
128
 
129
+ // Consecutive failed reads before the panel stops trusting its mirror. At the
130
+ // default QUEUE_POLL_MS of 5000 this is ~15s of sustained failure — long enough
131
+ // that the ordinary 20s-timeout blip the next tick clears only ever reaches
132
+ // 'degraded', short enough that a real credential outage is flagged in seconds.
133
+ // Named rather than inlined so the verdict and the tooltip copy read the same
134
+ // number from one place.
135
+ const QUEUE_STALE_AFTER_FAILURES = 3;
136
+
137
+ /** Merge one commit-queue poll result over the previous memo.
138
+ *
139
+ * THE RULE: a failed read RETAINS the previous `entries` AND the previous
140
+ * `readAt`, and records `error`. Retaining the entries is deliberate — a stale
141
+ * order beats a blank panel for an operator mid-triage. Leaving `readAt`
142
+ * untouched is what makes the staleness legible: it is the ONLY signal that
143
+ * distinguishes a frozen view from a live one.
144
+ *
145
+ * Advancing `readAt` on a failure is precisely the bug this exists to prevent.
146
+ * On 2026-08-14 /etc/codeyam/git-credentials on the operator host went to 0
147
+ * bytes and every origin git operation began failing; the queue panel went on
148
+ * naming VM-5 as HEAD for 3.5 hours after VM-5 had released, while the real
149
+ * queue was VM-2 -> VM-8 -> VM-4. Nothing on the page distinguished it from
150
+ * live. This is the same lesson mergeDashboardRevision records from
151
+ * 2026-07-31, applied to the second poller.
152
+ *
153
+ * A previous `readAt` of null (no successful read has EVER happened) stays
154
+ * null and is not coerced to a timestamp — "never read" and "read a moment
155
+ * ago" must stay distinguishable, the same reason mergeDashboardRevision
156
+ * refuses to push a null `behind` through Number().
157
+ *
158
+ * Success is the absence of `error`. Pure; no I/O. */
159
+ function mergeQueuePoll({ prev, entries, readAt, error } = {}) {
160
+ const base = (prev && typeof prev === 'object') ? prev : {};
161
+ const str = (v) => ((typeof v === 'string' && v) ? v : null);
162
+ const prevEntries = Array.isArray(base.entries) ? base.entries : [];
163
+ const prevReadAt = str(base.readAt);
164
+ const prevFailures = (typeof base.consecutiveFailures === 'number'
165
+ && Number.isFinite(base.consecutiveFailures) && base.consecutiveFailures >= 0)
166
+ ? base.consecutiveFailures : 0;
167
+ const err = str(error);
168
+ if (err) {
169
+ return {
170
+ entries: prevEntries,
171
+ readAt: prevReadAt,
172
+ error: err,
173
+ consecutiveFailures: prevFailures + 1,
174
+ };
175
+ }
176
+ return {
177
+ entries: Array.isArray(entries) ? entries : [],
178
+ readAt: str(readAt) || prevReadAt,
179
+ error: null,
180
+ consecutiveFailures: 0,
181
+ };
182
+ }
183
+
184
+ /** What does the current queue memo mean for the operator?
185
+ *
186
+ * Levels:
187
+ * 'fresh' — the last read succeeded. Render exactly as before; every
188
+ * control stays live.
189
+ * 'degraded' — erroring, but under QUEUE_STALE_AFTER_FAILURES consecutive
190
+ * failures. A blip. Noted quietly, controls stay live — a
191
+ * single 20s-timeout hiccup that the next tick clears must not
192
+ * cry wolf or lock the operator out of their own queue.
193
+ * 'stale' — erroring at or past the threshold. The mirror is not to be
194
+ * trusted and writes computed against it are blocked.
195
+ * 'unknown' — erroring and NO read has ever succeeded (the boot window with
196
+ * broken credentials). Worth saying out loud: an empty-looking
197
+ * queue here means "we cannot see the queue", not "go".
198
+ *
199
+ * AGE IS DELIBERATELY NOT AN INPUT. A genuinely quiet queue is not a broken
200
+ * one, so escalation keys on consecutive read FAILURES only; age is carried by
201
+ * projectQueueFreshness purely to phrase the message ("last read 3h 12m ago").
202
+ * An hours-old `readAt` with no error is 'fresh' and must stay that way. Taking
203
+ * a clock here would imply otherwise, so the signature omits one — the same
204
+ * take-only-what-you-use shape as classifyDashboardStaleness.
205
+ *
206
+ * Pure; no I/O. */
207
+ function classifyQueueFreshness({ readAt, error, consecutiveFailures } = {}) {
208
+ if (!(typeof error === 'string' && error)) return 'fresh';
209
+ if (!(typeof readAt === 'string' && readAt)) return 'unknown';
210
+ const failures = (typeof consecutiveFailures === 'number' && Number.isFinite(consecutiveFailures))
211
+ ? consecutiveFailures : 0;
212
+ return failures >= QUEUE_STALE_AFTER_FAILURES ? 'stale' : 'degraded';
213
+ }
214
+
215
+ /** The `/data` view of the queue memo, with the freshness VERDICT resolved
216
+ * server-side.
217
+ *
218
+ * The panel is then a pure renderer. Two consumers re-deriving "is this stale"
219
+ * from a raw timestamp would drift, and `public/index.html` is an inline
220
+ * browser script with no module boundary — any decision left in it could only
221
+ * ever be source-scan tested, which is how it ships bugs green. Same split, and
222
+ * same reason, as projectDashboardRevision.
223
+ *
224
+ * `now` is injected (epoch ms) rather than read from the clock so the
225
+ * projection stays pure and its tests deterministic. `ageSec` is null when no
226
+ * successful read has happened or no clock was supplied.
227
+ *
228
+ * Pure; no I/O. */
229
+ function projectQueueFreshness(memo = {}, now = null) {
230
+ const at = (typeof memo.readAt === 'string' && memo.readAt) ? Date.parse(memo.readAt) : NaN;
231
+ const clock = (typeof now === 'number' && Number.isFinite(now)) ? now : null;
232
+ const ageSec = (Number.isFinite(at) && clock !== null)
233
+ ? Math.max(0, Math.floor((clock - at) / 1000))
234
+ : null;
235
+ return { ...memo, level: classifyQueueFreshness(memo), ageSec };
236
+ }
237
+
238
+ /** The operator-facing reason a queue WRITE must be refused, or null when writes
239
+ * are allowed.
240
+ *
241
+ * A write computed against a mirror known to be frozen is never the right
242
+ * action, and the silent-no-op outcome is worse than a refusal: a Release
243
+ * fires at a uuid that no longer holds the head, reports "released head
244
+ * bb65addf", changes nothing, and leaves the real head held. A reorder is
245
+ * worse still — computeReorderTargets validates the posted order against the
246
+ * very mirror that is wrong, so a doomed reorder looks perfectly valid.
247
+ *
248
+ * The verbatim git error is interpolated rather than summarised to "poll
249
+ * failed". On 2026-08-14 the entire recovery was legible from one line —
250
+ * `could not read Username for 'https://github.com'` — and truncating it would
251
+ * have cost the same 3.5 hours.
252
+ *
253
+ * Lives here rather than in server.js so the refusal COPY is directly
254
+ * assertable: server.js has top-level side effects on require, so a message
255
+ * built there could only ever be source-scan tested. Pure; no I/O. */
256
+ function queueWriteBlockReason(memo = {}) {
257
+ const level = classifyQueueFreshness(memo);
258
+ if (level !== 'stale' && level !== 'unknown') return null;
259
+ const why = (typeof memo.error === 'string' && memo.error) ? ` (${memo.error})` : '';
260
+ return level === 'unknown'
261
+ ? `commit queue has never been read successfully${why} — cannot act on an unknown queue`
262
+ : `commit queue read is failing${why} — the displayed order cannot be trusted; fix the read before changing the queue`;
263
+ }
264
+
129
265
  module.exports = {
130
266
  decideAutoReleaseGoneHead,
131
267
  computeReorderTargets,
132
268
  nudgeQueueOrder,
133
269
  reconcileQueueDraft,
270
+ mergeQueuePoll,
271
+ classifyQueueFreshness,
272
+ projectQueueFreshness,
273
+ queueWriteBlockReason,
274
+ QUEUE_STALE_AFTER_FAILURES,
134
275
  };