@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.
- package/npm/cloud-branch-switch.js +1 -1
- package/npm/cloud.js +1415 -73
- package/npm/container.js +3 -0
- package/npm/disk-pressure.js +78 -0
- package/npm/editor-dev.js +96 -15
- package/npm/editor.js +85 -37
- package/npm/fleet-auth-health.js +75 -0
- package/npm/fleet-auth.js +6 -1
- package/npm/fleet-build-data.js +51 -2
- package/npm/fleet-cleanup.js +118 -7
- package/npm/fleet-destroy-safety.js +55 -0
- package/npm/fleet-effect-status.js +33 -32
- package/npm/fleet-host.js +74 -0
- package/npm/fleet-job-badge.js +1 -1
- package/npm/fleet-launchd.js +171 -0
- package/npm/fleet-live-status.js +123 -0
- package/npm/fleet-plans.js +135 -0
- package/npm/fleet-prewarm.js +165 -0
- package/npm/fleet-public-url.js +160 -0
- package/npm/fleet-queue.js +38 -1
- package/npm/fleet-reconcile.js +43 -1
- package/npm/fleet-reset.js +298 -13
- package/npm/fleet-roster.js +6 -8
- package/npm/fleet-self-update.js +142 -0
- package/npm/fleet-setup-steps.js +105 -0
- package/npm/fleet-sleep-status.js +33 -0
- package/npm/fleet-strand.js +150 -0
- package/npm/fleet-stuck-status.js +111 -0
- package/npm/fleet-vm-access.js +234 -0
- package/npm/fleet-vm-readiness.js +104 -0
- package/npm/fleet-vm-slugs.js +116 -0
- package/npm/google-ingress.js +211 -0
- package/npm/ingress-invoke.js +292 -0
- package/npm/launch-config.js +204 -3
- package/npm/scenario-check.js +603 -32
- package/npm/scenario-handlers.js +78 -12
- package/npm/scenario-metrics.js +116 -1
- package/npm/scenario-mocks.js +20 -0
- package/npm/scenario-playwright.js +366 -29
- package/npm/script-drift.js +81 -0
- package/npm/utils.js +329 -50
- package/npm/vm-urls.js +69 -8
- package/package.json +5 -5
- package/ui/dist/assets/ContainerInspector-D7VZkkaj.js +2 -0
- package/ui/dist/assets/EditorShell-DHhIaotd.css +1 -0
- package/ui/dist/assets/EditorShell-r-796N_W.js +191 -0
- package/ui/dist/assets/{Markdown-BHBqz-Mj.js → Markdown-BoLHHFXH.js} +1 -1
- package/ui/dist/assets/ProjectLauncherScreen-Ck-XWkQN.js +1 -0
- package/ui/dist/assets/ScenarioDataPanel-B9iDSUpA.js +5 -0
- package/ui/dist/assets/ScenarioDataPanelFullPage-CBLTGC4-.js +1 -0
- package/ui/dist/assets/ScenarioRenderer-BhLfT-nX.js +21 -0
- package/ui/dist/assets/StepsSimulator-BFldWyoX.js +1 -0
- package/ui/dist/assets/index-CIXZkwfB.css +1 -0
- package/ui/dist/assets/{index-BuK0lizt.js → index-QsOiOG9x.js} +9 -9
- package/ui/dist/assets/useEvents-DHkLJZR4.js +1 -0
- package/ui/dist/codeyam-mark.svg +4 -0
- package/ui/dist/codeyam-wordmark.svg +5 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +0 -2
- package/ui/dist/assets/EditorShell-tMMdarZD.js +0 -187
- package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +0 -5
- package/ui/dist/assets/ScenarioDataPanelFullPage-5G6KuZgw.js +0 -1
- package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +0 -1
- package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
- package/ui/dist/assets/index-BGqairFV.css +0 -1
- 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}`);
|
package/npm/disk-pressure.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
185
|
-
//
|
|
186
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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
|
-
//
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|
package/npm/fleet-build-data.js
CHANGED
|
@@ -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', '
|
|
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 };
|