@codeyam-editor/codeyam-editor 0.1.2 → 0.1.4

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 (37) hide show
  1. package/npm/cloud.js +779 -116
  2. package/npm/container.js +11 -0
  3. package/npm/disk-pressure.js +89 -0
  4. package/npm/editor-dev.js +144 -52
  5. package/npm/editor.js +47 -17
  6. package/npm/fleet-branch.js +49 -0
  7. package/npm/fleet-broker-death.js +44 -0
  8. package/npm/fleet-broker-debounce.js +63 -0
  9. package/npm/fleet-broker-status.js +45 -0
  10. package/npm/fleet-build-data.js +81 -0
  11. package/npm/fleet-cleanup.js +216 -16
  12. package/npm/fleet-effect-status.js +179 -0
  13. package/npm/fleet-grouping.js +56 -0
  14. package/npm/fleet-job-badge.js +83 -0
  15. package/npm/fleet-memory.js +35 -5
  16. package/npm/fleet-plans.js +96 -0
  17. package/npm/fleet-reconcile.js +58 -1
  18. package/npm/fleet-reset.js +94 -16
  19. package/npm/launch-config.js +197 -14
  20. package/npm/preflight-guidance.js +9 -0
  21. package/npm/scenario-check.js +106 -1
  22. package/npm/scenario-handlers.js +16 -0
  23. package/npm/scenario-interactivity.js +53 -7
  24. package/npm/scenario-playwright.js +184 -5
  25. package/npm/utils.js +238 -53
  26. package/package.json +5 -5
  27. package/ui/dist/assets/EditorShell-tMMdarZD.js +187 -0
  28. package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +5 -0
  29. package/ui/dist/assets/{ScenarioDataPanelFullPage-C0YHQB-T.js → ScenarioDataPanelFullPage-5G6KuZgw.js} +1 -1
  30. package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +1 -0
  31. package/ui/dist/assets/index-BGqairFV.css +1 -0
  32. package/ui/dist/assets/{index-BUYNFqKK.js → index-BuK0lizt.js} +9 -9
  33. package/ui/dist/index.html +2 -2
  34. package/ui/dist/assets/EditorShell-DWaeBxgj.js +0 -186
  35. package/ui/dist/assets/ScenarioDataPanel-zJSXtXL4.js +0 -5
  36. package/ui/dist/assets/ScenarioRenderer-CgDxuigm.js +0 -1
  37. package/ui/dist/assets/index-DVr5rY2d.css +0 -1
package/npm/container.js CHANGED
@@ -12,6 +12,8 @@ const {
12
12
  ensureServer,
13
13
  rootDir,
14
14
  killChildProcess,
15
+ isInteractiveLauncher,
16
+ logLauncherTeardownSignal,
15
17
  readMergedEditorConfig,
16
18
  resolveLauncherPort,
17
19
  } = require("./utils");
@@ -41,6 +43,15 @@ async function main() {
41
43
  const keepAlive = setInterval(() => {}, 60_000);
42
44
 
43
45
  process.on("SIGINT", () => {
46
+ // The container launcher is inherently headless: a SIGINT here is stray
47
+ // (the server is torn down via API shutdown / orchestration, never an
48
+ // interactive Ctrl+C), so do NOT forward it — the detached server stays
49
+ // alive. Record the signal's process-group forensics for root-cause tracing.
50
+ const interactive = isInteractiveLauncher();
51
+ logLauncherTeardownSignal("SIGINT", rootDir, { interactive, forwarded: interactive });
52
+ if (!interactive) {
53
+ return;
54
+ }
44
55
  clearInterval(keepAlive);
45
56
  killChildProcess(serverChild);
46
57
  process.exit(0);
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+ // Pure decision module for the VM root-disk guardrail. Given a single integer
3
+ // percent (from `df`'s Use%/Capacity column), classify the pressure into
4
+ // ok | warn | critical, and expose the allowlist of regenerable cache dirs the
5
+ // watchdog may auto-prune at the critical threshold. No I/O — the watchdog
6
+ // (bash) and the fleet dashboard (node) both consume these constants so the
7
+ // decision boundary is testable without a live VM, mirroring the
8
+ // pure-decision-vs-I/O split of npm/fleet-effect-status.js.
9
+ //
10
+ // Why two thresholds: warn (80%) is visibility only — a badge + log line so the
11
+ // operator sees pressure building. critical (90%) authorizes a bounded
12
+ // auto-prune of regenerable build caches, because a 100%-full disk cascades far
13
+ // worse (it truncated ~/.claude.json mid-write and OOM-killed the broker on the
14
+ // VM4 outage this guards against).
15
+
16
+ const DISK_WARN_PCT = 80;
17
+ const DISK_CRITICAL_PCT = 90;
18
+
19
+ // Allowlist of regenerable dev/build cache directories that are safe to delete
20
+ // on ANY stack because the toolchain rebuilds them on demand. This is an
21
+ // allowlist (known-safe), NOT a denylist, so an unknown stack's important files
22
+ // are never deleted — worst case we free less, never break a build.
23
+ //
24
+ // Stack assumption: each entry is a cache OUTPUT of a specific toolchain
25
+ // (.next/.turbo → Next.js/Turborepo, .svelte-kit → SvelteKit, .vite → Vite,
26
+ // .parcel-cache → Parcel, .angular/cache → Angular, node_modules/.cache →
27
+ // generic JS build caches). A project on none of these stacks simply has none
28
+ // of these dirs and the prune is a no-op.
29
+ //
30
+ // Deliberately disjoint from internal_paths.rs::ALWAYS_EXCLUDED_DIRS
31
+ // (node_modules, .codeyam, .git, target): those mean "don't index," NOT "safe
32
+ // to delete." node_modules is load-bearing and Rust target/ is expensive to
33
+ // rebuild, so neither appears here.
34
+ //
35
+ // Follow-up: a future migration can move this into stack.json so each project
36
+ // declares its own cache dirs; kept operator-side here to stay self-contained.
37
+ const PRUNABLE_CACHE_DIRS = [
38
+ '.next/cache',
39
+ '.next/dev/cache',
40
+ 'node_modules/.cache',
41
+ '.turbo',
42
+ '.svelte-kit',
43
+ '.vite',
44
+ '.parcel-cache',
45
+ '.angular/cache',
46
+ ];
47
+
48
+ // Classify an integer disk-usage percent into a pressure level. A non-numeric,
49
+ // non-finite, or out-of-range (<0 or >100) value is treated as a BAD READING
50
+ // and classified 'ok' with no action — a misread must never escalate to a
51
+ // destructive prune.
52
+ function classifyDiskPressure(usedPct) {
53
+ if (typeof usedPct !== 'number' || !Number.isFinite(usedPct)) return 'ok';
54
+ if (usedPct < 0 || usedPct > 100) return 'ok';
55
+ if (usedPct >= DISK_CRITICAL_PCT) return 'critical';
56
+ if (usedPct >= DISK_WARN_PCT) return 'warn';
57
+ return 'ok';
58
+ }
59
+
60
+ // Parse the integer used-percent from a `df -P /` block. `df -P` (POSIX) emits a
61
+ // header line then one data line whose Capacity field is like "82%". We find the
62
+ // first NN% token on the data line, which is robust to a mount point containing
63
+ // spaces (which would shift positional fields). Returns null on unparseable
64
+ // input so the caller can fail safe rather than prune on garbage.
65
+ function parseDfUsedPct(dfOutput) {
66
+ const lines = String(dfOutput == null ? '' : dfOutput)
67
+ .split('\n')
68
+ .map((l) => l.trim())
69
+ .filter((l) => l.length > 0);
70
+ // The data line is the second non-empty line (after the header). Fall back to
71
+ // scanning all lines so a header-less one-liner still parses.
72
+ const candidates = lines.length >= 2 ? [lines[1], ...lines] : lines;
73
+ for (const line of candidates) {
74
+ const m = line.match(/(\d+)%/);
75
+ if (m) {
76
+ const pct = parseInt(m[1], 10);
77
+ if (Number.isFinite(pct)) return pct;
78
+ }
79
+ }
80
+ return null;
81
+ }
82
+
83
+ module.exports = {
84
+ DISK_WARN_PCT,
85
+ DISK_CRITICAL_PCT,
86
+ PRUNABLE_CACHE_DIRS,
87
+ classifyDiskPressure,
88
+ parseDfUsedPct,
89
+ };
package/npm/editor-dev.js CHANGED
@@ -33,9 +33,10 @@ const {
33
33
  restartServer,
34
34
  waitForPort,
35
35
  ensureBinary,
36
- isSelfHostTarget,
37
36
  rootDir,
38
37
  killChildProcess,
38
+ isInteractiveLauncher,
39
+ logLauncherTeardownSignal,
39
40
  readMergedEditorConfig,
40
41
  resolveLauncherPort,
41
42
  staleBinaryBanner,
@@ -90,28 +91,27 @@ function buildBinary() {
90
91
 
91
92
  /**
92
93
  * 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.
94
+ * for the launcher path) before forwarding to it. The answer is always yes:
95
+ * `codeyam-editor-dev` ALWAYS rebuilds before running. Running fresh source is
96
+ * the entire purpose of the dev wrapper — if a developer wants a frozen tool
97
+ * that never rebuilds, the cargo-installed `codeyam-editor` binary is exactly
98
+ * that.
96
99
  *
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).
100
+ * `cargo build` is idempotent: a sub-second no-op that does not relink when
101
+ * nothing changed. So always-building costs nothing for a developer who only
102
+ * uses the editor as a tool against their own app (the binary never changes →
103
+ * the server-side stale-binary watcher never fires → zero mid-session
104
+ * restarts), and produces a rebuild+restart only when editor source actually
105
+ * changed — which is exactly when the developer wants the new code live.
103
106
  *
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.
107
+ * The injected cwd / repoRoot / env are retained so the contract — "rebuild
108
+ * regardless of where you run it or what env is set" — is demonstrable in a
109
+ * unit test across the dimensions that used to gate the decision (self-host
110
+ * vs. external project, `CODEYAM_DEV_REBUILD` set or not). They no longer
111
+ * influence the result.
111
112
  */
112
- function shouldRebuildOnPassthrough({ cwd, repoRoot, env }) {
113
- if (env.CODEYAM_DEV_REBUILD === "1") return true;
114
- return isSelfHostTarget(cwd, repoRoot);
113
+ function shouldRebuildOnPassthrough() {
114
+ return true;
115
115
  }
116
116
 
117
117
  /**
@@ -154,6 +154,12 @@ function hasStartCommand(mergedConfig) {
154
154
  * can migrate from the preserved classification, so auto-running init would
155
155
  * strand them. The Rust-side `start_preflight` stays the safety net for
156
156
  * direct `codeyam-editor start` calls; this launcher composes init + start.
157
+ *
158
+ * Intentional divergence (improve39): this launcher auto-inits ALL `none`
159
+ * projects (empty or existing-source); the Rust `start_preflight` now
160
+ * auto-inits only the EMPTY case and still bails (exit 2) on existing-source.
161
+ * That gap is pre-existing and deliberate — the dev launcher is a local
162
+ * convenience, not the direct-`start` contract.
157
163
  */
158
164
  function planPreflightAction(classification) {
159
165
  if (classification.kind === "legacy") {
@@ -181,16 +187,22 @@ function planPreflightAction(classification) {
181
187
  *
182
188
  * action !== "init" → null (modern: proceed, no banner;
183
189
  * legacy already bailed earlier)
184
- * action "init", wasEmpty → /codeyam-editor scaffold-from-scratch
190
+ * action "init", wasEmpty → greenfield message. /codeyam-editor is driven
191
+ * from inside the browser UI, never hand-invoked,
192
+ * so the wording follows `browserOpened`: if the
193
+ * launcher actually opened a tab, promise it; if
194
+ * not (e.g. --restart attach), point at the URL
195
+ * so the user still has a path forward. The
196
+ * banner must never promise a tab that didn't
197
+ * appear.
185
198
  * action "init", !wasEmpty → /codeyam-onboard migrate-existing
186
199
  */
187
- function postLaunchInitBanner({ preflightAction, wasEmpty }) {
200
+ function postLaunchInitBanner({ preflightAction, wasEmpty, browserOpened, url }) {
188
201
  if (preflightAction !== "init") return null;
189
202
  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
- );
203
+ return browserOpened
204
+ ? "\nA browser window is opening where you can begin your project."
205
+ : `\nOpen ${url} in your browser to begin your project.`;
194
206
  }
195
207
  return (
196
208
  "\nInitialized .codeyam/ in this directory. To populate scenarios, " +
@@ -198,6 +210,43 @@ function postLaunchInitBanner({ preflightAction, wasEmpty }) {
198
210
  );
199
211
  }
200
212
 
213
+ /**
214
+ * Decide whether the launcher should open a browser tab to the editor UI.
215
+ * Pure (no fs, no spawn) so the gating is unit-testable without a binary,
216
+ * mirroring `planPreflightAction` / `postLaunchInitBanner`.
217
+ *
218
+ * The browser open belongs ONLY on a genuine cold start — bringing the
219
+ * editor server up fresh against a not-yet-running port. Two cases must
220
+ * NOT re-pop a tab, because one is presumably already open:
221
+ *
222
+ * shouldRestart → false `--restart` (rebuild-self, restart-server routed
223
+ * through this wrapper, a manual --restart) tears
224
+ * down and respawns a server the user is already
225
+ * looking at. Re-opening spams tabs — the reported
226
+ * symptom. Mirrors the Rust `--no-open` restart
227
+ * spawn in restart_server.rs.
228
+ * wasRunning → false `ensureServer` attached to an already-running
229
+ * same-project server (returned null) — no fresh
230
+ * process, so no new tab.
231
+ *
232
+ * Only a fresh spawn on a cold start (neither restart nor attach) opens the
233
+ * UI, matching the Rust `no_open`-gated `open_url` in start.rs.
234
+ *
235
+ * freshInit → true A brand-new empty project that was just init'd
236
+ * this run. The entire greenfield onboarding flow
237
+ * is driven from inside the editor UI, and a
238
+ * project that didn't exist a moment ago has no
239
+ * pre-existing tab for the --restart tab-spam guard
240
+ * to protect. So a fresh init opens regardless of
241
+ * `shouldRestart` — UNLESS we merely attached to an
242
+ * already-running server (`wasRunning`), which
243
+ * implies a tab is already up.
244
+ */
245
+ function shouldOpenBrowser({ shouldRestart, wasRunning, freshInit }) {
246
+ if (freshInit) return !wasRunning;
247
+ return !shouldRestart && !wasRunning;
248
+ }
249
+
201
250
  module.exports = {
202
251
  hasSubcommand,
203
252
  buildBinary,
@@ -206,24 +255,19 @@ module.exports = {
206
255
  hasStartCommand,
207
256
  planPreflightAction,
208
257
  postLaunchInitBanner,
258
+ shouldOpenBrowser,
209
259
  };
210
260
 
211
261
  if (require.main === module) {
212
262
  // --- Passthrough mode: build binary, then forward to it ---
213
263
  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
- ) {
264
+ // Always rebuild before forwarding — running fresh source is the whole
265
+ // point of the dev wrapper. `cargo build` is a sub-second no-op when
266
+ // nothing changed (no relink → the server-side stale-binary watcher never
267
+ // fires), and relinks only when editor source actually changed, which is
268
+ // exactly when the developer wants the new code live. `ensureBinary()`
269
+ // still builds once if no binary exists at all.
270
+ if (shouldRebuildOnPassthrough()) {
227
271
  enforceFreshBinary(buildBinary(), { action: "run editor commands" });
228
272
  }
229
273
  const binary = ensureBinary();
@@ -300,19 +344,14 @@ if (require.main === module) {
300
344
  const projectMerged = readMergedEditorConfig(projectDir);
301
345
  const appPort = resolveLauncherPort(undefined, projectMerged.port, 3000);
302
346
 
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
347
+ // Always rebuild the Rust binary + UI before launching — same contract
348
+ // as the passthrough path. The build runs BEFORE the server (re)starts
349
+ // below, so the new server records its start time after the relink and
350
+ // the stale-binary watcher reads it as fresh (no immediate SIGUSR1). A
351
+ // no-op `cargo build` (nothing changed) doesn't relink, so an external
352
+ // client-project launch is never disrupted. `ensureBinary()` still
309
353
  // builds once if no binary exists.
310
- const shouldRebuildLaunch = shouldRebuildOnPassthrough({
311
- cwd: projectDir,
312
- repoRoot: rootDir,
313
- env: process.env,
314
- });
315
- if (shouldRebuildLaunch) {
354
+ if (shouldRebuildOnPassthrough()) {
316
355
  enforceFreshBinary(buildBinary(), {
317
356
  action: "run editor commands",
318
357
  continueVerb: "launching with",
@@ -356,21 +395,74 @@ if (require.main === module) {
356
395
  console.error(`Live Preview: http://localhost:${appPort}`);
357
396
  }
358
397
  console.error(`Project: ${projectDir}`);
359
- open(url);
398
+ // Open the browser only on a genuine cold start. On the --restart path
399
+ // (rebuild-self, restart-server, manual --restart) and when we attached
400
+ // to an already-running server, a tab is presumably already open — the
401
+ // `Editor UI: <url>` line above is the signal. `ensureServer` returns
402
+ // null when it attached rather than spawning, so that is `wasRunning`.
403
+ // Exception: a fresh greenfield init (`freshInit`) opens regardless of
404
+ // --restart, because a project that didn't exist a moment ago has no
405
+ // pre-existing tab to protect and the onboarding flow lives in the UI.
406
+ // Compute the open decision once and feed the SAME boolean into the
407
+ // banner so it can never promise a tab that didn't appear.
408
+ const freshInit = preflight.action === "init" && wasEmpty;
409
+ const openedBrowser = shouldOpenBrowser({
410
+ shouldRestart,
411
+ wasRunning: serverChild === null,
412
+ freshInit,
413
+ });
414
+ if (openedBrowser) {
415
+ open(url);
416
+ }
360
417
  const banner = postLaunchInitBanner({
361
418
  preflightAction: preflight.action,
362
419
  wasEmpty,
420
+ browserOpened: openedBrowser,
421
+ url,
363
422
  });
364
423
  if (banner) {
365
424
  console.error(banner);
366
425
  }
367
426
  const keepAlive = setInterval(() => {}, 60_000);
368
427
 
428
+ // Track launcher-initiated teardown so the unexpected-exit handler below
429
+ // can tell "we killed it on SIGINT" from "the server died on its own".
430
+ let tearingDown = false;
431
+
369
432
  process.on("SIGINT", () => {
433
+ // Forward a deliberate Ctrl+C (interactive launcher) to stop the editor.
434
+ // A SIGINT to a headless / agent-driven launcher is stray and must NOT
435
+ // reap the now-detached server, so swallow it and keep supervising.
436
+ // Record the signal's process-group forensics either way.
437
+ const interactive = isInteractiveLauncher();
438
+ logLauncherTeardownSignal("SIGINT", projectDir, { interactive, forwarded: interactive });
439
+ if (!interactive) {
440
+ return;
441
+ }
442
+ tearingDown = true;
370
443
  clearInterval(keepAlive);
371
444
  killChildProcess(serverChild);
372
445
  process.exit(0);
373
446
  });
447
+
448
+ // Defense-in-depth against orphaned launchers: if the spawned server
449
+ // child exits and we did NOT initiate it (SIGINT), clear keepAlive and
450
+ // exit rather than lingering as a node process holding a dead child
451
+ // handle. We deliberately do NOT respawn — an exited server should let
452
+ // the launcher exit cleanly so stale supervisors can't accumulate across
453
+ // repeated launches. The in-place SIGUSR1 re-exec keeps the SAME pid, so
454
+ // it does NOT emit an "exit" here and does not trip this path; only a
455
+ // genuinely dead server does. `serverChild` is null when we attached to
456
+ // an already-running server (nothing of ours to watch).
457
+ if (serverChild) {
458
+ serverChild.on("exit", (code) => {
459
+ if (tearingDown) {
460
+ return;
461
+ }
462
+ clearInterval(keepAlive);
463
+ process.exit(code ?? 1);
464
+ });
465
+ }
374
466
  };
375
467
 
376
468
  launcherMain();
package/npm/editor.js CHANGED
@@ -22,6 +22,8 @@ const {
22
22
  waitForPort,
23
23
  rootDir,
24
24
  killChildProcess,
25
+ isInteractiveLauncher,
26
+ logLauncherTeardownSignal,
25
27
  readMergedEditorConfig,
26
28
  resolveLauncherPort,
27
29
  resolveWrappedAppPort,
@@ -82,17 +84,19 @@ function linkOrCopyBinary(srcPath, dstPath, tmpPath) {
82
84
  }
83
85
  }
84
86
 
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.
87
+ // Install target/debug/codeyam-editor to <cargo_bin>/codeyam-editor and
88
+ // the dedicated target/debug/codeyam-editor-pty-broker to
89
+ // <cargo_bin>/codeyam-editor-pty-broker, both atomically (write to a tmp
90
+ // in the same dir, then rename).
88
91
  //
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.
92
+ // The broker is now its OWN binary (its own inode), NOT a hardlink of the
93
+ // monolith — a dedicated executable file is what gives the long-lived
94
+ // broker daemon a stable, honest process name on every platform
95
+ // (especially macOS, where hardlink aliases share one vnode name cache
96
+ // and bleed across roles). The PTY broker auto-spawn
97
+ // (control-api/src/pty_broker_startup.rs) prefers this dedicated binary
98
+ // next to the installed monolith; a stale or missing copy is what made
99
+ // build tabs hang on "Reconnecting...", so both must be refreshed here.
96
100
  function installBinary() {
97
101
  const srcPath = path.join(rootDir, "target", "debug", "codeyam-editor");
98
102
  if (!fs.existsSync(srcPath)) {
@@ -101,21 +105,37 @@ function installBinary() {
101
105
  }
102
106
  const binDir = cargoBinDir();
103
107
  const dstPath = path.join(binDir, "codeyam-editor");
104
- const linkPath = path.join(binDir, "codeyam-editor-pty-broker");
105
108
  const tmpPath = path.join(binDir, `.codeyam-editor.tmp.${process.pid}`);
106
109
  console.error(`Installing fresh binary to ${dstPath}...`);
107
110
  try {
108
111
  fs.mkdirSync(binDir, { recursive: true });
109
112
  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
113
  } catch (err) {
117
114
  console.error(`Failed to install binary: ${err.message}`);
118
115
  try { fs.unlinkSync(tmpPath); } catch {}
116
+ return;
117
+ }
118
+
119
+ // Dedicated broker binary — install as its own file (own inode), not a
120
+ // hardlink. Best-effort: if cargo didn't build it (older workspace),
121
+ // the broker auto-spawn falls back to the monolith `editor pty-broker
122
+ // daemon` shim, so a missing broker binary is a warning, not fatal.
123
+ const brokerSrc = path.join(rootDir, "target", "debug", "codeyam-editor-pty-broker");
124
+ const brokerDst = path.join(binDir, "codeyam-editor-pty-broker");
125
+ const brokerTmp = path.join(binDir, `.codeyam-editor-pty-broker.tmp.${process.pid}`);
126
+ if (!fs.existsSync(brokerSrc)) {
127
+ console.error(
128
+ `Note: ${brokerSrc} not found — broker will fall back to the monolith shim. ` +
129
+ `Run \`cargo build -p codeyam-pty-broker\` to build it.`
130
+ );
131
+ return;
132
+ }
133
+ console.error(`Installing dedicated broker binary to ${brokerDst}...`);
134
+ try {
135
+ linkOrCopyBinary(brokerSrc, brokerDst, brokerTmp);
136
+ } catch (err) {
137
+ console.error(`Failed to install broker binary: ${err.message}`);
138
+ try { fs.unlinkSync(brokerTmp); } catch {}
119
139
  }
120
140
  }
121
141
 
@@ -233,6 +253,16 @@ async function main() {
233
253
  const keepAlive = setInterval(() => {}, 60_000);
234
254
 
235
255
  process.on("SIGINT", () => {
256
+ // Forward a deliberate Ctrl+C (interactive launcher) to stop the editor.
257
+ // A SIGINT to a headless / agent-driven launcher is stray — the editor is
258
+ // torn down via API shutdown / pidfile there, never an interactive Ctrl+C —
259
+ // so do NOT forward it: the detached server stays alive. Either way, record
260
+ // the signal's process-group forensics for root-cause tracing.
261
+ const interactive = isInteractiveLauncher();
262
+ logLauncherTeardownSignal("SIGINT", rootDir, { interactive, forwarded: interactive });
263
+ if (!interactive) {
264
+ return;
265
+ }
236
266
  clearInterval(keepAlive);
237
267
  killChildProcess(serverChild);
238
268
  process.exit(0);
@@ -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 };