@codeyam-editor/codeyam-editor 0.1.2 → 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.
package/npm/editor-dev.js CHANGED
@@ -33,7 +33,6 @@ const {
33
33
  restartServer,
34
34
  waitForPort,
35
35
  ensureBinary,
36
- isSelfHostTarget,
37
36
  rootDir,
38
37
  killChildProcess,
39
38
  readMergedEditorConfig,
@@ -90,28 +89,27 @@ function buildBinary() {
90
89
 
91
90
  /**
92
91
  * Decide whether the dev wrapper should rebuild the Rust binary (and the UI,
93
- * for the launcher path) before forwarding to it. Pure: takes injected cwd /
94
- * repoRoot / env so the four-way decision is testable without spawning cargo
95
- * or reading process.env.
92
+ * for the launcher path) before forwarding to it. The answer is always yes:
93
+ * `codeyam-editor-dev` ALWAYS rebuilds before running. Running fresh source is
94
+ * the entire purpose of the dev wrapper — if a developer wants a frozen tool
95
+ * that never rebuilds, the cargo-installed `codeyam-editor` binary is exactly
96
+ * that.
96
97
  *
97
- * The wrapper rebuilds in two cases:
98
- * - The target IS the editor repo itself (developing the editor on its
99
- * own checkout). This is the canonical self-host case.
100
- * - `CODEYAM_DEV_REBUILD=1` is explicitly set (the documented opt-in for
101
- * "editing the editor source while running it against an external client
102
- * project", named in editor-dev.js's earlier history).
98
+ * `cargo build` is idempotent: a sub-second no-op that does not relink when
99
+ * nothing changed. So always-building costs nothing for a developer who only
100
+ * uses the editor as a tool against their own app (the binary never changes →
101
+ * the server-side stale-binary watcher never fires → zero mid-session
102
+ * restarts), and produces a rebuild+restart only when editor source actually
103
+ * changed — which is exactly when the developer wants the new code live.
103
104
  *
104
- * Otherwise — the common case where a developer uses the editor as a tool
105
- * against their own app — the wrapper skips the rebuild entirely. The
106
- * existing binary runs unchanged, and the server-side stale-binary watcher
107
- * (also gated on self-host) never SIGUSR1's. The combined effect is what the
108
- * plan calls out: an external-project session is never disrupted by a bare
109
- * commit, a fleet rebase, or any other event that would have relinked the
110
- * binary without changing what runs.
105
+ * The injected cwd / repoRoot / env are retained so the contract — "rebuild
106
+ * regardless of where you run it or what env is set" — is demonstrable in a
107
+ * unit test across the dimensions that used to gate the decision (self-host
108
+ * vs. external project, `CODEYAM_DEV_REBUILD` set or not). They no longer
109
+ * influence the result.
111
110
  */
112
- function shouldRebuildOnPassthrough({ cwd, repoRoot, env }) {
113
- if (env.CODEYAM_DEV_REBUILD === "1") return true;
114
- return isSelfHostTarget(cwd, repoRoot);
111
+ function shouldRebuildOnPassthrough() {
112
+ return true;
115
113
  }
116
114
 
117
115
  /**
@@ -154,6 +152,12 @@ function hasStartCommand(mergedConfig) {
154
152
  * can migrate from the preserved classification, so auto-running init would
155
153
  * strand them. The Rust-side `start_preflight` stays the safety net for
156
154
  * direct `codeyam-editor start` calls; this launcher composes init + start.
155
+ *
156
+ * Intentional divergence (improve39): this launcher auto-inits ALL `none`
157
+ * projects (empty or existing-source); the Rust `start_preflight` now
158
+ * auto-inits only the EMPTY case and still bails (exit 2) on existing-source.
159
+ * That gap is pre-existing and deliberate — the dev launcher is a local
160
+ * convenience, not the direct-`start` contract.
157
161
  */
158
162
  function planPreflightAction(classification) {
159
163
  if (classification.kind === "legacy") {
@@ -181,16 +185,22 @@ function planPreflightAction(classification) {
181
185
  *
182
186
  * action !== "init" → null (modern: proceed, no banner;
183
187
  * legacy already bailed earlier)
184
- * action "init", wasEmpty → /codeyam-editor scaffold-from-scratch
188
+ * action "init", wasEmpty → greenfield message. /codeyam-editor is driven
189
+ * from inside the browser UI, never hand-invoked,
190
+ * so the wording follows `browserOpened`: if the
191
+ * launcher actually opened a tab, promise it; if
192
+ * not (e.g. --restart attach), point at the URL
193
+ * so the user still has a path forward. The
194
+ * banner must never promise a tab that didn't
195
+ * appear.
185
196
  * action "init", !wasEmpty → /codeyam-onboard migrate-existing
186
197
  */
187
- function postLaunchInitBanner({ preflightAction, wasEmpty }) {
198
+ function postLaunchInitBanner({ preflightAction, wasEmpty, browserOpened, url }) {
188
199
  if (preflightAction !== "init") return null;
189
200
  if (wasEmpty) {
190
- return (
191
- "\nInitialized .codeyam/ in this directory. Open Claude Code here and " +
192
- "run `/codeyam-editor` to scaffold a new project from scratch."
193
- );
201
+ return browserOpened
202
+ ? "\nA browser window is opening where you can begin your project."
203
+ : `\nOpen ${url} in your browser to begin your project.`;
194
204
  }
195
205
  return (
196
206
  "\nInitialized .codeyam/ in this directory. To populate scenarios, " +
@@ -198,6 +208,43 @@ function postLaunchInitBanner({ preflightAction, wasEmpty }) {
198
208
  );
199
209
  }
200
210
 
211
+ /**
212
+ * Decide whether the launcher should open a browser tab to the editor UI.
213
+ * Pure (no fs, no spawn) so the gating is unit-testable without a binary,
214
+ * mirroring `planPreflightAction` / `postLaunchInitBanner`.
215
+ *
216
+ * The browser open belongs ONLY on a genuine cold start — bringing the
217
+ * editor server up fresh against a not-yet-running port. Two cases must
218
+ * NOT re-pop a tab, because one is presumably already open:
219
+ *
220
+ * shouldRestart → false `--restart` (rebuild-self, restart-server routed
221
+ * through this wrapper, a manual --restart) tears
222
+ * down and respawns a server the user is already
223
+ * looking at. Re-opening spams tabs — the reported
224
+ * symptom. Mirrors the Rust `--no-open` restart
225
+ * spawn in restart_server.rs.
226
+ * wasRunning → false `ensureServer` attached to an already-running
227
+ * same-project server (returned null) — no fresh
228
+ * process, so no new tab.
229
+ *
230
+ * Only a fresh spawn on a cold start (neither restart nor attach) opens the
231
+ * UI, matching the Rust `no_open`-gated `open_url` in start.rs.
232
+ *
233
+ * freshInit → true A brand-new empty project that was just init'd
234
+ * this run. The entire greenfield onboarding flow
235
+ * is driven from inside the editor UI, and a
236
+ * project that didn't exist a moment ago has no
237
+ * pre-existing tab for the --restart tab-spam guard
238
+ * to protect. So a fresh init opens regardless of
239
+ * `shouldRestart` — UNLESS we merely attached to an
240
+ * already-running server (`wasRunning`), which
241
+ * implies a tab is already up.
242
+ */
243
+ function shouldOpenBrowser({ shouldRestart, wasRunning, freshInit }) {
244
+ if (freshInit) return !wasRunning;
245
+ return !shouldRestart && !wasRunning;
246
+ }
247
+
201
248
  module.exports = {
202
249
  hasSubcommand,
203
250
  buildBinary,
@@ -206,24 +253,19 @@ module.exports = {
206
253
  hasStartCommand,
207
254
  planPreflightAction,
208
255
  postLaunchInitBanner,
256
+ shouldOpenBrowser,
209
257
  };
210
258
 
211
259
  if (require.main === module) {
212
260
  // --- Passthrough mode: build binary, then forward to it ---
213
261
  if (hasSubcommand()) {
214
- // Only rebuild when the target IS the editor repo itself, or when the
215
- // developer has explicitly opted into the "edit editor against external
216
- // project" workflow via CODEYAM_DEV_REBUILD=1. On any other project the
217
- // editor binary is a fixed tool and a `cargo build` here ripples into a
218
- // stale-binary SIGUSR1 re-exec that resets the user's session for no
219
- // reason. `ensureBinary()` still builds once if no binary exists at all.
220
- if (
221
- shouldRebuildOnPassthrough({
222
- cwd: process.cwd(),
223
- repoRoot: rootDir,
224
- env: process.env,
225
- })
226
- ) {
262
+ // Always rebuild before forwarding — running fresh source is the whole
263
+ // point of the dev wrapper. `cargo build` is a sub-second no-op when
264
+ // nothing changed (no relink → the server-side stale-binary watcher never
265
+ // fires), and relinks only when editor source actually changed, which is
266
+ // exactly when the developer wants the new code live. `ensureBinary()`
267
+ // still builds once if no binary exists at all.
268
+ if (shouldRebuildOnPassthrough()) {
227
269
  enforceFreshBinary(buildBinary(), { action: "run editor commands" });
228
270
  }
229
271
  const binary = ensureBinary();
@@ -300,19 +342,14 @@ if (require.main === module) {
300
342
  const projectMerged = readMergedEditorConfig(projectDir);
301
343
  const appPort = resolveLauncherPort(undefined, projectMerged.port, 3000);
302
344
 
303
- // Same gate as the passthrough path: only rebuild the Rust binary +
304
- // UI when the launcher target IS the editor repo itself, or when
305
- // CODEYAM_DEV_REBUILD=1 is explicitly set. Launching the editor
306
- // against an external project must not relink the binary — the
307
- // server-side stale-binary watcher would otherwise SIGUSR1-restart
308
- // the freshly-launched server immediately. `ensureBinary()` still
345
+ // Always rebuild the Rust binary + UI before launching — same contract
346
+ // as the passthrough path. The build runs BEFORE the server (re)starts
347
+ // below, so the new server records its start time after the relink and
348
+ // the stale-binary watcher reads it as fresh (no immediate SIGUSR1). A
349
+ // no-op `cargo build` (nothing changed) doesn't relink, so an external
350
+ // client-project launch is never disrupted. `ensureBinary()` still
309
351
  // builds once if no binary exists.
310
- const shouldRebuildLaunch = shouldRebuildOnPassthrough({
311
- cwd: projectDir,
312
- repoRoot: rootDir,
313
- env: process.env,
314
- });
315
- if (shouldRebuildLaunch) {
352
+ if (shouldRebuildOnPassthrough()) {
316
353
  enforceFreshBinary(buildBinary(), {
317
354
  action: "run editor commands",
318
355
  continueVerb: "launching with",
@@ -356,21 +393,65 @@ if (require.main === module) {
356
393
  console.error(`Live Preview: http://localhost:${appPort}`);
357
394
  }
358
395
  console.error(`Project: ${projectDir}`);
359
- open(url);
396
+ // Open the browser only on a genuine cold start. On the --restart path
397
+ // (rebuild-self, restart-server, manual --restart) and when we attached
398
+ // to an already-running server, a tab is presumably already open — the
399
+ // `Editor UI: <url>` line above is the signal. `ensureServer` returns
400
+ // null when it attached rather than spawning, so that is `wasRunning`.
401
+ // Exception: a fresh greenfield init (`freshInit`) opens regardless of
402
+ // --restart, because a project that didn't exist a moment ago has no
403
+ // pre-existing tab to protect and the onboarding flow lives in the UI.
404
+ // Compute the open decision once and feed the SAME boolean into the
405
+ // banner so it can never promise a tab that didn't appear.
406
+ const freshInit = preflight.action === "init" && wasEmpty;
407
+ const openedBrowser = shouldOpenBrowser({
408
+ shouldRestart,
409
+ wasRunning: serverChild === null,
410
+ freshInit,
411
+ });
412
+ if (openedBrowser) {
413
+ open(url);
414
+ }
360
415
  const banner = postLaunchInitBanner({
361
416
  preflightAction: preflight.action,
362
417
  wasEmpty,
418
+ browserOpened: openedBrowser,
419
+ url,
363
420
  });
364
421
  if (banner) {
365
422
  console.error(banner);
366
423
  }
367
424
  const keepAlive = setInterval(() => {}, 60_000);
368
425
 
426
+ // Track launcher-initiated teardown so the unexpected-exit handler below
427
+ // can tell "we killed it on SIGINT" from "the server died on its own".
428
+ let tearingDown = false;
429
+
369
430
  process.on("SIGINT", () => {
431
+ tearingDown = true;
370
432
  clearInterval(keepAlive);
371
433
  killChildProcess(serverChild);
372
434
  process.exit(0);
373
435
  });
436
+
437
+ // Defense-in-depth against orphaned launchers: if the spawned server
438
+ // child exits and we did NOT initiate it (SIGINT), clear keepAlive and
439
+ // exit rather than lingering as a node process holding a dead child
440
+ // handle. We deliberately do NOT respawn — an exited server should let
441
+ // the launcher exit cleanly so stale supervisors can't accumulate across
442
+ // repeated launches. The in-place SIGUSR1 re-exec keeps the SAME pid, so
443
+ // it does NOT emit an "exit" here and does not trip this path; only a
444
+ // genuinely dead server does. `serverChild` is null when we attached to
445
+ // an already-running server (nothing of ours to watch).
446
+ if (serverChild) {
447
+ serverChild.on("exit", (code) => {
448
+ if (tearingDown) {
449
+ return;
450
+ }
451
+ clearInterval(keepAlive);
452
+ process.exit(code ?? 1);
453
+ });
454
+ }
374
455
  };
375
456
 
376
457
  launcherMain();
package/npm/editor.js CHANGED
@@ -82,17 +82,19 @@ function linkOrCopyBinary(srcPath, dstPath, tmpPath) {
82
82
  }
83
83
  }
84
84
 
85
- // Install target/debug/codeyam-editor to <cargo_bin>/codeyam-editor
86
- // atomically (write to tmp in same dir, then rename), and refresh the
87
- // codeyam-editor-pty-broker hardlink so it points at the new inode.
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).
88
89
  //
89
- // The PTY broker auto-spawn (control-api/src/pty_broker_startup.rs) always
90
- // resolves the *installed* path, NOT current_exe() — that was a deliberate
91
- // fix to prevent a fork-bomb where test binaries spawned themselves. So a
92
- // fresh `target/debug/codeyam-editor` is not enough; the install path must
93
- // be refreshed too. Without this, build tabs hang on "Reconnecting..."
94
- // because the broker daemon errors out (`unrecognized subcommand`) before
95
- // the UDS socket opens.
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.
96
98
  function installBinary() {
97
99
  const srcPath = path.join(rootDir, "target", "debug", "codeyam-editor");
98
100
  if (!fs.existsSync(srcPath)) {
@@ -101,21 +103,37 @@ function installBinary() {
101
103
  }
102
104
  const binDir = cargoBinDir();
103
105
  const dstPath = path.join(binDir, "codeyam-editor");
104
- const linkPath = path.join(binDir, "codeyam-editor-pty-broker");
105
106
  const tmpPath = path.join(binDir, `.codeyam-editor.tmp.${process.pid}`);
106
107
  console.error(`Installing fresh binary to ${dstPath}...`);
107
108
  try {
108
109
  fs.mkdirSync(binDir, { recursive: true });
109
110
  linkOrCopyBinary(srcPath, dstPath, tmpPath);
110
- try {
111
- fs.unlinkSync(linkPath);
112
- } catch (e) {
113
- if (e.code !== "ENOENT") throw e;
114
- }
115
- fs.linkSync(dstPath, linkPath);
116
111
  } catch (err) {
117
112
  console.error(`Failed to install binary: ${err.message}`);
118
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 {}
119
137
  }
120
138
  }
121
139
 
@@ -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 };