@codeyam-editor/codeyam-editor 0.1.8 → 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 (35) hide show
  1. package/npm/cloud.js +361 -18
  2. package/npm/editor-dev.js +24 -2
  3. package/npm/editor.js +15 -5
  4. package/npm/fleet-add-failure.js +355 -21
  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-revision.js +108 -0
  10. package/npm/fleet-run-setup.js +66 -0
  11. package/npm/fleet-session-activity.js +162 -0
  12. package/npm/fleet-vm-readiness.js +57 -16
  13. package/npm/launch-config.js +126 -2
  14. package/npm/scenario-check.js +697 -34
  15. package/npm/scenario-interactivity.js +477 -23
  16. package/npm/scenario-issues.js +61 -0
  17. package/npm/scenario-playwright.js +67 -14
  18. package/npm/utils.js +262 -22
  19. package/package.json +5 -5
  20. package/ui/dist/assets/{ContainerInspector-DSVZvEkB.js → ContainerInspector-C7I9wctz.js} +1 -1
  21. package/ui/dist/assets/EditorShell-9aYMeOdZ.js +330 -0
  22. package/ui/dist/assets/EditorShell-CE8iG_EM.css +1 -0
  23. package/ui/dist/assets/ScenarioDataPanel-CLwkrex0.js +18 -0
  24. package/ui/dist/assets/{ScenarioDataPanelFullPage-Cyk6efiI.js → ScenarioDataPanelFullPage-DT0TZC4M.js} +1 -1
  25. package/ui/dist/assets/ScenarioRenderer-Bsarhcrl.js +21 -0
  26. package/ui/dist/assets/index-BRoAenc1.css +1 -0
  27. package/ui/dist/assets/{index-BPLKgwon.js → index-iMR5siiG.js} +9 -9
  28. package/ui/dist/index.html +2 -2
  29. package/ui/dist/preview-subdir-demo/index.html +62 -0
  30. package/ui/dist/preview-subdir-demo/sibling.html +48 -0
  31. package/ui/dist/assets/EditorShell-DnXdHrar.js +0 -256
  32. package/ui/dist/assets/EditorShell-FvbXyqqD.css +0 -1
  33. package/ui/dist/assets/ScenarioDataPanel-6qJE-0lA.js +0 -15
  34. package/ui/dist/assets/ScenarioRenderer-Uyt54vHI.js +0 -21
  35. package/ui/dist/assets/index-DKpgJLo3.css +0 -1
@@ -618,20 +618,37 @@ function reconcileRosteredVm({ exists, hasSession, hasPendingAdd, streak, resetH
618
618
  // The dashboard's `state` and `addVm` are injected so this stays a pure,
619
619
  // importable unit — `log`/`err` default to the console but are overridable in
620
620
  // tests. Behavior mirrors the previous inline server.js definition exactly.
621
+ //
622
+ // The exhausted branch records a TERMINAL DISPOSITION, not just a flag. The flag
623
+ // alone was write-only: nothing destroys the instance and the only code that
624
+ // clears the flag is a *subsequent* recovery attempt, which by definition never
625
+ // comes once the budget is spent. VM-10 therefore ran empty and unrostered for a
626
+ // full day on 2026-08-29, billing, while the dashboard rendered a generic
627
+ // needs-manual-recovery card and clearing it needed hand-edited JSON.
628
+ // `instancePresumedRunning` says the GCE instance is believed to still exist —
629
+ // PRESUMED, because this ladder step never probes GCP; `exhaustedOrphanDisposition`
630
+ // is what reconciles the presumption against reality. `exhaustedAt` anchors the
631
+ // accrued-cost clock the card renders. `addVm` is still never called: that is the
632
+ // safety property that stops a wedged VM looping cloud:up forever.
621
633
  function continueBootstrapRecovery(
622
634
  n,
623
635
  meta,
624
636
  cfg,
625
- { state, addVm, log = console.log, err = console.error } = {},
637
+ { state, addVm, log = console.log, err = console.error, now = Date.now } = {},
626
638
  ) {
627
639
  const { attempt, proceed, max } = recoverBootstrapDecision({
628
640
  recoverAttempts: meta.recoverAttempts,
629
641
  });
630
642
  if (!proceed) {
631
643
  err(
632
- `startup-reap: VM-${n} exceeded ${max} bootstrap-recovery attempts — leaving sidecar for manual action`,
644
+ `startup-reap: VM-${n} exceeded ${max} bootstrap-recovery attempts — instance presumed still running, leaving sidecar for manual action`,
633
645
  );
634
- state.orphans[n] = { reason: "bootstrap-recovery-exhausted", attempts: attempt - 1 };
646
+ state.orphans[n] = {
647
+ reason: "bootstrap-recovery-exhausted",
648
+ attempts: attempt - 1,
649
+ instancePresumedRunning: true,
650
+ exhaustedAt: now(),
651
+ };
635
652
  return;
636
653
  }
637
654
  log(
@@ -643,6 +660,68 @@ function continueBootstrapRecovery(
643
660
  addVm(n, cfg, { recovering: true, recoverAttempt: attempt });
644
661
  }
645
662
 
663
+ // The exit `continueBootstrapRecovery`'s exhausted flag never had. That flag is
664
+ // written once and, until this verdict existed, could only be cleared by a
665
+ // subsequent recovery attempt the spent budget guarantees will never run — so it
666
+ // outlived the instance it described and had to be removed by hand-editing
667
+ // `.codeyam/logs/dashboard-state.json`. This reconciles the flag's PRESUMPTION
668
+ // (`instancePresumedRunning`) against two live facts the sweep supplies:
669
+ //
670
+ // instanceExists true ⇒ the presumption holds, the box is still billing
671
+ // false ⇒ the instance is gone (destroyed out-of-band) → 'clear',
672
+ // so the card self-heals with no operator action
673
+ // null ⇒ the describe probe was INCONCLUSIVE. Never act on
674
+ // absence of evidence — same discipline reapVerdict
675
+ // applies to its own `exists === null`.
676
+ // rostered false ⇒ flagged, billing, and in nobody's roster: invisible to
677
+ // every roster-driven tool yet costing money → 'cost',
678
+ // the distinct ownerless surface, NOT a generic
679
+ // needs-manual-recovery card.
680
+ // true ⇒ still owned, so it is recoverable → 'keep'.
681
+ // null ⇒ roster unknown; keep rather than raise a cost alarm
682
+ // that may be false (positive-signal-only, mirroring
683
+ // how projectWired/agentReady downgrade).
684
+ //
685
+ // Deliberately NOT a destroy verdict. Destroying a VM is irreversible and a
686
+ // half-provisioned box occasionally holds the clue that explains the failure, so
687
+ // the terminal disposition is "surface it loudly and offer the button" — the
688
+ // operator presses it.
689
+ function exhaustedOrphanDisposition({ reason, instanceExists, rostered } = {}) {
690
+ if (reason !== 'bootstrap-recovery-exhausted') return 'leave';
691
+ if (instanceExists === null || instanceExists === undefined) return 'leave';
692
+ if (!instanceExists) return 'clear';
693
+ return rostered === false ? 'cost' : 'keep';
694
+ }
695
+
696
+ // Fold a 'cost'/'keep' verdict into the orphan entry the card renders. Split out
697
+ // of the dashboard's poller because the stickiness rule below is the only real
698
+ // logic in that branch, and inlined in server.js it was unreachable by any test:
699
+ // the dashboard has top-level side effects on require, so its pollers can only be
700
+ // covered by a source-scan wiring guard that cannot evaluate behavior.
701
+ //
702
+ // `ownerlessSince` dates the accrued cost from when the VM was FIRST confirmed
703
+ // ownerless, not from the latest probe — re-stamping it every tick would peg the
704
+ // card's elapsed time near zero forever and hide exactly the multi-day cost the
705
+ // surface exists to reveal. It is dropped (not zeroed, not left stale) the moment
706
+ // a VM stops being ownerless — an operator adopting it back into the roster flips
707
+ // the verdict to 'keep', and a leftover `ownerlessSince` would let the cost card
708
+ // resurrect itself with a fabricated start time if the VM ever went ownerless
709
+ // again. `instanceConfirmedAt` records that the presumption was checked live.
710
+ //
711
+ // Returns a NEW entry; the caller decides where to store it. Both verdicts keep
712
+ // the flag — the instance is real and only an operator may destroy it.
713
+ function annotateOwnerlessCost(orphan, { verdict, now = Date.now() } = {}) {
714
+ const next = { ...orphan, instancePresumedRunning: true, instanceConfirmedAt: now };
715
+ if (verdict === 'cost') {
716
+ next.ownerlessCost = true;
717
+ next.ownerlessSince = (orphan && orphan.ownerlessSince) || now;
718
+ return next;
719
+ }
720
+ next.ownerlessCost = false;
721
+ delete next.ownerlessSince;
722
+ return next;
723
+ }
724
+
646
725
  module.exports = {
647
726
  parsePendingJobFilename,
648
727
  persistPendingJob,
@@ -656,6 +735,8 @@ module.exports = {
656
735
  classifyLbReachableForReap,
657
736
  recoverBootstrapDecision,
658
737
  continueBootstrapRecovery,
738
+ exhaustedOrphanDisposition,
739
+ annotateOwnerlessCost,
659
740
  exitMarkerName,
660
741
  parseExitMarker,
661
742
  readExitMarker,
@@ -0,0 +1,107 @@
1
+ 'use strict';
2
+
3
+ // npm/fleet-datastore-health.js — pure classifier for the fleet dashboard's
4
+ // "declared datastore unreachable" surface: is the datastore a VM's project
5
+ // DECLARES actually answering?
6
+ //
7
+ // Nothing anywhere reported this, so a VM whose database was down read
8
+ // perfectly green. On VM-12 (2026-08-24) the dashboard, the LB backend health,
9
+ // the IAP edge and the editor's own server-identity all reported healthy while
10
+ // every DB-backed route returned 500 — the only evidence was `ECONNREFUSED
11
+ // 127.0.0.1:5432` buried in the container log, which nobody polls. The
12
+ // 2026-08-14 occurrence on the same VM presented as previewServing=false with an
13
+ // EMPTY degradedReasons list: the dashboard knew something was wrong and had
14
+ // nothing to say about it. This module is the "something to say".
15
+ //
16
+ // The editor probes (it is the only party that knows what the project declares,
17
+ // and the resolution already exists in-crate) and carries the result on the
18
+ // existing session-info poll; this module decides, and server.js renders. Pure
19
+ // like fleet-project-drift.js: all the decision logic lives here and unit-tests
20
+ // without a VM, while server.js stays the thin I/O shell.
21
+ //
22
+ // A new `datastore-unreachable` code needs NO change to
23
+ // fleet-setup-steps.js::classifyReasons — it routes unknown codes to
24
+ // attentionReasons by design ("an unrecognized signal is surfaced loudly, never
25
+ // silently hidden"), so this lands amber. It must NOT be added to SETUP_CODES: a
26
+ // dead datastore is never expected first-run wiring.
27
+
28
+ // Decide the datastore-health reasons for one VM.
29
+ //
30
+ // declared truthy when the project declares a datastore at all. A project
31
+ // with none (a Rust CLI, a static site) must never raise a reason.
32
+ // reachable TRI-STATE, matching the editor's own discipline: true = the probe
33
+ // reached it, false = the probe failed, null/undefined = unknown
34
+ // (not probed yet, or the probe went stale). Only an explicit
35
+ // `false` raises a reason — collapsing unknown into false would
36
+ // light up every VM during the window between editor boot and the
37
+ // first probe, which is the failure mode that trains an operator to
38
+ // ignore the surface.
39
+ // target credential-free rendering of where it lives ("127.0.0.1:5432/app",
40
+ // or a file path). Never the DSN — that carries credentials.
41
+ // shape 'file' (a database file on disk) or 'network' (a server on a
42
+ // socket). Published by the editor rather than inferred from `type`
43
+ // here, because "sqlite means file-shaped" is a per-stack fact this
44
+ // module would otherwise have to learn for every engine. It decides
45
+ // WHICH recovery applies at all: a down server gets started, a
46
+ // missing file gets restored.
47
+ // local whether the datastore is something this VM is expected to host
48
+ // (loopback host, or a file database). Decides where the recovery
49
+ // points; a remote/managed datastore is still reported, but telling
50
+ // an operator to start it here would send them somewhere there is
51
+ // nothing to do.
52
+ // type the declared engine ('postgresql' | 'sqlite' | 'mysql' | …), used
53
+ // only to name what is down.
54
+ // lastError the probe's own failure string ("connection refused: …"), so the
55
+ // card says WHY rather than just that.
56
+ // n the VM number, so a local recovery can name the VM to act on.
57
+ //
58
+ // Returns { unreachable, reasons }. `reasons` is [] whenever healthy, unknown, or
59
+ // nothing is declared, so the caller spreads it unconditionally.
60
+ function classifyDatastoreHealth({ declared, reachable, target, shape, local, type, lastError, n } = {}) {
61
+ // Explicit `false` only. `null`/`undefined` is unknown and never a fault —
62
+ // the same absence-of-evidence-is-not-a-fault rule the reachability and
63
+ // backend-health classifiers already follow.
64
+ const unreachable = !!declared && reachable === false;
65
+ if (!unreachable) return { unreachable: false, reasons: [] };
66
+ return {
67
+ unreachable: true,
68
+ reasons: [datastoreUnreachableReason({ type, target, shape, local, lastError, n })],
69
+ };
70
+ }
71
+
72
+ // Which recovery even applies, given what KIND of datastore this is and where
73
+ // it lives. Its own function because "is it down?" and "what do you do about
74
+ // it?" are separate decisions, and this is the one that grows a branch every
75
+ // time a new datastore shape shows up.
76
+ //
77
+ // It follows the ingress-unwired precedent — the whole point of that reason was
78
+ // carrying the exact fix instead of a bare complaint — but deliberately stops
79
+ // short of naming a COMMAND. What an operator runs to start a datastore is a
80
+ // property of the VM image, not of this module; naming one this module cannot
81
+ // know is installed would be a confident lie on an amber card. What IS knowable,
82
+ // and what actually unblocks someone, is which recovery applies and where to act.
83
+ //
84
+ // The `file` branch is not cosmetic: a file database has no server to start, so
85
+ // routing it through the local branch would print the nonsensical "start it".
86
+ function recoveryClause({ shape, local, n } = {}) {
87
+ if (shape === 'file') {
88
+ return 'Recover: this is a file database and the file is missing — restore or recreate it on the VM.';
89
+ }
90
+ if (local) {
91
+ return `Recover: start it on VM-${n} — it is declared local, so nothing outside the VM can be at fault.`;
92
+ }
93
+ return 'Recover: this is a remote/managed datastore — check the datastore service and the VM\'s network path. There is nothing local to start.';
94
+ }
95
+
96
+ // The single source for the `datastore-unreachable` reason string, mirroring
97
+ // fleet-public-url.js::ingressUnwiredReason. Named and exported so the card
98
+ // reason and any future log line cannot drift apart — the drift that a
99
+ // template literal inlined at its one call site invites the moment a second
100
+ // caller appears.
101
+ function datastoreUnreachableReason({ type, target, shape, local, lastError, n } = {}) {
102
+ const what = `${type || 'declared'} datastore${target ? ` at ${target}` : ''}`;
103
+ const why = lastError ? ` (${lastError})` : '';
104
+ return `datastore-unreachable: the ${what} is not answering${why}. ${recoveryClause({ shape, local, n })}`;
105
+ }
106
+
107
+ module.exports = { classifyDatastoreHealth, recoveryClause, datastoreUnreachableReason };
@@ -97,8 +97,116 @@ function projectDashboardRevision(memo = {}) {
97
97
  return { ...memo, level: classifyDashboardStaleness(memo) };
98
98
  }
99
99
 
100
+ /** Is the CLI BINARY the dashboard shells out to as current as the checkout?
101
+ *
102
+ * The sibling question to `classifyDashboardStaleness`, and until 2026-08-21
103
+ * nobody was asking it. The operator host's checkout self-updates hourly while
104
+ * the `codeyam-editor` binary beside it never did, so the control plane drifted
105
+ * a little further behind every hour until a CLI-visible schema change broke it
106
+ * outright: an `editor-shipped` glossarySkip reason merged at 05:12 could not be
107
+ * parsed by a binary built 2026-07-19, and every CLI invocation the dashboard
108
+ * made died at config load.
109
+ *
110
+ * Why this needs its own signal rather than riding on `behind`: NOTHING that
111
+ * normally means "down" was down. `dashboard-bes` stayed HEALTHY, `/healthz`
112
+ * answered in 0.8 ms, all 14 VMs read reachable, and the checkout itself was at
113
+ * tip — so a commits-behind chip reading 0 was correct AND useless. Two
114
+ * artifacts deploy on this host and only one of them was ever measured.
115
+ *
116
+ * Compares git SHAs, not versions: the published artifact stamps the source
117
+ * commit it was built from (`org.opencontainers.image.revision`), and a semver
118
+ * moves far more slowly than the schema does — `0.1.7` was both the working and
119
+ * the broken binary.
120
+ *
121
+ * Levels, deliberately the same four words `classifyDashboardStaleness` uses so
122
+ * the two chips read as one family:
123
+ * 'stale' — both SHAs known and different. The CLI predates the checkout;
124
+ * a schema change away from empty panels.
125
+ * 'current' — both known and equal.
126
+ * 'unknown' — cannot compare AND something failed. Includes the incident's
127
+ * own worst case: a binary too stale to answer what it is fails
128
+ * the very read that would have proven it stale, so "I could not
129
+ * tell" must never round down to 'current'.
130
+ * 'unchecked' — cannot compare and nothing failed (the boot window before the
131
+ * first slow-cycle read). Renders nothing.
132
+ *
133
+ * Pure; no I/O. */
134
+ function classifyBinaryStaleness({ installed, tip, error } = {}) {
135
+ const sha = (v) => ((typeof v === 'string' && v.trim()) ? v.trim() : null);
136
+ const a = sha(installed);
137
+ const b = sha(tip);
138
+ if (a === null || b === null) return error ? 'unknown' : 'unchecked';
139
+ return a === b ? 'current' : 'stale';
140
+ }
141
+
142
+ /** The `/data` view of the binary memo, with the verdict resolved server-side.
143
+ *
144
+ * Same split, for the same reason, as `projectDashboardRevision`: `server.js`
145
+ * has top-level side effects on require, so a projection defined there can
146
+ * never be executed by a test and ships its bugs green.
147
+ *
148
+ * Pure; no I/O. */
149
+ function projectBinaryRevision(memo = {}) {
150
+ return { ...memo, level: classifyBinaryStaleness(memo) };
151
+ }
152
+
153
+ /** Why did the CLI shell-out fail — and is it the nameable one?
154
+ *
155
+ * The 2026-08-21 outage surfaced ONLY as a truncated `queueFreshness.error`
156
+ * reading `" Fix the file and re-run."` — two lines of tail from a multi-line
157
+ * diagnostic, with the sentence naming the cause among the lines that got cut.
158
+ * An operator looking at empty Queue and Plans panels had to infer that the CLI
159
+ * was broken at all, and nothing on the page said so.
160
+ *
161
+ * So classify the FULL stderr before it is tailed. Two phrasings both mean
162
+ * "this binary cannot read this checkout's config", and BOTH must match:
163
+ * the current wording, and the pre-2026-08-19 `Fix the file and re-run` — which
164
+ * is not legacy trivia but the single most likely text to see here, because the
165
+ * binary emitting it is by definition an old one.
166
+ *
167
+ * Levels:
168
+ * 'ok' — no error.
169
+ * 'config' — the binary cannot read `.codeyam/editor.json`. Almost always a
170
+ * CLI older than the checkout; the file is usually correct and
171
+ * editing it is the wrong move, which is why the recovery text
172
+ * says so out loud.
173
+ * 'failing' — the CLI failed for some other reason (git credentials, a
174
+ * timeout). Still worth showing, still not this condition.
175
+ *
176
+ * Pure; no I/O. */
177
+ function classifyCliFailure(text) {
178
+ const s = (typeof text === 'string') ? text : '';
179
+ if (!s.trim()) return 'ok';
180
+ if (/cannot read [^\n]*editor\.json/i.test(s) || /Fix the file and re-run/i.test(s)) return 'config';
181
+ return 'failing';
182
+ }
183
+
184
+ /** The `/data` view of CLI health, with the verdict and its recovery resolved
185
+ * server-side — so the panel renders a named condition instead of leaving the
186
+ * operator to infer one from a blank list.
187
+ *
188
+ * `recovery` is only populated for 'config', where the right action is
189
+ * genuinely non-obvious and the intuitive one (edit the file) is wrong.
190
+ *
191
+ * Pure; no I/O. */
192
+ function projectCliHealth({ error } = {}) {
193
+ const level = classifyCliFailure(error);
194
+ return {
195
+ level,
196
+ broken: level !== 'ok',
197
+ error: (typeof error === 'string' && error.trim()) ? error.trim() : null,
198
+ recovery: level === 'config'
199
+ ? 'The codeyam-editor binary is older than the checkout it is reading — do NOT edit .codeyam/editor.json. Refresh the binary: scripts/operator/refresh-operator-repo.sh'
200
+ : null,
201
+ };
202
+ }
203
+
100
204
  module.exports = {
101
205
  mergeDashboardRevision,
102
206
  classifyDashboardStaleness,
103
207
  projectDashboardRevision,
208
+ classifyBinaryStaleness,
209
+ projectBinaryRevision,
210
+ classifyCliFailure,
211
+ projectCliHealth,
104
212
  };
@@ -0,0 +1,66 @@
1
+ 'use strict';
2
+ // Pure builder for the fleet dashboard's in-container `npm run setup` snippet
3
+ // (scripts/operator/fleet-dashboard/server.js requires this). String
4
+ // construction only — no I/O, no child processes — so the snippet's CONTENT is
5
+ // unit-testable in isolation (npm/fleet-run-setup.test.js), exactly like
6
+ // fleet-env.js. server.js itself has no unit-test harness, which is why this
7
+ // lives here rather than inline in actionRunSetup.
8
+ //
9
+ // Named for the run-setup ACTION, not "setup" generally: npm/fleet-setup-steps.js
10
+ // already exists and is a different concern (it is bound as `fsetup` in
11
+ // server.js, so a `fleet-setup.js` here collided on both the filename and the
12
+ // import identifier).
13
+
14
+ // Seconds before a prompting or hanging setup is killed. Matches the
15
+ // bootstrap's SETUP_TIMEOUT default so both paths time-box identically; the
16
+ // dashboard job budget (11 min) is deliberately larger, to cover this plus ssh.
17
+ const SETUP_TIMEOUT_SECS = 600;
18
+
19
+ // How many trailing lines of setup's own output ride on the CY_DEGRADED line.
20
+ // The marker is parsed line-by-line by the job-output reader, so these are
21
+ // flattened onto one line — keep it small enough to stay readable on a card.
22
+ const FAILURE_TAIL_LINES = 3;
23
+
24
+ /**
25
+ * Build the bash snippet that runs the project's `npm run setup` inside VM-n's
26
+ * container and reports what actually broke.
27
+ *
28
+ * The failure line carries the setup command's OWN trailing output. It used to
29
+ * hardcode "set env (e.g. DATABASE_URL) via Env, then retry Run setup" — a
30
+ * guess, and on VM-11 (2026-08-26) a wrong one: DATABASE_URL was present in
31
+ * /workspace/.env the whole time and the real failure was that Postgres was not
32
+ * installed. The failing script had already printed a precise, actionable
33
+ * message and the wrapper talked over it, sending the operator to configure env
34
+ * that was already correct. Report what broke; do not theorize about it.
35
+ *
36
+ * Exits 0 even on failure, emitting a `CY_DEGRADED` marker line instead — so
37
+ * the cause persists on the VM card rather than vanishing as a transient error.
38
+ *
39
+ * @param {number|string} n VM number, used only to name the VM in the message.
40
+ * @returns {string} bash snippet suitable for `inContainer(n, …)`.
41
+ */
42
+ function buildRunSetupScript(n) {
43
+ return [
44
+ 'cd /workspace || { echo "no /workspace in container"; exit 1; }',
45
+ `setup="$(node -p "(require('./package.json').scripts||{}).setup||''" 2>/dev/null || true)"`,
46
+ 'if [ -z "$setup" ]; then echo "no \'setup\' script in package.json — nothing to run"; exit 0; fi',
47
+ `echo "running 'npm run setup' (script: $setup; non-interactive, ${SETUP_TIMEOUT_SECS}s timeout)"`,
48
+ // tee so the full output still streams into the job log, while the tail is
49
+ // retained for the CY_DEGRADED line. ${PIPESTATUS[0]} is setup's exit code,
50
+ // not tee's — without it a failing setup would report success.
51
+ 'log="$(mktemp)"',
52
+ `timeout ${SETUP_TIMEOUT_SECS} npm run setup </dev/null 2>&1 | tee "$log"; rc=\${PIPESTATUS[0]}`,
53
+ 'if [ "$rc" -ne 0 ]; then',
54
+ ` why="$(grep -v '^[[:space:]]*$' "$log" | tail -n ${FAILURE_TAIL_LINES} | tr '\\n' '|' | sed 's/|$//; s/|/ | /g')"`,
55
+ ` [ -n "$why" ] || why="(no output — likely killed by the ${SETUP_TIMEOUT_SECS}s timeout)"`,
56
+ ` echo "CY_DEGRADED 'npm run setup' failed (exit $rc) on VM-${n}, in the container as root: $why"`,
57
+ 'fi',
58
+ 'rm -f "$log"',
59
+ ].join('\n');
60
+ }
61
+
62
+ module.exports = {
63
+ buildRunSetupScript,
64
+ SETUP_TIMEOUT_SECS,
65
+ FAILURE_TAIL_LINES,
66
+ };
@@ -0,0 +1,162 @@
1
+ 'use strict';
2
+ // Pure session-activity classification and stickiness for the Fleet Dashboard
3
+ // (scripts/operator/fleet-dashboard/server.js requires this). Given the raw
4
+ // per-poll `hasSession` / `sessionIdleSeconds` the editor's /api/session-info
5
+ // reports, decide what the card renders and what the maintenance gate reads. No
6
+ // I/O — server.js does the polling and threads the state.
7
+ //
8
+ // The problem this fixes (observed live 2026-08-30, VMs 1 and 9): `hasSession`
9
+ // is a bare boolean with no notion of activity age, so a session wedged two days
10
+ // ago is indistinguishable from one someone is actively driving. Both hold a
11
+ // live agent process, so both report `true`, and every refresh was refused with
12
+ // the same `BLOCKED: VM-N has a live session`. The only way through was
13
+ // `--force` — the same flag used to step over genuinely live work — which is how
14
+ // `--force` stops meaning anything. `hasSession` also flapped `false` → `true`
15
+ // between polls minutes apart, so even an operator watching the card could not
16
+ // tell the two apart by eye.
17
+ //
18
+ // Two independent fixes live here, deliberately separate:
19
+ //
20
+ // debounceHasSession — stops the flap. A value that alternates between
21
+ // adjacent polls is not a usable gate input at any
22
+ // age, so this is fixed first and on its own.
23
+ // classifySessionActivity — adds the age dimension the boolean lacks.
24
+ //
25
+ // Asymmetric by design, mirroring npm/fleet-broker-debounce.js: only the flip to
26
+ // `false` (no session) is debounced. `true` takes effect immediately, because
27
+ // the dangerous error is proceeding over live work — we never delay RAISING the
28
+ // block, only delay clearing it. An unreachable editor (`hasSession` neither
29
+ // true nor false) proves nothing and breaks a pending clear-streak rather than
30
+ // advancing it, the same "absence of evidence is never down" rule
31
+ // fleet-broker-status.js follows.
32
+ //
33
+ // State shape carried across polls: { hasSession, clearStreak, lastConfirmed }.
34
+ // hasSession — the debounced value the card and the gate read this poll.
35
+ // clearStreak — how many consecutive raw `false` polls we have seen.
36
+ // lastConfirmed — the last raw boolean we actually proved; held during a
37
+ // pending (sub-threshold) clear-streak so the gate keeps
38
+ // blocking through a blip.
39
+
40
+ // ~9s at the dashboard's 3s poll cadence, matching DOWN_DEBOUNCE_THRESHOLD in
41
+ // npm/fleet-broker-debounce.js. Named so retuning is a one-line edit.
42
+ const CLEAR_DEBOUNCE_THRESHOLD = 3;
43
+
44
+ // A full day of zero on-disk activity. Chosen to sit well clear of the longest
45
+ // legitimate idle a live session has: an agent parked at a confirmation gate
46
+ // overnight is waiting for its operator, not wedged, and forcing past it costs
47
+ // them their place. The observed wedge was two days cold, so this catches it
48
+ // with a wide margin rather than racing it.
49
+ const STALE_SESSION_IDLE_SECONDS = 24 * 60 * 60;
50
+
51
+ function debounceHasSession(prev, rawHasSession, opts) {
52
+ const threshold = (opts && opts.threshold) || CLEAR_DEBOUNCE_THRESHOLD;
53
+ const lastConfirmed = prev ? prev.lastConfirmed : undefined;
54
+
55
+ // Confirmed present: a session is live right now. Render immediately, reset
56
+ // the streak, and remember it as the last confirmed value.
57
+ if (rawHasSession === true) {
58
+ return { hasSession: true, clearStreak: 0, lastConfirmed: true };
59
+ }
60
+
61
+ // Neither true nor false — the editor was unreachable or answered no
62
+ // session-info at all, so we proved nothing. Never let that advance the
63
+ // clear-streak toward "no session"; hold what we last proved.
64
+ if (rawHasSession !== false) {
65
+ return {
66
+ hasSession: lastConfirmed === true,
67
+ clearStreak: 0,
68
+ lastConfirmed,
69
+ };
70
+ }
71
+
72
+ // Raw `false`: only believed once it persists. Count the streak.
73
+ const clearStreak = (prev ? prev.clearStreak : 0) + 1;
74
+ if (clearStreak >= threshold) {
75
+ return { hasSession: false, clearStreak, lastConfirmed: false };
76
+ }
77
+ // Pending (sub-threshold) clear: hold the last confirmed value, so a single
78
+ // dropped poll cannot open a window for maintenance to step over live work.
79
+ return {
80
+ hasSession: lastConfirmed === true,
81
+ clearStreak,
82
+ lastConfirmed,
83
+ };
84
+ }
85
+
86
+ // Classify a VM's session into the verdict the card renders and the maintenance
87
+ // gate branches on:
88
+ //
89
+ // 'none' — no session at all. Maintenance proceeds; nothing to step over.
90
+ // 'active' — a session with recent activity. Blocks exactly as it does
91
+ // today; only `--force` steps over it.
92
+ // 'stale' — a session idle beyond the threshold. Still blocks, but is
93
+ // eligible for the narrower stale-session override.
94
+ // 'unknown' — a session whose age could not be determined, because the editor
95
+ // is too old to report `sessionIdleSeconds` or could read no
96
+ // evidence. Treated as POSSIBLY LIVE and blocks like 'active'.
97
+ //
98
+ // That last mapping is the load-bearing one. An unknown age must never resolve
99
+ // toward 'stale': the override exists to skip work nobody is doing, and handing
100
+ // it out on absent evidence would step over live sessions on exactly the VMs
101
+ // whose reporting is broken. `classify_vm`'s `reachable` tri-state in
102
+ // scripts/operator/refresh-client-vm-editor.sh is the cautionary precedent —
103
+ // collapsing its unknown to a definite verdict once refused all 13 VMs at once
104
+ // on evidence that had never been gathered.
105
+ function classifySessionActivity(input) {
106
+ const hasSession = !!(input && input.hasSession);
107
+ if (!hasSession) return 'none';
108
+
109
+ const idleSeconds = input.idleSeconds;
110
+ if (typeof idleSeconds !== 'number' || !Number.isFinite(idleSeconds)) {
111
+ return 'unknown';
112
+ }
113
+
114
+ const threshold =
115
+ typeof input.thresholdSeconds === 'number' && Number.isFinite(input.thresholdSeconds)
116
+ ? input.thresholdSeconds
117
+ : STALE_SESSION_IDLE_SECONDS;
118
+
119
+ return idleSeconds >= threshold ? 'stale' : 'active';
120
+ }
121
+
122
+ // Render an idle age as the short human string the card and the refusal message
123
+ // both show ("2d", "5h", "3m", "12s"). Returns 'unknown' when there is no age,
124
+ // so a caller never has to decide how to word an absent value — and so the
125
+ // operator sees the word rather than a blank that reads as zero.
126
+ function formatIdleAge(idleSeconds) {
127
+ if (typeof idleSeconds !== 'number' || !Number.isFinite(idleSeconds) || idleSeconds < 0) {
128
+ return 'unknown';
129
+ }
130
+ const s = Math.floor(idleSeconds);
131
+ if (s < 60) return `${s}s`;
132
+ if (s < 3600) return `${Math.floor(s / 60)}m`;
133
+ if (s < 86400) return `${Math.floor(s / 3600)}h`;
134
+ return `${Math.floor(s / 86400)}d`;
135
+ }
136
+
137
+ // Does this VM read as WEDGED rather than merely busy? A card showing
138
+ // `status: building` with no feature named and the workflow still at step 1 is
139
+ // describing a session that never got started, and when its last on-disk
140
+ // activity is also days old there is nothing left that "busy" explains. This is
141
+ // the combination VMs 1 and 9 presented, and the card rendered it as work in
142
+ // progress.
143
+ //
144
+ // Deliberately narrower than `classifySessionActivity(...) === 'stale'`: a
145
+ // long-running step that is genuinely mid-feature is stale-but-working, and
146
+ // labelling it wedged would train operators to ignore the label.
147
+ function isWedgedShape(input) {
148
+ if (!input) return false;
149
+ if (classifySessionActivity(input) !== 'stale') return false;
150
+ if (input.status !== 'building') return false;
151
+ if (input.feature) return false;
152
+ return input.step === 1 || input.step === undefined || input.step === null;
153
+ }
154
+
155
+ module.exports = {
156
+ debounceHasSession,
157
+ classifySessionActivity,
158
+ formatIdleAge,
159
+ isWedgedShape,
160
+ CLEAR_DEBOUNCE_THRESHOLD,
161
+ STALE_SESSION_IDLE_SECONDS,
162
+ };