@codeyam-editor/codeyam-editor 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/npm/cloud-branch-switch.js +1 -1
  2. package/npm/cloud.js +1693 -96
  3. package/npm/container.js +14 -0
  4. package/npm/disk-pressure.js +167 -0
  5. package/npm/editor-dev.js +107 -15
  6. package/npm/editor.js +97 -37
  7. package/npm/fleet-auth-health.js +75 -0
  8. package/npm/fleet-auth.js +6 -1
  9. package/npm/fleet-build-data.js +130 -0
  10. package/npm/fleet-cleanup.js +145 -7
  11. package/npm/fleet-destroy-safety.js +55 -0
  12. package/npm/fleet-effect-status.js +33 -32
  13. package/npm/fleet-host.js +74 -0
  14. package/npm/fleet-job-badge.js +46 -2
  15. package/npm/fleet-launchd.js +171 -0
  16. package/npm/fleet-live-status.js +123 -0
  17. package/npm/fleet-plans.js +231 -0
  18. package/npm/fleet-prewarm.js +165 -0
  19. package/npm/fleet-public-url.js +160 -0
  20. package/npm/fleet-queue.js +38 -1
  21. package/npm/fleet-reconcile.js +100 -1
  22. package/npm/fleet-reset.js +298 -13
  23. package/npm/fleet-roster.js +6 -8
  24. package/npm/fleet-self-update.js +142 -0
  25. package/npm/fleet-setup-steps.js +105 -0
  26. package/npm/fleet-sleep-status.js +33 -0
  27. package/npm/fleet-strand.js +150 -0
  28. package/npm/fleet-stuck-status.js +111 -0
  29. package/npm/fleet-vm-access.js +234 -0
  30. package/npm/fleet-vm-readiness.js +104 -0
  31. package/npm/fleet-vm-slugs.js +116 -0
  32. package/npm/google-ingress.js +211 -0
  33. package/npm/ingress-invoke.js +292 -0
  34. package/npm/launch-config.js +310 -4
  35. package/npm/scenario-check.js +684 -33
  36. package/npm/scenario-handlers.js +94 -12
  37. package/npm/scenario-metrics.js +116 -1
  38. package/npm/scenario-mocks.js +20 -0
  39. package/npm/scenario-playwright.js +366 -29
  40. package/npm/script-drift.js +81 -0
  41. package/npm/utils.js +453 -43
  42. package/npm/vm-urls.js +69 -8
  43. package/package.json +5 -5
  44. package/ui/dist/assets/ContainerInspector-D7VZkkaj.js +2 -0
  45. package/ui/dist/assets/EditorShell-DHhIaotd.css +1 -0
  46. package/ui/dist/assets/EditorShell-r-796N_W.js +191 -0
  47. package/ui/dist/assets/{Markdown-BHBqz-Mj.js → Markdown-BoLHHFXH.js} +1 -1
  48. package/ui/dist/assets/ProjectLauncherScreen-Ck-XWkQN.js +1 -0
  49. package/ui/dist/assets/ScenarioDataPanel-B9iDSUpA.js +5 -0
  50. package/ui/dist/assets/ScenarioDataPanelFullPage-CBLTGC4-.js +1 -0
  51. package/ui/dist/assets/ScenarioRenderer-BhLfT-nX.js +21 -0
  52. package/ui/dist/assets/StepsSimulator-BFldWyoX.js +1 -0
  53. package/ui/dist/assets/index-CIXZkwfB.css +1 -0
  54. package/ui/dist/assets/{index-KDFAgt2e.js → index-QsOiOG9x.js} +9 -9
  55. package/ui/dist/assets/useEvents-DHkLJZR4.js +1 -0
  56. package/ui/dist/codeyam-mark.svg +4 -0
  57. package/ui/dist/codeyam-wordmark.svg +5 -0
  58. package/ui/dist/index.html +2 -2
  59. package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +0 -2
  60. package/ui/dist/assets/EditorShell-CGsBSA6u.js +0 -186
  61. package/ui/dist/assets/ScenarioDataPanel-nuLXJzLf.js +0 -5
  62. package/ui/dist/assets/ScenarioDataPanelFullPage-Bnghtj8a.js +0 -1
  63. package/ui/dist/assets/ScenarioRenderer-B_Za9jL8.js +0 -1
  64. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
  65. package/ui/dist/assets/index-Bih6HJ_u.css +0 -1
  66. package/ui/dist/assets/useEvents-DggWrtHe.js +0 -1
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");
@@ -33,6 +35,9 @@ async function main() {
33
35
  14199,
34
36
  );
35
37
 
38
+ // A cloud container's stdin is not a TTY, so the cross-project takeover prompt
39
+ // in `ensureServer` resolves to "abort" (print-and-exit) here — never a silent
40
+ // kill of another project's editor in a headless context.
36
41
  const serverChild = await ensureServer(PORT);
37
42
  const url = `http://localhost:${PORT}`;
38
43
  console.error(`Container UI: ${url}`);
@@ -41,6 +46,15 @@ async function main() {
41
46
  const keepAlive = setInterval(() => {}, 60_000);
42
47
 
43
48
  process.on("SIGINT", () => {
49
+ // The container launcher is inherently headless: a SIGINT here is stray
50
+ // (the server is torn down via API shutdown / orchestration, never an
51
+ // interactive Ctrl+C), so do NOT forward it — the detached server stays
52
+ // alive. Record the signal's process-group forensics for root-cause tracing.
53
+ const interactive = isInteractiveLauncher();
54
+ logLauncherTeardownSignal("SIGINT", rootDir, { interactive, forwarded: interactive });
55
+ if (!interactive) {
56
+ return;
57
+ }
44
58
  clearInterval(keepAlive);
45
59
  killChildProcess(serverChild);
46
60
  process.exit(0);
@@ -0,0 +1,167 @@
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
+ // The in-container mount of the persistent cargo build-target volume
49
+ // (`cargo-target:/codeyam-build-target`, set as CARGO_TARGET_DIR in
50
+ // docker-compose.cloud.template.yml). This is SEPARATE from PRUNABLE_CACHE_DIRS
51
+ // on purpose: the frontend-cache allowlist deliberately excludes Rust target/
52
+ // (expensive to rebuild), so it can never reclaim this volume — yet on a
53
+ // self-host VM that builds codeyam-editor, this volume is exactly what fills the
54
+ // disk (VM-3 grew it to ~69GB and wedged). The reclamation below is narrowly
55
+ // keyed on this known mount path, NOT folded into the generic allowlist, so a
56
+ // client project's own Rust target/ is never touched.
57
+ //
58
+ // Stack assumption: only self-host VMs that run cargo write to this volume;
59
+ // guest/client VMs (Next.js, Vite, Tauri, Python, …) carry an empty or absent
60
+ // volume, so cargoTargetReclaimPlan returns an empty plan there (a guaranteed
61
+ // no-op — honoring the stack-agnosticism rule in CLAUDE.md).
62
+ const CARGO_TARGET_DIR = '/codeyam-build-target';
63
+
64
+ // Sub-dirs of CARGO_TARGET_DIR that are ALWAYS safe to delete: pure coverage
65
+ // instrumentation, regenerated on every `llvm-cov` run. Reclaiming this costs
66
+ // the warm incremental build nothing (~13GB on VM-3). Pruned at WARN already.
67
+ const CARGO_TARGET_ALWAYS_PRUNE = ['llvm-cov-target'];
68
+
69
+ // Sub-trees reclaimed ONLY at CRITICAL and ONLY when no build is in flight:
70
+ // `debug/` is the incremental compile cache that keeps `rebuild-self` fast, so
71
+ // deleting it forces a ~14-min cold recompile. We pay that cost only when the
72
+ // disk is genuinely critical AND nothing is mid-build (else we'd sabotage an
73
+ // in-flight compile to free space — the failure mode this tiering avoids).
74
+ const CARGO_TARGET_CRITICAL_CLEAN = ['debug'];
75
+
76
+ // Pure decision function for tiered cargo-target reclamation. Given the current
77
+ // pressure `level`, whether a build is in flight, and whether this VM actually
78
+ // has the cargo-target volume, return which sub-dirs to prune and whether the
79
+ // heavy `debug` clean was deferred (so the caller can log it — no silent cap).
80
+ //
81
+ // Tiers (mirrored by the bash guard + watchdog):
82
+ // - no cargo-target (guest/client VM) → prune nothing (total no-op)
83
+ // - warn → ALWAYS_PRUNE only (llvm-cov)
84
+ // - critical & idle → ALWAYS_PRUNE + CRITICAL_CLEAN
85
+ // - critical & build in flight → ALWAYS_PRUNE only, defer heavy
86
+ // - ok / unknown level → fail-safe: prune nothing
87
+ function cargoTargetReclaimPlan({ level, buildInFlight, hasCargoTarget } = {}) {
88
+ if (!hasCargoTarget) return { prune: [], deferredHeavyClean: false };
89
+ if (level === 'warn') {
90
+ return { prune: [...CARGO_TARGET_ALWAYS_PRUNE], deferredHeavyClean: false };
91
+ }
92
+ if (level === 'critical') {
93
+ if (buildInFlight) {
94
+ return { prune: [...CARGO_TARGET_ALWAYS_PRUNE], deferredHeavyClean: true };
95
+ }
96
+ return {
97
+ prune: [...CARGO_TARGET_ALWAYS_PRUNE, ...CARGO_TARGET_CRITICAL_CLEAN],
98
+ deferredHeavyClean: false,
99
+ };
100
+ }
101
+ // 'ok', undefined, or any unknown level → do nothing (fail safe).
102
+ return { prune: [], deferredHeavyClean: false };
103
+ }
104
+
105
+ // Classify an integer disk-usage percent into a pressure level. A non-numeric,
106
+ // non-finite, or out-of-range (<0 or >100) value is treated as a BAD READING
107
+ // and classified 'ok' with no action — a misread must never escalate to a
108
+ // destructive prune.
109
+ function classifyDiskPressure(usedPct) {
110
+ if (typeof usedPct !== 'number' || !Number.isFinite(usedPct)) return 'ok';
111
+ if (usedPct < 0 || usedPct > 100) return 'ok';
112
+ if (usedPct >= DISK_CRITICAL_PCT) return 'critical';
113
+ if (usedPct >= DISK_WARN_PCT) return 'warn';
114
+ return 'ok';
115
+ }
116
+
117
+ // Parse the integer used-percent from a `df -P /` block. `df -P` (POSIX) emits a
118
+ // header line then one data line whose Capacity field is like "82%". We find the
119
+ // first NN% token on the data line, which is robust to a mount point containing
120
+ // spaces (which would shift positional fields). Returns null on unparseable
121
+ // input so the caller can fail safe rather than prune on garbage.
122
+ function parseDfUsedPct(dfOutput) {
123
+ const lines = String(dfOutput == null ? '' : dfOutput)
124
+ .split('\n')
125
+ .map((l) => l.trim())
126
+ .filter((l) => l.length > 0);
127
+ // The data line is the second non-empty line (after the header). Fall back to
128
+ // scanning all lines so a header-less one-liner still parses.
129
+ const candidates = lines.length >= 2 ? [lines[1], ...lines] : lines;
130
+ for (const line of candidates) {
131
+ const m = line.match(/(\d+)%/);
132
+ if (m) {
133
+ const pct = parseInt(m[1], 10);
134
+ if (Number.isFinite(pct)) return pct;
135
+ }
136
+ }
137
+ return null;
138
+ }
139
+
140
+ // Classify a disk-guard sidecar age (updatedAt vs current time).
141
+ // If the sidecar is older than 600s (10 min), classify it as stale.
142
+ function classifySidecarAge(updatedAt, currentEpoch) {
143
+ if (typeof updatedAt !== 'number' || typeof currentEpoch !== 'number') return 'stale';
144
+ const age = currentEpoch - updatedAt;
145
+ if (age > 600) return 'stale';
146
+ return 'healthy';
147
+ }
148
+
149
+ // Map or classify a general disk state level including 'unknown-exec-failed'
150
+ function mapDiskStateLevel(usedPct, hasExecError) {
151
+ if (hasExecError) return 'unknown-exec-failed';
152
+ return classifyDiskPressure(usedPct);
153
+ }
154
+
155
+ module.exports = {
156
+ DISK_WARN_PCT,
157
+ DISK_CRITICAL_PCT,
158
+ PRUNABLE_CACHE_DIRS,
159
+ CARGO_TARGET_DIR,
160
+ CARGO_TARGET_ALWAYS_PRUNE,
161
+ CARGO_TARGET_CRITICAL_CLEAN,
162
+ cargoTargetReclaimPlan,
163
+ classifyDiskPressure,
164
+ parseDfUsedPct,
165
+ classifySidecarAge,
166
+ mapDiskStateLevel,
167
+ };
package/npm/editor-dev.js CHANGED
@@ -28,6 +28,7 @@
28
28
 
29
29
  const { execSync, spawn, spawnSync } = require("child_process");
30
30
  const path = require("path");
31
+ const os = require("os");
31
32
  const {
32
33
  ensureServer,
33
34
  restartServer,
@@ -35,6 +36,8 @@ const {
35
36
  ensureBinary,
36
37
  rootDir,
37
38
  killChildProcess,
39
+ isInteractiveLauncher,
40
+ logLauncherTeardownSignal,
38
41
  readMergedEditorConfig,
39
42
  resolveLauncherPort,
40
43
  staleBinaryBanner,
@@ -87,6 +90,62 @@ function buildBinary() {
87
90
  }
88
91
  }
89
92
 
93
+ /**
94
+ * Build the React UI from the editor repo into ui/dist/. A failed build is
95
+ * non-fatal: log a warning and continue on the last-good bundle so a transient
96
+ * vite failure doesn't block launch. The cargo build embeds ui/dist/ at compile
97
+ * time via include_dir! (crates/control-api/src/embedded_ui.rs), so this MUST
98
+ * run before buildBinary() or the binary embeds the *previous* launch's bundle.
99
+ */
100
+ function buildUI() {
101
+ console.error("Building UI...");
102
+ try {
103
+ execSync("npx vite build", { stdio: "inherit", cwd: path.join(rootDir, "ui") });
104
+ } catch {
105
+ console.error("Failed to build UI. Continuing with existing build (if any)...");
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Build the launcher artifacts in embed-correct order: UI first (so the fresh
111
+ * ui/dist/ is on disk), then the Rust binary (which embeds that dist via
112
+ * include_dir!). build.rs declares `cargo:rerun-if-changed=ui/dist`, so a fresh
113
+ * bundle on disk before compilation forces cargo to recompile and embed it in
114
+ * the same launch — fixing the one-generation-stale UI bug. Only the launcher
115
+ * (no-subcommand) path calls this; the passthrough path rebuilds only the Rust
116
+ * binary and embeds no fresh UI, which is correct for non-launcher commands.
117
+ * Side-effecting steps are injectable so the ordering can be unit-tested.
118
+ */
119
+ function buildLauncherArtifacts(deps = {}) {
120
+ const {
121
+ buildUI: doBuildUI = buildUI,
122
+ buildBinary: doBuildBinary = buildBinary,
123
+ enforceFreshBinary: doEnforceFreshBinary = enforceFreshBinary,
124
+ } = deps;
125
+ // UI build FIRST — the fresh bundle must be on disk before cargo embeds it.
126
+ doBuildUI();
127
+ doEnforceFreshBinary(doBuildBinary(), {
128
+ action: "run editor commands",
129
+ continueVerb: "launching with",
130
+ });
131
+ }
132
+
133
+ /**
134
+ * Rebuild artifacts for the passthrough path (a known subcommand like
135
+ * `editor step`/`advance`): the Rust binary ONLY, no UI build. Non-launcher
136
+ * commands never serve a fresh UI, so embedding a rebuilt bundle would be
137
+ * wasted work — and pulling vite into every CLI invocation would slow the
138
+ * common `editor advance` round-trip. Side-effecting steps are injectable so
139
+ * the "no UI build on passthrough" contract is unit-testable.
140
+ */
141
+ function buildPassthroughArtifacts(deps = {}) {
142
+ const {
143
+ buildBinary: doBuildBinary = buildBinary,
144
+ enforceFreshBinary: doEnforceFreshBinary = enforceFreshBinary,
145
+ } = deps;
146
+ doEnforceFreshBinary(doBuildBinary(), { action: "run editor commands" });
147
+ }
148
+
90
149
  /**
91
150
  * Decide whether the dev wrapper should rebuild the Rust binary (and the UI,
92
151
  * for the launcher path) before forwarding to it. The answer is always yes:
@@ -112,6 +171,32 @@ function shouldRebuildOnPassthrough() {
112
171
  return true;
113
172
  }
114
173
 
174
+ /**
175
+ * Translate a passthrough child's exit into the code the dev wrapper should
176
+ * forward. A subcommand passthrough must report the inner editor binary's OWN
177
+ * outcome faithfully so every caller (gates, scripts, the agent) can trust the
178
+ * exit code:
179
+ *
180
+ * - A real exit code (including 0) is forwarded verbatim — a successful
181
+ * `analyze-imports` that printed valid JSON and exited 0 stays exit 0,
182
+ * never a spurious 1.
183
+ * - A null/undefined code means the child was terminated by a signal (e.g.
184
+ * SIGPIPE after it already produced its output, or SIGTERM at teardown).
185
+ * Report it distinctly via the conventional 128+signum so a clean run that
186
+ * was merely signalled at the end is not collapsed into a generic `1` that
187
+ * reads as a command failure. The previous `code ?? 1` erased that
188
+ * distinction, surfacing a successful inner command as exit 1.
189
+ *
190
+ * Pure (no process.exit) so the mapping is unit-testable.
191
+ */
192
+ function passthroughExitCode(code, signal) {
193
+ if (code !== null && code !== undefined) {
194
+ return code;
195
+ }
196
+ const signum = signal ? os.constants.signals[signal] : undefined;
197
+ return signum ? 128 + signum : 1;
198
+ }
199
+
115
200
  /**
116
201
  * Returns true when the merged project config has a non-empty start
117
202
  * command — either at `apps[0].startCommand` (per-app, preferred) or at
@@ -248,7 +333,11 @@ function shouldOpenBrowser({ shouldRestart, wasRunning, freshInit }) {
248
333
  module.exports = {
249
334
  hasSubcommand,
250
335
  buildBinary,
336
+ buildUI,
337
+ buildLauncherArtifacts,
338
+ buildPassthroughArtifacts,
251
339
  shouldRebuildOnPassthrough,
340
+ passthroughExitCode,
252
341
  staleBinaryBanner,
253
342
  hasStartCommand,
254
343
  planPreflightAction,
@@ -266,7 +355,7 @@ if (require.main === module) {
266
355
  // exactly when the developer wants the new code live. `ensureBinary()`
267
356
  // still builds once if no binary exists at all.
268
357
  if (shouldRebuildOnPassthrough()) {
269
- enforceFreshBinary(buildBinary(), { action: "run editor commands" });
358
+ buildPassthroughArtifacts();
270
359
  }
271
360
  const binary = ensureBinary();
272
361
  const child = spawn(binary, args, {
@@ -274,8 +363,11 @@ if (require.main === module) {
274
363
  cwd: process.cwd(),
275
364
  env: process.env,
276
365
  });
277
- child.on("exit", (code) => {
278
- process.exit(code ?? 1);
366
+ child.on("exit", (code, signal) => {
367
+ // Forward the inner editor binary's OWN outcome faithfully — exit 0
368
+ // stays 0, a signal-terminated child reports 128+signum, not a generic
369
+ // 1 that would mask a successful command (see passthroughExitCode).
370
+ process.exit(passthroughExitCode(code, signal));
279
371
  });
280
372
  } else {
281
373
  // --- Launcher mode: build everything, start server, open browser ---
@@ -350,18 +442,9 @@ if (require.main === module) {
350
442
  // client-project launch is never disrupted. `ensureBinary()` still
351
443
  // builds once if no binary exists.
352
444
  if (shouldRebuildOnPassthrough()) {
353
- enforceFreshBinary(buildBinary(), {
354
- action: "run editor commands",
355
- continueVerb: "launching with",
356
- });
357
-
358
- // Build the UI from the editor repo
359
- console.error("Building UI...");
360
- try {
361
- execSync("npx vite build", { stdio: "inherit", cwd: path.join(rootDir, "ui") });
362
- } catch {
363
- console.error("Failed to build UI. Continuing with existing build (if any)...");
364
- }
445
+ // Embed-correct order: UI build → cargo build (embeds the fresh
446
+ // ui/dist via include_dir!). See buildLauncherArtifacts.
447
+ buildLauncherArtifacts();
365
448
  }
366
449
 
367
450
  const serverChild = shouldRestart
@@ -428,6 +511,15 @@ if (require.main === module) {
428
511
  let tearingDown = false;
429
512
 
430
513
  process.on("SIGINT", () => {
514
+ // Forward a deliberate Ctrl+C (interactive launcher) to stop the editor.
515
+ // A SIGINT to a headless / agent-driven launcher is stray and must NOT
516
+ // reap the now-detached server, so swallow it and keep supervising.
517
+ // Record the signal's process-group forensics either way.
518
+ const interactive = isInteractiveLauncher();
519
+ logLauncherTeardownSignal("SIGINT", projectDir, { interactive, forwarded: interactive });
520
+ if (!interactive) {
521
+ return;
522
+ }
431
523
  tearingDown = true;
432
524
  clearInterval(keepAlive);
433
525
  killChildProcess(serverChild);
package/npm/editor.js CHANGED
@@ -22,12 +22,15 @@ const {
22
22
  waitForPort,
23
23
  rootDir,
24
24
  killChildProcess,
25
+ isInteractiveLauncher,
26
+ logLauncherTeardownSignal,
25
27
  readMergedEditorConfig,
26
28
  resolveLauncherPort,
27
29
  resolveWrappedAppPort,
28
30
  classifyPortHolder,
31
+ fetchServerIdentity,
29
32
  formatForeignListenerError,
30
- formatCrossProjectError,
33
+ resolveCrossProjectCollision,
31
34
  staleBinaryBanner,
32
35
  enforceFreshBinary,
33
36
  } = require("./utils");
@@ -45,6 +48,58 @@ function buildBinary() {
45
48
  }
46
49
  }
47
50
 
51
+ // Build the React UI into ui/dist/. A failed build is non-fatal: we log a
52
+ // warning and continue on the last-good bundle (a transient vite failure must
53
+ // not block launch). The cargo build embeds ui/dist/ at compile time via
54
+ // include_dir! (crates/control-api/src/embedded_ui.rs), so this MUST run
55
+ // before buildBinary() or the binary embeds the *previous* launch's bundle.
56
+ function buildUI() {
57
+ console.error("Building UI...");
58
+ try {
59
+ execSync("npx vite build", {
60
+ stdio: "inherit",
61
+ cwd: path.join(rootDir, "ui"),
62
+ });
63
+ } catch {
64
+ console.error("Failed to build UI. Continuing with existing build (if any)...");
65
+ }
66
+ }
67
+
68
+ // Build the launch artifacts in embed-correct order: UI first (so the fresh
69
+ // ui/dist/ is on disk), then the Rust binary (which embeds that dist via
70
+ // include_dir!), then install the freshly-compiled binary. build.rs declares
71
+ // `cargo:rerun-if-changed=ui/dist`, so a fresh bundle on disk before
72
+ // compilation forces cargo to recompile and embed it in the same launch —
73
+ // fixing the one-generation-stale UI bug. Side-effecting steps are injectable
74
+ // so the ordering can be unit-tested without shelling out to vite/cargo.
75
+ function buildArtifacts(deps = {}) {
76
+ const {
77
+ buildUI: doBuildUI = buildUI,
78
+ buildBinary: doBuildBinary = buildBinary,
79
+ enforceFreshBinary: doEnforceFreshBinary = enforceFreshBinary,
80
+ installBinary: doInstallBinary = installBinary,
81
+ } = deps;
82
+
83
+ // UI build FIRST — the fresh bundle must be on disk before cargo embeds it.
84
+ doBuildUI();
85
+ // Rebuild the Rust binary so code changes (and the just-built UI) are picked
86
+ // up on restart. This script is only invoked from a source checkout (via
87
+ // `npm run editor`), so Cargo.toml and the crates tree are always present.
88
+ const buildOk = doBuildBinary();
89
+ // A failed rebuild means the server would boot on a stale binary that no
90
+ // longer matches the source. Refuse by default; CODEYAM_ALLOW_STALE=1 forces
91
+ // the old behavior (continue with the last-good install).
92
+ doEnforceFreshBinary(buildOk, { action: "start the editor server" });
93
+ // Refresh <cargo_bin>/codeyam-editor (and the pty-broker hardlink) so the
94
+ // broker auto-spawn execs a binary that knows the `pty-broker daemon`
95
+ // subcommand. Skip if the build failed — installing a stale debug binary
96
+ // over a working install is worse than leaving it.
97
+ if (buildOk) {
98
+ doInstallBinary();
99
+ }
100
+ return buildOk;
101
+ }
102
+
48
103
  // Resolve <CARGO_HOME>/bin (falling back to $HOME/.cargo/bin). Mirrors
49
104
  // `installed_binary_path()` in crates/control-api/src/pty_broker_startup.rs
50
105
  // — the Rust broker auto-spawn execs the binary at this path, so we MUST
@@ -179,32 +234,9 @@ async function main() {
179
234
  // VITE_PORT + 1. We need to wait for the real Vite server, not the proxy.
180
235
  const VITE_INTERNAL_PORT = VITE_PORT + 1;
181
236
 
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
- }
237
+ // Build the launch artifacts in embed-correct order: UI build → cargo build
238
+ // (embeds the fresh ui/dist via include_dir!) → install. See buildArtifacts.
239
+ buildArtifacts();
208
240
 
209
241
  // Start the Rust backend — it serves ui/dist/ and launches the Vite dev
210
242
  // server (configured via startCommand in editor.json) for Live Preview HMR.
@@ -221,8 +253,12 @@ async function main() {
221
253
  process.exit(1);
222
254
  }
223
255
  if (holder.kind === "cross-project") {
224
- console.error(formatCrossProjectError(SERVER_PORT, holder, "restart"));
225
- process.exit(1);
256
+ const outcome = await resolveCrossProjectCollision(SERVER_PORT, holder, "restart");
257
+ if (outcome.action !== "took-over") {
258
+ // "abort" (non-TTY) or "declined": resolver already printed the message.
259
+ process.exit(1);
260
+ }
261
+ // took-over: stopServer freed the port; fall through to the fresh-boot path.
226
262
  }
227
263
  if (holder.kind === "same-project") {
228
264
  console.error(`Stopping existing server on port ${SERVER_PORT}...`);
@@ -235,22 +271,44 @@ async function main() {
235
271
  }
236
272
  const serverChild = await ensureServer(SERVER_PORT);
237
273
 
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.`);
274
+ // With 2+ projects registered the launch diverts to the Project Launcher
275
+ // (selector) on SERVER_PORT — there is no wrapped app, so the Vite wait and
276
+ // the "Live Preview" line don't apply. Probe the identity to detect it;
277
+ // best-effort, a missed detection only yields a harmless "Vite not ready"
278
+ // warning, never a failure.
279
+ const identity = await fetchServerIdentity(SERVER_PORT);
280
+ const launcherMode = Boolean(identity && identity.role === "launcher");
281
+
282
+ if (!launcherMode) {
283
+ // Wait for the actual Vite dev server on the internal port (not the reverse
284
+ // proxy on VITE_PORT, which starts immediately with the Rust binary).
285
+ console.error("Waiting for Vite dev server...");
286
+ const viteReady = await waitForPort(VITE_INTERNAL_PORT, 15000, 300);
287
+ if (!viteReady) {
288
+ console.error(`Warning: Vite dev server not ready on port ${VITE_INTERNAL_PORT}. Live Preview may not work initially.`);
289
+ }
244
290
  }
245
291
 
246
292
  const url = `http://localhost:${SERVER_PORT}`;
247
- console.error(`Editor UI: ${url}`);
248
- console.error(`Live Preview: http://localhost:${VITE_PORT}`);
293
+ console.error(launcherMode ? `Project selector: ${url}` : `Editor UI: ${url}`);
294
+ if (!launcherMode) {
295
+ console.error(`Live Preview: http://localhost:${VITE_PORT}`);
296
+ }
249
297
  console.error(`Logs: ${path.join(rootDir, ".codeyam", "logs")}`);
250
298
  open(url);
251
299
  const keepAlive = setInterval(() => {}, 60_000);
252
300
 
253
301
  process.on("SIGINT", () => {
302
+ // Forward a deliberate Ctrl+C (interactive launcher) to stop the editor.
303
+ // A SIGINT to a headless / agent-driven launcher is stray — the editor is
304
+ // torn down via API shutdown / pidfile there, never an interactive Ctrl+C —
305
+ // so do NOT forward it: the detached server stays alive. Either way, record
306
+ // the signal's process-group forensics for root-cause tracing.
307
+ const interactive = isInteractiveLauncher();
308
+ logLauncherTeardownSignal("SIGINT", rootDir, { interactive, forwarded: interactive });
309
+ if (!interactive) {
310
+ return;
311
+ }
254
312
  clearInterval(keepAlive);
255
313
  killChildProcess(serverChild);
256
314
  process.exit(0);
@@ -259,6 +317,8 @@ async function main() {
259
317
 
260
318
  module.exports = {
261
319
  buildBinary,
320
+ buildUI,
321
+ buildArtifacts,
262
322
  staleBinaryBanner,
263
323
  cargoBinDir,
264
324
  installBinary,
@@ -0,0 +1,75 @@
1
+ 'use strict';
2
+ // Pure decision helpers for the fleet dashboard's false-unreachable reboot
3
+ // safety (2026-06-25 incident). The dashboard's server.js execs gcloud and reads
4
+ // its in-memory state; the DECISIONS those I/O results drive live here so they
5
+ // are unit-testable in isolation (npm/fleet-auth-health.test.js), the same
6
+ // split npm/fleet-cleanup.js (reconcileRosteredVm) and npm/fleet-host.js
7
+ // (gcloudCommand) follow.
8
+ //
9
+ // Pure logic only — no I/O, no env reads, no clock.
10
+
11
+ // Classify operator gcloud auth health from the facts server.js gathers via the
12
+ // ${GCLOUD} chokepoint. Returns { degraded, reason }: degraded true means the
13
+ // operator's own auth is broken (which makes EVERY VM read unreachable), so the
14
+ // reset gate must suppress all reboots and the UI must surface a banner.
15
+ // account trimmed `gcloud config list account` output (may be empty)
16
+ // accountErrMsg non-null when the account-resolve exec itself errored
17
+ // tokenOk true/false when `auth print-access-token` was run; null when
18
+ // it was not (account-only phase) — a valid SA account with
19
+ // tokenOk null is "account looks fine, token not yet proven",
20
+ // which is NOT degraded, so the caller can proceed to the mint.
21
+ function classifyOperatorAuth({ account, accountErrMsg, tokenOk } = {}) {
22
+ const acct = typeof account === 'string' ? account.trim() : '';
23
+ if (accountErrMsg || !acct) {
24
+ return {
25
+ degraded: true,
26
+ reason: `operator gcloud could not resolve an account (${accountErrMsg || 'empty output'})`,
27
+ };
28
+ }
29
+ if (!/\.gserviceaccount\.com$/.test(acct)) {
30
+ return {
31
+ degraded: true,
32
+ reason: `operator gcloud is on a non-service-account '${acct}' — config drift; run scripts/operator/setup-service-account.sh`,
33
+ };
34
+ }
35
+ if (tokenOk === false) {
36
+ return {
37
+ degraded: true,
38
+ reason: `operator gcloud cannot mint a token for ${acct} (expired/absent credentials); run scripts/operator/setup-service-account.sh`,
39
+ };
40
+ }
41
+ return { degraded: false, reason: null };
42
+ }
43
+
44
+ // True when at least `fleetWideThreshold` VMs are simultaneously past the
45
+ // reset-unreachable `threshold` — the signature of an operator-side fault
46
+ // (auth/tunnel/network on the dashboard host) rather than independent VM
47
+ // failures. A real VM failure is isolated; a simultaneous N-VM "outage" is the
48
+ // operator, and the reset gate uses this to refuse rebooting the whole fleet.
49
+ // `vms` is an array of per-VM state objects (missing/null entries are ignored).
50
+ function isFleetWideUnreachable(vms, threshold, fleetWideThreshold) {
51
+ let count = 0;
52
+ for (const vm of Array.isArray(vms) ? vms : []) {
53
+ if (vm && !vm.reachable && (vm.unreachableStreak || 0) >= threshold) count++;
54
+ }
55
+ return count >= fleetWideThreshold;
56
+ }
57
+
58
+ // Classify a one-shot GCP-side IAP editor probe (server.js execs the SSH+curl).
59
+ // true → editor answered 200 from GCP's vantage (VM healthy; the dark local
60
+ // port is a laptop tunnel fault → respawn, never reset);
61
+ // false → the VM is up but the editor does not answer (genuinely unreachable);
62
+ // null → the IAP probe itself failed (inconclusive — never escalate a reset).
63
+ // The HTTP code is read from the LAST three chars of stdout because gcloud/ssh
64
+ // can prepend banner noise before curl's `%{http_code}` output.
65
+ function classifyIapEditorProbe({ err, stdout } = {}) {
66
+ if (err) return null;
67
+ const code = String(stdout || '').trim().slice(-3);
68
+ return code === '200';
69
+ }
70
+
71
+ module.exports = {
72
+ classifyOperatorAuth,
73
+ isFleetWideUnreachable,
74
+ classifyIapEditorProbe,
75
+ };
package/npm/fleet-auth.js CHANGED
@@ -108,4 +108,9 @@ function deriveDefaultAuthAccount(vms, lastAuthAt) {
108
108
  return best ? best.account : null;
109
109
  }
110
110
 
111
- module.exports = { classifyAuthVm, selectAuthTargets, summarizeAuthBatch, deriveDefaultAuthAccount };
111
+ // Return true if this one VM is eligible for an auth-start action now.
112
+ function isAuthStartable(v, job) {
113
+ return classifyAuthVm(v, job).eligible;
114
+ }
115
+
116
+ module.exports = { classifyAuthVm, selectAuthTargets, summarizeAuthBatch, deriveDefaultAuthAccount, isAuthStartable };