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