@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.
- package/npm/cloud-branch-switch.js +1 -1
- package/npm/cloud.js +1693 -96
- package/npm/container.js +14 -0
- package/npm/disk-pressure.js +167 -0
- package/npm/editor-dev.js +107 -15
- package/npm/editor.js +97 -37
- package/npm/fleet-auth-health.js +75 -0
- package/npm/fleet-auth.js +6 -1
- package/npm/fleet-build-data.js +130 -0
- package/npm/fleet-cleanup.js +145 -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 +46 -2
- package/npm/fleet-launchd.js +171 -0
- package/npm/fleet-live-status.js +123 -0
- package/npm/fleet-plans.js +231 -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 +100 -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 +310 -4
- package/npm/scenario-check.js +684 -33
- package/npm/scenario-handlers.js +94 -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 +453 -43
- 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-KDFAgt2e.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-CGsBSA6u.js +0 -186
- package/ui/dist/assets/ScenarioDataPanel-nuLXJzLf.js +0 -5
- package/ui/dist/assets/ScenarioDataPanelFullPage-Bnghtj8a.js +0 -1
- package/ui/dist/assets/ScenarioRenderer-B_Za9jL8.js +0 -1
- package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
- package/ui/dist/assets/index-Bih6HJ_u.css +0 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
183
|
-
//
|
|
184
|
-
|
|
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
|
-
|
|
225
|
-
|
|
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
|
-
//
|
|
239
|
-
//
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|