@codeyam-editor/codeyam-editor 0.1.0-staging.01efa1a
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 +19 -0
- package/npm/cloud-branch-switch.js +442 -0
- package/npm/cloud.js +2184 -0
- package/npm/container.js +54 -0
- package/npm/editor-dev.js +459 -0
- package/npm/editor.js +272 -0
- 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-build-data.js +81 -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 +83 -0
- package/npm/fleet-memory.js +157 -0
- package/npm/fleet-plans.js +96 -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 +93 -0
- package/npm/fleet-reset.js +222 -0
- package/npm/fleet-roster.js +81 -0
- package/npm/launch-config.js +717 -0
- package/npm/open.js +15 -0
- package/npm/preflight-guidance.js +77 -0
- package/npm/scenario-check.js +604 -0
- package/npm/scenario-handlers.js +82 -0
- package/npm/scenario-interactivity.js +245 -0
- package/npm/scenario-issues.js +35 -0
- package/npm/scenario-metrics.js +108 -0
- package/npm/scenario-mocks.js +107 -0
- package/npm/scenario-playwright.js +534 -0
- package/npm/utils.js +823 -0
- package/npm/vm-urls.js +117 -0
- package/package.json +32 -0
- package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +2 -0
- package/ui/dist/assets/EditorShell-By-2rl7D.js +187 -0
- package/ui/dist/assets/Markdown-BHBqz-Mj.js +1 -0
- package/ui/dist/assets/ScenarioDataPanel-Cg8zOFNZ.js +5 -0
- package/ui/dist/assets/ScenarioDataPanel-DYP7pi_n.css +32 -0
- package/ui/dist/assets/ScenarioDataPanelFullPage-WOUYbba7.js +1 -0
- package/ui/dist/assets/ScenarioRenderer-CBTlIVPs.js +1 -0
- package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +1 -0
- package/ui/dist/assets/index-DzoF9RKw.js +27 -0
- package/ui/dist/assets/index-ckz94rKI.css +1 -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/editor.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Start the editor.
|
|
5
|
+
*
|
|
6
|
+
* 1. Builds the React UI into ui/dist/ (stable outer shell, no HMR)
|
|
7
|
+
* 2. Starts the Rust backend which:
|
|
8
|
+
* - Serves ui/dist/ as static files on port 14199
|
|
9
|
+
* - Launches the Vite dev server (startCommand) on port 5173 for Live Preview HMR
|
|
10
|
+
* - Provides the API, WebSocket, and screenshot endpoints
|
|
11
|
+
* 3. Waits for the Vite dev server to be ready (so the Live Preview iframe works)
|
|
12
|
+
* 4. Opens http://localhost:14199 in the browser
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { execSync } = require("child_process");
|
|
16
|
+
const fs = require("fs");
|
|
17
|
+
const os = require("os");
|
|
18
|
+
const path = require("path");
|
|
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");
|
|
34
|
+
const { open } = require("./open");
|
|
35
|
+
|
|
36
|
+
const shouldRestart = process.argv.includes("--restart");
|
|
37
|
+
|
|
38
|
+
function buildBinary() {
|
|
39
|
+
console.error("Building Rust binary...");
|
|
40
|
+
try {
|
|
41
|
+
execSync("cargo build", { stdio: "inherit", cwd: rootDir });
|
|
42
|
+
return true;
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Resolve <CARGO_HOME>/bin (falling back to $HOME/.cargo/bin). Mirrors
|
|
49
|
+
// `installed_binary_path()` in crates/control-api/src/pty_broker_startup.rs
|
|
50
|
+
// — the Rust broker auto-spawn execs the binary at this path, so we MUST
|
|
51
|
+
// keep it in sync with the freshly-built debug binary or `npm run editor`
|
|
52
|
+
// will start a server whose PTY-broker auto-spawn execs a stale install.
|
|
53
|
+
function cargoBinDir() {
|
|
54
|
+
if (process.env.CARGO_HOME) return path.join(process.env.CARGO_HOME, "bin");
|
|
55
|
+
return path.join(os.homedir(), ".cargo", "bin");
|
|
56
|
+
}
|
|
57
|
+
|
|
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.
|
|
64
|
+
//
|
|
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.
|
|
98
|
+
function installBinary() {
|
|
99
|
+
const srcPath = path.join(rootDir, "target", "debug", "codeyam-editor");
|
|
100
|
+
if (!fs.existsSync(srcPath)) {
|
|
101
|
+
console.error(`Skipping install: ${srcPath} not found.`);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const binDir = cargoBinDir();
|
|
105
|
+
const dstPath = path.join(binDir, "codeyam-editor");
|
|
106
|
+
const tmpPath = path.join(binDir, `.codeyam-editor.tmp.${process.pid}`);
|
|
107
|
+
console.error(`Installing fresh binary to ${dstPath}...`);
|
|
108
|
+
try {
|
|
109
|
+
fs.mkdirSync(binDir, { recursive: true });
|
|
110
|
+
linkOrCopyBinary(srcPath, dstPath, tmpPath);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
console.error(`Failed to install binary: ${err.message}`);
|
|
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 {}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Evict Rust build artifacts not touched in the last day so target/ doesn't
|
|
141
|
+
// grow unboundedly. Must run after the old server is stopped (it holds files
|
|
142
|
+
// in target/debug/ open) and before the new server starts. Non-fatal.
|
|
143
|
+
function sweepStaleArtifacts() {
|
|
144
|
+
console.error("Sweeping stale Rust build artifacts (idle >1 day)...");
|
|
145
|
+
try {
|
|
146
|
+
execSync("cargo sweep --time 1", { stdio: "inherit", cwd: rootDir });
|
|
147
|
+
} catch {
|
|
148
|
+
console.error("cargo-sweep failed (install with `cargo install cargo-sweep`). Continuing.");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
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
|
+
*/
|
|
157
|
+
async function main() {
|
|
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;
|
|
181
|
+
|
|
182
|
+
// Rebuild the Rust binary so code changes are picked up on restart.
|
|
183
|
+
// This script is only invoked from a source checkout (via `npm run editor`),
|
|
184
|
+
// so Cargo.toml and the crates tree are always present.
|
|
185
|
+
const buildOk = buildBinary();
|
|
186
|
+
// A failed rebuild means the server would boot on a stale binary that no
|
|
187
|
+
// longer matches the source. Refuse by default; CODEYAM_ALLOW_STALE=1
|
|
188
|
+
// forces the old behavior (continue with the last-good install).
|
|
189
|
+
enforceFreshBinary(buildOk, { action: "start the editor server" });
|
|
190
|
+
// Refresh <cargo_bin>/codeyam-editor (and the pty-broker hardlink) so
|
|
191
|
+
// the broker auto-spawn execs a binary that knows the `pty-broker
|
|
192
|
+
// daemon` subcommand. Skip if the build failed — installing a stale
|
|
193
|
+
// debug binary over a working install is worse than leaving it.
|
|
194
|
+
if (buildOk) {
|
|
195
|
+
installBinary();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Build the UI so the backend can serve it as static files
|
|
199
|
+
console.error("Building UI...");
|
|
200
|
+
try {
|
|
201
|
+
execSync("npx vite build", {
|
|
202
|
+
stdio: "inherit",
|
|
203
|
+
cwd: path.join(rootDir, "ui"),
|
|
204
|
+
});
|
|
205
|
+
} catch {
|
|
206
|
+
console.error("Failed to build UI. Continuing with existing build (if any)...");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Start the Rust backend — it serves ui/dist/ and launches the Vite dev
|
|
210
|
+
// server (configured via startCommand in editor.json) for Live Preview HMR.
|
|
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.
|
|
232
|
+
}
|
|
233
|
+
if (shouldRestart) {
|
|
234
|
+
sweepStaleArtifacts();
|
|
235
|
+
}
|
|
236
|
+
const serverChild = await ensureServer(SERVER_PORT);
|
|
237
|
+
|
|
238
|
+
// Wait for the actual Vite dev server on the internal port (not the reverse
|
|
239
|
+
// proxy on VITE_PORT, which starts immediately with the Rust binary).
|
|
240
|
+
console.error("Waiting for Vite dev server...");
|
|
241
|
+
const viteReady = await waitForPort(VITE_INTERNAL_PORT, 15000, 300);
|
|
242
|
+
if (!viteReady) {
|
|
243
|
+
console.error(`Warning: Vite dev server not ready on port ${VITE_INTERNAL_PORT}. Live Preview may not work initially.`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const url = `http://localhost:${SERVER_PORT}`;
|
|
247
|
+
console.error(`Editor UI: ${url}`);
|
|
248
|
+
console.error(`Live Preview: http://localhost:${VITE_PORT}`);
|
|
249
|
+
console.error(`Logs: ${path.join(rootDir, ".codeyam", "logs")}`);
|
|
250
|
+
open(url);
|
|
251
|
+
const keepAlive = setInterval(() => {}, 60_000);
|
|
252
|
+
|
|
253
|
+
process.on("SIGINT", () => {
|
|
254
|
+
clearInterval(keepAlive);
|
|
255
|
+
killChildProcess(serverChild);
|
|
256
|
+
process.exit(0);
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
module.exports = {
|
|
261
|
+
buildBinary,
|
|
262
|
+
staleBinaryBanner,
|
|
263
|
+
cargoBinDir,
|
|
264
|
+
installBinary,
|
|
265
|
+
linkOrCopyBinary,
|
|
266
|
+
main,
|
|
267
|
+
sweepStaleArtifacts,
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
if (require.main === module) {
|
|
271
|
+
main();
|
|
272
|
+
}
|
|
@@ -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 };
|