@codeyam-editor/codeyam-editor 0.1.1 → 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/cli.js +5 -2
- package/npm/cloud-branch-switch.js +442 -0
- package/npm/cloud.js +1929 -0
- package/npm/container.js +22 -3
- package/npm/editor-dev.js +368 -37
- package/npm/editor.js +133 -52
- package/npm/empty-project-dir.js +33 -0
- package/npm/fleet-auth.js +111 -0
- 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 +297 -0
- package/npm/fleet-effect-status.js +179 -0
- package/npm/fleet-env.js +125 -0
- package/npm/fleet-grouping.js +56 -0
- package/npm/fleet-job-badge.js +39 -0
- package/npm/fleet-memory.js +157 -0
- package/npm/fleet-pool-policy.js +78 -0
- package/npm/fleet-pool.js +401 -0
- package/npm/fleet-queue.js +40 -0
- package/npm/fleet-reconcile.js +36 -0
- package/npm/fleet-reset.js +222 -0
- package/npm/fleet-roster.js +81 -0
- package/npm/launch-config.js +622 -0
- package/npm/preflight-guidance.js +77 -0
- package/npm/scenario-check.js +323 -14
- package/npm/scenario-handlers.js +16 -5
- package/npm/scenario-interactivity.js +245 -0
- package/npm/scenario-metrics.js +16 -2
- package/npm/scenario-mocks.js +62 -25
- package/npm/scenario-playwright.js +350 -8
- package/npm/utils.js +562 -22
- package/npm/vm-urls.js +117 -0
- package/package.json +8 -7
- package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +2 -0
- package/ui/dist/assets/EditorShell-CGsBSA6u.js +186 -0
- package/ui/dist/assets/Markdown-BHBqz-Mj.js +1 -0
- package/ui/dist/assets/ScenarioDataPanel-DYP7pi_n.css +32 -0
- package/ui/dist/assets/ScenarioDataPanel-nuLXJzLf.js +5 -0
- package/ui/dist/assets/ScenarioDataPanelFullPage-Bnghtj8a.js +1 -0
- package/ui/dist/assets/ScenarioRenderer-B_Za9jL8.js +1 -0
- package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +1 -0
- package/ui/dist/assets/index-Bih6HJ_u.css +1 -0
- package/ui/dist/assets/index-KDFAgt2e.js +27 -0
- package/ui/dist/assets/markdown-v0F0UEt3.js +37 -0
- package/ui/dist/assets/react-CSS0HapR.js +28 -0
- package/ui/dist/assets/useEvents-DggWrtHe.js +1 -0
- package/ui/dist/assets/xterm--24IGk-x.js +9 -0
- package/ui/dist/favicon.ico +0 -0
- package/ui/dist/favicon.png +0 -0
- package/ui/dist/index.html +16 -0
- package/npm/postinstall.js +0 -132
package/npm/editor.js
CHANGED
|
@@ -16,14 +16,23 @@ const { execSync } = require("child_process");
|
|
|
16
16
|
const fs = require("fs");
|
|
17
17
|
const os = require("os");
|
|
18
18
|
const path = require("path");
|
|
19
|
-
const {
|
|
19
|
+
const {
|
|
20
|
+
ensureServer,
|
|
21
|
+
stopServer,
|
|
22
|
+
waitForPort,
|
|
23
|
+
rootDir,
|
|
24
|
+
killChildProcess,
|
|
25
|
+
readMergedEditorConfig,
|
|
26
|
+
resolveLauncherPort,
|
|
27
|
+
resolveWrappedAppPort,
|
|
28
|
+
classifyPortHolder,
|
|
29
|
+
formatForeignListenerError,
|
|
30
|
+
formatCrossProjectError,
|
|
31
|
+
staleBinaryBanner,
|
|
32
|
+
enforceFreshBinary,
|
|
33
|
+
} = require("./utils");
|
|
20
34
|
const { open } = require("./open");
|
|
21
35
|
|
|
22
|
-
const SERVER_PORT = parseInt(process.env.CODEYAM_EDITOR_PORT || "14199", 10);
|
|
23
|
-
const VITE_PORT = parseInt(process.env.PORT || "5173", 10);
|
|
24
|
-
// The reverse proxy sits on VITE_PORT; the actual Vite dev server runs on
|
|
25
|
-
// VITE_PORT + 1. We need to wait for the real Vite server, not the proxy.
|
|
26
|
-
const VITE_INTERNAL_PORT = VITE_PORT + 1;
|
|
27
36
|
const shouldRestart = process.argv.includes("--restart");
|
|
28
37
|
|
|
29
38
|
function buildBinary() {
|
|
@@ -32,7 +41,6 @@ function buildBinary() {
|
|
|
32
41
|
execSync("cargo build", { stdio: "inherit", cwd: rootDir });
|
|
33
42
|
return true;
|
|
34
43
|
} catch {
|
|
35
|
-
console.error("Failed to build Rust binary. Continuing with existing build (if any)...");
|
|
36
44
|
return false;
|
|
37
45
|
}
|
|
38
46
|
}
|
|
@@ -47,17 +55,46 @@ function cargoBinDir() {
|
|
|
47
55
|
return path.join(os.homedir(), ".cargo", "bin");
|
|
48
56
|
}
|
|
49
57
|
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
58
|
+
// Place a hardlink at `dstPath` pointing at the same inode as `srcPath`,
|
|
59
|
+
// replacing any existing file at `dstPath`. On EXDEV (cross-filesystem,
|
|
60
|
+
// rare; target/ on a separate mount from the cargo bin dir) falls back
|
|
61
|
+
// to an atomic copy-and-rename via `tmpPath` so we still succeed — at
|
|
62
|
+
// the cost of distinct inodes, which means the dev binary's
|
|
63
|
+
// `stale_cargo_bin_message` check will fire on every boot.
|
|
53
64
|
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
65
|
+
// Hardlink-by-default is what keeps the stale-binary advisory silent on
|
|
66
|
+
// `npm run editor`: the freshly-installed binary at <cargo_bin> shares
|
|
67
|
+
// an inode with target/debug/codeyam-editor, so the inode-identity
|
|
68
|
+
// check returns "same underlying file" and nothing is printed.
|
|
69
|
+
function linkOrCopyBinary(srcPath, dstPath, tmpPath) {
|
|
70
|
+
try {
|
|
71
|
+
fs.unlinkSync(dstPath);
|
|
72
|
+
} catch (e) {
|
|
73
|
+
if (e.code !== "ENOENT") throw e;
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
fs.linkSync(srcPath, dstPath);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
if (err.code !== "EXDEV") throw err;
|
|
79
|
+
fs.copyFileSync(srcPath, tmpPath);
|
|
80
|
+
fs.chmodSync(tmpPath, 0o755);
|
|
81
|
+
fs.renameSync(tmpPath, dstPath);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
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).
|
|
89
|
+
//
|
|
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.
|
|
61
98
|
function installBinary() {
|
|
62
99
|
const srcPath = path.join(rootDir, "target", "debug", "codeyam-editor");
|
|
63
100
|
if (!fs.existsSync(srcPath)) {
|
|
@@ -66,23 +103,37 @@ function installBinary() {
|
|
|
66
103
|
}
|
|
67
104
|
const binDir = cargoBinDir();
|
|
68
105
|
const dstPath = path.join(binDir, "codeyam-editor");
|
|
69
|
-
const linkPath = path.join(binDir, "codeyam-editor-pty-broker");
|
|
70
106
|
const tmpPath = path.join(binDir, `.codeyam-editor.tmp.${process.pid}`);
|
|
71
107
|
console.error(`Installing fresh binary to ${dstPath}...`);
|
|
72
108
|
try {
|
|
73
109
|
fs.mkdirSync(binDir, { recursive: true });
|
|
74
|
-
|
|
75
|
-
fs.chmodSync(tmpPath, 0o755);
|
|
76
|
-
fs.renameSync(tmpPath, dstPath);
|
|
77
|
-
try {
|
|
78
|
-
fs.unlinkSync(linkPath);
|
|
79
|
-
} catch (e) {
|
|
80
|
-
if (e.code !== "ENOENT") throw e;
|
|
81
|
-
}
|
|
82
|
-
fs.linkSync(dstPath, linkPath);
|
|
110
|
+
linkOrCopyBinary(srcPath, dstPath, tmpPath);
|
|
83
111
|
} catch (err) {
|
|
84
112
|
console.error(`Failed to install binary: ${err.message}`);
|
|
85
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 {}
|
|
86
137
|
}
|
|
87
138
|
}
|
|
88
139
|
|
|
@@ -98,32 +149,44 @@ function sweepStaleArtifacts() {
|
|
|
98
149
|
}
|
|
99
150
|
}
|
|
100
151
|
|
|
152
|
+
/**
|
|
153
|
+
* npm wrapper entry point for the editor binary: resolves ports from
|
|
154
|
+
* the merged editor config, ensures the Rust backend is running, and
|
|
155
|
+
* opens the editor UI.
|
|
156
|
+
*/
|
|
101
157
|
async function main() {
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
158
|
+
// Read ports from the merged editor config (editor.json + per-developer
|
|
159
|
+
// editor.local.json). The Rust backend reads the same merged config on
|
|
160
|
+
// its own — we resolve here so the launcher's log lines, `waitForPort`
|
|
161
|
+
// calls, and `--port` arg agree with what the binary will actually
|
|
162
|
+
// bind. Env vars still win for scripted/CI invocations
|
|
163
|
+
// (CODEYAM_EDITOR_PORT, PORT), then the merged config, then a hard
|
|
164
|
+
// fallback for fresh checkouts with no override file.
|
|
165
|
+
const merged = readMergedEditorConfig(rootDir);
|
|
166
|
+
const SERVER_PORT = resolveLauncherPort(
|
|
167
|
+
process.env.CODEYAM_EDITOR_PORT,
|
|
168
|
+
merged.proxy && merged.proxy.controlPort,
|
|
169
|
+
14199,
|
|
170
|
+
);
|
|
171
|
+
// `resolveWrappedAppPort` mirrors `resolve_app_port` in
|
|
172
|
+
// `crates/codeyam-editor/src/commands/start.rs`: `env > apps[0].port >
|
|
173
|
+
// config.port`. Without this the launcher's log line and
|
|
174
|
+
// `waitForPort` probe targeted one port while the backend bound
|
|
175
|
+
// another, producing the misleading "Vite dev server not ready on
|
|
176
|
+
// port N" warning whenever apps[0] declared its own port.
|
|
177
|
+
const VITE_PORT = resolveWrappedAppPort(merged, process.env.PORT, 5173);
|
|
178
|
+
// The reverse proxy sits on VITE_PORT; the actual Vite dev server runs on
|
|
179
|
+
// VITE_PORT + 1. We need to wait for the real Vite server, not the proxy.
|
|
180
|
+
const VITE_INTERNAL_PORT = VITE_PORT + 1;
|
|
122
181
|
|
|
123
182
|
// Rebuild the Rust binary so code changes are picked up on restart.
|
|
124
183
|
// This script is only invoked from a source checkout (via `npm run editor`),
|
|
125
184
|
// so Cargo.toml and the crates tree are always present.
|
|
126
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" });
|
|
127
190
|
// Refresh <cargo_bin>/codeyam-editor (and the pty-broker hardlink) so
|
|
128
191
|
// the broker auto-spawn execs a binary that knows the `pty-broker
|
|
129
192
|
// daemon` subcommand. Skip if the build failed — installing a stale
|
|
@@ -145,11 +208,27 @@ async function main() {
|
|
|
145
208
|
|
|
146
209
|
// Start the Rust backend — it serves ui/dist/ and launches the Vite dev
|
|
147
210
|
// server (configured via startCommand in editor.json) for Live Preview HMR.
|
|
148
|
-
// On --restart: stop the existing server
|
|
149
|
-
// target/ is idle
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
211
|
+
// On --restart: identity-check first, then stop the existing server,
|
|
212
|
+
// sweep stale artifacts while target/ is idle, then start fresh.
|
|
213
|
+
//
|
|
214
|
+
// Stopping someone else's editor server on `--restart` is the worst
|
|
215
|
+
// possible outcome of a port collision — only stop the listener if
|
|
216
|
+
// the identity probe confirms it's this project's server.
|
|
217
|
+
if (shouldRestart) {
|
|
218
|
+
const holder = await classifyPortHolder(SERVER_PORT);
|
|
219
|
+
if (holder.kind === "foreign") {
|
|
220
|
+
console.error(formatForeignListenerError(SERVER_PORT));
|
|
221
|
+
process.exit(1);
|
|
222
|
+
}
|
|
223
|
+
if (holder.kind === "cross-project") {
|
|
224
|
+
console.error(formatCrossProjectError(SERVER_PORT, holder, "restart"));
|
|
225
|
+
process.exit(1);
|
|
226
|
+
}
|
|
227
|
+
if (holder.kind === "same-project") {
|
|
228
|
+
console.error(`Stopping existing server on port ${SERVER_PORT}...`);
|
|
229
|
+
await stopServer(SERVER_PORT);
|
|
230
|
+
}
|
|
231
|
+
// holder.kind === "free": nothing to stop, fall through to ensureServer.
|
|
153
232
|
}
|
|
154
233
|
if (shouldRestart) {
|
|
155
234
|
sweepStaleArtifacts();
|
|
@@ -180,8 +259,10 @@ async function main() {
|
|
|
180
259
|
|
|
181
260
|
module.exports = {
|
|
182
261
|
buildBinary,
|
|
262
|
+
staleBinaryBanner,
|
|
183
263
|
cargoBinDir,
|
|
184
264
|
installBinary,
|
|
265
|
+
linkOrCopyBinary,
|
|
185
266
|
main,
|
|
186
267
|
sweepStaleArtifacts,
|
|
187
268
|
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Launcher-side mirror of `is_empty_project_dir` in
|
|
5
|
+
* `crates/codeyam-editor/src/commands/init.rs`. The Rust side decides
|
|
6
|
+
* which `/codeyam-…` skill the init binary points at; this JS mirror
|
|
7
|
+
* decides the same for the launcher's post-launch tail banner. Keep
|
|
8
|
+
* both in sync — the test `empty-project-dir.test.js` and Rust's
|
|
9
|
+
* `is_empty_project_dir_ignores_hidden_entries` /
|
|
10
|
+
* `is_empty_project_dir_detects_visible_entry` pin the contract.
|
|
11
|
+
*
|
|
12
|
+
* A "fresh" project directory has no non-hidden entries — only things
|
|
13
|
+
* like `.git/`, `.gitignore`, or other dotfiles. Hidden entries don't
|
|
14
|
+
* count as project content, so a dir holding only `.git/` and
|
|
15
|
+
* `.gitignore` (the "I just ran `git init`" case) still reads as empty.
|
|
16
|
+
* A read error returns false, matching the Rust `Err(_) => false` branch:
|
|
17
|
+
* a missing / unreadable dir defaults to the existing-project path rather
|
|
18
|
+
* than falsely scaffolding over nothing.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const fs = require("node:fs");
|
|
22
|
+
|
|
23
|
+
function isEmptyProjectDir(projectDir) {
|
|
24
|
+
let entries;
|
|
25
|
+
try {
|
|
26
|
+
entries = fs.readdirSync(projectDir);
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
return entries.every((name) => name.startsWith("."));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { isEmptyProjectDir };
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Pure helpers for the fleet dashboard's "Authenticate idle VMs" batch flow
|
|
3
|
+
// (scripts/operator/fleet-dashboard/server.js requires this). The decision of
|
|
4
|
+
// WHICH VMs a fleet-auth pass touches — and how to summarize an in-flight
|
|
5
|
+
// batch — lives here so server.js's HTTP orchestration stays thin and the
|
|
6
|
+
// guardrail logic is unit-testable in isolation (npm/fleet-auth.test.js).
|
|
7
|
+
//
|
|
8
|
+
// Why per-VM real `claude /login` and not one shared credential: copying a
|
|
9
|
+
// `.credentials.json` between VMs 401s (Claude rotates/binds the OAuth access
|
|
10
|
+
// token to its originating VM), and the `CLAUDE_CODE_OAUTH_TOKEN` setup-token
|
|
11
|
+
// is limited-scope + non-durable (lost on container recreate). So every VM
|
|
12
|
+
// needs its own interactive login — this batches the human step across the
|
|
13
|
+
// fleet (start every idle VM's login in parallel, approve them as one list)
|
|
14
|
+
// instead of distributing a single token. See docs/cloud-editor-stability.md
|
|
15
|
+
// "Fleet auth".
|
|
16
|
+
|
|
17
|
+
// Classify one VM for the fleet-auth pass. Returns { eligible, reason }.
|
|
18
|
+
//
|
|
19
|
+
// The guardrail mirrors Destroy / Reconfigure / roll: NEVER touch an active
|
|
20
|
+
// session (a mid-build agent must not have its editor driven into a login
|
|
21
|
+
// PTY), and never start an auth pass on a VM that already has a running or
|
|
22
|
+
// queued job. Only a reachable, idle VM the editor reports as `needs-login` is
|
|
23
|
+
// a target. An `unknown` / absent auth state is deliberately NOT a target —
|
|
24
|
+
// the probe is indeterminate (a transient failure on an otherwise-healthy VM),
|
|
25
|
+
// and driving a login we can't confirm is needed risks disrupting a working
|
|
26
|
+
// VM. `v` is the dashboard's per-VM poll state (`state.vms[n]`); `job` is
|
|
27
|
+
// `state.jobs[n]` (may be undefined).
|
|
28
|
+
function classifyAuthVm(v, job) {
|
|
29
|
+
if (job && (job.state === 'running' || job.state === 'queued')) {
|
|
30
|
+
return { eligible: false, reason: 'busy' };
|
|
31
|
+
}
|
|
32
|
+
if (!v || !v.reachable) return { eligible: false, reason: 'unreachable' };
|
|
33
|
+
if (v.hasSession) return { eligible: false, reason: 'active-session' };
|
|
34
|
+
const state = v.providerAuth && v.providerAuth.state;
|
|
35
|
+
if (state === 'authenticated') return { eligible: false, reason: 'already-authenticated' };
|
|
36
|
+
if (state === 'needs-login') return { eligible: true, reason: 'needs-login' };
|
|
37
|
+
return { eligible: false, reason: 'auth-state-unknown' };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Select every VM the fleet-auth pass should drive a login on, plus the
|
|
41
|
+
// skipped VMs each with the reason it was excluded (surfaced to the operator
|
|
42
|
+
// so "why didn't VM-7 get authenticated?" is answerable without ssh).
|
|
43
|
+
//
|
|
44
|
+
// roster: number[] (the live VMS array). vms / jobs: maps keyed by VM number.
|
|
45
|
+
// Returns { targets: number[], skipped: [{ n, reason }] }, both sorted by n.
|
|
46
|
+
function selectAuthTargets({ roster, vms, jobs }) {
|
|
47
|
+
const targets = [];
|
|
48
|
+
const skipped = [];
|
|
49
|
+
for (const n of [...(roster || [])].sort((a, b) => a - b)) {
|
|
50
|
+
const c = classifyAuthVm((vms || {})[n], (jobs || {})[n]);
|
|
51
|
+
if (c.eligible) targets.push(n);
|
|
52
|
+
else skipped.push({ n, reason: c.reason });
|
|
53
|
+
}
|
|
54
|
+
return { targets, skipped };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Aggregate an in-flight batch (map of n → { phase }) into counts for the
|
|
58
|
+
// operator report. Phases mirror the editor's /api/auth/status states
|
|
59
|
+
// ('starting' | 'url-ready' | 'authenticated' | 'failed') plus the
|
|
60
|
+
// dashboard-side 'submitting' while a pasted code is being delivered. Pure
|
|
61
|
+
// over the values; `done` is true once no VM is still mid-flight, which is
|
|
62
|
+
// what lets the UI stop polling / show a final "N of M authenticated" line.
|
|
63
|
+
function summarizeAuthBatch(batchVms) {
|
|
64
|
+
const counts = { total: 0, starting: 0, urlReady: 0, submitting: 0, authenticated: 0, failed: 0 };
|
|
65
|
+
for (const n of Object.keys(batchVms || {})) {
|
|
66
|
+
const phase = batchVms[n] && batchVms[n].phase;
|
|
67
|
+
counts.total++;
|
|
68
|
+
if (phase === 'starting') counts.starting++;
|
|
69
|
+
else if (phase === 'url-ready') counts.urlReady++;
|
|
70
|
+
else if (phase === 'submitting') counts.submitting++;
|
|
71
|
+
else if (phase === 'authenticated') counts.authenticated++;
|
|
72
|
+
else if (phase === 'failed') counts.failed++;
|
|
73
|
+
}
|
|
74
|
+
// In-flight = still starting, or awaiting a pasted code, or submitting one.
|
|
75
|
+
// A `failed` VM is terminal until the operator restarts the batch, and an
|
|
76
|
+
// `authenticated` VM is done — so neither counts as in-flight.
|
|
77
|
+
counts.inFlight = counts.starting + counts.urlReady + counts.submitting;
|
|
78
|
+
counts.done = counts.total > 0 && counts.inFlight === 0;
|
|
79
|
+
return counts;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Derive the fleet's current default Claude account (improve38): the account a
|
|
83
|
+
// newly launched VM should authenticate as. Defined as the account of the
|
|
84
|
+
// rostered VM whose auth was updated most recently, where "auth-update time" =
|
|
85
|
+
// max(dashboard-recorded login-completion `lastAuthAt[n]`, the editor-reported
|
|
86
|
+
// `providerAuth.lastRefreshAt`). Re-authenticating ANY existing VM thus
|
|
87
|
+
// re-points the default. Pure over the polled VM snapshot + the lastAuthAt map.
|
|
88
|
+
//
|
|
89
|
+
// Returns the winning VM's account object ({ email, organizationName,
|
|
90
|
+
// organizationType, subscriptionType }), or null when no rostered VM reports an
|
|
91
|
+
// account. Ties — and the no-timestamp case — resolve to the first such VM in
|
|
92
|
+
// `vms` order, so the result is deterministic.
|
|
93
|
+
function deriveDefaultAuthAccount(vms, lastAuthAt) {
|
|
94
|
+
const at = lastAuthAt || {};
|
|
95
|
+
let best = null;
|
|
96
|
+
for (const v of vms || []) {
|
|
97
|
+
const pa = v && v.providerAuth;
|
|
98
|
+
const acct = pa && pa.account;
|
|
99
|
+
if (!acct || !acct.email) continue;
|
|
100
|
+
const refreshMs = pa.lastRefreshAt ? Date.parse(pa.lastRefreshAt) : NaN;
|
|
101
|
+
const loginMs = at[v.n] ? Date.parse(at[v.n]) : NaN;
|
|
102
|
+
const score = Math.max(
|
|
103
|
+
Number.isFinite(refreshMs) ? refreshMs : -Infinity,
|
|
104
|
+
Number.isFinite(loginMs) ? loginMs : -Infinity,
|
|
105
|
+
);
|
|
106
|
+
if (!best || score > best.score) best = { account: acct, score };
|
|
107
|
+
}
|
|
108
|
+
return best ? best.account : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = { classifyAuthVm, selectAuthTargets, summarizeAuthBatch, deriveDefaultAuthAccount };
|
|
@@ -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 };
|