@codeyam-editor/codeyam-editor 0.1.5 → 0.1.7

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 (80) hide show
  1. package/npm/cli.js +5 -1
  2. package/npm/cloud.js +2079 -242
  3. package/npm/codesign-plan.js +68 -0
  4. package/npm/compose-conflict.js +59 -0
  5. package/npm/empty-project-dir.js +92 -7
  6. package/npm/fleet-add-failure.js +361 -0
  7. package/npm/fleet-asset-version.js +37 -0
  8. package/npm/fleet-auth-health.js +143 -5
  9. package/npm/fleet-build-data.js +60 -5
  10. package/npm/fleet-cleanup.js +243 -9
  11. package/npm/fleet-destroy-safety.js +230 -13
  12. package/npm/fleet-early-roster.js +87 -0
  13. package/npm/fleet-effect-poll.js +110 -0
  14. package/npm/fleet-env.js +86 -0
  15. package/npm/fleet-ghcr-secret.js +129 -0
  16. package/npm/fleet-git-maintenance.js +142 -0
  17. package/npm/fleet-host-op-limiter.js +68 -0
  18. package/npm/fleet-iap-token.js +165 -0
  19. package/npm/fleet-job-badge.js +28 -12
  20. package/npm/fleet-live-status.js +40 -8
  21. package/npm/fleet-memory.js +289 -9
  22. package/npm/fleet-plans.js +98 -4
  23. package/npm/fleet-pool.js +75 -16
  24. package/npm/fleet-prewarm.js +87 -11
  25. package/npm/fleet-probe-transport.js +155 -0
  26. package/npm/fleet-project-drift.js +58 -0
  27. package/npm/fleet-public-url.js +234 -0
  28. package/npm/fleet-queue.js +58 -1
  29. package/npm/fleet-reachability-debounce.js +69 -0
  30. package/npm/fleet-reachability.js +80 -0
  31. package/npm/fleet-reset.js +32 -0
  32. package/npm/fleet-revision.js +104 -0
  33. package/npm/fleet-running-plan.js +115 -0
  34. package/npm/fleet-session-token-cache.js +65 -0
  35. package/npm/fleet-setup-steps.js +19 -12
  36. package/npm/fleet-status-refs.js +96 -0
  37. package/npm/fleet-strand.js +39 -8
  38. package/npm/fleet-stuck-status.js +13 -7
  39. package/npm/fleet-vm-access.js +170 -0
  40. package/npm/fleet-vm-probe-target.js +282 -0
  41. package/npm/fleet-vm-readiness.js +136 -3
  42. package/npm/google-ingress.js +15 -0
  43. package/npm/ingress-invoke.js +478 -0
  44. package/npm/launch-config.js +302 -10
  45. package/npm/scenario-check.js +318 -12
  46. package/npm/scenario-handlers.js +85 -27
  47. package/npm/scenario-interactivity.js +145 -6
  48. package/npm/scenario-issues.js +13 -1
  49. package/npm/scenario-mocks.js +129 -26
  50. package/npm/scenario-playwright.js +330 -22
  51. package/npm/script-drift.js +72 -1
  52. package/npm/utils.js +37 -3
  53. package/package.json +5 -5
  54. package/ui/dist/assets/{ContainerInspector-D7VZkkaj.js → ContainerInspector-Cu1tSN-o.js} +1 -1
  55. package/ui/dist/assets/EditorShell--V2ExYQ6.js +260 -0
  56. package/ui/dist/assets/EditorShell-DP5UY20G.css +1 -0
  57. package/ui/dist/assets/{Markdown-BoLHHFXH.js → Markdown-Chgdf3PW.js} +1 -1
  58. package/ui/dist/assets/ProjectLauncherScreen-Dmm6Z0mv.js +1 -0
  59. package/ui/dist/assets/{ScenarioDataPanel-DYP7pi_n.css → ScenarioDataPanel-C1LukYI6.css} +1 -1
  60. package/ui/dist/assets/ScenarioDataPanel-DjprZq2L.js +9 -0
  61. package/ui/dist/assets/{ScenarioDataPanelFullPage-CBLTGC4-.js → ScenarioDataPanelFullPage-BqMxWVKW.js} +1 -1
  62. package/ui/dist/assets/ScenarioRenderer-DqLVVMYS.js +21 -0
  63. package/ui/dist/assets/{StepsSimulator-BFldWyoX.js → StepsSimulator-CymGgfbu.js} +1 -1
  64. package/ui/dist/assets/index-CjxWhkSy.css +1 -0
  65. package/ui/dist/assets/{index-QsOiOG9x.js → index-VuyqBbGJ.js} +4 -4
  66. package/ui/dist/assets/{markdown-v0F0UEt3.js → markdown-C9tbsVHX.js} +1 -1
  67. package/ui/dist/assets/react-nrLBr15I.js +28 -0
  68. package/ui/dist/assets/{useEvents-DHkLJZR4.js → useEvents-S3Qk1Y_d.js} +1 -1
  69. package/ui/dist/assets/useServerIdentity-BdXy8TQv.js +1 -0
  70. package/ui/dist/index.html +4 -4
  71. package/ui/dist/open-source/codeyam-counter.png +0 -0
  72. package/ui/dist/open-source/el-carot.png +0 -0
  73. package/ui/dist/open-source/tabcommand.png +0 -0
  74. package/ui/dist/assets/EditorShell-DHhIaotd.css +0 -1
  75. package/ui/dist/assets/EditorShell-r-796N_W.js +0 -191
  76. package/ui/dist/assets/ProjectLauncherScreen-Ck-XWkQN.js +0 -1
  77. package/ui/dist/assets/ScenarioDataPanel-B9iDSUpA.js +0 -5
  78. package/ui/dist/assets/ScenarioRenderer-BhLfT-nX.js +0 -21
  79. package/ui/dist/assets/index-CIXZkwfB.css +0 -1
  80. package/ui/dist/assets/react-CSS0HapR.js +0 -28
@@ -74,4 +74,61 @@ function computeReorderTargets({ order, current }) {
74
74
  return { ok: true, targets };
75
75
  }
76
76
 
77
- module.exports = { decideAutoReleaseGoneHead, computeReorderTargets };
77
+ /** Move one uuid within a desired order array.
78
+ *
79
+ * The staged-draft panel shuffles a working uuid order before it is saved.
80
+ * `delta > 0` raises priority (moves the entry TOWARD the head / index 0);
81
+ * `delta < 0` lowers it (toward the tail). The target index is clamped to the
82
+ * array bounds, so a nudge past an end is a no-op and a full-span negative
83
+ * delta lands the entry at the very tail — which is exactly how Demote
84
+ * (head → back of the line) is expressed: `nudgeQueueOrder(order, head, -(N-1))`.
85
+ * Mirrors the editor UI's `useBranchQueueDraft.nudge` (arrayMove(from, from-delta)).
86
+ *
87
+ * `order` is a uuid array; a uuid not present is returned unchanged. Pure — the
88
+ * input array is never mutated.
89
+ */
90
+ function nudgeQueueOrder(order, uuid, delta) {
91
+ const arr = (order || []).slice();
92
+ const from = arr.findIndex((u) => u === uuid);
93
+ if (from < 0 || !delta) return arr;
94
+ let to = from - delta; // +delta → toward head; -delta → toward tail
95
+ if (to < 0) to = 0;
96
+ if (to > arr.length - 1) to = arr.length - 1;
97
+ if (to === from) return arr;
98
+ const [moved] = arr.splice(from, 1);
99
+ arr.splice(to, 0, moved);
100
+ return arr;
101
+ }
102
+
103
+ /** Merge a fresh poll's membership into a staged draft order without discarding
104
+ * the operator's in-progress reorder.
105
+ *
106
+ * - uuids still present live keep their DRAFT position (the working order wins);
107
+ * - draft uuids that vanished upstream are dropped;
108
+ * - live uuids not yet in the draft are appended at the tail so a sibling
109
+ * session's newly-enqueued entry doesn't silently disappear.
110
+ *
111
+ * Pure port of the editor UI's `reconcileDraft` order rule (uuid-only, no meta).
112
+ * Both arguments are uuid arrays; returns the merged uuid array. Never mutates
113
+ * its inputs.
114
+ */
115
+ function reconcileQueueDraft(draftOrder, liveOrder) {
116
+ const live = liveOrder || [];
117
+ const liveSet = new Set(live);
118
+ const seen = new Set();
119
+ const out = [];
120
+ for (const u of draftOrder || []) {
121
+ if (liveSet.has(u) && !seen.has(u)) { out.push(u); seen.add(u); }
122
+ }
123
+ for (const u of live) {
124
+ if (!seen.has(u)) { out.push(u); seen.add(u); }
125
+ }
126
+ return out;
127
+ }
128
+
129
+ module.exports = {
130
+ decideAutoReleaseGoneHead,
131
+ computeReorderTargets,
132
+ nudgeQueueOrder,
133
+ reconcileQueueDraft,
134
+ };
@@ -0,0 +1,69 @@
1
+ 'use strict';
2
+ // Pure consecutive-failure debounce for the Fleet Dashboard's per-VM top-level
3
+ // reachability. Mirrors npm/fleet-broker-debounce.js exactly: server.js does the
4
+ // polling and threads the state; this decides the reachability the card renders.
5
+ //
6
+ // The bug this fixes (observed live 2026-07-01, VM6): a healthy VM briefly
7
+ // flashed 'unreachable' and surfaced a reset-client-error even though it was up
8
+ // the whole time. The dashboard polls each VM's editor over a laptop-side
9
+ // SSH/cloudflared forward with NO fast respawner, so a single one-poll hiccup
10
+ // returned reachable:false and the card instantly read 'unreachable' — which
11
+ // then invited the destructive "Reset client" action on a perfectly healthy VM.
12
+ // The single probe is left as-is on the poller side; the right seam for
13
+ // absorbing a one-poll blip is here at the aggregation layer, just like broker.
14
+ //
15
+ // Asymmetric by design: ONLY the flip to 'unreachable' is debounced (it requires
16
+ // `threshold` consecutive failed polls). A reachable poll takes effect
17
+ // immediately — we never DELAY clearing a false alarm, only delay raising one.
18
+ //
19
+ // Three display states the card renders:
20
+ // 'reachable' — the editor answered this poll.
21
+ // 'reconnecting' — a sub-threshold failure streak (1..N-1), OR the GCP side
22
+ // confirms the editor answers over IAP while the local port
23
+ // is dark (a laptop tunnel fault, never a dead VM). Calm; the
24
+ // watchdog respawns the forward. A one-poll hiccup lives here.
25
+ // 'unreachable' — N consecutive failures AND no GCP-side confirmation. The
26
+ // honest sustained-outage state.
27
+ //
28
+ // State carried across polls: { display, failStreak, lastConfirmed }.
29
+ // display — the debounced value the card renders this poll.
30
+ // failStreak — how many consecutive raw failed polls we've seen.
31
+ // lastConfirmed — the last raw 'reachable' we proved (informational; the card
32
+ // holds 'reconnecting' during a pending sub-threshold streak).
33
+
34
+ // ~9s at the dashboard's 3s poll cadence, matching the broker debounce: enough
35
+ // to absorb a transient forward hiccup while still surfacing a real outage
36
+ // quickly. Named so retuning is a one-line edit.
37
+ const UNREACHABLE_DEBOUNCE_THRESHOLD = 3;
38
+
39
+ function debounceReachability(prev, rawReachable, opts) {
40
+ const threshold = (opts && opts.threshold) || UNREACHABLE_DEBOUNCE_THRESHOLD;
41
+ const gcpConfirmedReachable = !!(opts && opts.gcpConfirmedReachable);
42
+ const lastConfirmed = prev ? prev.lastConfirmed : undefined;
43
+
44
+ // Confirmed reachable: the editor answered now. Render immediately, reset the
45
+ // streak, remember this as the last-confirmed value.
46
+ if (rawReachable) {
47
+ return { display: 'reachable', failStreak: 0, lastConfirmed: 'reachable' };
48
+ }
49
+
50
+ // Raw failure: count the streak.
51
+ const failStreak = (prev ? prev.failStreak : 0) + 1;
52
+
53
+ // GCP-side confirmation: the editor answers over IAP while the local port is
54
+ // dark. That's a laptop tunnel fault, NOT a dead VM — never escalate to
55
+ // 'unreachable' no matter how long the local forward has been down; the
56
+ // watchdog respawns the forward. Hold the calm 'reconnecting' state.
57
+ if (gcpConfirmedReachable) {
58
+ return { display: 'reconnecting', failStreak, lastConfirmed };
59
+ }
60
+
61
+ // Sub-threshold failure: stay calm ('reconnecting'). Only a SUSTAINED failure
62
+ // past the threshold escalates to the honest 'unreachable'.
63
+ if (failStreak >= threshold) {
64
+ return { display: 'unreachable', failStreak, lastConfirmed };
65
+ }
66
+ return { display: 'reconnecting', failStreak, lastConfirmed };
67
+ }
68
+
69
+ module.exports = { debounceReachability, UNREACHABLE_DEBOUNCE_THRESHOLD };
@@ -0,0 +1,80 @@
1
+ 'use strict';
2
+
3
+ // The three-state reachability contract for the Fleet Dashboard.
4
+ //
5
+ // A VM's reachability has THREE states, not two:
6
+ // true — a probe reached the VM's editor
7
+ // false — a probe RAN and FAILED (a confirmed outage)
8
+ // null — no verdict at all (never polled, or held inconclusive)
9
+ //
10
+ // `npm/fleet-auth-health.js::classifyReachability` has always modelled this
11
+ // correctly, deliberately returning `{ reachable: null }` for the inconclusive
12
+ // case. Every consumer between it and the browser then coerced that care away
13
+ // with `!!v.reachable` and `{ n, reachable: false }` placeholders, so the null
14
+ // never survived to anyone who could act on it.
15
+ //
16
+ // On 2026-07-30 that collapse served all 13 rostered VMs as `reachable: false`
17
+ // with NO `reachableSource`, while each card's own `backendHealth` read
18
+ // `healthy` and operator auth read clean — a self-contradicting fleet-wide
19
+ // "outage" that had never been probed. It blocked both sanctioned refresh paths
20
+ // (`refresh-client-vm-editor.sh` exits 1, `fleet-advance-to.sh` classifies
21
+ // SKIP-unreachable) and, in a storm, is the signal that has driven reconcile
22
+ // into hard-rebooting healthy VMs.
23
+ //
24
+ // This module is the single home for that vocabulary so the distinction is
25
+ // stated once and grepped, rather than re-derived inline at each consumer. Pure:
26
+ // no I/O, no clock, no state.
27
+
28
+ // The `reachableSource` value for a record carrying no reachability verdict.
29
+ // `reachableSource` otherwise names the signal that DECIDED a verdict ('lb' |
30
+ // 'backend' | 'local' | 'iap', from classifyReachability), which is what makes a
31
+ // card self-diagnosing — a `reachable: false` with a missing source is exactly
32
+ // the fingerprint that identified the 2026-07-30 incident as manufactured by the
33
+ // serializer rather than reported by a probe. 'unpolled' extends that same
34
+ // self-diagnosis to the no-verdict case instead of leaving a silent gap.
35
+ const REACHABILITY_UNPOLLED = 'unpolled';
36
+
37
+ // Does this VM have a DEFINITE reachability verdict — did anyone actually ask?
38
+ // Only a real boolean counts; null/undefined (and any malformed value) read as
39
+ // "no verdict". Consumers use this to tell "never asked" from "asked and got an
40
+ // answer", which is the distinction the whole three-state contract exists for.
41
+ function hasReachabilityVerdict(reachable) {
42
+ return reachable === true || reachable === false;
43
+ }
44
+
45
+ // Is this VM CONFIRMED unreachable — probed, and the probe failed?
46
+ //
47
+ // Safety-critical. Reboot, reconcile, and destroy paths must gate on THIS, never
48
+ // on a falsy check: `!reachable` is also true for an unknown, so a `!reachable`
49
+ // guard lets a VM nobody has polled walk into a path that ends in a hard reset.
50
+ // An unknown is not evidence, and evidence is the bar for a destructive action.
51
+ function isConfirmedUnreachable(reachable) {
52
+ return reachable === false;
53
+ }
54
+
55
+ // Normalize a raw poll record's reachability into the pair the wire envelope
56
+ // carries: `{ reachable, reachableSource }`.
57
+ //
58
+ // This is the seam where the bug lived. `reachable` is preserved as a genuine
59
+ // tri-state — an ABSENT verdict stays absent rather than being coerced to a
60
+ // definite false — and `reachableSource` falls back to 'unpolled' so a card with
61
+ // no verdict says so out loud instead of looking like an unexplained outage.
62
+ //
63
+ // `v` is the merged poll state (`state.vms[n]`), or a placeholder standing in
64
+ // for a VM with no poll state at all.
65
+ function normalizeReachability(v) {
66
+ const raw = v ? v.reachable : null;
67
+ const reachable = hasReachabilityVerdict(raw) ? raw : null;
68
+ return {
69
+ reachable,
70
+ reachableSource: (v && v.reachableSource)
71
+ || (reachable === null ? REACHABILITY_UNPOLLED : null),
72
+ };
73
+ }
74
+
75
+ module.exports = {
76
+ REACHABILITY_UNPOLLED,
77
+ hasReachabilityVerdict,
78
+ isConfirmedUnreachable,
79
+ normalizeReachability,
80
+ };
@@ -494,6 +494,36 @@ function needsReconfigureFallback(log) {
494
494
  return typeof log === 'string' && log.includes('CY_NEEDS_RECONFIGURE');
495
495
  }
496
496
 
497
+ // The reset-family actions whose in-container recipe REBUILDS the codeyam-editor
498
+ // binary before restart when the target is a self-host VM. Both resetClientScript
499
+ // and resetEditorStateScript (default rebuildOnSelfHost=true) run selfHostRebuildStep,
500
+ // so on a self-host VM either one triggers a full cargo compile (~10-14 min). The
501
+ // composite reset-all budgets that rebuild separately; update-client (a non-destructive
502
+ // ff-merge with NO rebuild/restart) is deliberately absent.
503
+ const REBUILDS_ON_SELF_HOST = new Set(['reset-client', 'reset-editor']);
504
+
505
+ // Default per-action SHORT budgets (ms) — the historical non-self-host timeout for
506
+ // each reset action, kept so a genuine client-VM hang still surfaces promptly.
507
+ const RESET_SHORT_TIMEOUT_MS = { 'reset-client': 300000, 'reset-editor': 600000 };
508
+ // Default self-host REBUILD budget (ms): the same 27.5-min allowance reset-all
509
+ // already uses for a self-host binary rebuild. Overridable via the rebuildMs arg
510
+ // (server.js resolves it from RESET_REBUILD_TIMEOUT_MS).
511
+ const RESET_REBUILD_TIMEOUT_MS = 1650000;
512
+
513
+ // Select the startJob timeout (ms) for a reset-family action, accounting for the
514
+ // self-host rebuild. On a self-host VM a rebuild-bearing reset (see
515
+ // REBUILDS_ON_SELF_HOST) advances the editor's OWN source and MUST rebuild the
516
+ // binary before restart, a ~10-14 min cargo compile the old flat 5-min budget
517
+ // killed mid-compile — surfacing a false `error` badge on a reset that actually
518
+ // succeeded. A non-self-host client reset genuinely does not rebuild and keeps its
519
+ // short budget. Pure: budgets default to the constants above but are injectable for
520
+ // env-tunability + tests. Unknown/non-rebuild action or non-self-host → short budget.
521
+ function resetJobTimeoutMs({ action, selfHost, shortMs, rebuildMs } = {}) {
522
+ const short = Number.isFinite(shortMs) ? shortMs : RESET_SHORT_TIMEOUT_MS[action];
523
+ const rebuild = Number.isFinite(rebuildMs) ? rebuildMs : RESET_REBUILD_TIMEOUT_MS;
524
+ return selfHost && REBUILDS_ON_SELF_HOST.has(action) ? rebuild : short;
525
+ }
526
+
497
527
  module.exports = {
498
528
  codeyamDeepCleanSteps,
499
529
  codeyamBrokerEnsureStep,
@@ -504,4 +534,6 @@ module.exports = {
504
534
  resetEditorStateScript,
505
535
  endSessionScript,
506
536
  verifyIdleOrEndSessionStep,
537
+ resetJobTimeoutMs,
538
+ REBUILDS_ON_SELF_HOST,
507
539
  };
@@ -0,0 +1,104 @@
1
+ 'use strict';
2
+ // Pure helpers for the fleet dashboard's DEPLOYED-REVISION staleness signal
3
+ // (scripts/operator/fleet-dashboard/server.js requires this; unit tests in
4
+ // npm/fleet-revision.test.js).
5
+ //
6
+ // The dashboard header has always shown the branch name, which says nothing
7
+ // about WHICH COMMIT of that branch is serving. On 2026-07-31 the operator
8
+ // host's /opt/codeyam-editor sat 104 commits behind origin/editor-improvements-66,
9
+ // so the deployed dashboard predated its own memory fix and was
10
+ // indistinguishable from one at tip — the third recurrence of a landed fix
11
+ // surviving because the served checkout was behind (the June VM-5 slug bug was
12
+ // the same shape).
13
+ //
14
+ // server.js owns the I/O — the bounded `git fetch` / `rev-parse` / `rev-list`
15
+ // children and the slow-cycle memo. The two rules that actually matter are here
16
+ // and are pure: how a failed check merges over the last good one, and what
17
+ // verdict a given count means. Both were previously inline in an async IIFE
18
+ // that no test could reach.
19
+
20
+ /** Merge one revision-check result over the previous memo.
21
+ *
22
+ * THE RULE: a failed check RETAINS the previous `behind` and records `error` —
23
+ * it never reports zero. This is what makes the staleness chip sticky. A
24
+ * broken `git fetch` that reset the count to 0 would present a stale dashboard
25
+ * as current, which is precisely the failure this whole signal exists to catch;
26
+ * degrading to "last known count, plus a note that the check is failing" is
27
+ * the honest answer.
28
+ *
29
+ * A `behind` that is absent, negative, or unparseable counts as no reading and
30
+ * falls back to the previous value the same way. `sha` and `branch` likewise
31
+ * retain their last known values rather than blanking the header on a hiccup.
32
+ *
33
+ * A previous `behind` of null (never successfully counted) stays null — it is
34
+ * deliberately NOT coerced through Number(), because `Number(null) === 0` is
35
+ * the exact coercion that made "no reading" and "zero" indistinguishable in
36
+ * readCgroupMemory.
37
+ *
38
+ * Pure; no I/O. */
39
+ function mergeDashboardRevision({ prev, sha, branch, behind, checkedAt, error } = {}) {
40
+ const base = (prev && typeof prev === 'object') ? prev : {};
41
+ const str = (next, fallback) => ((typeof next === 'string' && next) ? next : (typeof fallback === 'string' && fallback ? fallback : null));
42
+ // Strictly a number: a string or null is NOT coerced, so a caller cannot
43
+ // accidentally turn "no reading" into 0 the way Number(null) would.
44
+ const count = (v) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : null);
45
+ const counted = count(behind);
46
+ return {
47
+ sha: str(sha, base.sha),
48
+ branch: str(branch, base.branch),
49
+ behind: counted === null ? count(base.behind) : counted,
50
+ checkedAt: str(checkedAt, base.checkedAt),
51
+ error: str(error, null),
52
+ };
53
+ }
54
+
55
+ /** What does the current revision memo mean for the operator?
56
+ *
57
+ * Split out from the header chip so the verdict is a readable table rather
58
+ * than branches buried in string concatenation — the same reason
59
+ * operatorAuthRecovery was split out of operatorAuthBannerHtml, and the same
60
+ * reason resolveCgroupMemoryDir was split out of readCgroupMemory: logic that
61
+ * can only be reached through a browser inline script or a real cgroup
62
+ * filesystem is logic that ships its bugs green.
63
+ *
64
+ * Levels:
65
+ * 'stale' — behind > 0. Landed fixes are not live; the chip warns.
66
+ * 'current' — behind === 0. Clears the chip, and clears it even when the
67
+ * latest check errored: the count reaching zero is the ONLY
68
+ * thing that clears the warning, and once it has, a later
69
+ * failed fetch has no stale count to keep showing.
70
+ * 'unknown' — no count has ever succeeded AND the check is failing. Worth
71
+ * saying out loud rather than rendering the reassuring nothing
72
+ * that an at-tip dashboard renders.
73
+ * 'unchecked' — no count yet and nothing has failed (the boot window before
74
+ * the first slow-cycle refresh). Renders nothing.
75
+ *
76
+ * Pure; no I/O. */
77
+ function classifyDashboardStaleness({ behind, error } = {}) {
78
+ const counted = (typeof behind === 'number' && Number.isFinite(behind) && behind >= 0) ? behind : null;
79
+ if (counted === null) return error ? 'unknown' : 'unchecked';
80
+ return counted > 0 ? 'stale' : 'current';
81
+ }
82
+
83
+ /** The `/data` view of the revision memo, with the staleness VERDICT resolved
84
+ * server-side.
85
+ *
86
+ * The chip is then a pure renderer: `public/index.html` is an inline browser
87
+ * script with no module boundary, so any decision left in it could only ever
88
+ * be source-scan tested — assertions on text rather than on behavior.
89
+ *
90
+ * Lives here rather than in `server.js` for the same reason
91
+ * `classifyDashboardStaleness` does: `server.js` has top-level side effects on
92
+ * require (createServer/listen/setInterval), so nothing there can be executed
93
+ * by a test and a projection defined inside it ships its bugs green.
94
+ *
95
+ * Pure; no I/O. */
96
+ function projectDashboardRevision(memo = {}) {
97
+ return { ...memo, level: classifyDashboardStaleness(memo) };
98
+ }
99
+
100
+ module.exports = {
101
+ mergeDashboardRevision,
102
+ classifyDashboardStaleness,
103
+ projectDashboardRevision,
104
+ };
@@ -0,0 +1,115 @@
1
+ 'use strict';
2
+
3
+ // Resolve the running-plan fields a fleet card shows — feature, step, mode,
4
+ // selectedPlan, holderSessionId, startedAt, featureStartedAt — from the two
5
+ // independent per-VM probes plus the last-known-good state.
6
+ //
7
+ // The two timestamps ride here because they share the restart problem below and
8
+ // nothing else resolves them: `startedAt` / `featureStartedAt` drive the card's
9
+ // "on step" / "on feature" tenure badges AND the server-side slow/stuck/needs-you
10
+ // verdict (fleet-stuck-status.js deriveAttention keys on `startedAt`). A null
11
+ // degrades that verdict to level:'ok', needsYou:false — so a VM whose editor
12
+ // restarted was silently exempt from the very detection it most needed.
13
+ //
14
+ // Why this exists: the card used to hang these fields off `/api/session-info`
15
+ // alone (spread as `...info` in server.js). But a VM whose editor server
16
+ // restarted while the PTY broker kept the agent alive reports
17
+ // `hasSession:false` / `sessionId:null` and session-info then OMITS
18
+ // feature/step/mode/selectedPlan — so an actively-building VM showed a BLANK
19
+ // card. `GET /api/step` reads `editor-step.json` directly, independent of
20
+ // session registration, so it stays populated across that restart. This helper
21
+ // makes `/api/step` the preferred source and falls back to session-info.
22
+ //
23
+ // Stickiness — the subtle part. The last-known `prev` is carried ONLY to bridge
24
+ // a genuine probe failure (the editor answered NOTHING this poll), so a single
25
+ // timed-out cycle can't blank a plan the card is already showing. It is NOT a
26
+ // blanket fallback: when the editor DID answer, the fresh read is authoritative
27
+ // for the plan the VM is executing NOW. Carrying prev unconditionally pinned a
28
+ // COMPLETED plan's feature onto the next one — feature-complete deletes
29
+ // editor-step.json (reset_step_state), and a plan's `feature` is null until
30
+ // step 4, so a stale prev would win over the correct null and the card kept
31
+ // showing plan A while the VM built plan B. So prev is carried only within the
32
+ // SAME plan (matching selectedPlan); a plan change (or going idle) drops it.
33
+ //
34
+ // Precedence per field, when the editor answered: `/api/step` (session-
35
+ // independent) > `/api/session-info` > same-plan `prev`. On a full probe
36
+ // failure: `prev` verbatim. Pure; no I/O — unit-tested in
37
+ // fleet-running-plan.test.js.
38
+ //
39
+ // @param {Object} params
40
+ // @param {?Object} params.step Parsed `/api/step` body (EditorStepState) or
41
+ // null when the VM has no `editor-step.json` (idle) / the probe failed.
42
+ // @param {?Object} params.info Parsed `/api/session-info` body (carries
43
+ // `reachable`), or null on a failed/timed-out probe.
44
+ // @param {?Object} params.prev The previous `state.vms[n]` (carries the
45
+ // last-known feature/step/mode/selectedPlan/holderSessionId/startedAt/
46
+ // featureStartedAt). null/undefined on the first poll.
47
+ // @returns {{feature: ?string, step: ?number, mode: ?string,
48
+ // selectedPlan: ?string, holderSessionId: ?string, startedAt: ?string,
49
+ // featureStartedAt: ?string}}
50
+ function resolveRunningPlan({ step, info, prev } = {}) {
51
+ const s = step || {};
52
+ const i = info || {};
53
+ const p = prev || {};
54
+
55
+ // Did the editor answer at all this poll? `/api/step` returning a body proves
56
+ // it; so does a reachable `/api/session-info`. If NEITHER answered, this is a
57
+ // transient outage — hold the last-known-good plan verbatim rather than blank
58
+ // the card for one cycle.
59
+ const editorAnswered = step != null || !!i.reachable;
60
+ if (!editorAnswered) {
61
+ return {
62
+ feature: p.feature || null,
63
+ step: p.step != null ? p.step : null,
64
+ mode: p.mode || null,
65
+ selectedPlan: p.selectedPlan || null,
66
+ holderSessionId: p.holderSessionId || null,
67
+ // Carried verbatim like the rest: a single timed-out cycle must not blink
68
+ // the tenure badges off and reset the stuck timer back to zero.
69
+ startedAt: p.startedAt || null,
70
+ featureStartedAt: p.featureStartedAt || null,
71
+ };
72
+ }
73
+
74
+ // Editor answered: the fresh read is authoritative for the current plan.
75
+ const freshSelectedPlan = s.selectedPlan || i.selectedPlan || null;
76
+ // Carry prev ONLY within the same plan — a blip-fallback for a momentarily
77
+ // missing field (e.g. /api/step timed out but session-info answered). Across
78
+ // a plan change or an idle poll, prev is stale and MUST be dropped.
79
+ const samePlan = freshSelectedPlan != null && freshSelectedPlan === (p.selectedPlan || null);
80
+ const carry = samePlan ? p : {};
81
+
82
+ // The module's precedence rule, in ONE place: /api/step (session-independent)
83
+ // > /api/session-info > same-plan carry. Four fields resolve exactly this way,
84
+ // so hoisting it keeps a future precedence change from being applied to some
85
+ // of them and not others. The two fields that deviate stay longhand below, and
86
+ // their being longhand is the signal that they deviate: `step` needs an
87
+ // explicit null check (0 is a real step number, and `||` would swallow it),
88
+ // and holderSessionId deliberately skips session-info, which never carries it.
89
+ const pick = (key) => s[key] || i[key] || carry[key] || null;
90
+
91
+ const stepNum =
92
+ s.step != null ? s.step : i.step != null ? i.step : carry.step != null ? carry.step : null;
93
+
94
+ return {
95
+ feature: pick("feature"),
96
+ step: stepNum,
97
+ mode: pick("mode"),
98
+ selectedPlan: freshSelectedPlan || carry.selectedPlan || null,
99
+ // session-info never carries holderSessionId — it comes from /api/step
100
+ // (editor-step.json) or the same-plan carry. This is what lets a claim map
101
+ // to the executing VM after an editor restart drops the live sessionId.
102
+ holderSessionId: s.holderSessionId || carry.holderSessionId || null,
103
+ // Workflow tenure timers. /api/step wins because EditorStepState serializes
104
+ // `started_at` (non-optional, camelCase) straight out of editor-step.json —
105
+ // session-independent, so it survives the editor restart that makes
106
+ // session-info report hasSession:false and omit them entirely. The same-plan
107
+ // `carry` applies unchanged: a completed plan's startedAt must never be
108
+ // pinned onto the next plan, or the badges would show a tenure the new plan
109
+ // has not accrued.
110
+ startedAt: pick("startedAt"),
111
+ featureStartedAt: pick("featureStartedAt"),
112
+ };
113
+ }
114
+
115
+ module.exports = { resolveRunningPlan };
@@ -0,0 +1,65 @@
1
+ 'use strict';
2
+ // Per-VM editor session-token cache for the fleet dashboard's gated probes.
3
+ //
4
+ // The post-501766261 editor runs FAIL-CLOSED: every control-API request must
5
+ // carry that VM's own editor session token in `Authorization`, or the editor
6
+ // answers 401. The dashboard reads each token once over the existing SSH/
7
+ // docker-exec path (server.js supplies the impure `fetchToken`) and caches it
8
+ // per VM — the token is stable for the container's lifetime, so re-reading it
9
+ // on every poll would be a wasted gcloud-ssh fork per VM per cycle.
10
+ //
11
+ // Pure logic only — no I/O, no env reads, no clock — so it stays unit-testable
12
+ // in isolation (npm/fleet-session-token-cache.test.js), exactly like the
13
+ // createIapTokenProvider it mirrors. The caller injects `fetchToken(n)`.
14
+
15
+ /** Build a per-VM session-token cache.
16
+ *
17
+ * `fetchToken(n)` resolves to the VM's session token string (or null/'' when
18
+ * it cannot be read). Returns `{ getToken, invalidate }`:
19
+ *
20
+ * getToken(n) — resolves the cached token, fetching once on a cold entry.
21
+ * Concurrent cold calls for the same VM share ONE in-flight
22
+ * fetch (the poll cycle probes several endpoints per VM at
23
+ * once). A null/empty/failed fetch resolves to null and is
24
+ * NOT cached, so the next poll retries rather than sticking a
25
+ * VM on a missing token.
26
+ * invalidate(n) — drop a VM's cached token so the next getToken re-fetches.
27
+ * Called on a fail-closed 401, which is how a container
28
+ * recreate / token rotation self-heals.
29
+ */
30
+ function createSessionTokenCache({ fetchToken } = {}) {
31
+ if (typeof fetchToken !== 'function') {
32
+ throw new Error('createSessionTokenCache requires a fetchToken(n) function');
33
+ }
34
+
35
+ const cache = new Map(); // n -> non-empty token string
36
+ const inFlight = new Map(); // n -> Promise<string|null>
37
+
38
+ async function getToken(n) {
39
+ if (cache.has(n)) return cache.get(n);
40
+ if (inFlight.has(n)) return inFlight.get(n);
41
+
42
+ const p = Promise.resolve()
43
+ .then(() => fetchToken(n))
44
+ .then((tok) => {
45
+ const value = tok == null ? '' : String(tok).trim();
46
+ // Only a real token is cached; a null/empty/errored read degrades to
47
+ // today's behavior (probe with no session token) and retries next poll.
48
+ if (value !== '') cache.set(n, value);
49
+ return value === '' ? null : value;
50
+ })
51
+ .catch(() => null)
52
+ .finally(() => { inFlight.delete(n); });
53
+
54
+ inFlight.set(n, p);
55
+ return p;
56
+ }
57
+
58
+ function invalidate(n) {
59
+ cache.delete(n);
60
+ }
61
+
62
+ return { getToken, invalidate };
63
+ }
64
+
65
+ module.exports = { createSessionTokenCache };
@@ -39,15 +39,6 @@ function parseDriftReason(reason) {
39
39
  return m ? { expected: m[1].trim(), running: m[2].trim() } : { expected: null, running: null };
40
40
  }
41
41
 
42
- // The re-wire command the ingress-unwired reason embeds after "Re-wire: " (see
43
- // fleet-public-url.js::ingressUnwiredReason). Surfaced as the setup step's
44
- // inline action so the calm checklist row carries the exact command, not just a
45
- // label. null when the reason carries no command tail.
46
- function parseIngressAction(reason) {
47
- const m = /Re-wire:\s*(.+)$/.exec(reason);
48
- return m ? m[1].trim() : null;
49
- }
50
-
51
42
  // Partition degradedReasons into { setupSteps, attentionReasons }.
52
43
  //
53
44
  // setupSteps ordered [{ code, label, detail, action? }] — expected
@@ -66,11 +57,14 @@ function classifyReasons(degradedReasons = []) {
66
57
  if (reason.startsWith('claude-auth-not-seeded')) {
67
58
  setupSteps.push({ code: 'auth', label: 'Claude login', detail: reason });
68
59
  } else if (reason.startsWith('ingress-unwired')) {
60
+ // System-handled: the dashboard reconcile wires the public URL
61
+ // automatically (incl. down+up self-heal for reused-slug orphans). NO
62
+ // operator `action`/command — render as in-progress, never a to-do.
69
63
  setupSteps.push({
70
64
  code: 'ingress',
71
65
  label: 'Public URL wiring',
72
- detail: reason,
73
- action: parseIngressAction(reason),
66
+ detail: 'Wiring the public URL — this happens automatically.',
67
+ auto: true,
74
68
  });
75
69
  } else if (reason.startsWith('project-dir-drift')) {
76
70
  const { expected, running } = parseDriftReason(reason);
@@ -102,4 +96,17 @@ function hasOnlySetup(degradedReasons = []) {
102
96
  return setupSteps.length > 0 && attentionReasons.length === 0;
103
97
  }
104
98
 
105
- module.exports = { classifyReasons, hasOnlySetup, SETUP_CODES, SCAFFOLD_PROJECT_DIR };
99
+ // True when a VM's reasons are ALL expected setup steps AND every one of them is
100
+ // the SAME given code — e.g. `onlySetupCode(reasons, 'auth')` is the gate for the
101
+ // calm, first-class `needs-login` status: the ONLY outstanding item is Claude
102
+ // login, nothing else. A MIXED set (auth + ingress) is not `onlySetupCode('auth')`
103
+ // — it reads the generic `finishing-setup` instead. Builds on the same classifier
104
+ // as hasOnlySetup, so an empty list or any attention reason is false.
105
+ function onlySetupCode(degradedReasons = [], code) {
106
+ const { setupSteps, attentionReasons } = classifyReasons(degradedReasons);
107
+ return setupSteps.length > 0
108
+ && attentionReasons.length === 0
109
+ && setupSteps.every((s) => s.code === code);
110
+ }
111
+
112
+ module.exports = { classifyReasons, hasOnlySetup, onlySetupCode, SETUP_CODES, SCAFFOLD_PROJECT_DIR };