@codeyam-editor/codeyam-editor 0.1.4 → 0.1.5

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 (66) hide show
  1. package/npm/cloud-branch-switch.js +1 -1
  2. package/npm/cloud.js +1415 -73
  3. package/npm/container.js +3 -0
  4. package/npm/disk-pressure.js +78 -0
  5. package/npm/editor-dev.js +96 -15
  6. package/npm/editor.js +85 -37
  7. package/npm/fleet-auth-health.js +75 -0
  8. package/npm/fleet-auth.js +6 -1
  9. package/npm/fleet-build-data.js +51 -2
  10. package/npm/fleet-cleanup.js +118 -7
  11. package/npm/fleet-destroy-safety.js +55 -0
  12. package/npm/fleet-effect-status.js +33 -32
  13. package/npm/fleet-host.js +74 -0
  14. package/npm/fleet-job-badge.js +1 -1
  15. package/npm/fleet-launchd.js +171 -0
  16. package/npm/fleet-live-status.js +123 -0
  17. package/npm/fleet-plans.js +135 -0
  18. package/npm/fleet-prewarm.js +165 -0
  19. package/npm/fleet-public-url.js +160 -0
  20. package/npm/fleet-queue.js +38 -1
  21. package/npm/fleet-reconcile.js +43 -1
  22. package/npm/fleet-reset.js +298 -13
  23. package/npm/fleet-roster.js +6 -8
  24. package/npm/fleet-self-update.js +142 -0
  25. package/npm/fleet-setup-steps.js +105 -0
  26. package/npm/fleet-sleep-status.js +33 -0
  27. package/npm/fleet-strand.js +150 -0
  28. package/npm/fleet-stuck-status.js +111 -0
  29. package/npm/fleet-vm-access.js +234 -0
  30. package/npm/fleet-vm-readiness.js +104 -0
  31. package/npm/fleet-vm-slugs.js +116 -0
  32. package/npm/google-ingress.js +211 -0
  33. package/npm/ingress-invoke.js +292 -0
  34. package/npm/launch-config.js +204 -3
  35. package/npm/scenario-check.js +603 -32
  36. package/npm/scenario-handlers.js +78 -12
  37. package/npm/scenario-metrics.js +116 -1
  38. package/npm/scenario-mocks.js +20 -0
  39. package/npm/scenario-playwright.js +366 -29
  40. package/npm/script-drift.js +81 -0
  41. package/npm/utils.js +329 -50
  42. package/npm/vm-urls.js +69 -8
  43. package/package.json +5 -5
  44. package/ui/dist/assets/ContainerInspector-D7VZkkaj.js +2 -0
  45. package/ui/dist/assets/EditorShell-DHhIaotd.css +1 -0
  46. package/ui/dist/assets/EditorShell-r-796N_W.js +191 -0
  47. package/ui/dist/assets/{Markdown-BHBqz-Mj.js → Markdown-BoLHHFXH.js} +1 -1
  48. package/ui/dist/assets/ProjectLauncherScreen-Ck-XWkQN.js +1 -0
  49. package/ui/dist/assets/ScenarioDataPanel-B9iDSUpA.js +5 -0
  50. package/ui/dist/assets/ScenarioDataPanelFullPage-CBLTGC4-.js +1 -0
  51. package/ui/dist/assets/ScenarioRenderer-BhLfT-nX.js +21 -0
  52. package/ui/dist/assets/StepsSimulator-BFldWyoX.js +1 -0
  53. package/ui/dist/assets/index-CIXZkwfB.css +1 -0
  54. package/ui/dist/assets/{index-BuK0lizt.js → index-QsOiOG9x.js} +9 -9
  55. package/ui/dist/assets/useEvents-DHkLJZR4.js +1 -0
  56. package/ui/dist/codeyam-mark.svg +4 -0
  57. package/ui/dist/codeyam-wordmark.svg +5 -0
  58. package/ui/dist/index.html +2 -2
  59. package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +0 -2
  60. package/ui/dist/assets/EditorShell-tMMdarZD.js +0 -187
  61. package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +0 -5
  62. package/ui/dist/assets/ScenarioDataPanelFullPage-5G6KuZgw.js +0 -1
  63. package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +0 -1
  64. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
  65. package/ui/dist/assets/index-BGqairFV.css +0 -1
  66. package/ui/dist/assets/useEvents-DggWrtHe.js +0 -1
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+ // Pure session/broker live-status derivation for the Fleet Dashboard's per-VM
3
+ // status LABEL (scripts/operator/fleet-dashboard/server.js requires this). This
4
+ // is the trailing decision of deriveStatus, after every heavy-job / orphan /
5
+ // failed-badge branch has been ruled out: given the reachable VM's session and
6
+ // broker signals, is it building, waiting, or genuinely idle?
7
+ //
8
+ // The bug this fixes (observed live 2026-06-20, VM-1): the label was derived
9
+ // SOLELY from `hasSession` — the editor server's own in-process build-session
10
+ // flag. But a Claude agent's lifetime is owned by the PTY broker, deliberately
11
+ // DECOUPLED from the editor-server lifetime (see docs/process-model.md). After a
12
+ // server restart, or for a broker-resumed/detached agent the server didn't spawn
13
+ // itself, `hasSession` is false while a fully-live agent keeps working in the
14
+ // broker. The card fell through to 'idle' even though the broker dot — driven by
15
+ // the same `brokerStatus` — already showed 'agent-live'. The label and the dot
16
+ // disagreed.
17
+ //
18
+ // The fix consults the already-debounced broker tri-state (from
19
+ // fleet-broker-status.js, threaded in by the caller) so a broker-held live agent
20
+ // reads as working, not idle. `awaitingUserInput` is honored independent of
21
+ // `hasSession`, so a broker-live agent blocked on input reads 'waiting'.
22
+ //
23
+ // The terminal-done refinement (observed live 2026-06-21): a broker-live agent
24
+ // whose WORKFLOW reached its terminal step is parked alive at its prompt but is
25
+ // NOT building — the editor masks the feature and reports `needsCleanup: true`
26
+ // (session_info_response.rs). Without this signal the card showed an active-green
27
+ // "Building" border on a finished, idle VM. We discriminate on `needsCleanup`
28
+ // (a positive terminal signal) rather than feature-emptiness (also briefly true
29
+ // at session START, which would mislabel a just-starting agent as done). This
30
+ // only ever DOWNGRADES a would-be-'building' verdict to 'session-done'; a
31
+ // mid-feature post-restart agent is not terminal (`needsCleanup` falsy) and stays
32
+ // 'building', preserving the 2026-06-20 decoupled-agent fix above.
33
+ //
34
+ // Returns 'waiting' | 'building' | 'session-done' | 'idle' for a reachable VM, or
35
+ // `null` when the VM is unreachable — the caller maps null to its own
36
+ // asleep/unreachable verdict (it owns the running-fleet snapshot this module
37
+ // deliberately does not touch) and otherwise passes the verdict through verbatim
38
+ // as the card's `status`, so 'session-done' must match the card's status class.
39
+ // Precedence is preserved: an unreachable VM returns null regardless of a stale
40
+ // 'agent-live', so the broker branch only ever upgrades a would-be-idle verdict.
41
+ function liveSessionStatus(v, brokerStatus) {
42
+ // Unreachable: we polled nothing reliable. Defer to the caller's
43
+ // asleep/unreachable mapping — a stale broker signal must not mask it.
44
+ if (!v.reachable) return null;
45
+ if (v.hasSession && v.awaitingUserInput) return 'waiting';
46
+ if (v.hasSession) return 'building';
47
+ // No editor-owned session, but a live agent may still be working in the PTY
48
+ // broker (decoupled lifetime — see docs/process-model.md). The broker
49
+ // tri-state is already debounced upstream; agent-live ⇒ working, not idle.
50
+ if (brokerStatus === 'agent-live') {
51
+ if (v.awaitingUserInput) return 'waiting';
52
+ // The agent is parked alive in the broker's build slot, but its workflow
53
+ // reached the terminal step (editor reports needsCleanup) — finished and
54
+ // idle, not building. A mid-feature post-restart agent is NOT terminal and
55
+ // still reads 'building' (preserves the 2026-06-20 decoupled-agent fix).
56
+ if (v.needsCleanup) return 'session-done';
57
+ return 'building';
58
+ }
59
+ return 'idle';
60
+ }
61
+
62
+ // Distinguish a freshly-rostered VM whose editor is still STARTING UP — a normal
63
+ // cold boot that hasn't bound :14199 yet, or (the VM-2 case) the ~10–15-min
64
+ // self-host `rebuild-self --force-in-container` compile that recompiles the
65
+ // editor binary from source before the server can listen — from a genuinely DEAD
66
+ // one. deriveStatus (scripts/operator/fleet-dashboard/server.js) calls this only
67
+ // after it has already established the VM is rostered, unreachable, and GCP still
68
+ // lists it RUNNING — i.e. exactly the case that otherwise falls through to the
69
+ // red 'unreachable' that made a successful Add look like a failure.
70
+ //
71
+ // Returns 'editor-starting' when EITHER the VM was launched within the grace
72
+ // window (`recentlyLaunched` — the cheap common case, no SSH needed) OR an
73
+ // in-container probe has CONFIRMED a first-run build is still running
74
+ // (`probeBuilding === true`), which lets the long self-host compile keep reading
75
+ // "Starting editor…" past the window with no fragile fixed timeout. Returns null
76
+ // otherwise so deriveStatus runs its existing unreachable path unchanged — an
77
+ // honest red card the moment the grace window lapses with no confirmed build. A
78
+ // DISTINCT key from the wake-path 'starting' (fleet-sleep-status.js) so the two
79
+ // in-progress states never collide. Pure; the caller owns the time + SSH I/O.
80
+ function classifyStartingVsUnreachable({ recentlyLaunched = false, probeBuilding = null } = {}) {
81
+ if (recentlyLaunched || probeBuilding === true) return 'editor-starting';
82
+ return null;
83
+ }
84
+
85
+ // Classify the in-container startup probe (server.js's editorStartingInContainer
86
+ // one-IAP-SSH round-trip) into the tri-state the verdict cache stores and
87
+ // classifyStartingVsUnreachable consumes. Mirrors fleet-cleanup.js's
88
+ // classifyProjectWiring shape: takes the raw `{ stdout, err }` and parses the
89
+ // three `---CY---`-separated segments the probe emits — the `docker ps` status
90
+ // line, the count of listeners on :14199, and the count of live first-run build
91
+ // processes (`rebuild-self` / `cargo build`):
92
+ // true — container up, :14199 NOT yet bound, and a build process is alive ⇒
93
+ // still building (keeps the card calm-blue past the grace window).
94
+ // false — container up but :14199 already bound (the normal reachable path
95
+ // will win) OR up-and-idle with no build process (let the card flip to
96
+ // honest 'unreachable'); also a clean response with the container down.
97
+ // null — an ssh/docker blip (`err`) ⇒ inconclusive; never upgrades a
98
+ // past-window card (stays honest), re-probed next poll.
99
+ function classifyStartupProbe({ stdout, err } = {}) {
100
+ if (err) return null;
101
+ const parts = String(stdout == null ? '' : stdout).split('---CY---').map((s) => s.trim());
102
+ const containerUp = /^Up\b/m.test(parts[0] || '');
103
+ const portBound = (Number((parts[1] || '0').trim()) || 0) > 0;
104
+ const buildProcAlive = (Number((parts[2] || '0').trim()) || 0) > 0;
105
+ if (!containerUp) return false; // clean response, container down → not building
106
+ if (portBound) return false; // already serving — the reachable path owns it
107
+ return buildProcAlive ? true : false;
108
+ }
109
+
110
+ // Pure gate deciding whether pollStartupProbes should fire the (costly) IAP-SSH
111
+ // build probe for a given VM this tick. We only probe a VM that is PLAUSIBLY
112
+ // still starting — present, unreachable, GCP still lists it RUNNING, NOT already
113
+ // owned by an active heavy job, and either recently launched OR already confirmed
114
+ // building on a prior tick (so a ~15-min self-host compile keeps being probed
115
+ // past the grace window with no fixed cap). A healthy/reachable VM, an
116
+ // asleep/vanished one, or a long-idle one that simply went unreachable is never
117
+ // probed. Pure so pollStartupProbes stays a thin SSH loop and this decision
118
+ // matrix is exhaustively unit-testable without booting the server.
119
+ function shouldProbeStartup({ vmPresent = false, reachable = false, gcpRunning = false, jobActive = false, recentlyLaunched = false, wasBuilding = false } = {}) {
120
+ return vmPresent && !reachable && gcpRunning && !jobActive && (recentlyLaunched || wasBuilding);
121
+ }
122
+
123
+ module.exports = { liveSessionStatus, classifyStartingVsUnreachable, classifyStartupProbe, shouldProbeStartup };
@@ -88,9 +88,144 @@ const REQUIRED_PLAN_ROW_FIELDS = [
88
88
  'slug', 'title', 'mode', 'order', 'prefix', 'blockedBy', 'claim'
89
89
  ];
90
90
 
91
+ /**
92
+ * Decides which data source a project's Queued Plans panel should read from.
93
+ * The dashboard's own project (codeyam-editor) reads plans fresh from origin
94
+ * via the laptop checkout; every other project keeps the VM probe.
95
+ * @param {Object} params
96
+ * @param {string} params.project The project name for the panel.
97
+ * @param {?string} params.selfHostProject The dashboard's own project name.
98
+ * @returns {'origin'|'vm'} The source to use.
99
+ */
100
+ function planSourceForProject({ project, selfHostProject }) {
101
+ return selfHostProject && project === selfHostProject ? 'origin' : 'vm';
102
+ }
103
+
104
+ /**
105
+ * Builds the static (non-ticking) part of a Queued Plans panel header label so
106
+ * the data source and its staleness risk are always visible. Origin-sourced
107
+ * panels name the branch + short sha; VM-sourced panels carry the "may lag
108
+ * origin" caveat. The relative age ("· 8s ago") is appended by the frontend's
109
+ * own ago ticker, so this returns the static prefix only.
110
+ * @param {Object} params
111
+ * @param {'origin'|'vm'} params.source Where the panel's data came from.
112
+ * @param {?string} params.ref The git ref (e.g. "origin/<branch>") for origin.
113
+ * @param {?string} params.sha The full commit sha for origin.
114
+ * @param {?number} params.fromVm The representative VM number for vm source.
115
+ * @param {?('stale-binary'|'fetch-failed'|'parse-error'|'unknown')} params.originError
116
+ * When the self-host origin path was *attempted* and failed, the classified
117
+ * reason it fell back to the VM probe. Absent/null for ordinary VM-sourced
118
+ * projects (which were never meant to read origin).
119
+ * @returns {string} The static header annotation.
120
+ */
121
+ function planPanelSourceLabel({ source, ref, sha, fromVm, originError }) {
122
+ if (source === 'origin') {
123
+ const shortSha = sha ? String(sha).slice(0, 7) : '?';
124
+ const where = ref || 'origin';
125
+ return `${where} @ ${shortSha}`;
126
+ }
127
+ // VM-sourced: surface the residual lag risk the origin path eliminates.
128
+ const vm = (fromVm !== null && fromVm !== undefined) ? `VM-${fromVm}` : 'VM';
129
+ if (originError) {
130
+ // The origin path was attempted and failed: this is a *degraded* panel, not
131
+ // a normal VM-sourced one. Name the failure class so it is self-diagnosing.
132
+ return `via ${vm} · origin unavailable: ${originErrorPhrase(originError)}`;
133
+ }
134
+ return `via ${vm} · may lag origin`;
135
+ }
136
+
137
+ /**
138
+ * Maps a classified origin-failure bucket to a short operator-facing phrase for
139
+ * the panel header. Mirrors the bucket set of classifyOriginError; an unknown
140
+ * bucket degrades to the generic "origin read failed" phrase.
141
+ * @param {string} originError The classified bucket.
142
+ * @returns {string} A short human-readable phrase.
143
+ */
144
+ function originErrorPhrase(originError) {
145
+ switch (originError) {
146
+ case 'stale-binary':
147
+ return 'stale editor binary';
148
+ case 'fetch-failed':
149
+ return 'git fetch failed';
150
+ case 'parse-error':
151
+ return 'bad snapshot output';
152
+ default:
153
+ return 'origin read failed';
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Classifies a caught origin-path error message into a small, stable set of
159
+ * buckets the operator can act on, rather than surfacing a noisy raw error
160
+ * (which can carry paths/branch names) in the panel header. Pure so it is
161
+ * unit-testable.
162
+ * - `unrecognized subcommand` → 'stale-binary' (CLI predates the command)
163
+ * - leading `git fetch origin` → 'fetch-failed' (couldn't reach origin)
164
+ * - no-JSON / malformed snapshot → 'parse-error' (plans-snapshot output)
165
+ * - anything else → 'unknown'
166
+ * @param {?string} message The caught error's message text.
167
+ * @returns {'stale-binary'|'fetch-failed'|'parse-error'|'unknown'}
168
+ */
169
+ function classifyOriginError(message) {
170
+ const msg = typeof message === 'string' ? message : '';
171
+ if (msg.includes('unrecognized subcommand')) return 'stale-binary';
172
+ if (/^git fetch origin\b/.test(msg)) return 'fetch-failed';
173
+ if (msg.includes('plans-snapshot produced no JSON') ||
174
+ msg.includes('plans-snapshot JSON parse')) return 'parse-error';
175
+ return 'unknown';
176
+ }
177
+
178
+ /**
179
+ * True when an origin-fetch error means the branch no longer exists on the
180
+ * remote — it was merged and deleted, so `git fetch origin <branch>` fails with
181
+ * git's `couldn't find remote ref <branch>`. This is NOT a transient fetch
182
+ * failure (network / auth): the branch is *gone*, so re-fetching it every poll
183
+ * cycle is pure noise. The dashboard uses this to record the branch as gone,
184
+ * fall back to the VM probe quietly, and stop logging an error every cycle —
185
+ * the difference between a healthy fleet that *reads* broken in the log and one
186
+ * that reads calm. Pure so it is unit-testable.
187
+ * @param {?string} message The caught fetch-error message text.
188
+ * @returns {boolean}
189
+ */
190
+ function isRemoteRefGone(message) {
191
+ const msg = typeof message === 'string' ? message : '';
192
+ return /couldn't find remote ref/i.test(msg);
193
+ }
194
+
195
+ /**
196
+ * Parses the stdout of `editor plans-snapshot --format json` into the snapshot
197
+ * envelope the dashboard consumes. The pure half of snapshotPlansFromOrigin:
198
+ * extracts the JSON object (the CLI may prefix a dev-build banner), parses it,
199
+ * and normalizes missing arrays to []. Throws a descriptive error on no-JSON
200
+ * or malformed-JSON so the caller can fall back to the VM probe.
201
+ * @param {string} stdout The plans-snapshot CLI stdout.
202
+ * @returns {{ref: string, sha: string, plans: Object[], completedSlugs: string[]}}
203
+ */
204
+ function parsePlansSnapshotStdout(stdout) {
205
+ const m = stdout && stdout.match(/\{[\s\S]*\}/);
206
+ if (!m) throw new Error('plans-snapshot produced no JSON');
207
+ let data;
208
+ try {
209
+ data = JSON.parse(m[0]);
210
+ } catch (e) {
211
+ throw new Error(`plans-snapshot JSON parse: ${e.message}`);
212
+ }
213
+ return {
214
+ ref: data.ref,
215
+ sha: data.sha,
216
+ plans: data.plans || [],
217
+ completedSlugs: data.completedSlugs || [],
218
+ };
219
+ }
220
+
91
221
  module.exports = {
92
222
  indexVmsBySessionId,
93
223
  unsatisfiedDeps,
94
224
  resolvePlanRows,
225
+ planSourceForProject,
226
+ planPanelSourceLabel,
227
+ classifyOriginError,
228
+ isRemoteRefGone,
229
+ parsePlansSnapshotStdout,
95
230
  REQUIRED_PLAN_ROW_FIELDS
96
231
  };
@@ -0,0 +1,165 @@
1
+ 'use strict';
2
+
3
+ // npm/fleet-prewarm.js — pure verdicts for the fleet dashboard's concurrent
4
+ // cold-add pre-warm (vm--concurrent-add-prewarm-branch-image).
5
+ //
6
+ // When several VMs are added at once on a branch whose prebuilt image is not yet
7
+ // published, EVERY VM compiles the editor from source (~10 min each) in
8
+ // parallel — none has finished compiling-and-publishing before its siblings
9
+ // start, so the self-feeding fast path (publish_compiled_binary in
10
+ // scripts/gcp-bootstrap.sh) never gets a chance to prime. The fix is to bring up
11
+ // ONE "pacer" VM first, wait until it has published the branch binary (or
12
+ // settled on a definite won't-publish), then release the rest so they pull it.
13
+ //
14
+ // This module holds the *decisions* — pace-or-fan-out, which tags constitute a
15
+ // usable image, parsing the bootstrap's machine markers, and the push-token
16
+ // diagnostic — so each case is unit-tested without a live VM or registry. The
17
+ // I/O (the manifest probe, reading the add log, threading the held queue) lives
18
+ // in the server / cloud.js shells that call these.
19
+
20
+ // Machine-detectable markers gcp-bootstrap.sh emits into the cloud:up add log
21
+ // (streamed to /tmp/dash-add-vm-<n>.log). They are stable contracts the
22
+ // dashboard greps for — keep them in sync with scripts/gcp-bootstrap.sh.
23
+ const MARKER_PUBLISHED = 'CY_BINARY_PUBLISHED'; // pacer published the branch binary; held adds can pull it
24
+ const MARKER_PUBLISH_SKIPPED = 'CY_BINARY_PUBLISH_SKIPPED'; // pacer will NOT publish (no token / no branch / failure)
25
+ const MARKER_PATH_PULL = 'CY_PROVISION_PATH pull'; // this VM pulled a prebuilt image (~2 min fast path)
26
+ const MARKER_PATH_COMPILE = 'CY_PROVISION_PATH compile'; // this VM compiled from source (~10 min slow path)
27
+
28
+ // Mirror scripts/gcp-bootstrap.sh's BRANCH_SLUG: '/' → '-', lowercased, capped
29
+ // at 128 chars. The dashboard and the VM MUST agree on the slug or the probe
30
+ // checks different tags than the bootstrap pulls. Empty / missing branch → the
31
+ // universal "main" slug, matching the bootstrap's detached-HEAD fallback.
32
+ function branchSlug(branch) {
33
+ if (!branch || typeof branch !== 'string') return 'main';
34
+ const slug = branch.replace(/\//g, '-').toLowerCase().slice(0, 128);
35
+ return slug || 'main';
36
+ }
37
+
38
+ // The tag chain that constitutes a "usable" prebuilt editor image, in the same
39
+ // precedence the dev bootstrap pulls (build_editor_image in gcp-bootstrap.sh):
40
+ // 1. cloud-smoke:<slug>-latest — the branch base image
41
+ // 2. cloud-smoke:main-latest — the universal CI fallback (skipped when
42
+ // the branch already IS main, no duplicate)
43
+ // 3. binary:<slug>-latest — the branch binary overlay
44
+ // If a manifest HEAD on ANY of these succeeds, a cold add can pull instead of
45
+ // compiling, so no pacer is needed. Registry defaults to the same
46
+ // CODEYAM_REGISTRY the bootstrap reads.
47
+ function usableImageTags({ registry, branch } = {}) {
48
+ const reg = registry || 'ghcr.io/codeyam-ai';
49
+ const slug = branchSlug(branch);
50
+ const tags = [`${reg}/codeyam-editor-cloud-smoke:${slug}-latest`];
51
+ if (slug !== 'main') tags.push(`${reg}/codeyam-editor-cloud-smoke:main-latest`);
52
+ tags.push(`${reg}/codeyam-editor-binary:${slug}-latest`);
53
+ return tags;
54
+ }
55
+
56
+ // The core verdict: should a batch add pace one VM ahead of the rest?
57
+ //
58
+ // count how many VMs the operator asked to add
59
+ // hasUsableImage the probe result — true (image present), false (definitely
60
+ // absent), or null (couldn't determine: no token, probe error)
61
+ // hasPushToken a write:packages token is present, so the pacer CAN publish
62
+ //
63
+ // Pace ONLY when it strictly pays AND can work:
64
+ // - count > 1 — a single add is never serialized (never slower)
65
+ // - hasUsableImage === false — known-absent; an existing image means all N can
66
+ // pull concurrently, and `null` (unknown) falls
67
+ // back to today's fan-out rather than risk a
68
+ // 10-min serialization that buys nothing
69
+ // - hasPushToken — without it the pacer can't publish, so pacing would
70
+ // just serialize N source compiles for no gain
71
+ // Any miss → fan out all N immediately (today's behavior, strictly additive).
72
+ function decideBatchPrewarm({ count, hasUsableImage, hasPushToken } = {}) {
73
+ const k = Math.round(Number(count) || 0);
74
+ if (k <= 1) return { pace: false, reason: 'single add — never serialized' };
75
+ if (hasUsableImage === true)
76
+ return { pace: false, reason: 'usable prebuilt image exists — all pull concurrently' };
77
+ if (hasUsableImage !== false)
78
+ return { pace: false, reason: 'image presence undetermined — fan out (no worse than today)' };
79
+ if (!hasPushToken)
80
+ return { pace: false, reason: 'no push token — pacer could not publish, fan out' };
81
+ return { pace: true, reason: `pacing 1 of ${k} — branch image absent, will publish for the rest` };
82
+ }
83
+
84
+ // Has the pacer's add log settled to a definite publish outcome the held batch
85
+ // can act on? Returns 'published' (binary is now pullable — release and pull),
86
+ // 'skipped' (pacer will NOT publish — release; they recompile, today's
87
+ // behavior), or null (still in flight — keep waiting). A SKIPPED that is later
88
+ // followed by a PUBLISHED still reports published (the success wins), so marker
89
+ // ordering can't strand the batch on a transient earlier skip.
90
+ function detectPublishSettled(logText) {
91
+ if (!logText || typeof logText !== 'string') return null;
92
+ if (logText.includes(MARKER_PUBLISHED)) return 'published';
93
+ if (logText.includes(MARKER_PUBLISH_SKIPPED)) return 'skipped';
94
+ return null;
95
+ }
96
+
97
+ // The pure release decision for the pre-warm hold (startPrewarmWatch's per-tick
98
+ // verdict): given the pacer's current signals, should the held batch be released
99
+ // now, and why? Returns a human reason string to release, or null to keep
100
+ // waiting. Releases on the FIRST of: a settled publish marker, the pacer job
101
+ // terminating without one, or the bounded timeout — so a publish failure or a
102
+ // stuck pacer can never strand the batch.
103
+ //
104
+ // settled detectPublishSettled() result: 'published' | 'skipped' | null
105
+ // pacerJob the pacer add job record ({ state, ... }) or null/undefined
106
+ // elapsedMs ms since the watch started
107
+ // timeoutMs the bounded fallback after which the batch releases regardless
108
+ function prewarmReleaseDecision({ settled, pacerJob, elapsedMs, timeoutMs } = {}) {
109
+ if (settled) return `pacer ${settled}`;
110
+ const st = pacerJob && pacerJob.state;
111
+ if (st === 'done' || st === 'error') return `pacer ${st}`;
112
+ if (Number(elapsedMs) > Number(timeoutMs)) return 'timeout';
113
+ return null;
114
+ }
115
+
116
+ // Which provision path did a VM take, parsed from its add log's path marker?
117
+ // Returns 'pull' (~2 min prebuilt pull), 'compile' (~10 min source compile), or
118
+ // null (not yet determined). The LAST marker wins — a VM that tries a pull,
119
+ // misses, then falls back to a compile correctly reads 'compile'.
120
+ function provisionPath(logText) {
121
+ if (!logText || typeof logText !== 'string') return null;
122
+ const pull = logText.lastIndexOf(MARKER_PATH_PULL);
123
+ const compile = logText.lastIndexOf(MARKER_PATH_COMPILE);
124
+ if (pull < 0 && compile < 0) return null;
125
+ return compile > pull ? 'compile' : 'pull';
126
+ }
127
+
128
+ // Operator-facing one-liner for a provision path, for the add card sub-status.
129
+ // null path → null (render nothing).
130
+ function provisionPathLabel(path) {
131
+ if (path === 'pull') return 'pulling prebuilt image (~2 min)';
132
+ if (path === 'compile') return 'compiling from source (~10 min)';
133
+ return null;
134
+ }
135
+
136
+ // The push-token diagnostic surfaced on the dashboard. Without a write:packages
137
+ // token the self-feeding publish is silently off, so EVERY cold add recompiles
138
+ // (~10 min) and the pacer can't help either — a state the operator must see, not
139
+ // discover from a buried per-VM log line.
140
+ function pushTokenDiagnostic({ hasPushToken } = {}) {
141
+ if (hasPushToken) return { warn: false, message: null };
142
+ return {
143
+ warn: true,
144
+ message:
145
+ 'Self-feeding disabled: no CODEYAM_GHCR_PUSH_TOKEN (write:packages). ' +
146
+ 'Cold adds will always recompile the editor from source (~10 min each) ' +
147
+ 'and concurrent batches cannot pre-warm. Install a write-scoped token and ' +
148
+ 'reinstall the fleet agents to enable the fast path.',
149
+ };
150
+ }
151
+
152
+ module.exports = {
153
+ MARKER_PUBLISHED,
154
+ MARKER_PUBLISH_SKIPPED,
155
+ MARKER_PATH_PULL,
156
+ MARKER_PATH_COMPILE,
157
+ branchSlug,
158
+ usableImageTags,
159
+ decideBatchPrewarm,
160
+ detectPublishSettled,
161
+ prewarmReleaseDecision,
162
+ provisionPath,
163
+ provisionPathLabel,
164
+ pushTokenDiagnostic,
165
+ };
@@ -0,0 +1,160 @@
1
+ 'use strict';
2
+ // Pure classification + reason-string derivation for the dashboard's public-URL
3
+ // reachability probe (vm: Dashboard validates the public ingress URL, not just
4
+ // the tunnel).
5
+ //
6
+ // The fleet dashboard probes each VM's editor through the local SSH tunnel and,
7
+ // in Google-LB mode, the per-VM backend SERVICE health (gcloud get-health) —
8
+ // but never the public `<slug>.editor.<domain>` URL the card advertises. VM5
9
+ // (2026-06-19) sat "healthy" on every one of those signals while the advertised
10
+ // URL returned 503 "no healthy upstream" for hours: a reused-slug Add aborted on
11
+ // a `Duplicate path matcher` collision before binding the host rule, so the
12
+ // hostname fell through to the default backend. A backend-health probe is
13
+ // structurally blind to a missing host rule; only an end-to-end GET of the
14
+ // advertised hostname catches it.
15
+ //
16
+ // This module is the PURE half — parsing + string derivation, no network —
17
+ // mirroring classifyBackendHealth's `absent|unknown|unhealthy|healthy`
18
+ // contract. The HTTPS GET I/O lives in server.js's gatherPublicUrlHealth, the
19
+ // same split gatherBackendHealth / classifyBackendHealth already follow. Unit-
20
+ // tested in npm/fleet-public-url.test.js.
21
+ //
22
+ // Stack note: this is fleet-operator infrastructure (the GCE fleet that runs
23
+ // codeyam-editor itself), GCP / HTTPS-LB-specific by design — the same posture
24
+ // as the scripts it backstops. A project that never joins the fleet never sets
25
+ // FLEET_DOMAIN and never reaches this probe.
26
+
27
+ // The Google HTTPS-LB "no backend answered" body signature: GFE emits this when
28
+ // a host rule routes nowhere (or to a backendless default). A single source so
29
+ // the classifier and any future log line agree on the string.
30
+ const NO_HEALTHY_UPSTREAM_RE = /no healthy upstream/i;
31
+
32
+ /** Classify a public-URL probe result into the ingress-wiring vocabulary,
33
+ * following classifyBackendHealth's contract — absence of evidence is never a
34
+ * problem state:
35
+ *
36
+ * 'unknown' — a network / TLS / timeout / DNS error (no statusCode): the
37
+ * probe couldn't reach a verdict. NEVER a degraded state, exactly
38
+ * like classifyBackendHealth's 'unknown'. Also covers an
39
+ * unexpected non-503 HTTP error (a 404/500 from the editor
40
+ * itself) — that means the host rule DID route, just not to a
41
+ * happy response, so it is neither 'unwired' nor a clean 'wired'.
42
+ * 'unwired' — the LB answered but no backend is bound to the advertised host:
43
+ * a 503, or a recognizable "no healthy upstream" default-backend
44
+ * body. THIS is the missing-host-rule signal a backend-health
45
+ * probe cannot see (the VM5 case).
46
+ * 'wired' — any 2xx / 3xx: the editor answered (a 200, or its login 302
47
+ * redirect — the real VM5-healthy shape). The host rule routes.
48
+ */
49
+ function classifyPublicUrlHealth({ statusCode, body, error } = {}) {
50
+ if (error || statusCode == null) return 'unknown';
51
+ const code = Number(statusCode);
52
+ if (code === 503) return 'unwired';
53
+ if (NO_HEALTHY_UPSTREAM_RE.test(String(body || ''))) return 'unwired';
54
+ if (code >= 200 && code < 400) return 'wired';
55
+ // Any other 4xx/5xx is the editor answering with an error of its own — the
56
+ // host rule routes, so it is NOT 'unwired'; but it is not a clean success
57
+ // either. Treat as 'unknown' (absence of the unwired signal) so we never raise
58
+ // a false ingress-unwired reason on it.
59
+ return 'unknown';
60
+ }
61
+
62
+ /** The sticky `ingress-unwired` degraded-reason string — the single source for
63
+ * both the dashboard card reason and any log line. Embeds the advertised URL
64
+ * and the EXACT re-wire command an operator runs to fix it, so the amber card
65
+ * is self-documenting (the surface VM5 should have shown for hours instead of
66
+ * reading healthy). */
67
+ function ingressUnwiredReason({ url, domain, slug, n } = {}) {
68
+ const target = url || `(vm-${n} editor URL)`;
69
+ const slugEnv = slug ? ` VM_SLUG=${slug}` : '';
70
+ const rewire = `FLEET_DOMAIN=${domain}${slugEnv} bash scripts/gcp/vm-ingress.sh ${n} up`;
71
+ return `ingress-unwired: ${target} returns 503 (host rule missing). Re-wire: ${rewire}`;
72
+ }
73
+
74
+ /** Decide whether a just-landed public-URL verdict warrants an auto-rewire
75
+ * self-heal. PURE — the dashboard reads the live VM state (I/O at the edge) and
76
+ * passes the facts in; the gate logic itself is unit-testable without network
77
+ * or server state. Fires only for a GENUINE missing-host-rule case:
78
+ * - `verdict` is 'unwired' (the URL 503s), AND
79
+ * - `reachable` (the tunnel is up — a down VM is not a missing-host-rule
80
+ * case; re-wiring it accomplishes nothing), AND
81
+ * - `backendEditorHealth` is 'healthy' (the backend SERVICE is fine, so only
82
+ * the host rule is missing — the VM5 host-rule-drift case) OR 'absent' (the
83
+ * per-VM backend service was NEVER created — the interrupted-add VM-4 case,
84
+ * where a SIGTERM killed `backend-services create` mid-wiring) OR
85
+ * 'unhealthy' *with* `backendEditorBackendless` true (the BES EXISTS but has
86
+ * ZERO NEG backends attached — the concurrent-add editor-band race, where the
87
+ * backend slipped its NEG attachment but the BES object landed). All three
88
+ * are recoverable by a single `vm-ingress.sh up`, which is
89
+ * describe-before-create idempotent and creates/attaches only the missing
90
+ * pieces. A backendless BES classifies as 'unhealthy' (get-health returns
91
+ * empty, indistinguishable from a still-cold backend) yet will NEVER converge
92
+ * on its own — it has no endpoint to become healthy — so it MUST re-wire, not
93
+ * wait. 'unknown' (a transient gcloud/auth error — absence of evidence must
94
+ * never trigger a re-wire storm) and a GENUINELY-converging 'unhealthy' (a
95
+ * backend IS attached, health still flipping — `backendEditorBackendless`
96
+ * false: let it converge, don't churn the LB) stay EXCLUDED, AND
97
+ * - no re-wire is already `inFlight` (one per VM), AND
98
+ * - the debounce window has elapsed since `lastRewireAt`.
99
+ * The debounce is load-bearing: GCP host-rule changes take minutes to roll out
100
+ * to all GFEs (VM5's CORRECT rule 503'd for ~minutes before flipping), so a
101
+ * per-cycle re-fire would storm the LB while a good change is still
102
+ * propagating. A missing/0 `lastRewireAt` means "never re-wired" → the window
103
+ * is satisfied. */
104
+ function shouldAutoRewireIngress({
105
+ verdict,
106
+ reachable,
107
+ backendEditorHealth,
108
+ backendEditorBackendless,
109
+ inFlight,
110
+ lastRewireAt,
111
+ now,
112
+ debounceMs,
113
+ } = {}) {
114
+ if (verdict !== 'unwired') return false;
115
+ if (!reachable) return false;
116
+ // 'healthy' = host-rule drift (VM5); 'absent' = backend never created
117
+ // (interrupted-add VM-4); 'unhealthy' + backendless = BES exists with zero NEG
118
+ // backends (concurrent-add editor-band race) — it can't converge unaided, so it
119
+ // re-wires too. A genuinely-converging 'unhealthy' (backend attached, health
120
+ // flipping → backendless false) and 'unknown' stay excluded.
121
+ const rewirable =
122
+ backendEditorHealth === 'healthy' ||
123
+ backendEditorHealth === 'absent' ||
124
+ (backendEditorHealth === 'unhealthy' && backendEditorBackendless === true);
125
+ if (!rewirable) return false;
126
+ if (inFlight) return false;
127
+ return (Number(now) - (Number(lastRewireAt) || 0)) >= Number(debounceMs);
128
+ }
129
+
130
+ /** Classify a `gcloud compute backend-services describe --format=value(backends[].group)`
131
+ * result into the attachment vocabulary — the signal `get-health` cannot give,
132
+ * because an empty health result is ambiguous between "no backend attached" and
133
+ * "a backend attached but still health-pending":
134
+ *
135
+ * 'backendless' — the BES EXISTS but has ZERO NEG backends attached (the
136
+ * describe printed empty / whitespace-only output). This BES
137
+ * will NEVER converge on its own — there is no endpoint to
138
+ * become healthy — so it is re-wirable, feeding
139
+ * shouldAutoRewireIngress's `backendEditorBackendless` branch
140
+ * (the concurrent-add editor-band slip).
141
+ * 'attached' — the describe printed at least one backend group, OR it
142
+ * ERRORED. An error is treated as 'attached' (absence of
143
+ * evidence): a NotFound is the 'absent' case classifyBackendHealth
144
+ * already owns, and a transient gcloud/auth failure must NEVER
145
+ * manufacture a 'backendless' verdict that storms the LB with a
146
+ * re-wire. Pure (no I/O), so the empty-backends classification is
147
+ * unit-testable without gcloud — the I/O lives in server.js's
148
+ * gatherEditorBackendless, the same split classifyPublicUrlHealth
149
+ * follows. */
150
+ function classifyBackendAttachment(err, stdout) {
151
+ if (err) return 'attached';
152
+ return String(stdout == null ? '' : stdout).trim() === '' ? 'backendless' : 'attached';
153
+ }
154
+
155
+ module.exports = {
156
+ classifyPublicUrlHealth,
157
+ ingressUnwiredReason,
158
+ shouldAutoRewireIngress,
159
+ classifyBackendAttachment,
160
+ };
@@ -37,4 +37,41 @@ function decideAutoReleaseGoneHead({ queue, vms, jobs, orphans }) {
37
37
  return { release: true, uuid: head.uuid, vm: n, reason: `owning VM-${n} left the fleet` };
38
38
  }
39
39
 
40
- module.exports = { decideAutoReleaseGoneHead };
40
+ /** Compute the set-priority writes a reorder requires.
41
+ *
42
+ * A reorder POSTs a desired uuid order; the queue's priority convention is
43
+ * first=N … last=1 (higher priority sorts earlier). This maps the desired
44
+ * order onto target priorities, drops the entries already at their target
45
+ * priority (no-ops — no `set-priority` shell-out needed), and sorts the
46
+ * remaining writes intended-HEAD-first so the head lands first even if a
47
+ * later write fails. `current` is the authoritative queue ([{uuid,priority}]);
48
+ * `order` is the desired uuid sequence.
49
+ *
50
+ * Returns one of:
51
+ * { ok: false, reason: 'empty' } — no order supplied
52
+ * { ok: false, reason: 'stale' } — order doesn't match current set
53
+ * { ok: true, targets: [{uuid, prio}] } — writes to apply (possibly [])
54
+ * Pure; no I/O.
55
+ */
56
+ function computeReorderTargets({ order, current }) {
57
+ const ord = (order || []).map((s) => String(s).trim()).filter(Boolean);
58
+ const cur = current || [];
59
+ const curUuids = cur.map((e) => e.uuid);
60
+ if (!ord.length) return { ok: false, reason: 'empty' };
61
+ // Same membership, same length — a reorder, not an add/remove. Anything else
62
+ // means the queue changed underneath the operator since they last saw it.
63
+ if (ord.length !== curUuids.length || !ord.every((u) => curUuids.includes(u))) {
64
+ return { ok: false, reason: 'stale' };
65
+ }
66
+ const N = ord.length;
67
+ const targets = ord
68
+ .map((uuid, i) => ({ uuid, prio: N - i })) // first=N … last=1
69
+ .filter(({ uuid, prio }) => {
70
+ const e = cur.find((x) => x.uuid === uuid);
71
+ return !e || e.priority !== prio; // skip no-ops
72
+ })
73
+ .sort((a, b) => b.prio - a.prio); // intended HEAD first
74
+ return { ok: true, targets };
75
+ }
76
+
77
+ module.exports = { decideAutoReleaseGoneHead, computeReorderTargets };