@codeyam-editor/codeyam-editor 0.1.1 → 0.1.3

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 (53) hide show
  1. package/npm/cli.js +5 -2
  2. package/npm/cloud-branch-switch.js +442 -0
  3. package/npm/cloud.js +1929 -0
  4. package/npm/container.js +22 -3
  5. package/npm/editor-dev.js +368 -37
  6. package/npm/editor.js +133 -52
  7. package/npm/empty-project-dir.js +33 -0
  8. package/npm/fleet-auth.js +111 -0
  9. package/npm/fleet-branch.js +49 -0
  10. package/npm/fleet-broker-death.js +44 -0
  11. package/npm/fleet-broker-debounce.js +63 -0
  12. package/npm/fleet-broker-status.js +45 -0
  13. package/npm/fleet-cleanup.js +297 -0
  14. package/npm/fleet-effect-status.js +179 -0
  15. package/npm/fleet-env.js +125 -0
  16. package/npm/fleet-grouping.js +56 -0
  17. package/npm/fleet-job-badge.js +39 -0
  18. package/npm/fleet-memory.js +157 -0
  19. package/npm/fleet-pool-policy.js +78 -0
  20. package/npm/fleet-pool.js +401 -0
  21. package/npm/fleet-queue.js +40 -0
  22. package/npm/fleet-reconcile.js +36 -0
  23. package/npm/fleet-reset.js +222 -0
  24. package/npm/fleet-roster.js +81 -0
  25. package/npm/launch-config.js +622 -0
  26. package/npm/preflight-guidance.js +77 -0
  27. package/npm/scenario-check.js +323 -14
  28. package/npm/scenario-handlers.js +16 -5
  29. package/npm/scenario-interactivity.js +245 -0
  30. package/npm/scenario-metrics.js +16 -2
  31. package/npm/scenario-mocks.js +62 -25
  32. package/npm/scenario-playwright.js +350 -8
  33. package/npm/utils.js +562 -22
  34. package/npm/vm-urls.js +117 -0
  35. package/package.json +8 -7
  36. package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +2 -0
  37. package/ui/dist/assets/EditorShell-CGsBSA6u.js +186 -0
  38. package/ui/dist/assets/Markdown-BHBqz-Mj.js +1 -0
  39. package/ui/dist/assets/ScenarioDataPanel-DYP7pi_n.css +32 -0
  40. package/ui/dist/assets/ScenarioDataPanel-nuLXJzLf.js +5 -0
  41. package/ui/dist/assets/ScenarioDataPanelFullPage-Bnghtj8a.js +1 -0
  42. package/ui/dist/assets/ScenarioRenderer-B_Za9jL8.js +1 -0
  43. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +1 -0
  44. package/ui/dist/assets/index-Bih6HJ_u.css +1 -0
  45. package/ui/dist/assets/index-KDFAgt2e.js +27 -0
  46. package/ui/dist/assets/markdown-v0F0UEt3.js +37 -0
  47. package/ui/dist/assets/react-CSS0HapR.js +28 -0
  48. package/ui/dist/assets/useEvents-DggWrtHe.js +1 -0
  49. package/ui/dist/assets/xterm--24IGk-x.js +9 -0
  50. package/ui/dist/favicon.ico +0 -0
  51. package/ui/dist/favicon.png +0 -0
  52. package/ui/dist/index.html +16 -0
  53. package/npm/postinstall.js +0 -132
package/npm/editor.js CHANGED
@@ -16,14 +16,23 @@ const { execSync } = require("child_process");
16
16
  const fs = require("fs");
17
17
  const os = require("os");
18
18
  const path = require("path");
19
- const { ensureServer, stopServer, isPortInUse, waitForPort, rootDir, killChildProcess } = require("./utils");
19
+ const {
20
+ ensureServer,
21
+ stopServer,
22
+ waitForPort,
23
+ rootDir,
24
+ killChildProcess,
25
+ readMergedEditorConfig,
26
+ resolveLauncherPort,
27
+ resolveWrappedAppPort,
28
+ classifyPortHolder,
29
+ formatForeignListenerError,
30
+ formatCrossProjectError,
31
+ staleBinaryBanner,
32
+ enforceFreshBinary,
33
+ } = require("./utils");
20
34
  const { open } = require("./open");
21
35
 
22
- const SERVER_PORT = parseInt(process.env.CODEYAM_EDITOR_PORT || "14199", 10);
23
- const VITE_PORT = parseInt(process.env.PORT || "5173", 10);
24
- // The reverse proxy sits on VITE_PORT; the actual Vite dev server runs on
25
- // VITE_PORT + 1. We need to wait for the real Vite server, not the proxy.
26
- const VITE_INTERNAL_PORT = VITE_PORT + 1;
27
36
  const shouldRestart = process.argv.includes("--restart");
28
37
 
29
38
  function buildBinary() {
@@ -32,7 +41,6 @@ function buildBinary() {
32
41
  execSync("cargo build", { stdio: "inherit", cwd: rootDir });
33
42
  return true;
34
43
  } catch {
35
- console.error("Failed to build Rust binary. Continuing with existing build (if any)...");
36
44
  return false;
37
45
  }
38
46
  }
@@ -47,17 +55,46 @@ function cargoBinDir() {
47
55
  return path.join(os.homedir(), ".cargo", "bin");
48
56
  }
49
57
 
50
- // Install target/debug/codeyam-editor to <cargo_bin>/codeyam-editor
51
- // atomically (write to tmp in same dir, then rename), and refresh the
52
- // codeyam-editor-pty-broker hardlink so it points at the new inode.
58
+ // Place a hardlink at `dstPath` pointing at the same inode as `srcPath`,
59
+ // replacing any existing file at `dstPath`. On EXDEV (cross-filesystem,
60
+ // rare; target/ on a separate mount from the cargo bin dir) falls back
61
+ // to an atomic copy-and-rename via `tmpPath` so we still succeed — at
62
+ // the cost of distinct inodes, which means the dev binary's
63
+ // `stale_cargo_bin_message` check will fire on every boot.
53
64
  //
54
- // The PTY broker auto-spawn (control-api/src/pty_broker_startup.rs) always
55
- // resolves the *installed* path, NOT current_exe() — that was a deliberate
56
- // fix to prevent a fork-bomb where test binaries spawned themselves. So a
57
- // fresh `target/debug/codeyam-editor` is not enough; the install path must
58
- // be refreshed too. Without this, build tabs hang on "Reconnecting..."
59
- // because the broker daemon errors out (`unrecognized subcommand`) before
60
- // the UDS socket opens.
65
+ // Hardlink-by-default is what keeps the stale-binary advisory silent on
66
+ // `npm run editor`: the freshly-installed binary at <cargo_bin> shares
67
+ // an inode with target/debug/codeyam-editor, so the inode-identity
68
+ // check returns "same underlying file" and nothing is printed.
69
+ function linkOrCopyBinary(srcPath, dstPath, tmpPath) {
70
+ try {
71
+ fs.unlinkSync(dstPath);
72
+ } catch (e) {
73
+ if (e.code !== "ENOENT") throw e;
74
+ }
75
+ try {
76
+ fs.linkSync(srcPath, dstPath);
77
+ } catch (err) {
78
+ if (err.code !== "EXDEV") throw err;
79
+ fs.copyFileSync(srcPath, tmpPath);
80
+ fs.chmodSync(tmpPath, 0o755);
81
+ fs.renameSync(tmpPath, dstPath);
82
+ }
83
+ }
84
+
85
+ // Install target/debug/codeyam-editor to <cargo_bin>/codeyam-editor and
86
+ // the dedicated target/debug/codeyam-editor-pty-broker to
87
+ // <cargo_bin>/codeyam-editor-pty-broker, both atomically (write to a tmp
88
+ // in the same dir, then rename).
89
+ //
90
+ // The broker is now its OWN binary (its own inode), NOT a hardlink of the
91
+ // monolith — a dedicated executable file is what gives the long-lived
92
+ // broker daemon a stable, honest process name on every platform
93
+ // (especially macOS, where hardlink aliases share one vnode name cache
94
+ // and bleed across roles). The PTY broker auto-spawn
95
+ // (control-api/src/pty_broker_startup.rs) prefers this dedicated binary
96
+ // next to the installed monolith; a stale or missing copy is what made
97
+ // build tabs hang on "Reconnecting...", so both must be refreshed here.
61
98
  function installBinary() {
62
99
  const srcPath = path.join(rootDir, "target", "debug", "codeyam-editor");
63
100
  if (!fs.existsSync(srcPath)) {
@@ -66,23 +103,37 @@ function installBinary() {
66
103
  }
67
104
  const binDir = cargoBinDir();
68
105
  const dstPath = path.join(binDir, "codeyam-editor");
69
- const linkPath = path.join(binDir, "codeyam-editor-pty-broker");
70
106
  const tmpPath = path.join(binDir, `.codeyam-editor.tmp.${process.pid}`);
71
107
  console.error(`Installing fresh binary to ${dstPath}...`);
72
108
  try {
73
109
  fs.mkdirSync(binDir, { recursive: true });
74
- fs.copyFileSync(srcPath, tmpPath);
75
- fs.chmodSync(tmpPath, 0o755);
76
- fs.renameSync(tmpPath, dstPath);
77
- try {
78
- fs.unlinkSync(linkPath);
79
- } catch (e) {
80
- if (e.code !== "ENOENT") throw e;
81
- }
82
- fs.linkSync(dstPath, linkPath);
110
+ linkOrCopyBinary(srcPath, dstPath, tmpPath);
83
111
  } catch (err) {
84
112
  console.error(`Failed to install binary: ${err.message}`);
85
113
  try { fs.unlinkSync(tmpPath); } catch {}
114
+ return;
115
+ }
116
+
117
+ // Dedicated broker binary — install as its own file (own inode), not a
118
+ // hardlink. Best-effort: if cargo didn't build it (older workspace),
119
+ // the broker auto-spawn falls back to the monolith `editor pty-broker
120
+ // daemon` shim, so a missing broker binary is a warning, not fatal.
121
+ const brokerSrc = path.join(rootDir, "target", "debug", "codeyam-editor-pty-broker");
122
+ const brokerDst = path.join(binDir, "codeyam-editor-pty-broker");
123
+ const brokerTmp = path.join(binDir, `.codeyam-editor-pty-broker.tmp.${process.pid}`);
124
+ if (!fs.existsSync(brokerSrc)) {
125
+ console.error(
126
+ `Note: ${brokerSrc} not found — broker will fall back to the monolith shim. ` +
127
+ `Run \`cargo build -p codeyam-pty-broker\` to build it.`
128
+ );
129
+ return;
130
+ }
131
+ console.error(`Installing dedicated broker binary to ${brokerDst}...`);
132
+ try {
133
+ linkOrCopyBinary(brokerSrc, brokerDst, brokerTmp);
134
+ } catch (err) {
135
+ console.error(`Failed to install broker binary: ${err.message}`);
136
+ try { fs.unlinkSync(brokerTmp); } catch {}
86
137
  }
87
138
  }
88
139
 
@@ -98,32 +149,44 @@ function sweepStaleArtifacts() {
98
149
  }
99
150
  }
100
151
 
152
+ /**
153
+ * npm wrapper entry point for the editor binary: resolves ports from
154
+ * the merged editor config, ensures the Rust backend is running, and
155
+ * opens the editor UI.
156
+ */
101
157
  async function main() {
102
- // Sync ports to editor.json so the Rust backend uses them
103
- const editorJsonPath = path.join(rootDir, ".codeyam", "editor.json");
104
- if (fs.existsSync(editorJsonPath)) {
105
- const editorJson = JSON.parse(fs.readFileSync(editorJsonPath, "utf8"));
106
- editorJson.port = VITE_PORT;
107
- editorJson.startCommand = `cd ui && npx vite --port ${VITE_PORT}`;
108
-
109
- const uiApp = editorJson.apps?.find(a => a.dir === "ui");
110
- if (uiApp) {
111
- uiApp.port = VITE_PORT;
112
- uiApp.startCommand = `npx vite --port ${VITE_PORT}`;
113
- }
114
-
115
- if (editorJson.proxy) {
116
- editorJson.proxy.controlPort = SERVER_PORT;
117
- editorJson.proxy.httpPort = SERVER_PORT - 99;
118
- editorJson.proxy.dbPort = SERVER_PORT - 98;
119
- }
120
- fs.writeFileSync(editorJsonPath, JSON.stringify(editorJson, null, 2));
121
- }
158
+ // Read ports from the merged editor config (editor.json + per-developer
159
+ // editor.local.json). The Rust backend reads the same merged config on
160
+ // its own — we resolve here so the launcher's log lines, `waitForPort`
161
+ // calls, and `--port` arg agree with what the binary will actually
162
+ // bind. Env vars still win for scripted/CI invocations
163
+ // (CODEYAM_EDITOR_PORT, PORT), then the merged config, then a hard
164
+ // fallback for fresh checkouts with no override file.
165
+ const merged = readMergedEditorConfig(rootDir);
166
+ const SERVER_PORT = resolveLauncherPort(
167
+ process.env.CODEYAM_EDITOR_PORT,
168
+ merged.proxy && merged.proxy.controlPort,
169
+ 14199,
170
+ );
171
+ // `resolveWrappedAppPort` mirrors `resolve_app_port` in
172
+ // `crates/codeyam-editor/src/commands/start.rs`: `env > apps[0].port >
173
+ // config.port`. Without this the launcher's log line and
174
+ // `waitForPort` probe targeted one port while the backend bound
175
+ // another, producing the misleading "Vite dev server not ready on
176
+ // port N" warning whenever apps[0] declared its own port.
177
+ const VITE_PORT = resolveWrappedAppPort(merged, process.env.PORT, 5173);
178
+ // The reverse proxy sits on VITE_PORT; the actual Vite dev server runs on
179
+ // VITE_PORT + 1. We need to wait for the real Vite server, not the proxy.
180
+ const VITE_INTERNAL_PORT = VITE_PORT + 1;
122
181
 
123
182
  // Rebuild the Rust binary so code changes are picked up on restart.
124
183
  // This script is only invoked from a source checkout (via `npm run editor`),
125
184
  // so Cargo.toml and the crates tree are always present.
126
185
  const buildOk = buildBinary();
186
+ // A failed rebuild means the server would boot on a stale binary that no
187
+ // longer matches the source. Refuse by default; CODEYAM_ALLOW_STALE=1
188
+ // forces the old behavior (continue with the last-good install).
189
+ enforceFreshBinary(buildOk, { action: "start the editor server" });
127
190
  // Refresh <cargo_bin>/codeyam-editor (and the pty-broker hardlink) so
128
191
  // the broker auto-spawn execs a binary that knows the `pty-broker
129
192
  // daemon` subcommand. Skip if the build failed — installing a stale
@@ -145,11 +208,27 @@ async function main() {
145
208
 
146
209
  // Start the Rust backend — it serves ui/dist/ and launches the Vite dev
147
210
  // server (configured via startCommand in editor.json) for Live Preview HMR.
148
- // On --restart: stop the existing server first, sweep stale artifacts while
149
- // target/ is idle (no process holding files open), then start fresh.
150
- if (shouldRestart && (await isPortInUse(SERVER_PORT))) {
151
- console.error(`Stopping existing server on port ${SERVER_PORT}...`);
152
- await stopServer(SERVER_PORT);
211
+ // On --restart: identity-check first, then stop the existing server,
212
+ // sweep stale artifacts while target/ is idle, then start fresh.
213
+ //
214
+ // Stopping someone else's editor server on `--restart` is the worst
215
+ // possible outcome of a port collision — only stop the listener if
216
+ // the identity probe confirms it's this project's server.
217
+ if (shouldRestart) {
218
+ const holder = await classifyPortHolder(SERVER_PORT);
219
+ if (holder.kind === "foreign") {
220
+ console.error(formatForeignListenerError(SERVER_PORT));
221
+ process.exit(1);
222
+ }
223
+ if (holder.kind === "cross-project") {
224
+ console.error(formatCrossProjectError(SERVER_PORT, holder, "restart"));
225
+ process.exit(1);
226
+ }
227
+ if (holder.kind === "same-project") {
228
+ console.error(`Stopping existing server on port ${SERVER_PORT}...`);
229
+ await stopServer(SERVER_PORT);
230
+ }
231
+ // holder.kind === "free": nothing to stop, fall through to ensureServer.
153
232
  }
154
233
  if (shouldRestart) {
155
234
  sweepStaleArtifacts();
@@ -180,8 +259,10 @@ async function main() {
180
259
 
181
260
  module.exports = {
182
261
  buildBinary,
262
+ staleBinaryBanner,
183
263
  cargoBinDir,
184
264
  installBinary,
265
+ linkOrCopyBinary,
185
266
  main,
186
267
  sweepStaleArtifacts,
187
268
  };
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Launcher-side mirror of `is_empty_project_dir` in
5
+ * `crates/codeyam-editor/src/commands/init.rs`. The Rust side decides
6
+ * which `/codeyam-…` skill the init binary points at; this JS mirror
7
+ * decides the same for the launcher's post-launch tail banner. Keep
8
+ * both in sync — the test `empty-project-dir.test.js` and Rust's
9
+ * `is_empty_project_dir_ignores_hidden_entries` /
10
+ * `is_empty_project_dir_detects_visible_entry` pin the contract.
11
+ *
12
+ * A "fresh" project directory has no non-hidden entries — only things
13
+ * like `.git/`, `.gitignore`, or other dotfiles. Hidden entries don't
14
+ * count as project content, so a dir holding only `.git/` and
15
+ * `.gitignore` (the "I just ran `git init`" case) still reads as empty.
16
+ * A read error returns false, matching the Rust `Err(_) => false` branch:
17
+ * a missing / unreadable dir defaults to the existing-project path rather
18
+ * than falsely scaffolding over nothing.
19
+ */
20
+
21
+ const fs = require("node:fs");
22
+
23
+ function isEmptyProjectDir(projectDir) {
24
+ let entries;
25
+ try {
26
+ entries = fs.readdirSync(projectDir);
27
+ } catch {
28
+ return false;
29
+ }
30
+ return entries.every((name) => name.startsWith("."));
31
+ }
32
+
33
+ module.exports = { isEmptyProjectDir };
@@ -0,0 +1,111 @@
1
+ 'use strict';
2
+ // Pure helpers for the fleet dashboard's "Authenticate idle VMs" batch flow
3
+ // (scripts/operator/fleet-dashboard/server.js requires this). The decision of
4
+ // WHICH VMs a fleet-auth pass touches — and how to summarize an in-flight
5
+ // batch — lives here so server.js's HTTP orchestration stays thin and the
6
+ // guardrail logic is unit-testable in isolation (npm/fleet-auth.test.js).
7
+ //
8
+ // Why per-VM real `claude /login` and not one shared credential: copying a
9
+ // `.credentials.json` between VMs 401s (Claude rotates/binds the OAuth access
10
+ // token to its originating VM), and the `CLAUDE_CODE_OAUTH_TOKEN` setup-token
11
+ // is limited-scope + non-durable (lost on container recreate). So every VM
12
+ // needs its own interactive login — this batches the human step across the
13
+ // fleet (start every idle VM's login in parallel, approve them as one list)
14
+ // instead of distributing a single token. See docs/cloud-editor-stability.md
15
+ // "Fleet auth".
16
+
17
+ // Classify one VM for the fleet-auth pass. Returns { eligible, reason }.
18
+ //
19
+ // The guardrail mirrors Destroy / Reconfigure / roll: NEVER touch an active
20
+ // session (a mid-build agent must not have its editor driven into a login
21
+ // PTY), and never start an auth pass on a VM that already has a running or
22
+ // queued job. Only a reachable, idle VM the editor reports as `needs-login` is
23
+ // a target. An `unknown` / absent auth state is deliberately NOT a target —
24
+ // the probe is indeterminate (a transient failure on an otherwise-healthy VM),
25
+ // and driving a login we can't confirm is needed risks disrupting a working
26
+ // VM. `v` is the dashboard's per-VM poll state (`state.vms[n]`); `job` is
27
+ // `state.jobs[n]` (may be undefined).
28
+ function classifyAuthVm(v, job) {
29
+ if (job && (job.state === 'running' || job.state === 'queued')) {
30
+ return { eligible: false, reason: 'busy' };
31
+ }
32
+ if (!v || !v.reachable) return { eligible: false, reason: 'unreachable' };
33
+ if (v.hasSession) return { eligible: false, reason: 'active-session' };
34
+ const state = v.providerAuth && v.providerAuth.state;
35
+ if (state === 'authenticated') return { eligible: false, reason: 'already-authenticated' };
36
+ if (state === 'needs-login') return { eligible: true, reason: 'needs-login' };
37
+ return { eligible: false, reason: 'auth-state-unknown' };
38
+ }
39
+
40
+ // Select every VM the fleet-auth pass should drive a login on, plus the
41
+ // skipped VMs each with the reason it was excluded (surfaced to the operator
42
+ // so "why didn't VM-7 get authenticated?" is answerable without ssh).
43
+ //
44
+ // roster: number[] (the live VMS array). vms / jobs: maps keyed by VM number.
45
+ // Returns { targets: number[], skipped: [{ n, reason }] }, both sorted by n.
46
+ function selectAuthTargets({ roster, vms, jobs }) {
47
+ const targets = [];
48
+ const skipped = [];
49
+ for (const n of [...(roster || [])].sort((a, b) => a - b)) {
50
+ const c = classifyAuthVm((vms || {})[n], (jobs || {})[n]);
51
+ if (c.eligible) targets.push(n);
52
+ else skipped.push({ n, reason: c.reason });
53
+ }
54
+ return { targets, skipped };
55
+ }
56
+
57
+ // Aggregate an in-flight batch (map of n → { phase }) into counts for the
58
+ // operator report. Phases mirror the editor's /api/auth/status states
59
+ // ('starting' | 'url-ready' | 'authenticated' | 'failed') plus the
60
+ // dashboard-side 'submitting' while a pasted code is being delivered. Pure
61
+ // over the values; `done` is true once no VM is still mid-flight, which is
62
+ // what lets the UI stop polling / show a final "N of M authenticated" line.
63
+ function summarizeAuthBatch(batchVms) {
64
+ const counts = { total: 0, starting: 0, urlReady: 0, submitting: 0, authenticated: 0, failed: 0 };
65
+ for (const n of Object.keys(batchVms || {})) {
66
+ const phase = batchVms[n] && batchVms[n].phase;
67
+ counts.total++;
68
+ if (phase === 'starting') counts.starting++;
69
+ else if (phase === 'url-ready') counts.urlReady++;
70
+ else if (phase === 'submitting') counts.submitting++;
71
+ else if (phase === 'authenticated') counts.authenticated++;
72
+ else if (phase === 'failed') counts.failed++;
73
+ }
74
+ // In-flight = still starting, or awaiting a pasted code, or submitting one.
75
+ // A `failed` VM is terminal until the operator restarts the batch, and an
76
+ // `authenticated` VM is done — so neither counts as in-flight.
77
+ counts.inFlight = counts.starting + counts.urlReady + counts.submitting;
78
+ counts.done = counts.total > 0 && counts.inFlight === 0;
79
+ return counts;
80
+ }
81
+
82
+ // Derive the fleet's current default Claude account (improve38): the account a
83
+ // newly launched VM should authenticate as. Defined as the account of the
84
+ // rostered VM whose auth was updated most recently, where "auth-update time" =
85
+ // max(dashboard-recorded login-completion `lastAuthAt[n]`, the editor-reported
86
+ // `providerAuth.lastRefreshAt`). Re-authenticating ANY existing VM thus
87
+ // re-points the default. Pure over the polled VM snapshot + the lastAuthAt map.
88
+ //
89
+ // Returns the winning VM's account object ({ email, organizationName,
90
+ // organizationType, subscriptionType }), or null when no rostered VM reports an
91
+ // account. Ties — and the no-timestamp case — resolve to the first such VM in
92
+ // `vms` order, so the result is deterministic.
93
+ function deriveDefaultAuthAccount(vms, lastAuthAt) {
94
+ const at = lastAuthAt || {};
95
+ let best = null;
96
+ for (const v of vms || []) {
97
+ const pa = v && v.providerAuth;
98
+ const acct = pa && pa.account;
99
+ if (!acct || !acct.email) continue;
100
+ const refreshMs = pa.lastRefreshAt ? Date.parse(pa.lastRefreshAt) : NaN;
101
+ const loginMs = at[v.n] ? Date.parse(at[v.n]) : NaN;
102
+ const score = Math.max(
103
+ Number.isFinite(refreshMs) ? refreshMs : -Infinity,
104
+ Number.isFinite(loginMs) ? loginMs : -Infinity,
105
+ );
106
+ if (!best || score > best.score) best = { account: acct, score };
107
+ }
108
+ return best ? best.account : null;
109
+ }
110
+
111
+ module.exports = { classifyAuthVm, selectAuthTargets, summarizeAuthBatch, deriveDefaultAuthAccount };
@@ -0,0 +1,49 @@
1
+ 'use strict';
2
+ // Pure branch-resolution precedence for the fleet dashboard (improve39 A).
3
+ //
4
+ // The dashboard's "current fleet branch" used to go stale: server.js read a
5
+ // baked process.env.BRANCH (set once by the launchd plist at install time) and
6
+ // a hardcoded 'editor-improvements-30' literal, so a `git checkout` to a new
7
+ // fleet branch on the laptop was invisible until the operator re-ran
8
+ // install-launchd-agents.sh (which then risked taking the dashboard down — the
9
+ // (B) half of improve39). This module makes the resolution RULE a pure,
10
+ // unit-tested function (npm/fleet-branch.test.js); server.js owns the I/O — the
11
+ // execSync git read and the short-TTL memo on the ~3s /data poll path.
12
+ //
13
+ // Precedence: explicit env override -> live git HEAD -> fallback sentinel.
14
+ // An operator can still pin a branch with BRANCH=... in the plist/env, but with
15
+ // no pin the dashboard tracks the laptop checkout's live HEAD, so a branch bump
16
+ // is just a `git checkout` — no re-install.
17
+
18
+ // Returned when neither an explicit pin nor a live git read yields a branch — a
19
+ // clearly-not-a-branch marker, chosen over a stale literal like
20
+ // 'editor-improvements-30' that would silently point Reset/Update at a dead
21
+ // branch. Callers can surface it as "unknown" instead of acting on it.
22
+ const UNKNOWN_BRANCH = 'unknown-branch';
23
+
24
+ /** Normalize a candidate branch value to a non-empty trimmed string, or null.
25
+ * Anything non-string, empty, or whitespace-only counts as "absent" so a
26
+ * failed git read (null) or an unset env var ('') falls through to the next
27
+ * source. */
28
+ function cleanBranch(v) {
29
+ if (typeof v !== 'string') return null;
30
+ const t = v.trim();
31
+ return t === '' ? null : t;
32
+ }
33
+
34
+ /** Resolve the fleet's active editor branch by precedence:
35
+ *
36
+ * explicit env override -> live git HEAD -> fallback.
37
+ *
38
+ * `fallback` defaults to the UNKNOWN_BRANCH sentinel, so a total failure
39
+ * (no pin, no git) yields a marker rather than a stale branch literal.
40
+ * Pure; no I/O. */
41
+ function resolveFleetBranch({ envBranch, gitBranch, fallback } = {}) {
42
+ const pin = cleanBranch(envBranch);
43
+ if (pin) return pin;
44
+ const live = cleanBranch(gitBranch);
45
+ if (live) return live;
46
+ return cleanBranch(fallback) || UNKNOWN_BRANCH;
47
+ }
48
+
49
+ module.exports = { UNKNOWN_BRANCH, cleanBranch, resolveFleetBranch };
@@ -0,0 +1,44 @@
1
+ 'use strict';
2
+ // Pure alarm-only classification for the Fleet Dashboard's broker-death line
3
+ // (scripts/operator/fleet-dashboard/server.js requires this). Given the rolling
4
+ // `brokerHistory` forensics and the live tri-state `brokerStatus`, decide
5
+ // whether the newest death is a LIVE problem worth alarming about. No I/O —
6
+ // server.js does the polling.
7
+ //
8
+ // Contract (improve40): the death line alarms ONLY when the broker is provably
9
+ // down right now. For every other status it returns null and the card omits the
10
+ // row entirely.
11
+ //
12
+ // Why alarm-only: the live broker-status dot (renderBrokerStatus) already shows
13
+ // current health. The death line's only job is to EXPLAIN a broker that is down
14
+ // now (with the recorded reason / uptime / count). A death record for a broker
15
+ // that is back up is superseded history — the old muted "↩ broker recovered"
16
+ // footnote was noise. And a death we can't confirm (editor unreachable →
17
+ // 'unknown') must not over-claim: "absence of evidence is never down" is the
18
+ // established principle in npm/fleet-broker-status.js. The earlier
19
+ // recovered/active/unconfirmed tri-state collapses to a single down-only alarm.
20
+ //
21
+ // `brokerStatus` here is the DEBOUNCED tri-state from
22
+ // npm/fleet-broker-debounce.js, so a single transient probe timeout can never
23
+ // reach this as 'down'.
24
+
25
+ function classifyBrokerDeath(brokerHistory, brokerStatus) {
26
+ // Alarm only when the broker is confirmed down now. Every other status (up,
27
+ // unreachable, missing) → no death line.
28
+ if (brokerStatus !== 'down') return null;
29
+ // Common "broker never died" case → card renders nothing, unchanged.
30
+ if (!Array.isArray(brokerHistory) || brokerHistory.length === 0) return null;
31
+ const newest = brokerHistory[brokerHistory.length - 1];
32
+ if (!newest) return null;
33
+ // Mirror renderBrokerDeath's existing reason fallback chain.
34
+ const reason = newest.reason || newest.signalName || ('exit ' + newest.exitCode);
35
+ return {
36
+ reason,
37
+ // Passed through verbatim (may be null/absent — the renderer guards both).
38
+ atUnixSecs: newest.atUnixSecs,
39
+ uptimeSeconds: newest.uptimeSeconds,
40
+ count: brokerHistory.length,
41
+ };
42
+ }
43
+
44
+ module.exports = { classifyBrokerDeath };
@@ -0,0 +1,63 @@
1
+ 'use strict';
2
+ // Pure consecutive-failure debounce for the Fleet Dashboard's broker tri-state
3
+ // (scripts/operator/fleet-dashboard/server.js requires this). Given the raw
4
+ // single-poll status from npm/fleet-broker-status.js::deriveBrokerStatus and the
5
+ // per-VM debounce state from the previous poll, decide the status the card
6
+ // actually renders. No I/O — server.js does the polling and threads the state.
7
+ //
8
+ // The bug this fixes (observed live 2026-06-02, VM3): the dashboard polls each
9
+ // VM's broker every 3s, and the editor probes the broker with a single 200ms
10
+ // socket attempt. One transient timeout (IAP / SSH-tunnel congestion — the same
11
+ // class of hiccup behind the known false "Editor stopped") instantly flipped a
12
+ // healthy VM's card to 'down', which raised both the red broker-down dot and the
13
+ // 💀 death alarm, even though the broker was fine (the build tab opened and chat
14
+ // started immediately). The single probe is left as-is on the editor side; the
15
+ // right seam for absorbing a one-poll blip is here at the aggregation layer.
16
+ //
17
+ // Asymmetric by design: ONLY the flip to 'down' is debounced (it requires
18
+ // `threshold` consecutive 'down' polls). 'idle' / 'agent-live' / 'unknown' take
19
+ // effect immediately — we never want to DELAY clearing a false alarm, only
20
+ // delay raising one. And an unreachable editor ('unknown') breaks a pending
21
+ // down-streak rather than escalating it, mirroring fleet-broker-status.js's
22
+ // "absence of evidence is never down" rule.
23
+ //
24
+ // State shape carried across polls: { status, downStreak, lastConfirmed }.
25
+ // status — the debounced value the card renders this poll.
26
+ // downStreak — how many consecutive raw 'down' polls we've seen.
27
+ // lastConfirmed — the last raw 'idle'/'agent-live' we proved; held during a
28
+ // pending (sub-threshold) down-streak so the card stays calm.
29
+
30
+ // ~9s at the dashboard's 3s poll cadence: comfortably absorbs a transient probe
31
+ // timeout and the brief down-window of a routine restart-server (broker down for
32
+ // <3s between SIGTERM and respawn), while still surfacing a real outage quickly.
33
+ // Named so retuning is a one-line edit.
34
+ const DOWN_DEBOUNCE_THRESHOLD = 3;
35
+
36
+ function debounceBrokerStatus(prev, rawStatus, opts) {
37
+ const threshold = (opts && opts.threshold) || DOWN_DEBOUNCE_THRESHOLD;
38
+ const lastConfirmed = prev ? prev.lastConfirmed : undefined;
39
+
40
+ // Confirmed up: the daemon answered now. Render immediately, reset the streak,
41
+ // and remember this as the last-confirmed-up value.
42
+ if (rawStatus === 'idle' || rawStatus === 'agent-live') {
43
+ return { status: rawStatus, downStreak: 0, lastConfirmed: rawStatus };
44
+ }
45
+
46
+ // Editor unreachable: we proved nothing. Never down — reset any pending
47
+ // down-streak and render 'unknown' (the card shows nothing for it).
48
+ if (rawStatus !== 'down') {
49
+ return { status: 'unknown', downStreak: 0, lastConfirmed };
50
+ }
51
+
52
+ // Raw 'down': only a real outage if it persists. Count the streak.
53
+ const downStreak = (prev ? prev.downStreak : 0) + 1;
54
+ if (downStreak >= threshold) {
55
+ // Confirmed down — surface the alarm.
56
+ return { status: 'down', downStreak, lastConfirmed };
57
+ }
58
+ // Pending (sub-threshold) down: hold the last confirmed up status if we have
59
+ // one, else 'unknown'. The card stays calm during the blip.
60
+ return { status: lastConfirmed || 'unknown', downStreak, lastConfirmed };
61
+ }
62
+
63
+ module.exports = { debounceBrokerStatus, DOWN_DEBOUNCE_THRESHOLD };
@@ -0,0 +1,45 @@
1
+ 'use strict';
2
+ // Pure tri-state derivation for the Fleet Dashboard's per-VM broker indicator
3
+ // (scripts/operator/fleet-dashboard/server.js requires this). Given the raw
4
+ // /api/session-info poll merge `v`, collapse it into the one label the card
5
+ // renders. No I/O — server.js does the polling.
6
+ //
7
+ // The bug this fixes (observed live 2026-06-01): the old indicator was
8
+ // `v.reachable ? !!v.agentBrokerLive : null`, where `agentBrokerLive` means
9
+ // "a build-mode agent session is live in the broker" — NOT "broker daemon up".
10
+ // So a perfectly IDLE VM (healthy broker daemon, no parked build agent) reported
11
+ // `agentBrokerLive=false`, indistinguishable from a VM whose broker was actually
12
+ // DOWN. An operator killed a working broker chasing that phantom. The editor now
13
+ // also reports `brokerDaemonReachable` (the daemon answered its socket) and a
14
+ // derived `brokerStatus`; this module layers the dashboard's own reachability on
15
+ // top so the four states stay distinct:
16
+ //
17
+ // 'unknown' — editor unreachable (can't probe the broker at all), OR an
18
+ // older VM whose editor predates `brokerDaemonReachable`. Never
19
+ // rendered as a problem: absence of evidence is not "down".
20
+ // 'down' — daemon unreachable → the real broker problem, the state that
21
+ // had NO signal before. A VM here cannot start an agent session.
22
+ // 'idle' — daemon up, no build agent → a normal idle VM, not an error.
23
+ // 'agent-live' — a live build-mode agent session in the broker's build slot.
24
+ const BROKER_STATUSES = ['unknown', 'down', 'idle', 'agent-live'];
25
+
26
+ function deriveBrokerStatus(v) {
27
+ // Editor unreachable: we polled nothing, so the broker state is unknowable.
28
+ // Reporting 'down' here is exactly the conflation that misled operators —
29
+ // an unreachable editor is not a dead broker.
30
+ if (!v || !v.reachable) return 'unknown';
31
+ // Prefer the editor's own tri-state when it reports one (newer VMs).
32
+ if (v.brokerStatus === 'down' || v.brokerStatus === 'idle' || v.brokerStatus === 'agent-live') {
33
+ return v.brokerStatus;
34
+ }
35
+ // Fallback derivation from the raw bits, for a VM reporting the bools but not
36
+ // the pre-derived `brokerStatus`. `agentBrokerLive` implies the daemon is up.
37
+ if (v.agentBrokerLive) return 'agent-live';
38
+ if (v.brokerDaemonReachable === true) return 'idle';
39
+ if (v.brokerDaemonReachable === false) return 'down';
40
+ // Older VM: reachable editor, but no broker-reachability field at all. Treat
41
+ // as 'unknown' (graceful fallback) — never falsely 'down'.
42
+ return 'unknown';
43
+ }
44
+
45
+ module.exports = { deriveBrokerStatus, BROKER_STATUSES };