@codeyam-editor/codeyam-editor 0.1.3 → 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 +1693 -96
  3. package/npm/container.js +14 -0
  4. package/npm/disk-pressure.js +167 -0
  5. package/npm/editor-dev.js +107 -15
  6. package/npm/editor.js +97 -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 +130 -0
  10. package/npm/fleet-cleanup.js +145 -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 +46 -2
  15. package/npm/fleet-launchd.js +171 -0
  16. package/npm/fleet-live-status.js +123 -0
  17. package/npm/fleet-plans.js +231 -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 +100 -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 +310 -4
  35. package/npm/scenario-check.js +684 -33
  36. package/npm/scenario-handlers.js +94 -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 +453 -43
  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-KDFAgt2e.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-CGsBSA6u.js +0 -186
  61. package/ui/dist/assets/ScenarioDataPanel-nuLXJzLf.js +0 -5
  62. package/ui/dist/assets/ScenarioDataPanelFullPage-Bnghtj8a.js +0 -1
  63. package/ui/dist/assets/ScenarioRenderer-B_Za9jL8.js +0 -1
  64. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
  65. package/ui/dist/assets/index-Bih6HJ_u.css +0 -1
  66. package/ui/dist/assets/useEvents-DggWrtHe.js +0 -1
@@ -0,0 +1,171 @@
1
+ 'use strict';
2
+ // Pure helpers for running a VM `add` provision as its OWN transient launchd
3
+ // job (`com.codeyam.fleet-add-vm-<n>`) — improve46 Phase 3. The provision job
4
+ // is a SIBLING of `com.codeyam.fleet-dashboard` rather than a `setsid` child of
5
+ // it, so a `launchctl bootout` of the dashboard label (which tears down the
6
+ // whole dashboard launchd job tree regardless of session/process-group) has no
7
+ // authority over the provision. Phases 1 (signal-kill reclassification) and 2
8
+ // (the reinstall in-flight-add guard) still compose with this — Phase 3 reduces
9
+ // how often a bootout reaches the provision at all.
10
+ //
11
+ // All the string-building and parsing lives here so server.js can stay thin and
12
+ // these pieces are unit-testable WITHOUT launchctl (npm/fleet-launchd.test.js) —
13
+ // the same decomposition `npm/fleet-cleanup.js` uses for the reap brain.
14
+ //
15
+ // Stack assumption: launchd is macOS-only. server.js gates the whole own-job
16
+ // path on platform + a `launchctl` probe and falls back to the existing `setsid`
17
+ // detached spawn on cloud containers / CI / Linux, where these helpers are never
18
+ // reached. They are pure either way.
19
+
20
+ // The launchd label for VM-n's transient add-provision job. ONE label per VM
21
+ // number so the re-add collision guard and the completion teardown each address
22
+ // exactly one job. Mirrors the `com.codeyam.fleet-*` label family used by the
23
+ // dashboard's other launchd agents (see scripts/operator/launchd-reinstall.sh).
24
+ function addVmLabel(n) {
25
+ return `com.codeyam.fleet-add-vm-${n}`;
26
+ }
27
+
28
+ // The file the transient job's stdout/stderr are redirected to (the plist's
29
+ // Standard{Out,Error}Path). The dashboard tails this for the in-progress card's
30
+ // live log when the launchd path is taken — the equivalent of the `setsid`
31
+ // path's stdout pipe, which a separate launchd job can't share. Kept DISTINCT
32
+ // from `/tmp/dash-add-vm-<n>.log` (the addVm script's own `cloud:up` redirect)
33
+ // so the provision shell's progress echoes ("provisioning…", "VM-n provisioned")
34
+ // aren't interleaved with the cloud:up build output.
35
+ function addVmJobLogPath(n) {
36
+ return `/tmp/dash-add-vm-${n}.provision.log`;
37
+ }
38
+
39
+ // XML-escape a string for safe inclusion in a plist `<string>` body. Plists are
40
+ // XML, so the five predefined entities must be escaped or a path/env value with
41
+ // `&`/`<`/`>`/quotes would corrupt the plist. Pure.
42
+ function xmlEscape(s) {
43
+ return String(s == null ? '' : s)
44
+ .replace(/&/g, '&amp;')
45
+ .replace(/</g, '&lt;')
46
+ .replace(/>/g, '&gt;')
47
+ .replace(/"/g, '&quot;')
48
+ .replace(/'/g, '&apos;');
49
+ }
50
+
51
+ // Build a transient launchd plist (XML text) that runs `programArguments` ONCE
52
+ // under `label`. RunAtLoad=true so `launchctl bootstrap` fires it immediately;
53
+ // KeepAlive=false so launchd NEVER relaunches the provision after it exits (a
54
+ // one-shot job, not a daemon). stdout/stderr go to `logPath` so the dashboard
55
+ // can tail the live card. `environment` (the bootstrap form env) and
56
+ // `workingDirectory` are optional. Pure — caller writes the returned text to a
57
+ // temp file and bootstraps it.
58
+ function buildAddVmPlist({ label, programArguments, logPath, workingDirectory, environment } = {}) {
59
+ const args = (Array.isArray(programArguments) ? programArguments : [])
60
+ .map((a) => ` <string>${xmlEscape(a)}</string>`)
61
+ .join('\n');
62
+ const lines = [
63
+ '<?xml version="1.0" encoding="UTF-8"?>',
64
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
65
+ '<plist version="1.0">',
66
+ '<dict>',
67
+ ' <key>Label</key>',
68
+ ` <string>${xmlEscape(label)}</string>`,
69
+ ' <key>ProgramArguments</key>',
70
+ ' <array>',
71
+ args,
72
+ ' </array>',
73
+ ' <key>RunAtLoad</key>',
74
+ ' <true/>',
75
+ ' <key>KeepAlive</key>',
76
+ ' <false/>',
77
+ ];
78
+ if (logPath) {
79
+ lines.push(' <key>StandardOutPath</key>', ` <string>${xmlEscape(logPath)}</string>`);
80
+ lines.push(' <key>StandardErrorPath</key>', ` <string>${xmlEscape(logPath)}</string>`);
81
+ }
82
+ if (workingDirectory) {
83
+ lines.push(' <key>WorkingDirectory</key>', ` <string>${xmlEscape(workingDirectory)}</string>`);
84
+ }
85
+ const env = environment && typeof environment === 'object' ? environment : null;
86
+ if (env && Object.keys(env).length > 0) {
87
+ lines.push(' <key>EnvironmentVariables</key>', ' <dict>');
88
+ for (const [k, v] of Object.entries(env)) {
89
+ lines.push(` <key>${xmlEscape(k)}</key>`, ` <string>${xmlEscape(v)}</string>`);
90
+ }
91
+ lines.push(' </dict>');
92
+ }
93
+ lines.push('</dict>', '</plist>', '');
94
+ return lines.join('\n');
95
+ }
96
+
97
+ // Parse the integer pid from `launchctl print gui/$UID/<label>` output. While
98
+ // the job's process is alive the block contains a `pid = N` line; this resolves
99
+ // it so the dashboard records the REAL provision pid into the sidecar `meta.pid`
100
+ // (the field `reattachVerdict`'s `kill -0` re-attach reads) rather than the
101
+ // launchctl invocation's own pid. Returns the integer pid, or null when there's
102
+ // no live process line (job not started yet, already exited, or junk). `pid = 0`
103
+ // is treated as no-pid. Pure: takes the text so it's testable without launchctl.
104
+ function parseLaunchdPrintPid(text) {
105
+ const m = String(text == null ? '' : text).match(/^\s*pid\s*=\s*(\d+)\b/m);
106
+ if (!m) return null;
107
+ const pid = Number(m[1]);
108
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
109
+ }
110
+
111
+ // Filter a captured transcript down to the live-card tail: drop the noisy
112
+ // gcloud/IAP/NumPy lines and keep the last `maxLines`. Pure — the same shaping
113
+ // startJob's `tailLog` applies to the setsid stdout pipe, extracted so the
114
+ // launchd path's logfile tail and the setsid path share ONE tested filter
115
+ // instead of duplicating the regex + slice.
116
+ function filterTailLog(text, maxLines = 14) {
117
+ return String(text == null ? '' : text).split('\n')
118
+ .filter((l) => !/WARNING|NumPy|google\.com\/iap/.test(l))
119
+ .join('\n').trim().split('\n').slice(-maxLines).join('\n');
120
+ }
121
+
122
+ // True when a `spawnSync` result represents a successful launchctl invocation:
123
+ // a defined result with a zero exit status and no spawn error (ENOENT etc.).
124
+ // Pure — lets the bootstrap/`load -w` success checks be one tested predicate.
125
+ function launchctlSpawnSucceeded(result) {
126
+ return !!result && result.status === 0 && !result.error;
127
+ }
128
+
129
+ // Decide what the launchd add-completion watcher should do on one tick, from the
130
+ // durable signals it gathers: the exit marker's code (or null when absent),
131
+ // whether the launchd process is still alive, and how many consecutive ticks it
132
+ // has been gone. Pure state machine — the I/O (reading the marker, `kill -0`,
133
+ // tailing the log) stays in the server.js watcher; this owns the decision so it
134
+ // is exhaustively testable without launchd.
135
+ // - marker present → done, that exit code (the normal path)
136
+ // - process gone for `graceTicks` → done, exit 1 (EXIT trap never wrote a
137
+ // marker — resolve the card as a failure
138
+ // rather than tailing forever)
139
+ // - process gone, under grace → keep waiting, bump goneTicks
140
+ // - process alive → keep waiting, reset goneTicks
141
+ function launchdWatchVerdict({ markerExitCode, pidAlive, goneTicks = 0, graceTicks = 3 } = {}) {
142
+ if (markerExitCode != null) return { done: true, exitCode: markerExitCode, goneTicks: 0 };
143
+ if (!pidAlive) {
144
+ const next = goneTicks + 1;
145
+ if (next >= graceTicks) return { done: true, exitCode: 1, goneTicks: next };
146
+ return { done: false, goneTicks: next };
147
+ }
148
+ return { done: false, goneTicks: 0 };
149
+ }
150
+
151
+ // Decide whether the own-launchd-job path should be taken for a spawn. Pure
152
+ // gate: it's taken only for a `detached` add (the sole caller that passes
153
+ // `detached`) AND on a host where `launchctl` is actually present. Everything
154
+ // else — a non-detached job, or a host with no launchctl (cloud container / CI /
155
+ // Linux) — falls back to the existing `setsid` detached spawn. Centralised here
156
+ // so the gate is one tested predicate, not an inline `&&` at the spawn site.
157
+ function shouldUseLaunchd({ detached, hasLaunchctl } = {}) {
158
+ return !!detached && !!hasLaunchctl;
159
+ }
160
+
161
+ module.exports = {
162
+ addVmLabel,
163
+ addVmJobLogPath,
164
+ xmlEscape,
165
+ buildAddVmPlist,
166
+ parseLaunchdPrintPid,
167
+ filterTailLog,
168
+ launchctlSpawnSucceeded,
169
+ launchdWatchVerdict,
170
+ shouldUseLaunchd,
171
+ };
@@ -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 };
@@ -0,0 +1,231 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Indexes the currently-reachable VMs by their live sessionId.
5
+ * @param {Array|Object} vms The merged VM states on the dashboard.
6
+ * @returns {Object} A map of sessionId -> VM number (n).
7
+ */
8
+ function indexVmsBySessionId(vms) {
9
+ const map = {};
10
+ if (!vms) return map;
11
+ const list = Array.isArray(vms) ? vms : Object.values(vms);
12
+ for (const vm of list) {
13
+ if (vm && vm.sessionId && vm.n !== undefined) {
14
+ map[vm.sessionId] = vm.n;
15
+ }
16
+ }
17
+ return map;
18
+ }
19
+
20
+ /**
21
+ * Computes which dependencies are unsatisfied (i.e. not in the completedSlugs set).
22
+ * @param {string[]} dependsOn Prerequisite slugs.
23
+ * @param {Set|string[]} completedSlugs Set or array of completed plan slugs.
24
+ * @returns {string[]} The unsatisfied dependency slugs.
25
+ */
26
+ function unsatisfiedDeps(dependsOn, completedSlugs) {
27
+ const deps = Array.isArray(dependsOn) ? dependsOn : [];
28
+ const completedSet = completedSlugs instanceof Set ? completedSlugs : new Set(completedSlugs || []);
29
+ return deps.filter(dep => !completedSet.has(dep));
30
+ }
31
+
32
+ /**
33
+ * Resolves claims and dependency states for each queued plan.
34
+ * @param {Object} params
35
+ * @param {Object[]} params.plans Array of PlanSummary objects from /api/plans.
36
+ * @param {Object[]} params.claims Array of PlanClaim objects from /api/plan-claims.
37
+ * @param {Set|string[]} params.completedSlugs Completed plan slugs.
38
+ * @param {Object} params.vmsBySessionId Map of sessionId -> VM number.
39
+ * @returns {Object[]} Annotated plan rows.
40
+ */
41
+ function resolvePlanRows({ plans, claims, completedSlugs, vmsBySessionId }) {
42
+ const list = Array.isArray(plans) ? plans : [];
43
+ const claimsList = Array.isArray(claims) ? claims : [];
44
+
45
+ return list.map(plan => {
46
+ // 1. Extract plan fields
47
+ const slug = plan.slug;
48
+ const title = plan.title;
49
+ const mode = plan.mode || null;
50
+ const order = plan.order != null ? plan.order : null;
51
+ const prefix = plan.prefix || null;
52
+
53
+ // 2. Resolve dependency-blocked state
54
+ const dependsOn = Array.isArray(plan.depends_on) ? plan.depends_on : (plan.dependsOn ? plan.dependsOn : []);
55
+ const blockedBy = unsatisfiedDeps(dependsOn, completedSlugs);
56
+
57
+ // 3. Resolve claim state
58
+ const claim = claimsList.find(c => c.slug === slug);
59
+ let claimInfo = null;
60
+ if (claim) {
61
+ const claimedBy = claim.claimedBy || claim.claimed_by || {};
62
+ const sessionId = claimedBy.sessionId || claimedBy.session_id || null;
63
+ const vm = (sessionId && vmsBySessionId && vmsBySessionId[sessionId] !== undefined) ? vmsBySessionId[sessionId] : null;
64
+
65
+ claimInfo = {
66
+ vm,
67
+ machine: claimedBy.machine || null,
68
+ agent: claimedBy.agentProvider || claimedBy.agent_provider || null,
69
+ sessionAlive: claim.sessionAlive !== undefined ? claim.sessionAlive : (claim.session_alive !== undefined ? claim.session_alive : null),
70
+ source: claim.source || null,
71
+ currentStep: claimedBy.currentStep || claimedBy.current_step || null,
72
+ };
73
+ }
74
+
75
+ return {
76
+ slug,
77
+ title,
78
+ mode,
79
+ order,
80
+ prefix,
81
+ blockedBy,
82
+ claim: claimInfo
83
+ };
84
+ });
85
+ }
86
+
87
+ const REQUIRED_PLAN_ROW_FIELDS = [
88
+ 'slug', 'title', 'mode', 'order', 'prefix', 'blockedBy', 'claim'
89
+ ];
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
+
221
+ module.exports = {
222
+ indexVmsBySessionId,
223
+ unsatisfiedDeps,
224
+ resolvePlanRows,
225
+ planSourceForProject,
226
+ planPanelSourceLabel,
227
+ classifyOriginError,
228
+ isRemoteRefGone,
229
+ parsePlansSnapshotStdout,
230
+ REQUIRED_PLAN_ROW_FIELDS
231
+ };