@codeyam-editor/codeyam-editor 0.1.0-staging.01efa1a

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 (56) hide show
  1. package/npm/cli.js +19 -0
  2. package/npm/cloud-branch-switch.js +442 -0
  3. package/npm/cloud.js +2184 -0
  4. package/npm/container.js +54 -0
  5. package/npm/editor-dev.js +459 -0
  6. package/npm/editor.js +272 -0
  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-build-data.js +81 -0
  14. package/npm/fleet-cleanup.js +297 -0
  15. package/npm/fleet-effect-status.js +179 -0
  16. package/npm/fleet-env.js +125 -0
  17. package/npm/fleet-grouping.js +56 -0
  18. package/npm/fleet-job-badge.js +83 -0
  19. package/npm/fleet-memory.js +157 -0
  20. package/npm/fleet-plans.js +96 -0
  21. package/npm/fleet-pool-policy.js +78 -0
  22. package/npm/fleet-pool.js +401 -0
  23. package/npm/fleet-queue.js +40 -0
  24. package/npm/fleet-reconcile.js +93 -0
  25. package/npm/fleet-reset.js +222 -0
  26. package/npm/fleet-roster.js +81 -0
  27. package/npm/launch-config.js +717 -0
  28. package/npm/open.js +15 -0
  29. package/npm/preflight-guidance.js +77 -0
  30. package/npm/scenario-check.js +604 -0
  31. package/npm/scenario-handlers.js +82 -0
  32. package/npm/scenario-interactivity.js +245 -0
  33. package/npm/scenario-issues.js +35 -0
  34. package/npm/scenario-metrics.js +108 -0
  35. package/npm/scenario-mocks.js +107 -0
  36. package/npm/scenario-playwright.js +534 -0
  37. package/npm/utils.js +823 -0
  38. package/npm/vm-urls.js +117 -0
  39. package/package.json +32 -0
  40. package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +2 -0
  41. package/ui/dist/assets/EditorShell-By-2rl7D.js +187 -0
  42. package/ui/dist/assets/Markdown-BHBqz-Mj.js +1 -0
  43. package/ui/dist/assets/ScenarioDataPanel-Cg8zOFNZ.js +5 -0
  44. package/ui/dist/assets/ScenarioDataPanel-DYP7pi_n.css +32 -0
  45. package/ui/dist/assets/ScenarioDataPanelFullPage-WOUYbba7.js +1 -0
  46. package/ui/dist/assets/ScenarioRenderer-CBTlIVPs.js +1 -0
  47. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +1 -0
  48. package/ui/dist/assets/index-DzoF9RKw.js +27 -0
  49. package/ui/dist/assets/index-ckz94rKI.css +1 -0
  50. package/ui/dist/assets/markdown-v0F0UEt3.js +37 -0
  51. package/ui/dist/assets/react-CSS0HapR.js +28 -0
  52. package/ui/dist/assets/useEvents-DggWrtHe.js +1 -0
  53. package/ui/dist/assets/xterm--24IGk-x.js +9 -0
  54. package/ui/dist/favicon.ico +0 -0
  55. package/ui/dist/favicon.png +0 -0
  56. package/ui/dist/index.html +16 -0
package/npm/editor.js ADDED
@@ -0,0 +1,272 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Start the editor.
5
+ *
6
+ * 1. Builds the React UI into ui/dist/ (stable outer shell, no HMR)
7
+ * 2. Starts the Rust backend which:
8
+ * - Serves ui/dist/ as static files on port 14199
9
+ * - Launches the Vite dev server (startCommand) on port 5173 for Live Preview HMR
10
+ * - Provides the API, WebSocket, and screenshot endpoints
11
+ * 3. Waits for the Vite dev server to be ready (so the Live Preview iframe works)
12
+ * 4. Opens http://localhost:14199 in the browser
13
+ */
14
+
15
+ const { execSync } = require("child_process");
16
+ const fs = require("fs");
17
+ const os = require("os");
18
+ const path = require("path");
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");
34
+ const { open } = require("./open");
35
+
36
+ const shouldRestart = process.argv.includes("--restart");
37
+
38
+ function buildBinary() {
39
+ console.error("Building Rust binary...");
40
+ try {
41
+ execSync("cargo build", { stdio: "inherit", cwd: rootDir });
42
+ return true;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ // Resolve <CARGO_HOME>/bin (falling back to $HOME/.cargo/bin). Mirrors
49
+ // `installed_binary_path()` in crates/control-api/src/pty_broker_startup.rs
50
+ // — the Rust broker auto-spawn execs the binary at this path, so we MUST
51
+ // keep it in sync with the freshly-built debug binary or `npm run editor`
52
+ // will start a server whose PTY-broker auto-spawn execs a stale install.
53
+ function cargoBinDir() {
54
+ if (process.env.CARGO_HOME) return path.join(process.env.CARGO_HOME, "bin");
55
+ return path.join(os.homedir(), ".cargo", "bin");
56
+ }
57
+
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.
64
+ //
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.
98
+ function installBinary() {
99
+ const srcPath = path.join(rootDir, "target", "debug", "codeyam-editor");
100
+ if (!fs.existsSync(srcPath)) {
101
+ console.error(`Skipping install: ${srcPath} not found.`);
102
+ return;
103
+ }
104
+ const binDir = cargoBinDir();
105
+ const dstPath = path.join(binDir, "codeyam-editor");
106
+ const tmpPath = path.join(binDir, `.codeyam-editor.tmp.${process.pid}`);
107
+ console.error(`Installing fresh binary to ${dstPath}...`);
108
+ try {
109
+ fs.mkdirSync(binDir, { recursive: true });
110
+ linkOrCopyBinary(srcPath, dstPath, tmpPath);
111
+ } catch (err) {
112
+ console.error(`Failed to install binary: ${err.message}`);
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 {}
137
+ }
138
+ }
139
+
140
+ // Evict Rust build artifacts not touched in the last day so target/ doesn't
141
+ // grow unboundedly. Must run after the old server is stopped (it holds files
142
+ // in target/debug/ open) and before the new server starts. Non-fatal.
143
+ function sweepStaleArtifacts() {
144
+ console.error("Sweeping stale Rust build artifacts (idle >1 day)...");
145
+ try {
146
+ execSync("cargo sweep --time 1", { stdio: "inherit", cwd: rootDir });
147
+ } catch {
148
+ console.error("cargo-sweep failed (install with `cargo install cargo-sweep`). Continuing.");
149
+ }
150
+ }
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
+ */
157
+ async function main() {
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;
181
+
182
+ // Rebuild the Rust binary so code changes are picked up on restart.
183
+ // This script is only invoked from a source checkout (via `npm run editor`),
184
+ // so Cargo.toml and the crates tree are always present.
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" });
190
+ // Refresh <cargo_bin>/codeyam-editor (and the pty-broker hardlink) so
191
+ // the broker auto-spawn execs a binary that knows the `pty-broker
192
+ // daemon` subcommand. Skip if the build failed — installing a stale
193
+ // debug binary over a working install is worse than leaving it.
194
+ if (buildOk) {
195
+ installBinary();
196
+ }
197
+
198
+ // Build the UI so the backend can serve it as static files
199
+ console.error("Building UI...");
200
+ try {
201
+ execSync("npx vite build", {
202
+ stdio: "inherit",
203
+ cwd: path.join(rootDir, "ui"),
204
+ });
205
+ } catch {
206
+ console.error("Failed to build UI. Continuing with existing build (if any)...");
207
+ }
208
+
209
+ // Start the Rust backend — it serves ui/dist/ and launches the Vite dev
210
+ // server (configured via startCommand in editor.json) for Live Preview HMR.
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.
232
+ }
233
+ if (shouldRestart) {
234
+ sweepStaleArtifacts();
235
+ }
236
+ const serverChild = await ensureServer(SERVER_PORT);
237
+
238
+ // Wait for the actual Vite dev server on the internal port (not the reverse
239
+ // proxy on VITE_PORT, which starts immediately with the Rust binary).
240
+ console.error("Waiting for Vite dev server...");
241
+ const viteReady = await waitForPort(VITE_INTERNAL_PORT, 15000, 300);
242
+ if (!viteReady) {
243
+ console.error(`Warning: Vite dev server not ready on port ${VITE_INTERNAL_PORT}. Live Preview may not work initially.`);
244
+ }
245
+
246
+ const url = `http://localhost:${SERVER_PORT}`;
247
+ console.error(`Editor UI: ${url}`);
248
+ console.error(`Live Preview: http://localhost:${VITE_PORT}`);
249
+ console.error(`Logs: ${path.join(rootDir, ".codeyam", "logs")}`);
250
+ open(url);
251
+ const keepAlive = setInterval(() => {}, 60_000);
252
+
253
+ process.on("SIGINT", () => {
254
+ clearInterval(keepAlive);
255
+ killChildProcess(serverChild);
256
+ process.exit(0);
257
+ });
258
+ }
259
+
260
+ module.exports = {
261
+ buildBinary,
262
+ staleBinaryBanner,
263
+ cargoBinDir,
264
+ installBinary,
265
+ linkOrCopyBinary,
266
+ main,
267
+ sweepStaleArtifacts,
268
+ };
269
+
270
+ if (require.main === module) {
271
+ main();
272
+ }
@@ -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 };