@codeyam-editor/codeyam-editor 0.1.14 → 0.1.15
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/agent-cli-install.js +102 -0
- package/npm/cloud.js +50 -53
- package/npm/editor.js +23 -0
- package/npm/fleet-add-failure.js +53 -2
- package/npm/fleet-auth.js +17 -1
- package/npm/launch-config.js +20 -3
- package/npm/launcher-boot-log.js +57 -0
- package/npm/operator-host-disk.js +44 -0
- package/npm/scenario-check.js +172 -1
- package/npm/scenario-metrics.js +101 -0
- package/npm/scenario-playwright.js +77 -5
- package/npm/utils.js +140 -21
- package/package.json +5 -5
- package/ui/dist/assets/{ContainerInspector-9w8TlRwl.js → ContainerInspector-Ceop11Fv.js} +1 -1
- package/ui/dist/assets/EditorShell-DjI5cEF_.js +366 -0
- package/ui/dist/assets/ScenarioDataPanel-D-nWT7xD.js +21 -0
- package/ui/dist/assets/{ScenarioDataPanelFullPage-DkysGs6X.js → ScenarioDataPanelFullPage-ZNFZcTQU.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-BW6QLGFW.js +26 -0
- package/ui/dist/assets/index-B3SMxYUB.css +1 -0
- package/ui/dist/assets/{index-CqKB9WVm.js → index-ClrdYzlw.js} +6 -6
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/EditorShell-CmEoMxKh.js +0 -366
- package/ui/dist/assets/ScenarioDataPanel-BM7COEfv.js +0 -21
- package/ui/dist/assets/ScenarioRenderer-M3HRiuEn.js +0 -26
- package/ui/dist/assets/index-Bk0fHYF3.css +0 -1
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// How each build agent's CLI gets installed on a fleet VM, and the in-container
|
|
3
|
+
// install script that does it. Pure — no I/O; npm/cloud.js runs the script over
|
|
4
|
+
// SSH (ensureAgentCliInstalled) and owns the gates around it. Split out of
|
|
5
|
+
// cloud.js so the install dimension is one concern with one test file.
|
|
6
|
+
//
|
|
7
|
+
// Two install kinds, mirroring the Rust `CliInstall::{Npm, Script}` split in
|
|
8
|
+
// BuildAgentProvider::cli_spec() (crates/types/src/config/harness.rs):
|
|
9
|
+
// npm — a global npm package (claude, codex, gemini)
|
|
10
|
+
// script — the agent's own install command, run verbatim (muse)
|
|
11
|
+
|
|
12
|
+
// Muse is not on npm: it installs through Meta's curl launcher into
|
|
13
|
+
// `~/.local/bin`. Mirrors `MUSE_INSTALL_COMMAND` in
|
|
14
|
+
// crates/types/src/config/harness.rs (the spike-verified command — the launcher
|
|
15
|
+
// download answers unauthenticated, so a VM needs no sign-in to install).
|
|
16
|
+
// Cross-reference: keep the two strings identical.
|
|
17
|
+
const MUSE_INSTALL_COMMAND =
|
|
18
|
+
'mkdir -p "$HOME/.local/bin" && curl -fsSL https://api.meta.ai/muse-launcher.sh -o "$HOME/.local/bin/muse" && chmod +x "$HOME/.local/bin/muse" && MUSE_LAUNCHER_INSTALL=1 "$HOME/.local/bin/muse"';
|
|
19
|
+
|
|
20
|
+
const AGENT_CLI_INSTALLS = {
|
|
21
|
+
claude: { kind: "npm", package: "@anthropic-ai/claude-code" },
|
|
22
|
+
codex: { kind: "npm", package: "@openai/codex" },
|
|
23
|
+
gemini: { kind: "npm", package: "@google/gemini-cli" },
|
|
24
|
+
muse: { kind: "script", command: MUSE_INSTALL_COMMAND },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Map an agent name to how its CLI is installed — `{ kind: "npm", package }`
|
|
28
|
+
* or `{ kind: "script", command }`. Throws on unknown agents so adding an
|
|
29
|
+
* agent is a one-entry change in `AGENT_CLI_INSTALLS`. */
|
|
30
|
+
function agentCliInstall(agent) {
|
|
31
|
+
if (!Object.prototype.hasOwnProperty.call(AGENT_CLI_INSTALLS, agent)) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`Unknown agent: ${agent}. Supported: ${Object.keys(AGENT_CLI_INSTALLS).join(", ")}`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
return AGENT_CLI_INSTALLS[agent];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The bash script that installs `agent`'s CLI inside the editor container,
|
|
40
|
+
* dispatched on its install kind (see `agentCliInstall`). Pure — returns the
|
|
41
|
+
* script text. Both kinds short-circuit when the binary already resolves. A
|
|
42
|
+
* script install must end with a binary that runs and reports a version, so a
|
|
43
|
+
* launcher that downloaded but cannot execute fails provisioning loudly (with
|
|
44
|
+
* the install output attached by runFatalSshStep) instead of rostering a VM
|
|
45
|
+
* whose CLI was never usable. */
|
|
46
|
+
function buildAgentCliInstallScript(agent, install) {
|
|
47
|
+
const header = [
|
|
48
|
+
"#!/usr/bin/env bash",
|
|
49
|
+
"set -euo pipefail",
|
|
50
|
+
`BINARY="${agent}"`,
|
|
51
|
+
"",
|
|
52
|
+
];
|
|
53
|
+
if (install.kind === "script") {
|
|
54
|
+
return [
|
|
55
|
+
...header,
|
|
56
|
+
"if command -v \"$BINARY\" >/dev/null 2>&1 || [ -x \"$HOME/.local/bin/$BINARY\" ]; then",
|
|
57
|
+
" echo \" ✓ $BINARY already installed\"",
|
|
58
|
+
" exit 0",
|
|
59
|
+
"fi",
|
|
60
|
+
"",
|
|
61
|
+
"echo \"Installing $BINARY via its install script in container...\"",
|
|
62
|
+
install.command,
|
|
63
|
+
"RESOLVED=$(command -v \"$BINARY\" 2>/dev/null || echo \"$HOME/.local/bin/$BINARY\")",
|
|
64
|
+
"if [ ! -x \"$RESOLVED\" ]; then",
|
|
65
|
+
" echo \" ✗ $BINARY install script finished but no $BINARY binary was found (looked on PATH and in $HOME/.local/bin)\" >&2",
|
|
66
|
+
" exit 1",
|
|
67
|
+
"fi",
|
|
68
|
+
"\"$RESOLVED\" --version",
|
|
69
|
+
"echo \" ✓ Successfully installed $BINARY at $RESOLVED\"",
|
|
70
|
+
].join("\n");
|
|
71
|
+
}
|
|
72
|
+
return [
|
|
73
|
+
...header,
|
|
74
|
+
`PACKAGE="${install.package}"`,
|
|
75
|
+
`AGENT="${agent}"`,
|
|
76
|
+
"",
|
|
77
|
+
"if command -v \"$BINARY\" >/dev/null 2>&1; then",
|
|
78
|
+
" echo \" ✓ $BINARY already installed on PATH\"",
|
|
79
|
+
" exit 0",
|
|
80
|
+
"fi",
|
|
81
|
+
"",
|
|
82
|
+
"echo \"Installing $PACKAGE globally in container...\"",
|
|
83
|
+
"if [ \"$AGENT\" = \"claude\" ]; then",
|
|
84
|
+
" rm -rf /usr/lib/node_modules/@anthropic-ai/.claude-code-*",
|
|
85
|
+
" npm install -g \"$PACKAGE\"",
|
|
86
|
+
" RESOLVED=$(find /usr/lib/node_modules/@anthropic-ai -name claude -type f -path '*linux-x64*' 2>/dev/null | head -1)",
|
|
87
|
+
" if [ -n \"$RESOLVED\" ]; then",
|
|
88
|
+
" ln -sf \"$RESOLVED\" /usr/local/bin/claude",
|
|
89
|
+
" fi",
|
|
90
|
+
"else",
|
|
91
|
+
" npm install -g \"$PACKAGE\"",
|
|
92
|
+
"fi",
|
|
93
|
+
"echo \" ✓ Successfully installed $BINARY\"",
|
|
94
|
+
].join("\n");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = {
|
|
98
|
+
MUSE_INSTALL_COMMAND,
|
|
99
|
+
AGENT_CLI_INSTALLS,
|
|
100
|
+
agentCliInstall,
|
|
101
|
+
buildAgentCliInstallScript,
|
|
102
|
+
};
|
package/npm/cloud.js
CHANGED
|
@@ -139,13 +139,17 @@ const AGENT_ENV_VARS = {
|
|
|
139
139
|
claude: "ANTHROPIC_API_KEY",
|
|
140
140
|
codex: "OPENAI_API_KEY",
|
|
141
141
|
gemini: "GEMINI_API_KEY",
|
|
142
|
+
muse: "META_API_KEY",
|
|
142
143
|
};
|
|
143
144
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
145
|
+
// How each agent's CLI is installed (npm package vs Muse's launcher script)
|
|
146
|
+
// lives in its own module; re-exported below so existing importers keep working.
|
|
147
|
+
const {
|
|
148
|
+
MUSE_INSTALL_COMMAND,
|
|
149
|
+
AGENT_CLI_INSTALLS,
|
|
150
|
+
agentCliInstall,
|
|
151
|
+
buildAgentCliInstallScript,
|
|
152
|
+
} = require("./agent-cli-install");
|
|
149
153
|
|
|
150
154
|
const KNOWN_FLAGS = [
|
|
151
155
|
"repo", "branch", "instance-name", "project", "zone", "agent",
|
|
@@ -273,16 +277,6 @@ function agentEnvVarName(agent) {
|
|
|
273
277
|
return AGENT_ENV_VARS[agent];
|
|
274
278
|
}
|
|
275
279
|
|
|
276
|
-
/** Map an agent name to its global npm package. Throws on unknown agents so
|
|
277
|
-
* adding an agent is a one-entry change in `AGENT_NPM_PACKAGES`. */
|
|
278
|
-
function agentNpmPackage(agent) {
|
|
279
|
-
if (!Object.prototype.hasOwnProperty.call(AGENT_NPM_PACKAGES, agent)) {
|
|
280
|
-
throw new Error(
|
|
281
|
-
`Unknown agent: ${agent}. Supported: ${Object.keys(AGENT_NPM_PACKAGES).join(", ")}`,
|
|
282
|
-
);
|
|
283
|
-
}
|
|
284
|
-
return AGENT_NPM_PACKAGES[agent];
|
|
285
|
-
}
|
|
286
280
|
|
|
287
281
|
/** Parse `--flag value` argv into an object. Unknown flags or missing values
|
|
288
282
|
* throw with a usage hint — silently dropping them would let typos like
|
|
@@ -3653,6 +3647,34 @@ async function healPreviewOriginAtReady(cfg, appPort = VITE_PORT, { resolveAppPo
|
|
|
3653
3647
|
);
|
|
3654
3648
|
}
|
|
3655
3649
|
|
|
3650
|
+
/** The notice cloud:up prints when the agent's API key is not set locally, so
|
|
3651
|
+
* only the provider is seeded. Names every way the operator can still
|
|
3652
|
+
* authenticate the agent on the VM. Muse also accepts an account login
|
|
3653
|
+
* (`muse login`, run once on the VM), which the editor's auth gate honours
|
|
3654
|
+
* in place of a key — so for Muse a key-less VM is "sign in on the VM", not
|
|
3655
|
+
* broken. Pure. */
|
|
3656
|
+
function noLocalKeySeedingNotice(agent) {
|
|
3657
|
+
const varName = agentEnvVarName(agent);
|
|
3658
|
+
const accountLogin =
|
|
3659
|
+
agent === "muse"
|
|
3660
|
+
? ` • signing in once on the VM with \`muse login\` (a Meta account login; no key needed).\n`
|
|
3661
|
+
: "";
|
|
3662
|
+
const oauthCaveat =
|
|
3663
|
+
agent === "muse"
|
|
3664
|
+
? ""
|
|
3665
|
+
: `Interactive browser-based OAuth login (e.g. \`codex login\`) is not yet ` +
|
|
3666
|
+
`supported headlessly on the VM.\n`;
|
|
3667
|
+
return (
|
|
3668
|
+
`\nNo ${varName} set locally — seeding provider only.\n` +
|
|
3669
|
+
`Authenticate ${agent} on the VM by either:\n` +
|
|
3670
|
+
` • exporting ${varName} locally and re-running cloud:up, or\n` +
|
|
3671
|
+
` • pasting the key into the Build tab's auth gate in the editor${accountLogin ? ", or" : "."}\n` +
|
|
3672
|
+
accountLogin +
|
|
3673
|
+
`Each path flows the credential through to the agent process on the VM.\n` +
|
|
3674
|
+
oauthCaveat
|
|
3675
|
+
);
|
|
3676
|
+
}
|
|
3677
|
+
|
|
3656
3678
|
/** Write the chosen agent provider and local API keys into editor.local.json on
|
|
3657
3679
|
* the VM BEFORE compose up so the file exists when the bind-mount happens —
|
|
3658
3680
|
* the provider seed MUST run before ensureEditorContainerUp, because the editor
|
|
@@ -3670,15 +3692,7 @@ async function seedAgentApiKey(cfg, { sleepFn } = {}) {
|
|
|
3670
3692
|
const patch = buildEditorLocalEnvPatch(process.env, cfg.agent);
|
|
3671
3693
|
const hasKey = Boolean(patch.env);
|
|
3672
3694
|
if (!hasKey) {
|
|
3673
|
-
console.error(
|
|
3674
|
-
`\nNo ${agentEnvVarName(cfg.agent)} set locally — seeding provider only.\n` +
|
|
3675
|
-
`Authenticate ${cfg.agent} on the VM by either:\n` +
|
|
3676
|
-
` • exporting ${agentEnvVarName(cfg.agent)} locally and re-running cloud:up, or\n` +
|
|
3677
|
-
` • pasting the key into the Build tab's auth gate in the editor.\n` +
|
|
3678
|
-
`Either path now flows the key through to the agent process on the VM.\n` +
|
|
3679
|
-
`Interactive browser-based OAuth login (e.g. \`codex login\`) is not yet ` +
|
|
3680
|
-
`supported headlessly on the VM.\n`,
|
|
3681
|
-
);
|
|
3695
|
+
console.error(noLocalKeySeedingNotice(cfg.agent));
|
|
3682
3696
|
}
|
|
3683
3697
|
const repoName = resolveProject(cfg).name;
|
|
3684
3698
|
const remotePath = `~/projects/${repoName}/.codeyam/editor.local.json`;
|
|
@@ -3833,7 +3847,10 @@ function probeAgentBinaryInstalled(cfg, container, binary) {
|
|
|
3833
3847
|
// idempotent install (a fatal step) rather than a throw of its own.
|
|
3834
3848
|
const probe = runGcloud(
|
|
3835
3849
|
buildGcloudArgs("ssh-probe", cfg, {
|
|
3836
|
-
|
|
3850
|
+
// `~/.local/bin` is checked too: a script-installed CLI (Muse) lands there,
|
|
3851
|
+
// which a login shell's PATH need not include — the editor's own resolver
|
|
3852
|
+
// scans the same dir, so it finds the binary either way.
|
|
3853
|
+
command: `docker exec ${container} bash -lc 'command -v ${binary} || { test -x "$HOME/.local/bin/${binary}" && echo "$HOME/.local/bin/${binary}"; }'`,
|
|
3837
3854
|
}),
|
|
3838
3855
|
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
3839
3856
|
);
|
|
@@ -3901,7 +3918,7 @@ async function waitForContainerRunning(
|
|
|
3901
3918
|
* pass `{ containerStarted: true }`. */
|
|
3902
3919
|
async function ensureAgentCliInstalled(cfg, waitOpts = {}) {
|
|
3903
3920
|
const agent = cfg.agent;
|
|
3904
|
-
const
|
|
3921
|
+
const install = agentCliInstall(agent);
|
|
3905
3922
|
const container = process.env.CONTAINER || "codeyam-editor-editor-1";
|
|
3906
3923
|
|
|
3907
3924
|
// The container may not be up the instant this runs — a slow `docker compose
|
|
@@ -3953,31 +3970,7 @@ async function ensureAgentCliInstalled(cfg, waitOpts = {}) {
|
|
|
3953
3970
|
|
|
3954
3971
|
console.error(`Ensuring build-agent CLI for '${agent}' is installed in container '${container}'...`);
|
|
3955
3972
|
|
|
3956
|
-
const containerScript =
|
|
3957
|
-
"#!/usr/bin/env bash",
|
|
3958
|
-
"set -euo pipefail",
|
|
3959
|
-
`BINARY="${agent}"`,
|
|
3960
|
-
`PACKAGE="${pkg}"`,
|
|
3961
|
-
`AGENT="${agent}"`,
|
|
3962
|
-
"",
|
|
3963
|
-
"if command -v \"$BINARY\" >/dev/null 2>&1; then",
|
|
3964
|
-
" echo \" ✓ $BINARY already installed on PATH\"",
|
|
3965
|
-
" exit 0",
|
|
3966
|
-
"fi",
|
|
3967
|
-
"",
|
|
3968
|
-
"echo \"Installing $PACKAGE globally in container...\"",
|
|
3969
|
-
"if [ \"$AGENT\" = \"claude\" ]; then",
|
|
3970
|
-
" rm -rf /usr/lib/node_modules/@anthropic-ai/.claude-code-*",
|
|
3971
|
-
" npm install -g \"$PACKAGE\"",
|
|
3972
|
-
" RESOLVED=$(find /usr/lib/node_modules/@anthropic-ai -name claude -type f -path '*linux-x64*' 2>/dev/null | head -1)",
|
|
3973
|
-
" if [ -n \"$RESOLVED\" ]; then",
|
|
3974
|
-
" ln -sf \"$RESOLVED\" /usr/local/bin/claude",
|
|
3975
|
-
" fi",
|
|
3976
|
-
"else",
|
|
3977
|
-
" npm install -g \"$PACKAGE\"",
|
|
3978
|
-
"fi",
|
|
3979
|
-
"echo \" ✓ Successfully installed $BINARY\"",
|
|
3980
|
-
].join("\n");
|
|
3973
|
+
const containerScript = buildAgentCliInstallScript(agent, install);
|
|
3981
3974
|
|
|
3982
3975
|
const hostCommand = `docker exec -i ${container} bash -s <<'EOF'\n${containerScript}\nEOF`;
|
|
3983
3976
|
|
|
@@ -3990,6 +3983,7 @@ async function ensureAgentCliInstalled(cfg, waitOpts = {}) {
|
|
|
3990
3983
|
});
|
|
3991
3984
|
}
|
|
3992
3985
|
|
|
3986
|
+
|
|
3993
3987
|
async function openTunnel(cfg, appPort = VITE_PORT) {
|
|
3994
3988
|
console.error(`Opening IAP tunnel to ${cfg.instanceName}...`);
|
|
3995
3989
|
const child = spawn("gcloud", buildGcloudArgs("ssh-tunnel", cfg, { appPort }), { stdio: "inherit" });
|
|
@@ -6108,9 +6102,12 @@ module.exports = {
|
|
|
6108
6102
|
provisionFailureMessage,
|
|
6109
6103
|
GCLOUD_PROVISION_TIMEOUT_MS,
|
|
6110
6104
|
agentEnvVarName,
|
|
6111
|
-
|
|
6105
|
+
agentCliInstall,
|
|
6112
6106
|
AGENT_ENV_VARS,
|
|
6113
|
-
|
|
6107
|
+
AGENT_CLI_INSTALLS,
|
|
6108
|
+
MUSE_INSTALL_COMMAND,
|
|
6109
|
+
buildAgentCliInstallScript,
|
|
6110
|
+
noLocalKeySeedingNotice,
|
|
6114
6111
|
ensureAgentCliInstalled,
|
|
6115
6112
|
ensureEditorContainerUp,
|
|
6116
6113
|
seedAgentApiKey,
|
package/npm/editor.js
CHANGED
|
@@ -24,6 +24,9 @@ const {
|
|
|
24
24
|
killChildProcess,
|
|
25
25
|
isInteractiveLauncher,
|
|
26
26
|
logLauncherTeardownSignal,
|
|
27
|
+
ownServerIdentity,
|
|
28
|
+
waitForOwnServer,
|
|
29
|
+
formatServerNotAnsweringError,
|
|
27
30
|
readMergedEditorConfig,
|
|
28
31
|
resolveLauncherPort,
|
|
29
32
|
resolveWrappedAppPort,
|
|
@@ -36,6 +39,7 @@ const {
|
|
|
36
39
|
enforceFreshBinary,
|
|
37
40
|
} = require("./utils");
|
|
38
41
|
const { open } = require("./open");
|
|
42
|
+
const { logLauncherBootEvent } = require("./launcher-boot-log");
|
|
39
43
|
|
|
40
44
|
const shouldRestart = process.argv.includes("--restart");
|
|
41
45
|
|
|
@@ -299,6 +303,25 @@ async function main() {
|
|
|
299
303
|
}
|
|
300
304
|
}
|
|
301
305
|
|
|
306
|
+
// Only ever open the browser onto a confirmed-live server. The server can die
|
|
307
|
+
// between `ensureServer` and here (the Vite wait alone is up to 15s), and a
|
|
308
|
+
// page opened onto a dead port looks loaded but is frozen — so ask the server
|
|
309
|
+
// once more, and fail loudly instead of printing a cheerful URL.
|
|
310
|
+
// Judged against the same project dir `ensureServer` used (its default, cwd).
|
|
311
|
+
const projectDir = process.cwd();
|
|
312
|
+
const live = await waitForOwnServer(SERVER_PORT, {
|
|
313
|
+
ownServerIdentityFn: (p) => ownServerIdentity(p, projectDir),
|
|
314
|
+
});
|
|
315
|
+
logLauncherBootEvent(projectDir, "browser-gate", {
|
|
316
|
+
port: SERVER_PORT,
|
|
317
|
+
verdict: live ? "live" : "dead",
|
|
318
|
+
server_pid: live ? live.pid : undefined,
|
|
319
|
+
});
|
|
320
|
+
if (!live) {
|
|
321
|
+
console.error(formatServerNotAnsweringError(SERVER_PORT, projectDir));
|
|
322
|
+
process.exit(1);
|
|
323
|
+
}
|
|
324
|
+
|
|
302
325
|
const url = `http://localhost:${SERVER_PORT}`;
|
|
303
326
|
console.error(launcherMode ? `Project selector: ${url}` : `Editor UI: ${url}`);
|
|
304
327
|
if (!launcherMode) {
|
package/npm/fleet-add-failure.js
CHANGED
|
@@ -446,6 +446,31 @@ function streamedLibraryMissingRecovery({ script, library, cleanTeardown } = {})
|
|
|
446
446
|
);
|
|
447
447
|
}
|
|
448
448
|
|
|
449
|
+
// Did the OPERATOR HOST run out of filesystem? Node spells it `ENOSPC: no space
|
|
450
|
+
// left on device`, the shell `No space left on device`, gcloud `OSError: [Errno
|
|
451
|
+
// 28] No space left on device` — matched on the message, never on the errno
|
|
452
|
+
// number, which means something else on other platforms. A transcript that says a
|
|
453
|
+
// script "failed on the VM" is quoting the VM's own log tail (npm/cloud.js prints
|
|
454
|
+
// that header above it), so an ENOSPC there is the VM's disk, not the host's, and
|
|
455
|
+
// stays with the generic arm.
|
|
456
|
+
const HOST_DISK_EXHAUSTED = /ENOSPC: no space left on device|No space left on device/i;
|
|
457
|
+
|
|
458
|
+
function hostDiskExhausted(logTail) {
|
|
459
|
+
const log = String(logTail || '');
|
|
460
|
+
return HOST_DISK_EXHAUSTED.test(log) && !/failed on the VM/i.test(log);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Operator-facing recovery copy for the host-disk-exhausted arm. The ENOSPC fires
|
|
464
|
+
// on the operator host before `instances create`, so nothing exists on GCP; the
|
|
465
|
+
// fix is freeing the host's filesystem, which every add needs first.
|
|
466
|
+
function hostDiskExhaustedRecovery() {
|
|
467
|
+
return (
|
|
468
|
+
'The operator host ran out of filesystem space (bytes or inodes) before anything was created, so there is no VM to ' +
|
|
469
|
+
'reconcile or destroy. Prune old date directories under /root/.config/gcloud/logs — gcloud writes one log per ' +
|
|
470
|
+
'invocation and is the usual cause — and confirm with `df -i` and `df -h`. Every add fails the same way until then.'
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
449
474
|
// Operator-facing recovery copy for the generic cloud-up-failed arm. "A partial
|
|
450
475
|
// instance may exist" is only true when the transcript does not show cloud:up
|
|
451
476
|
// deleting what it created; saying it regardless sends the operator hunting for
|
|
@@ -700,9 +725,27 @@ function classifyAddFailure({ logTail, exitCode, instanceAlive, containerHealthy
|
|
|
700
725
|
streamedLibraryMissingRecovery({ ...missingLib, cleanTeardown: cleanTeardownReported(log) }));
|
|
701
726
|
}
|
|
702
727
|
|
|
728
|
+
// 10b. host-disk-exhausted — the OPERATOR HOST's filesystem is full. Observed
|
|
729
|
+
// 2026-10-16: six adds died in 5–27s on `ENOSPC: no space left on device`
|
|
730
|
+
// writing `cloud.json.NNN.tmp`, with 6.4 GB free and 0 inodes free. No arm
|
|
731
|
+
// matched, so all six landed on the calm `verifying` residue: the badge held
|
|
732
|
+
// calm and Retry was refused as "still resolving", about adds that were dead.
|
|
733
|
+
//
|
|
734
|
+
// NOT retryable even though nothing was provisioned: a retry before the disk
|
|
735
|
+
// is freed fails identically — the streamed-library-missing reasoning. Not in
|
|
736
|
+
// CALM_ADD_CATEGORIES: an explicit error proves the failure, so it reads red.
|
|
737
|
+
//
|
|
738
|
+
// Tested BEFORE cloud-up-failed for the reason arm 6 documents: the provision
|
|
739
|
+
// shell echoes cloud:up's tail, so this transcript ALSO carries the generic
|
|
740
|
+
// `cloud:up FAILED (exit N)` line.
|
|
741
|
+
if (hostDiskExhausted(log)) {
|
|
742
|
+
return buildFailureVerdict('host-disk-exhausted', 'Operator host disk full',
|
|
743
|
+
hostDiskExhaustedRecovery());
|
|
744
|
+
}
|
|
745
|
+
|
|
703
746
|
// 11. cloud-up-failed — a generic cloud:up failure, reached ONLY when the drift /
|
|
704
747
|
// vanished / stuck / stockout / missing-ref / wedged-insert / iap-not-ready /
|
|
705
|
-
// streamed-library signatures above did not match. Not retryable. Whether a
|
|
748
|
+
// streamed-library / host-disk signatures above did not match. Not retryable. Whether a
|
|
706
749
|
// partial instance may exist is read off the transcript's teardown evidence.
|
|
707
750
|
if (/cloud:up FAILED \(exit/i.test(log)) {
|
|
708
751
|
return buildFailureVerdict('cloud-up-failed', 'Provisioning failed',
|
|
@@ -977,4 +1020,12 @@ function tornDownRetryMessage(n) {
|
|
|
977
1020
|
);
|
|
978
1021
|
}
|
|
979
1022
|
|
|
980
|
-
|
|
1023
|
+
// Refusal copy for a one-click "Retry add" after the operator host ran out of
|
|
1024
|
+
// filesystem. The generic refusal's "half-provisioned instance" is false — the
|
|
1025
|
+
// ENOSPC fired before anything was created — and a retry fails identically until
|
|
1026
|
+
// the host is pruned, so the copy names the prune. Pure; no I/O.
|
|
1027
|
+
function hostDiskExhaustedRetryMessage(n) {
|
|
1028
|
+
return `VM-${n} cannot be retried until the operator host has free space: ${hostDiskExhaustedRecovery()}`;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
module.exports = { classifyAddFailure, extractDriftFiles, lastNonEmptyLines, canRetryAdd, transcriptRostered, holdConvergingBadge, addFailureCorroborated, containerHealthWitness, stockoutCapacityPhrase, missingBranchRefFacts, missingBranchRefRecovery, deadRefRetryMessage, wedgedInsertFacts, wedgedInsertRecovery, wedgedRetryMessage, iapNotReadyFacts, iapNotReadyRecovery, streamedLibraryMissingFacts, cleanTeardownReported, streamedLibraryMissingRecovery, cloudUpFailedRecovery, streamedLibraryRetryMessage, tornDownRetryMessage, hostDiskExhausted, hostDiskExhaustedRecovery, hostDiskExhaustedRetryMessage, settledAgeMs, instanceAliveSince, CALM_ADD_CATEGORIES, ADD_FAILURE_RETRYABLE, CLASSIFY_TAIL_LINES };
|
package/npm/fleet-auth.js
CHANGED
|
@@ -14,6 +14,15 @@
|
|
|
14
14
|
// instead of distributing a single token. See docs/cloud-editor-stability.md
|
|
15
15
|
// "Fleet auth".
|
|
16
16
|
|
|
17
|
+
// Whether VM `v` runs Claude as its build agent — the only agent the fleet's
|
|
18
|
+
// Claude-credential machinery (batched `claude /login`, donor credential copy)
|
|
19
|
+
// applies to. An editor that predates `currentProvider` reports null; that
|
|
20
|
+
// editor could only run Claude, so null reads as Claude. Pure.
|
|
21
|
+
function isClaudeVm(v) {
|
|
22
|
+
const p = v && v.currentProvider;
|
|
23
|
+
return !p || p === 'claude';
|
|
24
|
+
}
|
|
25
|
+
|
|
17
26
|
// Classify one VM for the fleet-auth pass. Returns { eligible, reason }.
|
|
18
27
|
//
|
|
19
28
|
// The guardrail mirrors Destroy / Reconfigure / roll: NEVER touch an active
|
|
@@ -31,6 +40,9 @@ function classifyAuthVm(v, job) {
|
|
|
31
40
|
}
|
|
32
41
|
if (!v || !v.reachable) return { eligible: false, reason: 'unreachable' };
|
|
33
42
|
if (v.hasSession) return { eligible: false, reason: 'active-session' };
|
|
43
|
+
// The batch drives `claude /login`. A VM running another agent (Muse signs in
|
|
44
|
+
// with its own `muse login` on the VM) must not be swept into it.
|
|
45
|
+
if (!isClaudeVm(v)) return { eligible: false, reason: 'not-claude' };
|
|
34
46
|
const state = v.providerAuth && v.providerAuth.state;
|
|
35
47
|
if (state === 'authenticated') return { eligible: false, reason: 'already-authenticated' };
|
|
36
48
|
if (state === 'needs-login') return { eligible: true, reason: 'needs-login' };
|
|
@@ -154,12 +166,16 @@ function selectDonorForAccount(reachableDonors, vms, email) {
|
|
|
154
166
|
// copied and those the integrity probe failed, so the add can drop the failed
|
|
155
167
|
// ones BEFORE resolveCredsSource's first-donor fallback could pick one, and name
|
|
156
168
|
// each dropped donor in the add log. Order is preserved; unprobed is usable.
|
|
169
|
+
// A VM running a non-Claude agent (Muse) holds no Claude credential to copy, so
|
|
170
|
+
// it is never a donor — left out of both lists, since it is not a failed probe.
|
|
157
171
|
// Returns { usable: number[], skipped: number[] }. Pure.
|
|
158
172
|
function partitionDonorsByIntegrity(reachableDonors, vms) {
|
|
159
173
|
const usable = [];
|
|
160
174
|
const skipped = [];
|
|
161
175
|
for (const n of reachableDonors || []) {
|
|
162
|
-
const
|
|
176
|
+
const v = vms && vms[n];
|
|
177
|
+
if (v && !isClaudeVm(v)) continue;
|
|
178
|
+
const pa = v && v.providerAuth;
|
|
163
179
|
if (pa && pa.credentialIntact === false) skipped.push(n);
|
|
164
180
|
else usable.push(n);
|
|
165
181
|
}
|
package/npm/launch-config.js
CHANGED
|
@@ -14,6 +14,7 @@ const { normalizeAccessList } = require("./fleet-vm-access");
|
|
|
14
14
|
const { normalizeRepoUrl } = require("./fleet-env");
|
|
15
15
|
const { API_BUDGET_DEFAULTS, wouldExceedApiBudget } = require("./fleet-api-budget");
|
|
16
16
|
const { posix: posixPath } = require("node:path");
|
|
17
|
+
const { hostDiskHeadroomCheck } = require("./operator-host-disk");
|
|
17
18
|
|
|
18
19
|
/** The build agents a VM's editor terminal can run, as the keys of cloud.js's
|
|
19
20
|
* AGENT_ENV_VARS. Deriving the selectable set from that map keeps it a single
|
|
@@ -30,7 +31,7 @@ const EMPTY_CONFIG = Object.freeze({
|
|
|
30
31
|
newProjectName: "",
|
|
31
32
|
editorVariant: "dev",
|
|
32
33
|
editorBranch: "",
|
|
33
|
-
// Build agent provider the VM's editor terminal runs (claude | codex | gemini).
|
|
34
|
+
// Build agent provider the VM's editor terminal runs (claude | codex | gemini | muse).
|
|
34
35
|
// Defaults to claude; cloudUpArgs forwards it as cloud:up's --agent flag.
|
|
35
36
|
agent: "claude",
|
|
36
37
|
// Optional human-readable hostname slug for a SINGLE VM. When set, cloudUpArgs
|
|
@@ -210,8 +211,22 @@ function missingBranchRefMessage(missing) {
|
|
|
210
211
|
* - no GitHub token → the fresh VM's bootstrap dies cloning the PRIVATE editor
|
|
211
212
|
* repo at `could not read Username for 'https://github.com'`.
|
|
212
213
|
* `allowNoGitCredentials` mirrors cloud:up's --allow-no-git-credentials opt-out
|
|
213
|
-
* for an intentionally read-only VM (no push token needed).
|
|
214
|
-
|
|
214
|
+
* for an intentionally read-only VM (no push token needed).
|
|
215
|
+
*
|
|
216
|
+
*
|
|
217
|
+
* It also guards the operator host's own filesystem (the 2026-10-16 fault: six
|
|
218
|
+
* adds died on ENOSPC with bytes free and 0 inodes free). The caller measures
|
|
219
|
+
* `inodesFree` / `inodesTotal` / `bytesFree`; each is optional, and absent means
|
|
220
|
+
* "not measured", never zero — see operator-host-disk.js. Pure; no I/O. */
|
|
221
|
+
function addPreflight({
|
|
222
|
+
zone,
|
|
223
|
+
project,
|
|
224
|
+
token,
|
|
225
|
+
allowNoGitCredentials = false,
|
|
226
|
+
inodesFree,
|
|
227
|
+
inodesTotal,
|
|
228
|
+
bytesFree,
|
|
229
|
+
} = {}) {
|
|
215
230
|
const blank = (v) => typeof v !== "string" || v.trim() === "";
|
|
216
231
|
if (blank(zone)) {
|
|
217
232
|
return {
|
|
@@ -238,6 +253,8 @@ function addPreflight({ zone, project, token, allowNoGitCredentials = false } =
|
|
|
238
253
|
"read-only VM with --allow-no-git-credentials.",
|
|
239
254
|
};
|
|
240
255
|
}
|
|
256
|
+
const diskFull = hostDiskHeadroomCheck({ inodesFree, inodesTotal, bytesFree });
|
|
257
|
+
if (diskFull) return { ok: false, msg: diskFull };
|
|
241
258
|
return { ok: true };
|
|
242
259
|
}
|
|
243
260
|
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Launch-attempt forensics for the npm launcher.
|
|
3
|
+
*
|
|
4
|
+
* One line per event of `ensureServer`'s boot — spawn, readiness verdict (and
|
|
5
|
+
* how it was decided), kill, retry, adoption, final outcome — plus the browser
|
|
6
|
+
* gate's verdict, appended to `.codeyam/logs/launcher-boot.log`. A launch that
|
|
7
|
+
* ends with no server, or with a server SIGTERM'd by its own launch, is then
|
|
8
|
+
* self-explaining from this file beside `editor-server.log`, even when nobody
|
|
9
|
+
* can reproduce it.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require("fs");
|
|
13
|
+
const path = require("path");
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Format one launch-attempt trace line: an ISO-8601 UTC timestamp (the same
|
|
17
|
+
* shape `editor-server.log` uses, so the two files line up by eye), the event
|
|
18
|
+
* name, the launcher pid, then `key=value` fields in insertion order.
|
|
19
|
+
* `undefined` fields are dropped so callers can pass optional context without
|
|
20
|
+
* branching; a value containing whitespace is quoted so the line stays
|
|
21
|
+
* splittable on spaces.
|
|
22
|
+
*/
|
|
23
|
+
function formatLauncherBootRecord({ timestamp, launcherPid, event, fields = {} }) {
|
|
24
|
+
const parts = [`${timestamp} launcher-boot event=${event} launcher_pid=${launcherPid}`];
|
|
25
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
26
|
+
if (value === undefined) continue;
|
|
27
|
+
const text = typeof value === "string" ? value : JSON.stringify(value);
|
|
28
|
+
parts.push(`${key}=${/\s/.test(text) ? JSON.stringify(text) : text}`);
|
|
29
|
+
}
|
|
30
|
+
return parts.join(" ") + "\n";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Append one launch-attempt trace line to `<projectDir>/.codeyam/logs/
|
|
35
|
+
* launcher-boot.log`. Best-effort: never throws, so tracing can never change a
|
|
36
|
+
* launch's outcome.
|
|
37
|
+
*/
|
|
38
|
+
function logLauncherBootEvent(projectDir, event, fields) {
|
|
39
|
+
try {
|
|
40
|
+
const dir = path.join(projectDir, ".codeyam", "logs");
|
|
41
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
42
|
+
const record = formatLauncherBootRecord({
|
|
43
|
+
timestamp: new Date().toISOString(),
|
|
44
|
+
launcherPid: process.pid,
|
|
45
|
+
event,
|
|
46
|
+
fields,
|
|
47
|
+
});
|
|
48
|
+
fs.appendFileSync(path.join(dir, "launcher-boot.log"), record);
|
|
49
|
+
} catch {
|
|
50
|
+
/* best-effort forensics — never change a launch's outcome on a logging failure */
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = {
|
|
55
|
+
formatLauncherBootRecord,
|
|
56
|
+
logLauncherBootEvent,
|
|
57
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Filesystem headroom of the OPERATOR HOST, judged before a dashboard Add.
|
|
4
|
+
//
|
|
5
|
+
// On 2026-10-16 six adds died in 5–27s on `ENOSPC: no space left on device`
|
|
6
|
+
// writing `cloud.json.NNN.tmp`, with 6.4 GB free and 0 of 3,268,608 inodes free.
|
|
7
|
+
// So headroom is judged on BOTH axes: a bytes-only reading would have passed
|
|
8
|
+
// every one of those adds through. Pure; the caller takes the readings.
|
|
9
|
+
|
|
10
|
+
// A cloud:up writes only a handful of files, so these are "effectively
|
|
11
|
+
// exhausted" floors, not capacity planning.
|
|
12
|
+
const MIN_FREE_INODES = 1000;
|
|
13
|
+
const MIN_FREE_BYTES = 256 * 1024 * 1024;
|
|
14
|
+
|
|
15
|
+
// The remedy: gcloud's per-invocation log files are what filled the inode table.
|
|
16
|
+
function hostDiskRemedy() {
|
|
17
|
+
return (
|
|
18
|
+
'free space on the operator host before adding a VM — prune old date directories ' +
|
|
19
|
+
'under /root/.config/gcloud/logs (gcloud writes a log file per invocation and is ' +
|
|
20
|
+
'the usual cause), then confirm with `df -i` and `df -h`. Nothing was provisioned.'
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const measured = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
25
|
+
|
|
26
|
+
// Returns the refusal message when either axis is below its floor, else null.
|
|
27
|
+
//
|
|
28
|
+
// Every reading is optional: an absent (non-finite) value means "not measured"
|
|
29
|
+
// and passes, never "zero", or an unmeasurable filesystem would refuse every add.
|
|
30
|
+
// An `inodesTotal` of 0 means the filesystem does not count inodes (btrfs reports
|
|
31
|
+
// this), so the inode axis is skipped there.
|
|
32
|
+
function hostDiskHeadroomCheck({ inodesFree, inodesTotal, bytesFree } = {}) {
|
|
33
|
+
const countsInodes = !measured(inodesTotal) || inodesTotal > 0;
|
|
34
|
+
if (countsInodes && measured(inodesFree) && inodesFree < MIN_FREE_INODES) {
|
|
35
|
+
return `the operator host is out of inodes (${inodesFree} free) even if bytes remain — ${hostDiskRemedy()}`;
|
|
36
|
+
}
|
|
37
|
+
if (measured(bytesFree) && bytesFree < MIN_FREE_BYTES) {
|
|
38
|
+
const mb = Math.floor(bytesFree / (1024 * 1024));
|
|
39
|
+
return `the operator host is out of disk space (${mb} MB free) — ${hostDiskRemedy()}`;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { hostDiskHeadroomCheck, hostDiskRemedy, MIN_FREE_INODES, MIN_FREE_BYTES };
|