@codeyam-editor/codeyam-editor 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/npm/cloud.js +289 -34
- package/npm/container.js +11 -0
- package/npm/disk-pressure.js +89 -0
- package/npm/editor-dev.js +11 -0
- package/npm/editor.js +12 -0
- package/npm/fleet-build-data.js +81 -0
- package/npm/fleet-cleanup.js +27 -0
- package/npm/fleet-job-badge.js +45 -1
- package/npm/fleet-plans.js +96 -0
- package/npm/fleet-reconcile.js +58 -1
- package/npm/launch-config.js +109 -4
- package/npm/scenario-check.js +81 -1
- package/npm/scenario-handlers.js +16 -0
- package/npm/utils.js +138 -7
- package/package.json +5 -5
- package/ui/dist/assets/EditorShell-tMMdarZD.js +187 -0
- package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +5 -0
- package/ui/dist/assets/{ScenarioDataPanelFullPage-Bnghtj8a.js → ScenarioDataPanelFullPage-5G6KuZgw.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +1 -0
- package/ui/dist/assets/index-BGqairFV.css +1 -0
- package/ui/dist/assets/{index-KDFAgt2e.js → index-BuK0lizt.js} +9 -9
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/EditorShell-CGsBSA6u.js +0 -186
- package/ui/dist/assets/ScenarioDataPanel-nuLXJzLf.js +0 -5
- package/ui/dist/assets/ScenarioRenderer-B_Za9jL8.js +0 -1
- package/ui/dist/assets/index-Bih6HJ_u.css +0 -1
package/npm/cloud.js
CHANGED
|
@@ -51,6 +51,12 @@ const AGENT_ENV_VARS = {
|
|
|
51
51
|
gemini: "GEMINI_API_KEY",
|
|
52
52
|
};
|
|
53
53
|
|
|
54
|
+
const AGENT_NPM_PACKAGES = {
|
|
55
|
+
claude: "@anthropic-ai/claude-code",
|
|
56
|
+
codex: "@openai/codex",
|
|
57
|
+
gemini: "@google/gemini-cli",
|
|
58
|
+
};
|
|
59
|
+
|
|
54
60
|
const KNOWN_FLAGS = [
|
|
55
61
|
"repo", "branch", "instance-name", "project", "zone", "agent",
|
|
56
62
|
// Editor-source selection, decoupled from the client project (repo/branch):
|
|
@@ -129,6 +135,17 @@ function agentEnvVarName(agent) {
|
|
|
129
135
|
return AGENT_ENV_VARS[agent];
|
|
130
136
|
}
|
|
131
137
|
|
|
138
|
+
/** Map an agent name to its global npm package. Throws on unknown agents so
|
|
139
|
+
* adding an agent is a one-entry change in `AGENT_NPM_PACKAGES`. */
|
|
140
|
+
function agentNpmPackage(agent) {
|
|
141
|
+
if (!Object.prototype.hasOwnProperty.call(AGENT_NPM_PACKAGES, agent)) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`Unknown agent: ${agent}. Supported: ${Object.keys(AGENT_NPM_PACKAGES).join(", ")}`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
return AGENT_NPM_PACKAGES[agent];
|
|
147
|
+
}
|
|
148
|
+
|
|
132
149
|
/** Parse `--flag value` argv into an object. Unknown flags or missing values
|
|
133
150
|
* throw with a usage hint — silently dropping them would let typos like
|
|
134
151
|
* `--branchh main` succeed with no branch flag at all. */
|
|
@@ -540,14 +557,26 @@ function saveCloudConfig(projectDir, cfg) {
|
|
|
540
557
|
}
|
|
541
558
|
|
|
542
559
|
/** Build the JSON patch to merge into the VM's `editor.local.json` so the
|
|
543
|
-
* configured agent
|
|
544
|
-
*
|
|
545
|
-
*
|
|
560
|
+
* configured agent becomes the editor's build-agent provider AND (when the
|
|
561
|
+
* key is available) picks up the user's local API key on first launch. The
|
|
562
|
+
* top-level `provider` field (lowercase — matching `BuildAgentProvider`'s
|
|
563
|
+
* serde) makes the VM's editor default to the chosen agent instead of Claude;
|
|
564
|
+
* `editor.local.json` is deep-merged over `editor.json` on the VM, so this
|
|
565
|
+
* overrides the packaged default.
|
|
566
|
+
*
|
|
567
|
+
* The patch ALWAYS carries `provider` — the agent is a launch *choice* that
|
|
568
|
+
* must persist on the VM regardless of whether the operator happens to have
|
|
569
|
+
* the agent's API key exported locally (Gemini, for instance, is normally
|
|
570
|
+
* authenticated interactively on the VM). `env.<KEY>` is the separate,
|
|
571
|
+
* opportunistic seed: it's included only when the matching key is set in
|
|
572
|
+
* `localEnv`. The function never returns `null`. Writing `provider: "claude"`
|
|
573
|
+
* when claude is the default deep-merges to a no-op, so the provider can be
|
|
574
|
+
* written unconditionally without special-casing the default. */
|
|
546
575
|
function buildEditorLocalEnvPatch(localEnv, agent) {
|
|
547
576
|
const varName = agentEnvVarName(agent);
|
|
548
577
|
const value = localEnv[varName];
|
|
549
|
-
if (!value) return
|
|
550
|
-
return { env: { [varName]: value } };
|
|
578
|
+
if (!value) return { provider: agent };
|
|
579
|
+
return { provider: agent, env: { [varName]: value } };
|
|
551
580
|
}
|
|
552
581
|
|
|
553
582
|
/** Parse `docker compose ps --format json` (compose v2 — one JSON object per
|
|
@@ -700,6 +729,72 @@ function runGcloud(args, opts = {}) {
|
|
|
700
729
|
});
|
|
701
730
|
}
|
|
702
731
|
|
|
732
|
+
// Build a bounded, de-noised failure detail from a runGcloud (spawnSync)
|
|
733
|
+
// result: prefer stderr, fall back to stdout, and surface a spawn-level error
|
|
734
|
+
// (e.g. ENOENT when gcloud is not on PATH) when there is no captured output.
|
|
735
|
+
// Trims the known gcloud noise lines — the same `/WARNING|NumPy|google.com/iap/`
|
|
736
|
+
// filter the dashboard's tailLog applies — and keeps the last ~15 lines so the
|
|
737
|
+
// actionable message is not buried under auth/IAP warnings. Pure: result in,
|
|
738
|
+
// string out, so it is unit-testable without spawning gcloud.
|
|
739
|
+
function formatGcloudFailure(result) {
|
|
740
|
+
const denoise = (text) =>
|
|
741
|
+
String(text || "")
|
|
742
|
+
.split("\n")
|
|
743
|
+
.map((l) => l.replace(/\r$/, ""))
|
|
744
|
+
.filter((l) => l.trim() !== "")
|
|
745
|
+
.filter((l) => !/WARNING|NumPy|google\.com\/iap/.test(l))
|
|
746
|
+
.slice(-15)
|
|
747
|
+
.join("\n");
|
|
748
|
+
|
|
749
|
+
const stderr = denoise(result && result.stderr);
|
|
750
|
+
if (stderr) return stderr;
|
|
751
|
+
const stdout = denoise(result && result.stdout);
|
|
752
|
+
if (stdout) return stdout;
|
|
753
|
+
if (result && result.error && result.error.message) {
|
|
754
|
+
return result.error.message;
|
|
755
|
+
}
|
|
756
|
+
return "(no output captured from gcloud)";
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// A bounded, pure classifier that keys on status === 255 (gcloud-ssh's exit
|
|
760
|
+
// for an ssh failure) and a signature match against known transient strings.
|
|
761
|
+
function isTransientSshFailure(result) {
|
|
762
|
+
if (!result || result.status !== 255) {
|
|
763
|
+
return false;
|
|
764
|
+
}
|
|
765
|
+
const output = String(result.stderr || "") + String(result.stdout || "");
|
|
766
|
+
const transientRegex = /kex_exchange_identification|Connection closed|failed to connect to backend|Failed to connect to port 22|Connection refused|Connection reset|Connection timed out|4003|4047/i;
|
|
767
|
+
return transientRegex.test(output);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// A retry wrapper for gcloud ssh-script calls that handles transient connection drops
|
|
771
|
+
// with exponential backoff, mimicking the IAP ready backoff.
|
|
772
|
+
async function runGcloudSshWithRetry(verb, cfg, extras, { stdio, retries = 5, label, sleepFn } = {}) {
|
|
773
|
+
let lastResult;
|
|
774
|
+
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
775
|
+
const result = runGcloud(buildGcloudArgs(verb, cfg, extras), { stdio });
|
|
776
|
+
if (result.status === 0) {
|
|
777
|
+
return result;
|
|
778
|
+
}
|
|
779
|
+
lastResult = result;
|
|
780
|
+
if (isTransientSshFailure(result)) {
|
|
781
|
+
if (attempt < retries) {
|
|
782
|
+
const delay = Math.min(2000 * Math.pow(1.5, attempt - 1), 8000);
|
|
783
|
+
console.error(` ↺ transient SSH failure (attempt ${attempt}/${retries}) [${label || verb}] ... retrying in ${delay / 1000}s`);
|
|
784
|
+
if (sleepFn) {
|
|
785
|
+
await sleepFn(delay);
|
|
786
|
+
} else {
|
|
787
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
788
|
+
}
|
|
789
|
+
continue;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
// For non-transient failure, return immediately, or if we exhausted our attempts.
|
|
793
|
+
return result;
|
|
794
|
+
}
|
|
795
|
+
return lastResult;
|
|
796
|
+
}
|
|
797
|
+
|
|
703
798
|
function ensureGcloud() {
|
|
704
799
|
const result = spawnSync("gcloud", ["version"], { stdio: "ignore" });
|
|
705
800
|
if (result.error || result.status !== 0) {
|
|
@@ -799,9 +894,11 @@ async function ensureFirewallRule(cfg) {
|
|
|
799
894
|
async function ensureInstance(cfg) {
|
|
800
895
|
console.error(`Checking instance ${cfg.instanceName}...`);
|
|
801
896
|
const initialState = fetchInstanceState(cfg);
|
|
897
|
+
const createdThisRun = initialState === null;
|
|
898
|
+
|
|
802
899
|
if (initialState === "RUNNING") {
|
|
803
900
|
console.error(` ✓ ${cfg.instanceName} is RUNNING`);
|
|
804
|
-
return;
|
|
901
|
+
return { createdThisRun };
|
|
805
902
|
}
|
|
806
903
|
if (initialState === "TERMINATED") {
|
|
807
904
|
console.error(` ${cfg.instanceName} is TERMINATED — starting...`);
|
|
@@ -823,7 +920,7 @@ async function ensureInstance(cfg) {
|
|
|
823
920
|
if (fetchInstanceState(cfg) === "RUNNING") {
|
|
824
921
|
console.error(` ✓ ${cfg.instanceName} is RUNNING`);
|
|
825
922
|
await waitForIapReady(cfg);
|
|
826
|
-
return;
|
|
923
|
+
return { createdThisRun };
|
|
827
924
|
}
|
|
828
925
|
await new Promise((r) => setTimeout(r, 2000));
|
|
829
926
|
}
|
|
@@ -1079,7 +1176,7 @@ function sshScriptCommand(scriptBody, scriptArgs = [], envVars = {}) {
|
|
|
1079
1176
|
return `echo '${b64}' | base64 -d | ${envPrefix}bash -s -- ${argsString}`.trim();
|
|
1080
1177
|
}
|
|
1081
1178
|
|
|
1082
|
-
async function ensureBootstrapped(cfg) {
|
|
1179
|
+
async function ensureBootstrapped(cfg, { sleepFn } = {}) {
|
|
1083
1180
|
console.error("Checking VM bootstrap state...");
|
|
1084
1181
|
const probe = runGcloud(
|
|
1085
1182
|
buildGcloudArgs("ssh-probe", cfg, {
|
|
@@ -1140,12 +1237,17 @@ async function ensureBootstrapped(cfg) {
|
|
|
1140
1237
|
// fresh detached build that will survive an SSH disconnect.
|
|
1141
1238
|
console.error(" launching detached bootstrap on the VM (survives SSH disconnect; ~1-14 min)...");
|
|
1142
1239
|
const launch = bootstrapLaunchCommand(scriptBody, bootstrapArgs, bootstrapEnv);
|
|
1143
|
-
const launched =
|
|
1144
|
-
|
|
1145
|
-
|
|
1240
|
+
const launched = await runGcloudSshWithRetry(
|
|
1241
|
+
"ssh-script",
|
|
1242
|
+
cfg,
|
|
1243
|
+
{ command: launch },
|
|
1244
|
+
{ stdio: ["ignore", "pipe", "pipe"], label: "bootstrap launch", sleepFn }
|
|
1146
1245
|
);
|
|
1147
1246
|
if (launched.status !== 0) {
|
|
1148
|
-
|
|
1247
|
+
const detail = formatGcloudFailure(launched);
|
|
1248
|
+
throw new Error(
|
|
1249
|
+
`failed to launch the detached bootstrap on the VM (gcloud ssh exit ${launched.status}).\n${detail}`,
|
|
1250
|
+
);
|
|
1149
1251
|
}
|
|
1150
1252
|
}
|
|
1151
1253
|
|
|
@@ -1280,6 +1382,7 @@ async function ensureEditorContainerUp(cfg) {
|
|
|
1280
1382
|
);
|
|
1281
1383
|
if (probe.status === 0 && parseEditorComposeState(probe.stdout) === "running") {
|
|
1282
1384
|
console.error(" ✓ editor container is already running");
|
|
1385
|
+
await ensureAgentCliInstalled(cfg);
|
|
1283
1386
|
return;
|
|
1284
1387
|
}
|
|
1285
1388
|
console.error(" starting editor container (docker compose up -d)...");
|
|
@@ -1292,6 +1395,34 @@ async function ensureEditorContainerUp(cfg) {
|
|
|
1292
1395
|
if (up.status !== 0) {
|
|
1293
1396
|
throw new Error("docker compose up -d failed on the VM");
|
|
1294
1397
|
}
|
|
1398
|
+
await ensureAgentCliInstalled(cfg);
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
// Hard gate: provisioning success REQUIRES a running editor container. Reuses
|
|
1402
|
+
// the exact compose-ps probe ensureEditorContainerUp uses, but as a TERMINAL
|
|
1403
|
+
// re-check rather than a precondition — `docker compose up -d` can exit 0 over
|
|
1404
|
+
// a container that then crash-loops or exits, and an upstream phase that never
|
|
1405
|
+
// ran leaves no /workspace at all. Either way `cmdUp` would otherwise report
|
|
1406
|
+
// success. This throws (non-zero exit) when the editor service isn't
|
|
1407
|
+
// `running`, converting a silent partial provision into a loud, actionable
|
|
1408
|
+
// failure the operator (and the dashboard's fail-fast-on-nonzero path) can act
|
|
1409
|
+
// on. The `|| true` keeps the probe's exit status 0 so the real signal is the
|
|
1410
|
+
// parsed compose state, not the SSH/cd exit code.
|
|
1411
|
+
async function assertEditorContainerRunning(cfg) {
|
|
1412
|
+
const repoName = resolveProject(cfg).name;
|
|
1413
|
+
const composeCmd = `docker compose -f docker-compose.cloud.yml`;
|
|
1414
|
+
const probe = runGcloud(
|
|
1415
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
1416
|
+
command: `cd ~/projects/${repoName} 2>/dev/null && ${composeCmd} ps --format json 2>/dev/null || true`,
|
|
1417
|
+
}),
|
|
1418
|
+
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
1419
|
+
);
|
|
1420
|
+
if (!(probe.status === 0 && parseEditorComposeState(probe.stdout) === "running")) {
|
|
1421
|
+
throw new Error(
|
|
1422
|
+
`provisioning incomplete: editor container is not running on ${cfg.instanceName} ` +
|
|
1423
|
+
`(no /workspace or container). Re-run: npm run cloud:up -- --instance-name ${cfg.instanceName}`,
|
|
1424
|
+
);
|
|
1425
|
+
}
|
|
1295
1426
|
}
|
|
1296
1427
|
|
|
1297
1428
|
/** Read the laptop's git author identity (`user.name`, `user.email`) so
|
|
@@ -1560,20 +1691,35 @@ async function seedPreviewOrigin(cfg, appPort = VITE_PORT) {
|
|
|
1560
1691
|
}
|
|
1561
1692
|
}
|
|
1562
1693
|
|
|
1694
|
+
/** Write the chosen agent provider and local API keys into editor.local.json on
|
|
1695
|
+
* the VM BEFORE compose up so the file exists when the bind-mount happens —
|
|
1696
|
+
* the provider seed MUST run before ensureEditorContainerUp, because the editor
|
|
1697
|
+
* caches config.provider at startup and reports it as currentProvider; a post-start
|
|
1698
|
+
* seed leaves the running editor (and therefore the dashboard badge) on the default
|
|
1699
|
+
* "claude" until a config reload. */
|
|
1563
1700
|
async function seedAgentApiKey(cfg) {
|
|
1701
|
+
// The provider seed is unconditional — `buildEditorLocalEnvPatch` always
|
|
1702
|
+
// returns a patch carrying `provider`, so the chosen agent always lands on
|
|
1703
|
+
// the VM. The API-key seed is opportunistic: when the matching key isn't set
|
|
1704
|
+
// locally the patch carries `provider` only, and we still print the
|
|
1705
|
+
// interactive-auth fallback line so the operator knows to authenticate the
|
|
1706
|
+
// agent on the VM later (Gemini's normal path).
|
|
1564
1707
|
const patch = buildEditorLocalEnvPatch(process.env, cfg.agent);
|
|
1565
|
-
|
|
1708
|
+
const hasKey = Boolean(patch.env);
|
|
1709
|
+
if (!hasKey) {
|
|
1566
1710
|
console.error(
|
|
1567
|
-
`\nNo ${agentEnvVarName(cfg.agent)} set locally —
|
|
1711
|
+
`\nNo ${agentEnvVarName(cfg.agent)} set locally — seeding provider only.\n` +
|
|
1568
1712
|
`Authenticate ${cfg.agent} later with:\n` +
|
|
1569
1713
|
` gcloud compute ssh ${cfg.instanceName} --project ${cfg.project} --zone ${cfg.zone} ` +
|
|
1570
1714
|
`--tunnel-through-iap -- ${cfg.agent} auth login\n`,
|
|
1571
1715
|
);
|
|
1572
|
-
return;
|
|
1573
1716
|
}
|
|
1574
1717
|
const repoName = resolveProject(cfg).name;
|
|
1575
1718
|
const remotePath = `~/projects/${repoName}/.codeyam/editor.local.json`;
|
|
1576
|
-
|
|
1719
|
+
const seedLabel = hasKey
|
|
1720
|
+
? `Seeding ${agentEnvVarName(cfg.agent)} into ${remotePath}...`
|
|
1721
|
+
: `Seeding provider '${cfg.agent}' into ${remotePath}...`;
|
|
1722
|
+
console.error(seedLabel);
|
|
1577
1723
|
|
|
1578
1724
|
const read = runGcloud(
|
|
1579
1725
|
buildGcloudArgs("ssh-probe", cfg, {
|
|
@@ -1606,6 +1752,75 @@ async function seedAgentApiKey(cfg) {
|
|
|
1606
1752
|
if (write.status !== 0) {
|
|
1607
1753
|
throw new Error(`Failed to write ${remotePath} on VM`);
|
|
1608
1754
|
}
|
|
1755
|
+
|
|
1756
|
+
await ensureAgentCliInstalled(cfg);
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
/** Install the selected agent's CLI inside the editor container on the live VM */
|
|
1760
|
+
async function ensureAgentCliInstalled(cfg) {
|
|
1761
|
+
const agent = cfg.agent;
|
|
1762
|
+
const pkg = agentNpmPackage(agent);
|
|
1763
|
+
const container = process.env.CONTAINER || "codeyam-editor-editor-1";
|
|
1764
|
+
|
|
1765
|
+
// First, check if the container is running on the VM host.
|
|
1766
|
+
// If the container is not running, we do nothing and return.
|
|
1767
|
+
const checkProbe = runGcloud(
|
|
1768
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
1769
|
+
command: `docker ps --filter name=^/${container}$ --filter status=running --format '{{.Name}}'`,
|
|
1770
|
+
}),
|
|
1771
|
+
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
1772
|
+
);
|
|
1773
|
+
|
|
1774
|
+
const isRunning = checkProbe.status === 0 && checkProbe.stdout.trim() !== "";
|
|
1775
|
+
if (!isRunning) {
|
|
1776
|
+
console.error(` Container ${container} is not running. Skipping build-agent CLI install for now.`);
|
|
1777
|
+
return;
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
console.error(`Ensuring build-agent CLI for '${agent}' is installed in container '${container}'...`);
|
|
1781
|
+
|
|
1782
|
+
const containerScript = [
|
|
1783
|
+
"#!/usr/bin/env bash",
|
|
1784
|
+
"set -euo pipefail",
|
|
1785
|
+
`BINARY="${agent}"`,
|
|
1786
|
+
`PACKAGE="${pkg}"`,
|
|
1787
|
+
`AGENT="${agent}"`,
|
|
1788
|
+
"",
|
|
1789
|
+
"if command -v \"$BINARY\" >/dev/null 2>&1; then",
|
|
1790
|
+
" if [ \"$AGENT\" = \"claude\" ]; then",
|
|
1791
|
+
" if [ -x /usr/local/bin/claude ]; then",
|
|
1792
|
+
" echo \" ✓ $BINARY already installed and executable at /usr/local/bin/claude\"",
|
|
1793
|
+
" exit 0",
|
|
1794
|
+
" fi",
|
|
1795
|
+
" else",
|
|
1796
|
+
" echo \" ✓ $BINARY already installed on PATH\"",
|
|
1797
|
+
" exit 0",
|
|
1798
|
+
" fi",
|
|
1799
|
+
"fi",
|
|
1800
|
+
"",
|
|
1801
|
+
"echo \"Installing $PACKAGE globally in container...\"",
|
|
1802
|
+
"if [ \"$AGENT\" = \"claude\" ]; then",
|
|
1803
|
+
" rm -rf /usr/lib/node_modules/@anthropic-ai/.claude-code-*",
|
|
1804
|
+
" npm install -g \"$PACKAGE\"",
|
|
1805
|
+
" ln -sf /usr/lib/node_modules/@anthropic-ai/claude-code/node_modules/@anthropic-ai/claude-code-linux-x64/claude /usr/local/bin/claude",
|
|
1806
|
+
"else",
|
|
1807
|
+
" npm install -g \"$PACKAGE\"",
|
|
1808
|
+
"fi",
|
|
1809
|
+
"echo \" ✓ Successfully installed $BINARY\"",
|
|
1810
|
+
].join("\n");
|
|
1811
|
+
|
|
1812
|
+
const hostCommand = `docker exec -i ${container} bash -s <<'EOF'\n${containerScript}\nEOF`;
|
|
1813
|
+
|
|
1814
|
+
const installCmd = runGcloud(
|
|
1815
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
1816
|
+
command: hostCommand,
|
|
1817
|
+
}),
|
|
1818
|
+
{ stdio: ["ignore", "inherit", "inherit"] }
|
|
1819
|
+
);
|
|
1820
|
+
|
|
1821
|
+
if (installCmd.status !== 0) {
|
|
1822
|
+
throw new Error(`Failed to install build-agent CLI in container for agent: ${agent}`);
|
|
1823
|
+
}
|
|
1609
1824
|
}
|
|
1610
1825
|
|
|
1611
1826
|
async function openTunnel(cfg, appPort = VITE_PORT) {
|
|
@@ -1663,24 +1878,55 @@ async function cmdUp(args) {
|
|
|
1663
1878
|
assertVariantProvisionable(resolveEditorConfig(cfg).editorVariant);
|
|
1664
1879
|
|
|
1665
1880
|
await ensureFirewallRule(cfg);
|
|
1666
|
-
await ensureInstance(cfg);
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1881
|
+
const { createdThisRun } = await ensureInstance(cfg);
|
|
1882
|
+
|
|
1883
|
+
try {
|
|
1884
|
+
await ensureBootstrapped(cfg);
|
|
1885
|
+
await ensureProjectBootstrapped(cfg);
|
|
1886
|
+
// Resolve the app's dev-server port from the VM's now-bootstrapped config so
|
|
1887
|
+
// the previewOrigin seed and the laptop tunnel both target it (not 5173).
|
|
1888
|
+
const appPort = await readVmAppPort(cfg);
|
|
1889
|
+
await seedPreviewOrigin(cfg, appPort);
|
|
1890
|
+
// Write the GitHub token BEFORE compose up so the credentials file exists
|
|
1891
|
+
// when the bind-mount happens — bind-mounting a missing file would silently
|
|
1892
|
+
// create a directory instead and the agent's first `git pull` would fail.
|
|
1893
|
+
await seedGithubCredentials(cfg);
|
|
1894
|
+
await seedAgentApiKey(cfg);
|
|
1895
|
+
await ensureEditorContainerUp(cfg);
|
|
1896
|
+
// Roster the VM into the fleet dashboard NOW that it's provisioned and the
|
|
1897
|
+
// editor container is up. Runs BEFORE the --no-tunnel early-return so the
|
|
1898
|
+
// dashboard's own `cloud:up --no-tunnel` Add path also rosters (idempotent
|
|
1899
|
+
// with the dashboard's onSuccess roster — a double-add is a harmless no-op).
|
|
1900
|
+
rosterProvisionedVm(cfg.instanceName);
|
|
1901
|
+
// Terminal gate: provisioning is NOT "done" until the editor container is
|
|
1902
|
+
// verifiably Up. Placed BEFORE the --no-tunnel early-return so the dashboard's
|
|
1903
|
+
// own `cloud:up --no-tunnel` Add path is covered too (it's the path that bit
|
|
1904
|
+
// us — a run reported success with no container). A failure here is a loud
|
|
1905
|
+
// non-zero exit, which the dashboard fail-fast-on-nonzero path acts on.
|
|
1906
|
+
await assertEditorContainerRunning(cfg);
|
|
1907
|
+
} catch (err) {
|
|
1908
|
+
if (createdThisRun) {
|
|
1909
|
+
console.error(
|
|
1910
|
+
`✗ provisioning aborted — tearing down the VM cloud:up created this run (no orphan left behind)`,
|
|
1911
|
+
);
|
|
1912
|
+
try {
|
|
1913
|
+
console.error(`Deleting instance ${cfg.instanceName}...`);
|
|
1914
|
+
const del = runGcloud(buildGcloudArgs("instances-delete", cfg));
|
|
1915
|
+
if (del.status !== 0) {
|
|
1916
|
+
console.error(
|
|
1917
|
+
` Warning: failed to delete instance ${cfg.instanceName} during abort teardown. ` +
|
|
1918
|
+
`Please delete it manually with 'npm run cloud:destroy'.`,
|
|
1919
|
+
);
|
|
1920
|
+
}
|
|
1921
|
+
} catch (delErr) {
|
|
1922
|
+
console.error(
|
|
1923
|
+
` Warning: error deleting instance ${cfg.instanceName} during abort teardown: ${delErr.message}. ` +
|
|
1924
|
+
`Please delete it manually with 'npm run cloud:destroy'.`,
|
|
1925
|
+
);
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
throw err;
|
|
1929
|
+
}
|
|
1684
1930
|
// --no-tunnel: provisioning-only mode (the fleet dashboard opens its own
|
|
1685
1931
|
// per-VM tunnel). Skipping openTunnel lets cloud:up EXIT cleanly instead of
|
|
1686
1932
|
// hanging on a tunnel, and — critically for parallel multi-add — avoids N
|
|
@@ -1861,7 +2107,10 @@ async function cloudMain() {
|
|
|
1861
2107
|
module.exports = {
|
|
1862
2108
|
// pure helpers
|
|
1863
2109
|
agentEnvVarName,
|
|
2110
|
+
agentNpmPackage,
|
|
1864
2111
|
AGENT_ENV_VARS,
|
|
2112
|
+
AGENT_NPM_PACKAGES,
|
|
2113
|
+
ensureAgentCliInstalled,
|
|
1865
2114
|
KNOWN_FLAGS,
|
|
1866
2115
|
FIREWALL_RULE_NAME,
|
|
1867
2116
|
INSTANCE_TAG,
|
|
@@ -1912,6 +2161,12 @@ module.exports = {
|
|
|
1912
2161
|
FORWARD_ENV_KEYS_ENV,
|
|
1913
2162
|
parseEditorComposeState,
|
|
1914
2163
|
fetchInstanceState,
|
|
2164
|
+
formatGcloudFailure,
|
|
2165
|
+
isTransientSshFailure,
|
|
2166
|
+
runGcloudSshWithRetry,
|
|
2167
|
+
ensureInstance,
|
|
2168
|
+
ensureBootstrapped,
|
|
2169
|
+
assertEditorContainerRunning,
|
|
1915
2170
|
// side-effecting handlers
|
|
1916
2171
|
cmdUp,
|
|
1917
2172
|
cmdTunnel,
|
package/npm/container.js
CHANGED
|
@@ -12,6 +12,8 @@ const {
|
|
|
12
12
|
ensureServer,
|
|
13
13
|
rootDir,
|
|
14
14
|
killChildProcess,
|
|
15
|
+
isInteractiveLauncher,
|
|
16
|
+
logLauncherTeardownSignal,
|
|
15
17
|
readMergedEditorConfig,
|
|
16
18
|
resolveLauncherPort,
|
|
17
19
|
} = require("./utils");
|
|
@@ -41,6 +43,15 @@ async function main() {
|
|
|
41
43
|
const keepAlive = setInterval(() => {}, 60_000);
|
|
42
44
|
|
|
43
45
|
process.on("SIGINT", () => {
|
|
46
|
+
// The container launcher is inherently headless: a SIGINT here is stray
|
|
47
|
+
// (the server is torn down via API shutdown / orchestration, never an
|
|
48
|
+
// interactive Ctrl+C), so do NOT forward it — the detached server stays
|
|
49
|
+
// alive. Record the signal's process-group forensics for root-cause tracing.
|
|
50
|
+
const interactive = isInteractiveLauncher();
|
|
51
|
+
logLauncherTeardownSignal("SIGINT", rootDir, { interactive, forwarded: interactive });
|
|
52
|
+
if (!interactive) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
44
55
|
clearInterval(keepAlive);
|
|
45
56
|
killChildProcess(serverChild);
|
|
46
57
|
process.exit(0);
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Pure decision module for the VM root-disk guardrail. Given a single integer
|
|
3
|
+
// percent (from `df`'s Use%/Capacity column), classify the pressure into
|
|
4
|
+
// ok | warn | critical, and expose the allowlist of regenerable cache dirs the
|
|
5
|
+
// watchdog may auto-prune at the critical threshold. No I/O — the watchdog
|
|
6
|
+
// (bash) and the fleet dashboard (node) both consume these constants so the
|
|
7
|
+
// decision boundary is testable without a live VM, mirroring the
|
|
8
|
+
// pure-decision-vs-I/O split of npm/fleet-effect-status.js.
|
|
9
|
+
//
|
|
10
|
+
// Why two thresholds: warn (80%) is visibility only — a badge + log line so the
|
|
11
|
+
// operator sees pressure building. critical (90%) authorizes a bounded
|
|
12
|
+
// auto-prune of regenerable build caches, because a 100%-full disk cascades far
|
|
13
|
+
// worse (it truncated ~/.claude.json mid-write and OOM-killed the broker on the
|
|
14
|
+
// VM4 outage this guards against).
|
|
15
|
+
|
|
16
|
+
const DISK_WARN_PCT = 80;
|
|
17
|
+
const DISK_CRITICAL_PCT = 90;
|
|
18
|
+
|
|
19
|
+
// Allowlist of regenerable dev/build cache directories that are safe to delete
|
|
20
|
+
// on ANY stack because the toolchain rebuilds them on demand. This is an
|
|
21
|
+
// allowlist (known-safe), NOT a denylist, so an unknown stack's important files
|
|
22
|
+
// are never deleted — worst case we free less, never break a build.
|
|
23
|
+
//
|
|
24
|
+
// Stack assumption: each entry is a cache OUTPUT of a specific toolchain
|
|
25
|
+
// (.next/.turbo → Next.js/Turborepo, .svelte-kit → SvelteKit, .vite → Vite,
|
|
26
|
+
// .parcel-cache → Parcel, .angular/cache → Angular, node_modules/.cache →
|
|
27
|
+
// generic JS build caches). A project on none of these stacks simply has none
|
|
28
|
+
// of these dirs and the prune is a no-op.
|
|
29
|
+
//
|
|
30
|
+
// Deliberately disjoint from internal_paths.rs::ALWAYS_EXCLUDED_DIRS
|
|
31
|
+
// (node_modules, .codeyam, .git, target): those mean "don't index," NOT "safe
|
|
32
|
+
// to delete." node_modules is load-bearing and Rust target/ is expensive to
|
|
33
|
+
// rebuild, so neither appears here.
|
|
34
|
+
//
|
|
35
|
+
// Follow-up: a future migration can move this into stack.json so each project
|
|
36
|
+
// declares its own cache dirs; kept operator-side here to stay self-contained.
|
|
37
|
+
const PRUNABLE_CACHE_DIRS = [
|
|
38
|
+
'.next/cache',
|
|
39
|
+
'.next/dev/cache',
|
|
40
|
+
'node_modules/.cache',
|
|
41
|
+
'.turbo',
|
|
42
|
+
'.svelte-kit',
|
|
43
|
+
'.vite',
|
|
44
|
+
'.parcel-cache',
|
|
45
|
+
'.angular/cache',
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
// Classify an integer disk-usage percent into a pressure level. A non-numeric,
|
|
49
|
+
// non-finite, or out-of-range (<0 or >100) value is treated as a BAD READING
|
|
50
|
+
// and classified 'ok' with no action — a misread must never escalate to a
|
|
51
|
+
// destructive prune.
|
|
52
|
+
function classifyDiskPressure(usedPct) {
|
|
53
|
+
if (typeof usedPct !== 'number' || !Number.isFinite(usedPct)) return 'ok';
|
|
54
|
+
if (usedPct < 0 || usedPct > 100) return 'ok';
|
|
55
|
+
if (usedPct >= DISK_CRITICAL_PCT) return 'critical';
|
|
56
|
+
if (usedPct >= DISK_WARN_PCT) return 'warn';
|
|
57
|
+
return 'ok';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Parse the integer used-percent from a `df -P /` block. `df -P` (POSIX) emits a
|
|
61
|
+
// header line then one data line whose Capacity field is like "82%". We find the
|
|
62
|
+
// first NN% token on the data line, which is robust to a mount point containing
|
|
63
|
+
// spaces (which would shift positional fields). Returns null on unparseable
|
|
64
|
+
// input so the caller can fail safe rather than prune on garbage.
|
|
65
|
+
function parseDfUsedPct(dfOutput) {
|
|
66
|
+
const lines = String(dfOutput == null ? '' : dfOutput)
|
|
67
|
+
.split('\n')
|
|
68
|
+
.map((l) => l.trim())
|
|
69
|
+
.filter((l) => l.length > 0);
|
|
70
|
+
// The data line is the second non-empty line (after the header). Fall back to
|
|
71
|
+
// scanning all lines so a header-less one-liner still parses.
|
|
72
|
+
const candidates = lines.length >= 2 ? [lines[1], ...lines] : lines;
|
|
73
|
+
for (const line of candidates) {
|
|
74
|
+
const m = line.match(/(\d+)%/);
|
|
75
|
+
if (m) {
|
|
76
|
+
const pct = parseInt(m[1], 10);
|
|
77
|
+
if (Number.isFinite(pct)) return pct;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = {
|
|
84
|
+
DISK_WARN_PCT,
|
|
85
|
+
DISK_CRITICAL_PCT,
|
|
86
|
+
PRUNABLE_CACHE_DIRS,
|
|
87
|
+
classifyDiskPressure,
|
|
88
|
+
parseDfUsedPct,
|
|
89
|
+
};
|
package/npm/editor-dev.js
CHANGED
|
@@ -35,6 +35,8 @@ const {
|
|
|
35
35
|
ensureBinary,
|
|
36
36
|
rootDir,
|
|
37
37
|
killChildProcess,
|
|
38
|
+
isInteractiveLauncher,
|
|
39
|
+
logLauncherTeardownSignal,
|
|
38
40
|
readMergedEditorConfig,
|
|
39
41
|
resolveLauncherPort,
|
|
40
42
|
staleBinaryBanner,
|
|
@@ -428,6 +430,15 @@ if (require.main === module) {
|
|
|
428
430
|
let tearingDown = false;
|
|
429
431
|
|
|
430
432
|
process.on("SIGINT", () => {
|
|
433
|
+
// Forward a deliberate Ctrl+C (interactive launcher) to stop the editor.
|
|
434
|
+
// A SIGINT to a headless / agent-driven launcher is stray and must NOT
|
|
435
|
+
// reap the now-detached server, so swallow it and keep supervising.
|
|
436
|
+
// Record the signal's process-group forensics either way.
|
|
437
|
+
const interactive = isInteractiveLauncher();
|
|
438
|
+
logLauncherTeardownSignal("SIGINT", projectDir, { interactive, forwarded: interactive });
|
|
439
|
+
if (!interactive) {
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
431
442
|
tearingDown = true;
|
|
432
443
|
clearInterval(keepAlive);
|
|
433
444
|
killChildProcess(serverChild);
|
package/npm/editor.js
CHANGED
|
@@ -22,6 +22,8 @@ const {
|
|
|
22
22
|
waitForPort,
|
|
23
23
|
rootDir,
|
|
24
24
|
killChildProcess,
|
|
25
|
+
isInteractiveLauncher,
|
|
26
|
+
logLauncherTeardownSignal,
|
|
25
27
|
readMergedEditorConfig,
|
|
26
28
|
resolveLauncherPort,
|
|
27
29
|
resolveWrappedAppPort,
|
|
@@ -251,6 +253,16 @@ async function main() {
|
|
|
251
253
|
const keepAlive = setInterval(() => {}, 60_000);
|
|
252
254
|
|
|
253
255
|
process.on("SIGINT", () => {
|
|
256
|
+
// Forward a deliberate Ctrl+C (interactive launcher) to stop the editor.
|
|
257
|
+
// A SIGINT to a headless / agent-driven launcher is stray — the editor is
|
|
258
|
+
// torn down via API shutdown / pidfile there, never an interactive Ctrl+C —
|
|
259
|
+
// so do NOT forward it: the detached server stays alive. Either way, record
|
|
260
|
+
// the signal's process-group forensics for root-cause tracing.
|
|
261
|
+
const interactive = isInteractiveLauncher();
|
|
262
|
+
logLauncherTeardownSignal("SIGINT", rootDir, { interactive, forwarded: interactive });
|
|
263
|
+
if (!interactive) {
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
254
266
|
clearInterval(keepAlive);
|
|
255
267
|
killChildProcess(serverChild);
|
|
256
268
|
process.exit(0);
|