@codeyam-editor/codeyam-editor 0.1.4 → 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 +1415 -73
  3. package/npm/container.js +3 -0
  4. package/npm/disk-pressure.js +78 -0
  5. package/npm/editor-dev.js +96 -15
  6. package/npm/editor.js +85 -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 +51 -2
  10. package/npm/fleet-cleanup.js +118 -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 +1 -1
  15. package/npm/fleet-launchd.js +171 -0
  16. package/npm/fleet-live-status.js +123 -0
  17. package/npm/fleet-plans.js +135 -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 +43 -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 +204 -3
  35. package/npm/scenario-check.js +603 -32
  36. package/npm/scenario-handlers.js +78 -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 +329 -50
  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-BuK0lizt.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-tMMdarZD.js +0 -187
  61. package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +0 -5
  62. package/ui/dist/assets/ScenarioDataPanelFullPage-5G6KuZgw.js +0 -1
  63. package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +0 -1
  64. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
  65. package/ui/dist/assets/index-BGqairFV.css +0 -1
  66. package/ui/dist/assets/useEvents-DggWrtHe.js +0 -1
package/npm/container.js CHANGED
@@ -35,6 +35,9 @@ async function main() {
35
35
  14199,
36
36
  );
37
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.
38
41
  const serverChild = await ensureServer(PORT);
39
42
  const url = `http://localhost:${PORT}`;
40
43
  console.error(`Container UI: ${url}`);
@@ -45,6 +45,63 @@ const PRUNABLE_CACHE_DIRS = [
45
45
  '.angular/cache',
46
46
  ];
47
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
+
48
105
  // Classify an integer disk-usage percent into a pressure level. A non-numeric,
49
106
  // non-finite, or out-of-range (<0 or >100) value is treated as a BAD READING
50
107
  // and classified 'ok' with no action — a misread must never escalate to a
@@ -80,10 +137,31 @@ function parseDfUsedPct(dfOutput) {
80
137
  return null;
81
138
  }
82
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
+
83
155
  module.exports = {
84
156
  DISK_WARN_PCT,
85
157
  DISK_CRITICAL_PCT,
86
158
  PRUNABLE_CACHE_DIRS,
159
+ CARGO_TARGET_DIR,
160
+ CARGO_TARGET_ALWAYS_PRUNE,
161
+ CARGO_TARGET_CRITICAL_CLEAN,
162
+ cargoTargetReclaimPlan,
87
163
  classifyDiskPressure,
88
164
  parseDfUsedPct,
165
+ classifySidecarAge,
166
+ mapDiskStateLevel,
89
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,
@@ -89,6 +90,62 @@ function buildBinary() {
89
90
  }
90
91
  }
91
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
+
92
149
  /**
93
150
  * Decide whether the dev wrapper should rebuild the Rust binary (and the UI,
94
151
  * for the launcher path) before forwarding to it. The answer is always yes:
@@ -114,6 +171,32 @@ function shouldRebuildOnPassthrough() {
114
171
  return true;
115
172
  }
116
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
+
117
200
  /**
118
201
  * Returns true when the merged project config has a non-empty start
119
202
  * command — either at `apps[0].startCommand` (per-app, preferred) or at
@@ -250,7 +333,11 @@ function shouldOpenBrowser({ shouldRestart, wasRunning, freshInit }) {
250
333
  module.exports = {
251
334
  hasSubcommand,
252
335
  buildBinary,
336
+ buildUI,
337
+ buildLauncherArtifacts,
338
+ buildPassthroughArtifacts,
253
339
  shouldRebuildOnPassthrough,
340
+ passthroughExitCode,
254
341
  staleBinaryBanner,
255
342
  hasStartCommand,
256
343
  planPreflightAction,
@@ -268,7 +355,7 @@ if (require.main === module) {
268
355
  // exactly when the developer wants the new code live. `ensureBinary()`
269
356
  // still builds once if no binary exists at all.
270
357
  if (shouldRebuildOnPassthrough()) {
271
- enforceFreshBinary(buildBinary(), { action: "run editor commands" });
358
+ buildPassthroughArtifacts();
272
359
  }
273
360
  const binary = ensureBinary();
274
361
  const child = spawn(binary, args, {
@@ -276,8 +363,11 @@ if (require.main === module) {
276
363
  cwd: process.cwd(),
277
364
  env: process.env,
278
365
  });
279
- child.on("exit", (code) => {
280
- 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));
281
371
  });
282
372
  } else {
283
373
  // --- Launcher mode: build everything, start server, open browser ---
@@ -352,18 +442,9 @@ if (require.main === module) {
352
442
  // client-project launch is never disrupted. `ensureBinary()` still
353
443
  // builds once if no binary exists.
354
444
  if (shouldRebuildOnPassthrough()) {
355
- enforceFreshBinary(buildBinary(), {
356
- action: "run editor commands",
357
- continueVerb: "launching with",
358
- });
359
-
360
- // Build the UI from the editor repo
361
- console.error("Building UI...");
362
- try {
363
- execSync("npx vite build", { stdio: "inherit", cwd: path.join(rootDir, "ui") });
364
- } catch {
365
- console.error("Failed to build UI. Continuing with existing build (if any)...");
366
- }
445
+ // Embed-correct order: UI build → cargo build (embeds the fresh
446
+ // ui/dist via include_dir!). See buildLauncherArtifacts.
447
+ buildLauncherArtifacts();
367
448
  }
368
449
 
369
450
  const serverChild = shouldRestart
package/npm/editor.js CHANGED
@@ -28,8 +28,9 @@ const {
28
28
  resolveLauncherPort,
29
29
  resolveWrappedAppPort,
30
30
  classifyPortHolder,
31
+ fetchServerIdentity,
31
32
  formatForeignListenerError,
32
- formatCrossProjectError,
33
+ resolveCrossProjectCollision,
33
34
  staleBinaryBanner,
34
35
  enforceFreshBinary,
35
36
  } = require("./utils");
@@ -47,6 +48,58 @@ function buildBinary() {
47
48
  }
48
49
  }
49
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
+
50
103
  // Resolve <CARGO_HOME>/bin (falling back to $HOME/.cargo/bin). Mirrors
51
104
  // `installed_binary_path()` in crates/control-api/src/pty_broker_startup.rs
52
105
  // — the Rust broker auto-spawn execs the binary at this path, so we MUST
@@ -181,32 +234,9 @@ async function main() {
181
234
  // VITE_PORT + 1. We need to wait for the real Vite server, not the proxy.
182
235
  const VITE_INTERNAL_PORT = VITE_PORT + 1;
183
236
 
184
- // Rebuild the Rust binary so code changes are picked up on restart.
185
- // This script is only invoked from a source checkout (via `npm run editor`),
186
- // so Cargo.toml and the crates tree are always present.
187
- const buildOk = buildBinary();
188
- // A failed rebuild means the server would boot on a stale binary that no
189
- // longer matches the source. Refuse by default; CODEYAM_ALLOW_STALE=1
190
- // forces the old behavior (continue with the last-good install).
191
- enforceFreshBinary(buildOk, { action: "start the editor server" });
192
- // Refresh <cargo_bin>/codeyam-editor (and the pty-broker hardlink) so
193
- // the broker auto-spawn execs a binary that knows the `pty-broker
194
- // daemon` subcommand. Skip if the build failed — installing a stale
195
- // debug binary over a working install is worse than leaving it.
196
- if (buildOk) {
197
- installBinary();
198
- }
199
-
200
- // Build the UI so the backend can serve it as static files
201
- console.error("Building UI...");
202
- try {
203
- execSync("npx vite build", {
204
- stdio: "inherit",
205
- cwd: path.join(rootDir, "ui"),
206
- });
207
- } catch {
208
- console.error("Failed to build UI. Continuing with existing build (if any)...");
209
- }
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();
210
240
 
211
241
  // Start the Rust backend — it serves ui/dist/ and launches the Vite dev
212
242
  // server (configured via startCommand in editor.json) for Live Preview HMR.
@@ -223,8 +253,12 @@ async function main() {
223
253
  process.exit(1);
224
254
  }
225
255
  if (holder.kind === "cross-project") {
226
- console.error(formatCrossProjectError(SERVER_PORT, holder, "restart"));
227
- 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.
228
262
  }
229
263
  if (holder.kind === "same-project") {
230
264
  console.error(`Stopping existing server on port ${SERVER_PORT}...`);
@@ -237,17 +271,29 @@ async function main() {
237
271
  }
238
272
  const serverChild = await ensureServer(SERVER_PORT);
239
273
 
240
- // Wait for the actual Vite dev server on the internal port (not the reverse
241
- // proxy on VITE_PORT, which starts immediately with the Rust binary).
242
- console.error("Waiting for Vite dev server...");
243
- const viteReady = await waitForPort(VITE_INTERNAL_PORT, 15000, 300);
244
- if (!viteReady) {
245
- 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
+ }
246
290
  }
247
291
 
248
292
  const url = `http://localhost:${SERVER_PORT}`;
249
- console.error(`Editor UI: ${url}`);
250
- 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
+ }
251
297
  console.error(`Logs: ${path.join(rootDir, ".codeyam", "logs")}`);
252
298
  open(url);
253
299
  const keepAlive = setInterval(() => {}, 60_000);
@@ -271,6 +317,8 @@ async function main() {
271
317
 
272
318
  module.exports = {
273
319
  buildBinary,
320
+ buildUI,
321
+ buildArtifacts,
274
322
  staleBinaryBanner,
275
323
  cargoBinDir,
276
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 };
@@ -32,6 +32,14 @@ function buildVmEnvelope(v, ctx) {
32
32
  // upstream, debounced). Richer than brokerLive's is-an-agent-live boolean.
33
33
  brokerStatus: ctx.brokerStatus,
34
34
  awaitingInput: !!v.awaitingUserInput,
35
+ // True when the editor reports the build workflow reached its terminal step
36
+ // and is parked awaiting cleanup (session_info_response.rs emits it at
37
+ // step >= total). Flows onto poll state via the `...info` spread in server.js
38
+ // exactly like `feature`/`awaitingUserInput`; defaulted false here so a legacy
39
+ // editor that predates the field degrades to today's behavior. Consumed by
40
+ // fleet-live-status.js to read a parked-but-done agent as calm 'session-done'
41
+ // instead of active-green 'building'.
42
+ needsCleanup: !!v.needsCleanup,
35
43
  feature: v.feature || null,
36
44
  step: v.step != null ? v.step : null,
37
45
  mode: v.mode || null,
@@ -39,6 +47,15 @@ function buildVmEnvelope(v, ctx) {
39
47
  status: ctx.status,
40
48
  orphan: ctx.orphan || null,
41
49
  degradedReasons: ctx.degradedReasons || [],
50
+ // Derived presentation split of degradedReasons (vm: Dashboard 'Finishing
51
+ // setup' state): `setupSteps` are expected first-run wiring rendered as a
52
+ // calm blue checklist, `attentionReasons` are genuine problems kept in the
53
+ // amber ⚠ treatment. `degradedReasons` is left intact for the many places
54
+ // that still consume the flat list (filters, sort, reconcile); these two are
55
+ // purely additive. Both default to [] so a legacy/healthy VM renders neither
56
+ // block. See npm/fleet-setup-steps.js::classifyReasons.
57
+ setupSteps: Array.isArray(ctx.setupSteps) ? ctx.setupSteps : [],
58
+ attentionReasons: Array.isArray(ctx.attentionReasons) ? ctx.attentionReasons : [],
42
59
  isHead: !!ctx.isHead,
43
60
  // True when this VM holds the queue head but is unreachable (operator
44
61
  // decides manually whether to release it).
@@ -61,6 +78,35 @@ function buildVmEnvelope(v, ctx) {
61
78
  // card's 🤖 agent badge. null for an older VM whose editor predates the
62
79
  // field (the card omits the badge rather than rendering a blank chip).
63
80
  currentProvider: v.currentProvider || null,
81
+ // Raw workflow timestamps (ISO) surfaced by /api/session-info and merged onto
82
+ // the poll state via the `...info` spread, exactly like feature/step/label.
83
+ // The card live-ticks the elapsed minutes off these between polls. null for a
84
+ // legacy editor that predates the fields, or a VM with no active step.
85
+ startedAt: v.startedAt || null,
86
+ featureStartedAt: v.featureStartedAt || null,
87
+ // The server-computed stuck / needs-you verdict (npm/fleet-stuck-status.js):
88
+ // { stepElapsedMs, level: 'ok'|'slow'|'stuck', needsYou, needsYouElapsedMs }.
89
+ // The authoritative level only changes on poll; the card colors the step
90
+ // tenure badge by it. null when buildData couldn't compute one.
91
+ attention: ctx.attention || null,
92
+ // Per-VM card readiness derived upstream (npm/fleet-vm-readiness.js): one of
93
+ // READINESS_STATES. The card colors the border and GATES the editor link on
94
+ // it — a non-'ready' VM never presents its public URL as live, because the
95
+ // LB would 503. Computed in buildData where the broker tri-state / job / LB
96
+ // mode are on hand, then carried here so the browser needs no extra request.
97
+ readiness: ctx.readiness || null,
98
+ // The raw LB backend-health map { editor, preview } the readiness derivation
99
+ // consumed, surfaced so the card can show a precise tooltip (which surface
100
+ // is unwired). null in legacy mode or before the first slow probe lands.
101
+ backendHealth: v.backendHealth || null,
102
+ // The per-VM IAP access whitelist (the principals explicitly granted
103
+ // resource-level access to THIS VM, from the fleet-vm-access sidecar). Merged
104
+ // onto the poll state at server.js, it drives the card's 🔐 access line and
105
+ // the Manage access modal's "this VM only" list. It was previously dropped
106
+ // here — the exact currentProvider-style envelope drift this module guards
107
+ // against — so the line and list rendered empty even when the sidecar held
108
+ // principals. Defaults to [] for a VM with no list.
109
+ vmAccess: Array.isArray(v.vmAccess) ? v.vmAccess : [],
64
110
  };
65
111
  }
66
112
 
@@ -72,10 +118,13 @@ function buildVmEnvelope(v, ctx) {
72
118
  // if you do one without the other.
73
119
  const REQUIRED_CARD_FIELDS = [
74
120
  'n', 'url', 'reachable', 'hasSession', 'brokerLive', 'brokerStatus',
75
- 'awaitingInput', 'feature', 'step', 'mode', 'label', 'status', 'orphan',
76
- 'degradedReasons', 'isHead', 'headHolderUnreachable', 'queuePos', 'queueLen',
121
+ 'awaitingInput', 'needsCleanup', 'feature', 'step', 'mode', 'label', 'status', 'orphan',
122
+ 'degradedReasons', 'setupSteps', 'attentionReasons',
123
+ 'isHead', 'headHolderUnreachable', 'queuePos', 'queueLen',
77
124
  'job', 'config', 'project', 'variant', 'cardIdentity', 'brokerHistory',
78
125
  'brokerDeath', 'providerAuth', 'effects', 'currentProvider',
126
+ 'readiness', 'backendHealth', 'vmAccess',
127
+ 'startedAt', 'featureStartedAt', 'attention',
79
128
  ];
80
129
 
81
130
  module.exports = { buildVmEnvelope, REQUIRED_CARD_FIELDS };