@codeyam-editor/codeyam-editor 0.1.2 → 0.1.3
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 +494 -86
- package/npm/editor-dev.js +133 -52
- package/npm/editor.js +35 -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-cleanup.js +189 -16
- package/npm/fleet-effect-status.js +179 -0
- package/npm/fleet-grouping.js +56 -0
- package/npm/fleet-job-badge.js +39 -0
- package/npm/fleet-memory.js +35 -5
- package/npm/fleet-reset.js +94 -16
- package/npm/launch-config.js +88 -10
- package/npm/preflight-guidance.js +9 -0
- package/npm/scenario-check.js +25 -0
- package/npm/scenario-interactivity.js +53 -7
- package/npm/scenario-playwright.js +184 -5
- package/npm/utils.js +100 -46
- package/package.json +5 -5
- package/ui/dist/assets/EditorShell-CGsBSA6u.js +186 -0
- package/ui/dist/assets/ScenarioDataPanel-nuLXJzLf.js +5 -0
- package/ui/dist/assets/{ScenarioDataPanelFullPage-C0YHQB-T.js → ScenarioDataPanelFullPage-Bnghtj8a.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-B_Za9jL8.js +1 -0
- package/ui/dist/assets/index-Bih6HJ_u.css +1 -0
- package/ui/dist/assets/{index-BUYNFqKK.js → index-KDFAgt2e.js} +2 -2
- 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/editor-dev.js
CHANGED
|
@@ -33,7 +33,6 @@ const {
|
|
|
33
33
|
restartServer,
|
|
34
34
|
waitForPort,
|
|
35
35
|
ensureBinary,
|
|
36
|
-
isSelfHostTarget,
|
|
37
36
|
rootDir,
|
|
38
37
|
killChildProcess,
|
|
39
38
|
readMergedEditorConfig,
|
|
@@ -90,28 +89,27 @@ function buildBinary() {
|
|
|
90
89
|
|
|
91
90
|
/**
|
|
92
91
|
* Decide whether the dev wrapper should rebuild the Rust binary (and the UI,
|
|
93
|
-
* for the launcher path) before forwarding to it.
|
|
94
|
-
*
|
|
95
|
-
*
|
|
92
|
+
* for the launcher path) before forwarding to it. The answer is always yes:
|
|
93
|
+
* `codeyam-editor-dev` ALWAYS rebuilds before running. Running fresh source is
|
|
94
|
+
* the entire purpose of the dev wrapper — if a developer wants a frozen tool
|
|
95
|
+
* that never rebuilds, the cargo-installed `codeyam-editor` binary is exactly
|
|
96
|
+
* that.
|
|
96
97
|
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
98
|
+
* `cargo build` is idempotent: a sub-second no-op that does not relink when
|
|
99
|
+
* nothing changed. So always-building costs nothing for a developer who only
|
|
100
|
+
* uses the editor as a tool against their own app (the binary never changes →
|
|
101
|
+
* the server-side stale-binary watcher never fires → zero mid-session
|
|
102
|
+
* restarts), and produces a rebuild+restart only when editor source actually
|
|
103
|
+
* changed — which is exactly when the developer wants the new code live.
|
|
103
104
|
*
|
|
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.
|
|
105
|
+
* The injected cwd / repoRoot / env are retained so the contract — "rebuild
|
|
106
|
+
* regardless of where you run it or what env is set" — is demonstrable in a
|
|
107
|
+
* unit test across the dimensions that used to gate the decision (self-host
|
|
108
|
+
* vs. external project, `CODEYAM_DEV_REBUILD` set or not). They no longer
|
|
109
|
+
* influence the result.
|
|
111
110
|
*/
|
|
112
|
-
function shouldRebuildOnPassthrough(
|
|
113
|
-
|
|
114
|
-
return isSelfHostTarget(cwd, repoRoot);
|
|
111
|
+
function shouldRebuildOnPassthrough() {
|
|
112
|
+
return true;
|
|
115
113
|
}
|
|
116
114
|
|
|
117
115
|
/**
|
|
@@ -154,6 +152,12 @@ function hasStartCommand(mergedConfig) {
|
|
|
154
152
|
* can migrate from the preserved classification, so auto-running init would
|
|
155
153
|
* strand them. The Rust-side `start_preflight` stays the safety net for
|
|
156
154
|
* direct `codeyam-editor start` calls; this launcher composes init + start.
|
|
155
|
+
*
|
|
156
|
+
* Intentional divergence (improve39): this launcher auto-inits ALL `none`
|
|
157
|
+
* projects (empty or existing-source); the Rust `start_preflight` now
|
|
158
|
+
* auto-inits only the EMPTY case and still bails (exit 2) on existing-source.
|
|
159
|
+
* That gap is pre-existing and deliberate — the dev launcher is a local
|
|
160
|
+
* convenience, not the direct-`start` contract.
|
|
157
161
|
*/
|
|
158
162
|
function planPreflightAction(classification) {
|
|
159
163
|
if (classification.kind === "legacy") {
|
|
@@ -181,16 +185,22 @@ function planPreflightAction(classification) {
|
|
|
181
185
|
*
|
|
182
186
|
* action !== "init" → null (modern: proceed, no banner;
|
|
183
187
|
* legacy already bailed earlier)
|
|
184
|
-
* action "init", wasEmpty → /codeyam-editor
|
|
188
|
+
* action "init", wasEmpty → greenfield message. /codeyam-editor is driven
|
|
189
|
+
* from inside the browser UI, never hand-invoked,
|
|
190
|
+
* so the wording follows `browserOpened`: if the
|
|
191
|
+
* launcher actually opened a tab, promise it; if
|
|
192
|
+
* not (e.g. --restart attach), point at the URL
|
|
193
|
+
* so the user still has a path forward. The
|
|
194
|
+
* banner must never promise a tab that didn't
|
|
195
|
+
* appear.
|
|
185
196
|
* action "init", !wasEmpty → /codeyam-onboard migrate-existing
|
|
186
197
|
*/
|
|
187
|
-
function postLaunchInitBanner({ preflightAction, wasEmpty }) {
|
|
198
|
+
function postLaunchInitBanner({ preflightAction, wasEmpty, browserOpened, url }) {
|
|
188
199
|
if (preflightAction !== "init") return null;
|
|
189
200
|
if (wasEmpty) {
|
|
190
|
-
return
|
|
191
|
-
"\
|
|
192
|
-
|
|
193
|
-
);
|
|
201
|
+
return browserOpened
|
|
202
|
+
? "\nA browser window is opening where you can begin your project."
|
|
203
|
+
: `\nOpen ${url} in your browser to begin your project.`;
|
|
194
204
|
}
|
|
195
205
|
return (
|
|
196
206
|
"\nInitialized .codeyam/ in this directory. To populate scenarios, " +
|
|
@@ -198,6 +208,43 @@ function postLaunchInitBanner({ preflightAction, wasEmpty }) {
|
|
|
198
208
|
);
|
|
199
209
|
}
|
|
200
210
|
|
|
211
|
+
/**
|
|
212
|
+
* Decide whether the launcher should open a browser tab to the editor UI.
|
|
213
|
+
* Pure (no fs, no spawn) so the gating is unit-testable without a binary,
|
|
214
|
+
* mirroring `planPreflightAction` / `postLaunchInitBanner`.
|
|
215
|
+
*
|
|
216
|
+
* The browser open belongs ONLY on a genuine cold start — bringing the
|
|
217
|
+
* editor server up fresh against a not-yet-running port. Two cases must
|
|
218
|
+
* NOT re-pop a tab, because one is presumably already open:
|
|
219
|
+
*
|
|
220
|
+
* shouldRestart → false `--restart` (rebuild-self, restart-server routed
|
|
221
|
+
* through this wrapper, a manual --restart) tears
|
|
222
|
+
* down and respawns a server the user is already
|
|
223
|
+
* looking at. Re-opening spams tabs — the reported
|
|
224
|
+
* symptom. Mirrors the Rust `--no-open` restart
|
|
225
|
+
* spawn in restart_server.rs.
|
|
226
|
+
* wasRunning → false `ensureServer` attached to an already-running
|
|
227
|
+
* same-project server (returned null) — no fresh
|
|
228
|
+
* process, so no new tab.
|
|
229
|
+
*
|
|
230
|
+
* Only a fresh spawn on a cold start (neither restart nor attach) opens the
|
|
231
|
+
* UI, matching the Rust `no_open`-gated `open_url` in start.rs.
|
|
232
|
+
*
|
|
233
|
+
* freshInit → true A brand-new empty project that was just init'd
|
|
234
|
+
* this run. The entire greenfield onboarding flow
|
|
235
|
+
* is driven from inside the editor UI, and a
|
|
236
|
+
* project that didn't exist a moment ago has no
|
|
237
|
+
* pre-existing tab for the --restart tab-spam guard
|
|
238
|
+
* to protect. So a fresh init opens regardless of
|
|
239
|
+
* `shouldRestart` — UNLESS we merely attached to an
|
|
240
|
+
* already-running server (`wasRunning`), which
|
|
241
|
+
* implies a tab is already up.
|
|
242
|
+
*/
|
|
243
|
+
function shouldOpenBrowser({ shouldRestart, wasRunning, freshInit }) {
|
|
244
|
+
if (freshInit) return !wasRunning;
|
|
245
|
+
return !shouldRestart && !wasRunning;
|
|
246
|
+
}
|
|
247
|
+
|
|
201
248
|
module.exports = {
|
|
202
249
|
hasSubcommand,
|
|
203
250
|
buildBinary,
|
|
@@ -206,24 +253,19 @@ module.exports = {
|
|
|
206
253
|
hasStartCommand,
|
|
207
254
|
planPreflightAction,
|
|
208
255
|
postLaunchInitBanner,
|
|
256
|
+
shouldOpenBrowser,
|
|
209
257
|
};
|
|
210
258
|
|
|
211
259
|
if (require.main === module) {
|
|
212
260
|
// --- Passthrough mode: build binary, then forward to it ---
|
|
213
261
|
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
|
-
) {
|
|
262
|
+
// Always rebuild before forwarding — running fresh source is the whole
|
|
263
|
+
// point of the dev wrapper. `cargo build` is a sub-second no-op when
|
|
264
|
+
// nothing changed (no relink → the server-side stale-binary watcher never
|
|
265
|
+
// fires), and relinks only when editor source actually changed, which is
|
|
266
|
+
// exactly when the developer wants the new code live. `ensureBinary()`
|
|
267
|
+
// still builds once if no binary exists at all.
|
|
268
|
+
if (shouldRebuildOnPassthrough()) {
|
|
227
269
|
enforceFreshBinary(buildBinary(), { action: "run editor commands" });
|
|
228
270
|
}
|
|
229
271
|
const binary = ensureBinary();
|
|
@@ -300,19 +342,14 @@ if (require.main === module) {
|
|
|
300
342
|
const projectMerged = readMergedEditorConfig(projectDir);
|
|
301
343
|
const appPort = resolveLauncherPort(undefined, projectMerged.port, 3000);
|
|
302
344
|
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
//
|
|
345
|
+
// Always rebuild the Rust binary + UI before launching — same contract
|
|
346
|
+
// as the passthrough path. The build runs BEFORE the server (re)starts
|
|
347
|
+
// below, so the new server records its start time after the relink and
|
|
348
|
+
// the stale-binary watcher reads it as fresh (no immediate SIGUSR1). A
|
|
349
|
+
// no-op `cargo build` (nothing changed) doesn't relink, so an external
|
|
350
|
+
// client-project launch is never disrupted. `ensureBinary()` still
|
|
309
351
|
// builds once if no binary exists.
|
|
310
|
-
|
|
311
|
-
cwd: projectDir,
|
|
312
|
-
repoRoot: rootDir,
|
|
313
|
-
env: process.env,
|
|
314
|
-
});
|
|
315
|
-
if (shouldRebuildLaunch) {
|
|
352
|
+
if (shouldRebuildOnPassthrough()) {
|
|
316
353
|
enforceFreshBinary(buildBinary(), {
|
|
317
354
|
action: "run editor commands",
|
|
318
355
|
continueVerb: "launching with",
|
|
@@ -356,21 +393,65 @@ if (require.main === module) {
|
|
|
356
393
|
console.error(`Live Preview: http://localhost:${appPort}`);
|
|
357
394
|
}
|
|
358
395
|
console.error(`Project: ${projectDir}`);
|
|
359
|
-
|
|
396
|
+
// Open the browser only on a genuine cold start. On the --restart path
|
|
397
|
+
// (rebuild-self, restart-server, manual --restart) and when we attached
|
|
398
|
+
// to an already-running server, a tab is presumably already open — the
|
|
399
|
+
// `Editor UI: <url>` line above is the signal. `ensureServer` returns
|
|
400
|
+
// null when it attached rather than spawning, so that is `wasRunning`.
|
|
401
|
+
// Exception: a fresh greenfield init (`freshInit`) opens regardless of
|
|
402
|
+
// --restart, because a project that didn't exist a moment ago has no
|
|
403
|
+
// pre-existing tab to protect and the onboarding flow lives in the UI.
|
|
404
|
+
// Compute the open decision once and feed the SAME boolean into the
|
|
405
|
+
// banner so it can never promise a tab that didn't appear.
|
|
406
|
+
const freshInit = preflight.action === "init" && wasEmpty;
|
|
407
|
+
const openedBrowser = shouldOpenBrowser({
|
|
408
|
+
shouldRestart,
|
|
409
|
+
wasRunning: serverChild === null,
|
|
410
|
+
freshInit,
|
|
411
|
+
});
|
|
412
|
+
if (openedBrowser) {
|
|
413
|
+
open(url);
|
|
414
|
+
}
|
|
360
415
|
const banner = postLaunchInitBanner({
|
|
361
416
|
preflightAction: preflight.action,
|
|
362
417
|
wasEmpty,
|
|
418
|
+
browserOpened: openedBrowser,
|
|
419
|
+
url,
|
|
363
420
|
});
|
|
364
421
|
if (banner) {
|
|
365
422
|
console.error(banner);
|
|
366
423
|
}
|
|
367
424
|
const keepAlive = setInterval(() => {}, 60_000);
|
|
368
425
|
|
|
426
|
+
// Track launcher-initiated teardown so the unexpected-exit handler below
|
|
427
|
+
// can tell "we killed it on SIGINT" from "the server died on its own".
|
|
428
|
+
let tearingDown = false;
|
|
429
|
+
|
|
369
430
|
process.on("SIGINT", () => {
|
|
431
|
+
tearingDown = true;
|
|
370
432
|
clearInterval(keepAlive);
|
|
371
433
|
killChildProcess(serverChild);
|
|
372
434
|
process.exit(0);
|
|
373
435
|
});
|
|
436
|
+
|
|
437
|
+
// Defense-in-depth against orphaned launchers: if the spawned server
|
|
438
|
+
// child exits and we did NOT initiate it (SIGINT), clear keepAlive and
|
|
439
|
+
// exit rather than lingering as a node process holding a dead child
|
|
440
|
+
// handle. We deliberately do NOT respawn — an exited server should let
|
|
441
|
+
// the launcher exit cleanly so stale supervisors can't accumulate across
|
|
442
|
+
// repeated launches. The in-place SIGUSR1 re-exec keeps the SAME pid, so
|
|
443
|
+
// it does NOT emit an "exit" here and does not trip this path; only a
|
|
444
|
+
// genuinely dead server does. `serverChild` is null when we attached to
|
|
445
|
+
// an already-running server (nothing of ours to watch).
|
|
446
|
+
if (serverChild) {
|
|
447
|
+
serverChild.on("exit", (code) => {
|
|
448
|
+
if (tearingDown) {
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
clearInterval(keepAlive);
|
|
452
|
+
process.exit(code ?? 1);
|
|
453
|
+
});
|
|
454
|
+
}
|
|
374
455
|
};
|
|
375
456
|
|
|
376
457
|
launcherMain();
|
package/npm/editor.js
CHANGED
|
@@ -82,17 +82,19 @@ function linkOrCopyBinary(srcPath, dstPath, tmpPath) {
|
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
// Install target/debug/codeyam-editor to <cargo_bin>/codeyam-editor
|
|
86
|
-
//
|
|
87
|
-
// codeyam-editor-pty-broker
|
|
85
|
+
// Install target/debug/codeyam-editor to <cargo_bin>/codeyam-editor and
|
|
86
|
+
// the dedicated target/debug/codeyam-editor-pty-broker to
|
|
87
|
+
// <cargo_bin>/codeyam-editor-pty-broker, both atomically (write to a tmp
|
|
88
|
+
// in the same dir, then rename).
|
|
88
89
|
//
|
|
89
|
-
// The
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
// the
|
|
90
|
+
// The broker is now its OWN binary (its own inode), NOT a hardlink of the
|
|
91
|
+
// monolith — a dedicated executable file is what gives the long-lived
|
|
92
|
+
// broker daemon a stable, honest process name on every platform
|
|
93
|
+
// (especially macOS, where hardlink aliases share one vnode name cache
|
|
94
|
+
// and bleed across roles). The PTY broker auto-spawn
|
|
95
|
+
// (control-api/src/pty_broker_startup.rs) prefers this dedicated binary
|
|
96
|
+
// next to the installed monolith; a stale or missing copy is what made
|
|
97
|
+
// build tabs hang on "Reconnecting...", so both must be refreshed here.
|
|
96
98
|
function installBinary() {
|
|
97
99
|
const srcPath = path.join(rootDir, "target", "debug", "codeyam-editor");
|
|
98
100
|
if (!fs.existsSync(srcPath)) {
|
|
@@ -101,21 +103,37 @@ function installBinary() {
|
|
|
101
103
|
}
|
|
102
104
|
const binDir = cargoBinDir();
|
|
103
105
|
const dstPath = path.join(binDir, "codeyam-editor");
|
|
104
|
-
const linkPath = path.join(binDir, "codeyam-editor-pty-broker");
|
|
105
106
|
const tmpPath = path.join(binDir, `.codeyam-editor.tmp.${process.pid}`);
|
|
106
107
|
console.error(`Installing fresh binary to ${dstPath}...`);
|
|
107
108
|
try {
|
|
108
109
|
fs.mkdirSync(binDir, { recursive: true });
|
|
109
110
|
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
111
|
} catch (err) {
|
|
117
112
|
console.error(`Failed to install binary: ${err.message}`);
|
|
118
113
|
try { fs.unlinkSync(tmpPath); } catch {}
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Dedicated broker binary — install as its own file (own inode), not a
|
|
118
|
+
// hardlink. Best-effort: if cargo didn't build it (older workspace),
|
|
119
|
+
// the broker auto-spawn falls back to the monolith `editor pty-broker
|
|
120
|
+
// daemon` shim, so a missing broker binary is a warning, not fatal.
|
|
121
|
+
const brokerSrc = path.join(rootDir, "target", "debug", "codeyam-editor-pty-broker");
|
|
122
|
+
const brokerDst = path.join(binDir, "codeyam-editor-pty-broker");
|
|
123
|
+
const brokerTmp = path.join(binDir, `.codeyam-editor-pty-broker.tmp.${process.pid}`);
|
|
124
|
+
if (!fs.existsSync(brokerSrc)) {
|
|
125
|
+
console.error(
|
|
126
|
+
`Note: ${brokerSrc} not found — broker will fall back to the monolith shim. ` +
|
|
127
|
+
`Run \`cargo build -p codeyam-pty-broker\` to build it.`
|
|
128
|
+
);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
console.error(`Installing dedicated broker binary to ${brokerDst}...`);
|
|
132
|
+
try {
|
|
133
|
+
linkOrCopyBinary(brokerSrc, brokerDst, brokerTmp);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
console.error(`Failed to install broker binary: ${err.message}`);
|
|
136
|
+
try { fs.unlinkSync(brokerTmp); } catch {}
|
|
119
137
|
}
|
|
120
138
|
}
|
|
121
139
|
|
|
@@ -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 };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Pure consecutive-failure debounce for the Fleet Dashboard's broker tri-state
|
|
3
|
+
// (scripts/operator/fleet-dashboard/server.js requires this). Given the raw
|
|
4
|
+
// single-poll status from npm/fleet-broker-status.js::deriveBrokerStatus and the
|
|
5
|
+
// per-VM debounce state from the previous poll, decide the status the card
|
|
6
|
+
// actually renders. No I/O — server.js does the polling and threads the state.
|
|
7
|
+
//
|
|
8
|
+
// The bug this fixes (observed live 2026-06-02, VM3): the dashboard polls each
|
|
9
|
+
// VM's broker every 3s, and the editor probes the broker with a single 200ms
|
|
10
|
+
// socket attempt. One transient timeout (IAP / SSH-tunnel congestion — the same
|
|
11
|
+
// class of hiccup behind the known false "Editor stopped") instantly flipped a
|
|
12
|
+
// healthy VM's card to 'down', which raised both the red broker-down dot and the
|
|
13
|
+
// 💀 death alarm, even though the broker was fine (the build tab opened and chat
|
|
14
|
+
// started immediately). The single probe is left as-is on the editor side; the
|
|
15
|
+
// right seam for absorbing a one-poll blip is here at the aggregation layer.
|
|
16
|
+
//
|
|
17
|
+
// Asymmetric by design: ONLY the flip to 'down' is debounced (it requires
|
|
18
|
+
// `threshold` consecutive 'down' polls). 'idle' / 'agent-live' / 'unknown' take
|
|
19
|
+
// effect immediately — we never want to DELAY clearing a false alarm, only
|
|
20
|
+
// delay raising one. And an unreachable editor ('unknown') breaks a pending
|
|
21
|
+
// down-streak rather than escalating it, mirroring fleet-broker-status.js's
|
|
22
|
+
// "absence of evidence is never down" rule.
|
|
23
|
+
//
|
|
24
|
+
// State shape carried across polls: { status, downStreak, lastConfirmed }.
|
|
25
|
+
// status — the debounced value the card renders this poll.
|
|
26
|
+
// downStreak — how many consecutive raw 'down' polls we've seen.
|
|
27
|
+
// lastConfirmed — the last raw 'idle'/'agent-live' we proved; held during a
|
|
28
|
+
// pending (sub-threshold) down-streak so the card stays calm.
|
|
29
|
+
|
|
30
|
+
// ~9s at the dashboard's 3s poll cadence: comfortably absorbs a transient probe
|
|
31
|
+
// timeout and the brief down-window of a routine restart-server (broker down for
|
|
32
|
+
// <3s between SIGTERM and respawn), while still surfacing a real outage quickly.
|
|
33
|
+
// Named so retuning is a one-line edit.
|
|
34
|
+
const DOWN_DEBOUNCE_THRESHOLD = 3;
|
|
35
|
+
|
|
36
|
+
function debounceBrokerStatus(prev, rawStatus, opts) {
|
|
37
|
+
const threshold = (opts && opts.threshold) || DOWN_DEBOUNCE_THRESHOLD;
|
|
38
|
+
const lastConfirmed = prev ? prev.lastConfirmed : undefined;
|
|
39
|
+
|
|
40
|
+
// Confirmed up: the daemon answered now. Render immediately, reset the streak,
|
|
41
|
+
// and remember this as the last-confirmed-up value.
|
|
42
|
+
if (rawStatus === 'idle' || rawStatus === 'agent-live') {
|
|
43
|
+
return { status: rawStatus, downStreak: 0, lastConfirmed: rawStatus };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Editor unreachable: we proved nothing. Never down — reset any pending
|
|
47
|
+
// down-streak and render 'unknown' (the card shows nothing for it).
|
|
48
|
+
if (rawStatus !== 'down') {
|
|
49
|
+
return { status: 'unknown', downStreak: 0, lastConfirmed };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Raw 'down': only a real outage if it persists. Count the streak.
|
|
53
|
+
const downStreak = (prev ? prev.downStreak : 0) + 1;
|
|
54
|
+
if (downStreak >= threshold) {
|
|
55
|
+
// Confirmed down — surface the alarm.
|
|
56
|
+
return { status: 'down', downStreak, lastConfirmed };
|
|
57
|
+
}
|
|
58
|
+
// Pending (sub-threshold) down: hold the last confirmed up status if we have
|
|
59
|
+
// one, else 'unknown'. The card stays calm during the blip.
|
|
60
|
+
return { status: lastConfirmed || 'unknown', downStreak, lastConfirmed };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { debounceBrokerStatus, DOWN_DEBOUNCE_THRESHOLD };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Pure tri-state derivation for the Fleet Dashboard's per-VM broker indicator
|
|
3
|
+
// (scripts/operator/fleet-dashboard/server.js requires this). Given the raw
|
|
4
|
+
// /api/session-info poll merge `v`, collapse it into the one label the card
|
|
5
|
+
// renders. No I/O — server.js does the polling.
|
|
6
|
+
//
|
|
7
|
+
// The bug this fixes (observed live 2026-06-01): the old indicator was
|
|
8
|
+
// `v.reachable ? !!v.agentBrokerLive : null`, where `agentBrokerLive` means
|
|
9
|
+
// "a build-mode agent session is live in the broker" — NOT "broker daemon up".
|
|
10
|
+
// So a perfectly IDLE VM (healthy broker daemon, no parked build agent) reported
|
|
11
|
+
// `agentBrokerLive=false`, indistinguishable from a VM whose broker was actually
|
|
12
|
+
// DOWN. An operator killed a working broker chasing that phantom. The editor now
|
|
13
|
+
// also reports `brokerDaemonReachable` (the daemon answered its socket) and a
|
|
14
|
+
// derived `brokerStatus`; this module layers the dashboard's own reachability on
|
|
15
|
+
// top so the four states stay distinct:
|
|
16
|
+
//
|
|
17
|
+
// 'unknown' — editor unreachable (can't probe the broker at all), OR an
|
|
18
|
+
// older VM whose editor predates `brokerDaemonReachable`. Never
|
|
19
|
+
// rendered as a problem: absence of evidence is not "down".
|
|
20
|
+
// 'down' — daemon unreachable → the real broker problem, the state that
|
|
21
|
+
// had NO signal before. A VM here cannot start an agent session.
|
|
22
|
+
// 'idle' — daemon up, no build agent → a normal idle VM, not an error.
|
|
23
|
+
// 'agent-live' — a live build-mode agent session in the broker's build slot.
|
|
24
|
+
const BROKER_STATUSES = ['unknown', 'down', 'idle', 'agent-live'];
|
|
25
|
+
|
|
26
|
+
function deriveBrokerStatus(v) {
|
|
27
|
+
// Editor unreachable: we polled nothing, so the broker state is unknowable.
|
|
28
|
+
// Reporting 'down' here is exactly the conflation that misled operators —
|
|
29
|
+
// an unreachable editor is not a dead broker.
|
|
30
|
+
if (!v || !v.reachable) return 'unknown';
|
|
31
|
+
// Prefer the editor's own tri-state when it reports one (newer VMs).
|
|
32
|
+
if (v.brokerStatus === 'down' || v.brokerStatus === 'idle' || v.brokerStatus === 'agent-live') {
|
|
33
|
+
return v.brokerStatus;
|
|
34
|
+
}
|
|
35
|
+
// Fallback derivation from the raw bits, for a VM reporting the bools but not
|
|
36
|
+
// the pre-derived `brokerStatus`. `agentBrokerLive` implies the daemon is up.
|
|
37
|
+
if (v.agentBrokerLive) return 'agent-live';
|
|
38
|
+
if (v.brokerDaemonReachable === true) return 'idle';
|
|
39
|
+
if (v.brokerDaemonReachable === false) return 'down';
|
|
40
|
+
// Older VM: reachable editor, but no broker-reachability field at all. Treat
|
|
41
|
+
// as 'unknown' (graceful fallback) — never falsely 'down'.
|
|
42
|
+
return 'unknown';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = { deriveBrokerStatus, BROKER_STATUSES };
|