@codeyam-editor/codeyam-editor 0.1.0-staging.gac4ca32 → 0.1.0-staging.gc446a6f
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 +104 -1
- package/npm/empty-project-dir.js +92 -7
- package/npm/fleet-add-failure.js +155 -0
- package/npm/fleet-effect-poll.js +110 -0
- package/npm/fleet-plans.js +38 -3
- package/npm/fleet-pool.js +75 -16
- package/npm/fleet-public-url.js +34 -0
- package/npm/fleet-queue.js +58 -1
- package/npm/fleet-vm-access.js +75 -7
- package/npm/fleet-vm-readiness.js +21 -4
- package/npm/scenario-check.js +55 -1
- package/npm/scenario-handlers.js +15 -3
- package/npm/scenario-issues.js +13 -1
- package/npm/scenario-mocks.js +129 -26
- package/npm/scenario-playwright.js +75 -7
- package/npm/utils.js +37 -3
- package/package.json +5 -5
- package/ui/dist/assets/{ContainerInspector-urDZ9zOC.js → ContainerInspector-OQ1X2doF.js} +1 -1
- package/ui/dist/assets/EditorShell-D5woOSRy.css +1 -0
- package/ui/dist/assets/EditorShell-EKM2i9O9.js +232 -0
- package/ui/dist/assets/{ScenarioDataPanel-DuU3_hdh.js → ScenarioDataPanel-BU2tHhl4.js} +2 -2
- package/ui/dist/assets/{ScenarioDataPanelFullPage-DlAIUrnk.js → ScenarioDataPanelFullPage-BrbAtXE5.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-d3MLbcAe.js +21 -0
- package/ui/dist/assets/{index-B7_KRKaQ.js → index-iE2jFez3.js} +2 -2
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/EditorShell-D7wGyetm.css +0 -1
- package/ui/dist/assets/EditorShell-eZm_8CsG.js +0 -230
- package/ui/dist/assets/ScenarioRenderer-od1vF8-p.js +0 -21
package/npm/cloud.js
CHANGED
|
@@ -159,11 +159,17 @@ const KNOWN_FLAGS = [
|
|
|
159
159
|
// default is loud — a tokenless VM cannot sync plan claims and must never
|
|
160
160
|
// report healthy silently. Use only when push genuinely isn't wanted.
|
|
161
161
|
"allow-no-git-credentials",
|
|
162
|
+
// Provision the VM in admin mode: forward CODEYAM_ADMIN=1 into the container
|
|
163
|
+
// so the Home-tab Setup door renders. Equivalent to `CODEYAM_ADMIN=1 npm run
|
|
164
|
+
// cloud:up …`; absent = strict non-admin (the safe default). Survives
|
|
165
|
+
// re-provision because it rides the compose-render substitution, unlike a
|
|
166
|
+
// manual VM compose edit.
|
|
167
|
+
"admin",
|
|
162
168
|
];
|
|
163
169
|
|
|
164
170
|
// Flags that take no value (presence = true). parseFlags must not demand a
|
|
165
171
|
// value for these or `--no-tunnel` would error "requires a value".
|
|
166
|
-
const BOOLEAN_FLAGS = ["no-tunnel", "skip-setup", "list", "allow-no-git-credentials"];
|
|
172
|
+
const BOOLEAN_FLAGS = ["no-tunnel", "skip-setup", "list", "allow-no-git-credentials", "admin"];
|
|
167
173
|
|
|
168
174
|
const FIREWALL_RULE_NAME = "codeyam-editor-iap-ssh";
|
|
169
175
|
const INSTANCE_TAG = "codeyam-editor";
|
|
@@ -1496,6 +1502,43 @@ function bootstrapLogTail(cfg, lines = 40) {
|
|
|
1496
1502
|
return String(r.stdout || "").trim() || "(log unavailable)";
|
|
1497
1503
|
}
|
|
1498
1504
|
|
|
1505
|
+
/** Assemble the extra provision-time env forwarded into the bootstrap shell,
|
|
1506
|
+
* where it feeds the compose-render substitution. Currently just the admin
|
|
1507
|
+
* toggle: CODEYAM_ADMIN gates the Home-tab Setup door and is read from the
|
|
1508
|
+
* editor-server process env (handlers.rs::admin_enabled, strict literal "1").
|
|
1509
|
+
* The operator opts in with `CODEYAM_ADMIN=1 npm run cloud:up …` or `--admin`
|
|
1510
|
+
* (which lands as cfg.admin); anything else forwards "" so a normal launch —
|
|
1511
|
+
* and a re-provision without the flag — is strict non-admin. Pure over its
|
|
1512
|
+
* inputs so cloud.test.js can pin it without touching gcloud. */
|
|
1513
|
+
function buildProvisionEnv(env = process.env, flags = {}) {
|
|
1514
|
+
const admin = flags.admin === true || env.CODEYAM_ADMIN === "1";
|
|
1515
|
+
return { CODEYAM_ADMIN: admin ? "1" : "" };
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
/** Build the one-shot SSH shell command that reconciles a running VM's admin
|
|
1519
|
+
* mode without a full re-provision: rewrite the rendered compose's
|
|
1520
|
+
* CODEYAM_ADMIN line to `value` — or insert it under `environment:` on a legacy
|
|
1521
|
+
* VM whose compose predates the placeholder — then recreate the editor service
|
|
1522
|
+
* so admin_enabled() re-reads it. `value` is always "1" or "" (the caller
|
|
1523
|
+
* normalizes), so inlining it into sed is safe. Pure/string-only so
|
|
1524
|
+
* cloud.test.js can assert its shape without SSHing. */
|
|
1525
|
+
function buildReconcileAdminCommand(repoName, value) {
|
|
1526
|
+
const f = "docker-compose.cloud.yml";
|
|
1527
|
+
const entry = ` - CODEYAM_ADMIN=${value}`;
|
|
1528
|
+
// Capture only the leading indentation (\1) and re-emit the literal key, so a
|
|
1529
|
+
// value that starts with a digit (e.g. "1") never lands right after the
|
|
1530
|
+
// backreference — `\11` would be read by sed as group 11, not `\1` + "1".
|
|
1531
|
+
return (
|
|
1532
|
+
`cd ~/projects/${repoName} && ` +
|
|
1533
|
+
`if grep -qE '^[[:space:]]*- CODEYAM_ADMIN=' ${f}; then ` +
|
|
1534
|
+
`sed -i -E 's#^([[:space:]]*)- CODEYAM_ADMIN=.*#\\1- CODEYAM_ADMIN=${value}#' ${f}; ` +
|
|
1535
|
+
`else ` +
|
|
1536
|
+
`sed -i -E 's#^([[:space:]]*environment:[[:space:]]*)$#\\1\\n${entry}#' ${f}; ` +
|
|
1537
|
+
`fi && ` +
|
|
1538
|
+
`docker compose -f ${f} up -d editor`
|
|
1539
|
+
);
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1499
1542
|
async function ensureProjectBootstrapped(cfg) {
|
|
1500
1543
|
const repoName = resolveProject(cfg).name;
|
|
1501
1544
|
console.error(`Checking project bootstrap state for ${repoName}...`);
|
|
@@ -1549,12 +1592,18 @@ async function ensureProjectBootstrapped(cfg) {
|
|
|
1549
1592
|
const ingressEnv = {};
|
|
1550
1593
|
const ingressPlan = ingressWiringPlan(cfg);
|
|
1551
1594
|
if (ingressPlan.wire) ingressEnv.FLEET_DOMAIN = ingressPlan.domain;
|
|
1595
|
+
// Forward the admin-mode toggle (Home-tab Setup door) into the compose
|
|
1596
|
+
// render. cfg.admin carries the `--admin` flag; process.env.CODEYAM_ADMIN
|
|
1597
|
+
// carries the `CODEYAM_ADMIN=1 npm run cloud:up` form. Default "" =
|
|
1598
|
+
// non-admin, so this leaves the normal launch and re-provision unchanged.
|
|
1599
|
+
const provisionEnv = buildProvisionEnv(process.env, cfg);
|
|
1552
1600
|
const command = sshScriptCommand(scriptBody, scriptArgs, {
|
|
1553
1601
|
GITHUB_TOKEN: resolveGithubToken(),
|
|
1554
1602
|
CY_GIT_USER_NAME: laptopGit.name,
|
|
1555
1603
|
CY_GIT_USER_EMAIL: laptopGit.email,
|
|
1556
1604
|
...ingressEnv,
|
|
1557
1605
|
...forwardEnv,
|
|
1606
|
+
...provisionEnv,
|
|
1558
1607
|
});
|
|
1559
1608
|
const result = runGcloud(
|
|
1560
1609
|
buildGcloudArgs("ssh-script", cfg, { command }),
|
|
@@ -1842,6 +1891,15 @@ function classifyGitRemoteError(stderr) {
|
|
|
1842
1891
|
) {
|
|
1843
1892
|
return "network";
|
|
1844
1893
|
}
|
|
1894
|
+
// A greenfield project (`--new-project` scaffold) has no origin remote and an
|
|
1895
|
+
// unborn `main`, so `git ls-remote origin` fails with "does not appear to be a
|
|
1896
|
+
// git repository". No token would fix this — there is simply nothing to push
|
|
1897
|
+
// yet — so it is neither `credential` nor `network` nor `git-spawn`. Keep the
|
|
1898
|
+
// match narrow: `repository not found` is a genuine access failure against a
|
|
1899
|
+
// real remote and must NOT be swallowed here.
|
|
1900
|
+
if (lower.includes("does not appear to be a git repository")) {
|
|
1901
|
+
return "no-remote";
|
|
1902
|
+
}
|
|
1845
1903
|
return "other";
|
|
1846
1904
|
}
|
|
1847
1905
|
|
|
@@ -1879,6 +1937,14 @@ function decideCredentialHeal({ stagingEmpty, lsRemoteOk, lsRemoteStderr }) {
|
|
|
1879
1937
|
reason: "git binary failed to launch — not a credential gap, not re-seeding",
|
|
1880
1938
|
};
|
|
1881
1939
|
}
|
|
1940
|
+
if (category === "no-remote") {
|
|
1941
|
+
return {
|
|
1942
|
+
heal: false,
|
|
1943
|
+
category,
|
|
1944
|
+
reason:
|
|
1945
|
+
"no git remote configured — greenfield project, nothing to push yet (not a credential gap)",
|
|
1946
|
+
};
|
|
1947
|
+
}
|
|
1882
1948
|
return {
|
|
1883
1949
|
heal: true,
|
|
1884
1950
|
category,
|
|
@@ -3941,6 +4007,39 @@ async function cmdReconcileCredentials(args, projectDir = rootDir) {
|
|
|
3941
4007
|
);
|
|
3942
4008
|
}
|
|
3943
4009
|
|
|
4010
|
+
/** `cloud:reconcile-admin` — flip admin mode on/off for an already-running VM
|
|
4011
|
+
* WITHOUT a full re-provision. Resolves the desired value from this invocation
|
|
4012
|
+
* (`--admin` / `CODEYAM_ADMIN`, else off), rewrites the rendered compose's
|
|
4013
|
+
* CODEYAM_ADMIN line, and recreates the editor service — matching what a manual
|
|
4014
|
+
* compose edit + `docker compose up -d editor` does today, so the durable admin
|
|
4015
|
+
* setting no longer requires a full re-provision to change. */
|
|
4016
|
+
async function cmdReconcileAdmin(args, projectDir = rootDir) {
|
|
4017
|
+
ensureGcloud();
|
|
4018
|
+
const flags = parseFlags(args);
|
|
4019
|
+
const cfg = loadCloudConfig(projectDir);
|
|
4020
|
+
if (!cfg) {
|
|
4021
|
+
throw new Error("No .codeyam/cloud.json found. Run `npm run cloud:up` first.");
|
|
4022
|
+
}
|
|
4023
|
+
const desired =
|
|
4024
|
+
buildProvisionEnv(process.env, flags).CODEYAM_ADMIN === "1" ? "1" : "";
|
|
4025
|
+
const repoName = resolveProject(cfg).name;
|
|
4026
|
+
console.error(
|
|
4027
|
+
`Reconciling admin mode to ${desired === "1" ? "ON" : "off"} on ${cfg.instanceName}...`,
|
|
4028
|
+
);
|
|
4029
|
+
const result = runGcloud(
|
|
4030
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
4031
|
+
command: buildReconcileAdminCommand(repoName, desired),
|
|
4032
|
+
}),
|
|
4033
|
+
{ stdio: "inherit" },
|
|
4034
|
+
);
|
|
4035
|
+
if (result.status !== 0) {
|
|
4036
|
+
throw new Error(`Failed to reconcile admin mode on ${cfg.instanceName}.`);
|
|
4037
|
+
}
|
|
4038
|
+
console.error(
|
|
4039
|
+
` ✓ editor service recreated; /api/config now reports admin=${desired === "1" ? "true" : "false"}.`,
|
|
4040
|
+
);
|
|
4041
|
+
}
|
|
4042
|
+
|
|
3944
4043
|
/** Provision-time guarantee that a just-provisioned VM can actually reach
|
|
3945
4044
|
* GitHub. Probes the staging file + in-container `git ls-remote origin` and
|
|
3946
4045
|
* routes the outcome through the SAME {@link decideCredentialHeal} classifier
|
|
@@ -4072,6 +4171,7 @@ const HANDLERS = {
|
|
|
4072
4171
|
"reconcile-ingress": cmdReconcileIngress,
|
|
4073
4172
|
"wire-add-tail": cmdWireAddTail,
|
|
4074
4173
|
"reconcile-credentials": cmdReconcileCredentials,
|
|
4174
|
+
"reconcile-admin": cmdReconcileAdmin,
|
|
4075
4175
|
access: cmdAccess,
|
|
4076
4176
|
};
|
|
4077
4177
|
|
|
@@ -4141,6 +4241,9 @@ module.exports = {
|
|
|
4141
4241
|
loadCloudConfig,
|
|
4142
4242
|
saveCloudConfig,
|
|
4143
4243
|
loadOrInitCloudConfig,
|
|
4244
|
+
buildProvisionEnv,
|
|
4245
|
+
buildReconcileAdminCommand,
|
|
4246
|
+
cmdReconcileAdmin,
|
|
4144
4247
|
buildEditorLocalEnvPatch,
|
|
4145
4248
|
buildEditorLocalSeedWriteCommand,
|
|
4146
4249
|
seedPreviewOrigin,
|
package/npm/empty-project-dir.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Launcher-side
|
|
4
|
+
* Launcher-side mirrors of `is_empty_project_dir` and
|
|
5
|
+
* `looks_like_onboardable_project` in
|
|
5
6
|
* `crates/codeyam-editor/src/commands/init.rs`. The Rust side decides
|
|
6
|
-
* which `/codeyam-…` skill the init binary points at;
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* `
|
|
10
|
-
*
|
|
7
|
+
* which `/codeyam-…` skill the init binary points at; these JS mirrors
|
|
8
|
+
* decide the same for the launcher's post-launch tail banner. Keep both
|
|
9
|
+
* predicates in sync with Rust — the test `empty-project-dir.test.js`
|
|
10
|
+
* and Rust's `is_empty_project_dir_*` / `looks_like_onboardable_project_*`
|
|
11
|
+
* tests pin the contracts.
|
|
11
12
|
*
|
|
12
13
|
* A "fresh" project directory has no non-hidden entries — only things
|
|
13
14
|
* like `.git/`, `.gitignore`, or other dotfiles. Hidden entries don't
|
|
@@ -19,6 +20,7 @@
|
|
|
19
20
|
*/
|
|
20
21
|
|
|
21
22
|
const fs = require("node:fs");
|
|
23
|
+
const path = require("node:path");
|
|
22
24
|
|
|
23
25
|
function isEmptyProjectDir(projectDir) {
|
|
24
26
|
let entries;
|
|
@@ -30,4 +32,87 @@ function isEmptyProjectDir(projectDir) {
|
|
|
30
32
|
return entries.every((name) => name.startsWith("."));
|
|
31
33
|
}
|
|
32
34
|
|
|
33
|
-
|
|
35
|
+
// Root files that signal a real project: dependency/build manifests,
|
|
36
|
+
// lockfiles, and the static-site `index.html`. Mirror of the Rust
|
|
37
|
+
// `MANIFEST_MARKERS` list.
|
|
38
|
+
const MANIFEST_MARKERS = new Set([
|
|
39
|
+
"package.json",
|
|
40
|
+
"Cargo.toml",
|
|
41
|
+
"pyproject.toml",
|
|
42
|
+
"requirements.txt",
|
|
43
|
+
"setup.py",
|
|
44
|
+
"go.mod",
|
|
45
|
+
"Gemfile",
|
|
46
|
+
"pom.xml",
|
|
47
|
+
"build.gradle",
|
|
48
|
+
"build.gradle.kts",
|
|
49
|
+
"composer.json",
|
|
50
|
+
"pubspec.yaml",
|
|
51
|
+
"Package.swift",
|
|
52
|
+
"mix.exs",
|
|
53
|
+
"deno.json",
|
|
54
|
+
"CMakeLists.txt",
|
|
55
|
+
"Makefile",
|
|
56
|
+
"tsconfig.json",
|
|
57
|
+
"index.html",
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
// Subdirectories that signal an organized source tree. Mirror of the Rust
|
|
61
|
+
// `SOURCE_DIR_MARKERS` list.
|
|
62
|
+
const SOURCE_DIR_MARKERS = new Set([
|
|
63
|
+
"src",
|
|
64
|
+
"app",
|
|
65
|
+
"lib",
|
|
66
|
+
"pages",
|
|
67
|
+
"components",
|
|
68
|
+
"crates",
|
|
69
|
+
"packages",
|
|
70
|
+
"source",
|
|
71
|
+
"include",
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Does this directory contain a *project* worth onboarding, as opposed to
|
|
76
|
+
* being empty or holding only a stray file or two? Mirror of Rust
|
|
77
|
+
* `looks_like_onboardable_project`. A folder "contains a project" when it
|
|
78
|
+
* holds a recognized manifest / lockfile, a `.csproj` / `.xcodeproj`, a root
|
|
79
|
+
* `index.html`, OR an organized source subdirectory. Intentionally
|
|
80
|
+
* marker-based, not file-count based: a home dir full of loose files is still
|
|
81
|
+
* not a project. A read error returns false (not a project → the
|
|
82
|
+
* auto-init/open path), matching Rust's `Err(_) => false`.
|
|
83
|
+
*/
|
|
84
|
+
function looksLikeOnboardableProject(projectDir) {
|
|
85
|
+
let entries;
|
|
86
|
+
try {
|
|
87
|
+
entries = fs.readdirSync(projectDir, { withFileTypes: true });
|
|
88
|
+
} catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
for (const entry of entries) {
|
|
92
|
+
const name = entry.name;
|
|
93
|
+
if (MANIFEST_MARKERS.has(name)) {
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
if (name.endsWith(".csproj") || name.endsWith(".xcodeproj")) {
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
// Source-tree markers only count as directories, not like-named files.
|
|
100
|
+
if (SOURCE_DIR_MARKERS.has(name)) {
|
|
101
|
+
let isDir = entry.isDirectory();
|
|
102
|
+
// A symlinked source dir surfaces as a symlink Dirent; resolve it.
|
|
103
|
+
if (!isDir && entry.isSymbolicLink()) {
|
|
104
|
+
try {
|
|
105
|
+
isDir = fs.statSync(path.join(projectDir, name)).isDirectory();
|
|
106
|
+
} catch {
|
|
107
|
+
isDir = false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (isDir) {
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = { isEmptyProjectDir, looksLikeOnboardableProject };
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fc = require('./fleet-cleanup');
|
|
4
|
+
|
|
5
|
+
// npm/fleet-add-failure.js — pure classifier for a FAILED fleet-dashboard `add`
|
|
6
|
+
// provision. Before this, every failed add rendered an identical red "add error"
|
|
7
|
+
// badge whose only action was "Clear badge", and a restart mid-add collapsed the
|
|
8
|
+
// message to a content-free `(re-attached after restart — detached provision
|
|
9
|
+
// exited 1)`. The real cause — captured to the provision transcript on disk — was
|
|
10
|
+
// thrown away. This happened live with VM9/VM10: both aborted at the streamed-
|
|
11
|
+
// script drift guard because a bootstrap script had uncommitted local edits, but
|
|
12
|
+
// the operator saw only "exited 1" and had to dig through /tmp/dash-add-vm-N.log.
|
|
13
|
+
//
|
|
14
|
+
// classifyAddFailure turns a transcript tail + exit code into a differentiated,
|
|
15
|
+
// ACTIONABLE verdict: a plain-language headline, a concrete recovery instruction,
|
|
16
|
+
// and — crucially — a `retryable` flag that is true ONLY for failures where the
|
|
17
|
+
// classifier can PROVE nothing was provisioned, so a one-click retry can never
|
|
18
|
+
// silently orphan or duplicate a half-provisioned GCP box. The decision logic
|
|
19
|
+
// lives here as a pure module (like fleet-job-badge.js::canClearBadge) so the
|
|
20
|
+
// whole failure matrix is unit-tested without a live VM; server.js is the thin
|
|
21
|
+
// I/O shell that gathers the transcript and turns the verdict into a card + a
|
|
22
|
+
// server-re-guarded retry action.
|
|
23
|
+
|
|
24
|
+
// The SINGLE source of truth for which categories are safe to auto-retry: only
|
|
25
|
+
// failures where nothing was provisioned. `retryable` on every returned verdict
|
|
26
|
+
// is stamped from this Set, so the invariant `retryable === has(category)` holds
|
|
27
|
+
// by construction and the server can re-guard a retry against the same Set.
|
|
28
|
+
const ADD_FAILURE_RETRYABLE = new Set(['preflight-drift', 'instance-vanished']);
|
|
29
|
+
|
|
30
|
+
// Collect the ` - <path>` bullets the drift guard prints AFTER the "following
|
|
31
|
+
// files differ" header (see npm/cloud.js), stopping at the first non-bullet line
|
|
32
|
+
// so an unrelated "- " elsewhere in the transcript can't leak in. Returns [] when
|
|
33
|
+
// no drift block is present.
|
|
34
|
+
function extractDriftFiles(logTail) {
|
|
35
|
+
const lines = String(logTail || '').split('\n');
|
|
36
|
+
const files = [];
|
|
37
|
+
let inBlock = false;
|
|
38
|
+
for (const line of lines) {
|
|
39
|
+
if (!inBlock) {
|
|
40
|
+
if (/following files differ/i.test(line)) inBlock = true;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const m = line.match(/^\s+-\s+(.+?)\s*$/);
|
|
44
|
+
if (m) { files.push(m[1]); continue; }
|
|
45
|
+
break; // first non-bullet after the header ends the block
|
|
46
|
+
}
|
|
47
|
+
return files;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Pure transform: the last `maxLines` non-empty lines of a transcript, each
|
|
51
|
+
// right-trimmed. Extracted from readProvisionTail (server.js) so the aggregation
|
|
52
|
+
// is unit-tested and that function stays a thin filesystem-boundary call. Returns
|
|
53
|
+
// '' for empty/whitespace-only input.
|
|
54
|
+
function lastNonEmptyLines(text, maxLines = 50) {
|
|
55
|
+
const lines = String(text || '')
|
|
56
|
+
.split('\n')
|
|
57
|
+
.map((l) => l.replace(/\s+$/, ''))
|
|
58
|
+
.filter((l) => l.length > 0)
|
|
59
|
+
.slice(-maxLines);
|
|
60
|
+
return lines.join('\n');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Build a verdict, stamping `retryable` from the single-source-of-truth Set so it
|
|
64
|
+
// can never drift from the category.
|
|
65
|
+
function buildFailureVerdict(category, headline, recovery, extra) {
|
|
66
|
+
const out = { category, headline, recovery, retryable: ADD_FAILURE_RETRYABLE.has(category) };
|
|
67
|
+
if (extra && extra.files) out.files = extra.files;
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Classify a failed add from its transcript tail + exit code.
|
|
72
|
+
//
|
|
73
|
+
// logTail the last lines of the provision transcript (string; '' when the
|
|
74
|
+
// log was unreadable — must never throw)
|
|
75
|
+
// exitCode the provision's exit code (number; used for signal-kill detection)
|
|
76
|
+
//
|
|
77
|
+
// Returns { category, headline, recovery, retryable, files? }.
|
|
78
|
+
//
|
|
79
|
+
// Detection is ORDER-SENSITIVE. The provision shell echoes cloud:up's tail, so a
|
|
80
|
+
// drift / instance-vanished / container-stuck transcript ALSO contains the
|
|
81
|
+
// generic `cloud:up FAILED (exit N)` line. Each specific (and every retryable)
|
|
82
|
+
// signature is therefore tested BEFORE the generic cloud-up-failed signature, or
|
|
83
|
+
// a retryable drift would misclassify as a non-retryable cloud:up failure and the
|
|
84
|
+
// operator would lose the safe one-click recovery.
|
|
85
|
+
function classifyAddFailure({ logTail, exitCode } = {}) {
|
|
86
|
+
const log = String(logTail || '');
|
|
87
|
+
|
|
88
|
+
// 1. preflight-drift — the streamed-script drift guard aborted BEFORE any
|
|
89
|
+
// gcloud call, so nothing was provisioned. Retryable once the file is fixed.
|
|
90
|
+
// We match the unambiguous abort header / verdict label, NOT the bare
|
|
91
|
+
// `CODEYAM_SKIP_SCRIPT_DRIFT_CHECK` token: that token ALSO appears in the
|
|
92
|
+
// "bypass active — skipping drift check" warning, so keying on it would
|
|
93
|
+
// misclassify a bypass-active add that later failed for a real reason as a
|
|
94
|
+
// (retryable!) drift — inviting an unsafe retry. The header below is printed
|
|
95
|
+
// only on a genuine drift abort.
|
|
96
|
+
if (/Streamed scripts carry unreviewed local content/i.test(log)
|
|
97
|
+
|| /UNCOMMITTED LOCAL EDITS/.test(log)) {
|
|
98
|
+
const files = extractDriftFiles(log);
|
|
99
|
+
const fileList = files.length ? files.join(', ') : 'the streamed scripts';
|
|
100
|
+
const fix = files.length
|
|
101
|
+
? `Commit & push them, or discard with \`git checkout -- ${files.join(' ')}\`, then Retry add.`
|
|
102
|
+
: 'Commit & push them, or discard the local edits, then Retry add.';
|
|
103
|
+
return buildFailureVerdict('preflight-drift', 'Blocked by local edits',
|
|
104
|
+
`Uncommitted local edits in ${fileList} tripped the streamed-script drift guard, which aborts before any cloud resource is created. ${fix}`,
|
|
105
|
+
{ files });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 2. instance-vanished — the GCP instance is confirmed gone; nothing to orphan.
|
|
109
|
+
if (/instance .* is gone/i.test(log)) {
|
|
110
|
+
return buildFailureVerdict('instance-vanished', 'Instance never came up',
|
|
111
|
+
'The GCP instance is gone — provisioning failed before the container started, so nothing was left behind. Retrying the add is safe.');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 3. container-stuck — the instance exists but the editor container never
|
|
115
|
+
// became healthy, so a half-provisioned box may exist. Not retryable.
|
|
116
|
+
if (/container never came up/i.test(log)) {
|
|
117
|
+
return buildFailureVerdict('container-stuck', 'Container never became healthy',
|
|
118
|
+
'The instance came up but the editor container never became healthy — it may be half-provisioned. Reconcile or destroy this VM before re-adding.');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 4. cloud-up-failed — a generic cloud:up failure, reached ONLY when the drift /
|
|
122
|
+
// vanished / stuck signatures above did not match. A partial instance may
|
|
123
|
+
// exist, so not retryable.
|
|
124
|
+
if (/cloud:up FAILED \(exit/i.test(log)) {
|
|
125
|
+
return buildFailureVerdict('cloud-up-failed', 'Provisioning failed',
|
|
126
|
+
'cloud:up failed during provisioning, so a partial instance may exist. Reconcile or destroy this VM before re-adding.');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 5. interrupted — a signal kill (OOM 137, SIGTERM 143, …) or an explicit
|
|
130
|
+
// Killed / out-of-memory marker. The real VM state is unknown. Not retryable.
|
|
131
|
+
if (fc.isSignalKillExit(Number(exitCode)) || /\bKilled\b/.test(log) || /Out of memory/i.test(log)) {
|
|
132
|
+
return buildFailureVerdict('interrupted', 'Provisioning interrupted',
|
|
133
|
+
'The provision was interrupted (killed or out of memory), so the VM state is unknown. Reconcile or destroy this VM before re-adding.');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 6. unknown — fallback. Never invite an unproven retry.
|
|
137
|
+
return buildFailureVerdict('unknown', 'Add failed',
|
|
138
|
+
'The add failed for an unclassified reason — inspect the transcript below, then reconcile or destroy this VM before re-adding.');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Pure guard for the operator "Retry add" action: given a VM number and its
|
|
142
|
+
// carried job, decide whether a one-click retry is SAFE. Retry is allowed ONLY
|
|
143
|
+
// for a terminal add-error whose classifier verdict is retryable (a category
|
|
144
|
+
// where nothing was provisioned). Returns { ok, reason } — reason is a stable
|
|
145
|
+
// code the server shell (actionRetryAdd) maps to an operator message. Mirrors
|
|
146
|
+
// fleet-job-badge.js::canClearBadge so the handler stays a thin I/O shell and the
|
|
147
|
+
// safety decision is unit-tested without a live dashboard.
|
|
148
|
+
function canRetryAdd({ n, job } = {}) {
|
|
149
|
+
if (!Number.isInteger(n) || n < 1) return { ok: false, reason: 'invalid-vm' };
|
|
150
|
+
if (!job || job.action !== 'add' || job.state !== 'error') return { ok: false, reason: 'no-failed-add' };
|
|
151
|
+
if (!job.retryable) return { ok: false, reason: 'not-retryable' };
|
|
152
|
+
return { ok: true, reason: null };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = { classifyAddFailure, extractDriftFiles, lastNonEmptyLines, canRetryAdd, ADD_FAILURE_RETRYABLE };
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Self-healing cycle guard for the Fleet Dashboard's slow effect poll
|
|
3
|
+
// (scripts/operator/fleet-dashboard/server.js requires this). No gcloud/SSH
|
|
4
|
+
// I/O — this module owns the control-flow guarantee ONLY; the server keeps its
|
|
5
|
+
// gather edges and worker pools.
|
|
6
|
+
//
|
|
7
|
+
// The bug this fixes: `pollEffects` guards against overlap with a
|
|
8
|
+
// `effectPollRunning` flag (two concurrent full cycles would double the gcloud
|
|
9
|
+
// load), releasing it in a `finally` that runs only AFTER
|
|
10
|
+
// `await Promise.all(...)` of the reachable + launcher-strand sweeps. On
|
|
11
|
+
// 2026-07-09 a single probe's callback never fired (an SSH/docker-exec against
|
|
12
|
+
// VM-8, whose GCP instance had been deleted mid-session), so that awaited
|
|
13
|
+
// promise never resolved, the `finally` never ran, and `effectPollRunning`
|
|
14
|
+
// stayed `true` forever — every subsequent 45s tick early-returned and fleet
|
|
15
|
+
// readiness (iapAccess, backendHealth, readiness, launcher-strand self-heal)
|
|
16
|
+
// froze fleet-wide until the dashboard was restarted.
|
|
17
|
+
//
|
|
18
|
+
// Two guarantees make that freeze impossible:
|
|
19
|
+
// makeGuardedCycle — the no-overlap guard is kept (it's correct), but its
|
|
20
|
+
// release is made unconditional: the returned promise ALWAYS settles, either
|
|
21
|
+
// when the tasks finish or when a whole-cycle watchdog fires, and the guard
|
|
22
|
+
// flag is cleared in a `finally` either way. A hung task can no longer strand
|
|
23
|
+
// the next tick.
|
|
24
|
+
// withTimeout — wraps a single probe promise so a never-firing callback
|
|
25
|
+
// resolves to a safe fallback verdict instead of hanging the sweep.
|
|
26
|
+
|
|
27
|
+
// Wrap a promise so it can never hang the caller: resolve to `fallback` if
|
|
28
|
+
// `promise` has not settled within `ms`, and also on rejection (a probe that
|
|
29
|
+
// throws must degrade gracefully, never reject the whole sweep). `ms <= 0`
|
|
30
|
+
// disables the timeout and just awaits the promise (rejections still fold to
|
|
31
|
+
// `fallback`), so a caller can opt a leg out without branching. Whichever of
|
|
32
|
+
// (settle, timeout) happens first wins; the other is a no-op.
|
|
33
|
+
function withTimeout(promise, ms, fallback) {
|
|
34
|
+
const settled = Promise.resolve(promise).catch(() => fallback);
|
|
35
|
+
if (!(ms > 0)) return settled;
|
|
36
|
+
return new Promise((resolve) => {
|
|
37
|
+
let done = false;
|
|
38
|
+
const finish = (val) => { if (!done) { done = true; resolve(val); } };
|
|
39
|
+
// NOT unref'd: this timer is load-bearing — it resolves the promise the
|
|
40
|
+
// caller awaits, so letting the event loop exit before it fires would leave
|
|
41
|
+
// the await hung. It is cleared the instant the real promise settles, so it
|
|
42
|
+
// holds the loop open only for the (short) timeout window.
|
|
43
|
+
const timer = setTimeout(() => finish(fallback), ms);
|
|
44
|
+
settled.then((val) => { clearTimeout(timer); finish(val); });
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Build a guarded cycle runner. `run(tasks)` executes an array of zero-arg async
|
|
49
|
+
// task factories (or bare promises) under a no-overlap guard:
|
|
50
|
+
// - while a cycle is in flight, a second `run(...)` returns `{ skipped: true }`
|
|
51
|
+
// immediately without launching the tasks — the no-overlap contract.
|
|
52
|
+
// - the returned promise ALWAYS settles: when the tasks finish, or — if
|
|
53
|
+
// `watchdogMs > 0` — when the whole-cycle watchdog fires first (returning
|
|
54
|
+
// `{ wedged: true }`). Either way the guard flag is cleared in a `finally`,
|
|
55
|
+
// so the NEXT tick can always run. A never-resolving task therefore cannot
|
|
56
|
+
// permanently wedge the loop (the 2026-07-09 freeze).
|
|
57
|
+
// `onWedge(elapsedMs)` (optional) is called when the watchdog fires, so the
|
|
58
|
+
// caller can log a wedge warning. A task that rejects is swallowed (one bad probe
|
|
59
|
+
// must not fail the whole cycle); the caller composes concurrency/ordering.
|
|
60
|
+
function makeGuardedCycle(opts) {
|
|
61
|
+
const o = opts || {};
|
|
62
|
+
const watchdogMs = o.watchdogMs > 0 ? o.watchdogMs : 0;
|
|
63
|
+
const onWedge = typeof o.onWedge === 'function' ? o.onWedge : null;
|
|
64
|
+
let running = false;
|
|
65
|
+
const run = async (tasks) => {
|
|
66
|
+
if (running) return { skipped: true };
|
|
67
|
+
running = true;
|
|
68
|
+
try {
|
|
69
|
+
const work = Promise.all((tasks || []).map((t) => (typeof t === 'function' ? t() : t)))
|
|
70
|
+
.then(() => false, () => false); // settle to `wedged=false`; swallow a rejecting probe
|
|
71
|
+
let wedged = false;
|
|
72
|
+
if (watchdogMs > 0) {
|
|
73
|
+
wedged = await new Promise((resolve) => {
|
|
74
|
+
// NOT unref'd: this watchdog resolves the promise the caller awaits, so
|
|
75
|
+
// it must keep the loop alive until it fires (or the work settles first
|
|
76
|
+
// and clears it). Unref'ing it would let a standalone await hang.
|
|
77
|
+
const timer = setTimeout(() => resolve(true), watchdogMs);
|
|
78
|
+
work.then((w) => { clearTimeout(timer); resolve(w); });
|
|
79
|
+
});
|
|
80
|
+
} else {
|
|
81
|
+
await work;
|
|
82
|
+
}
|
|
83
|
+
if (wedged && onWedge) { try { onWedge(watchdogMs); } catch (e) { /* logging must never re-wedge */ } }
|
|
84
|
+
return { skipped: false, wedged };
|
|
85
|
+
} finally {
|
|
86
|
+
// Unconditional release — the whole point. Whether the tasks finished or
|
|
87
|
+
// the watchdog fired, the guard is cleared so the next tick proceeds. Any
|
|
88
|
+
// still-pending task keeps running detached, but holds no guard.
|
|
89
|
+
running = false;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
return { run };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// A roster entry whose Add errored out is an `add-failed` ghost: its GCP instance
|
|
96
|
+
// may be half-created or already reconciled away, so an SSH/docker-exec strand
|
|
97
|
+
// probe against it is the exact call that hung the 2026-07-09 sweep (VM-8). The
|
|
98
|
+
// strand selection skips it — a failed Add is owned by the reachable / vanish
|
|
99
|
+
// reconcilers, not the launcher-strand self-heal. `withTimeout` (above) is the
|
|
100
|
+
// general backstop for any hung probe; this removes the doomed probe at the
|
|
101
|
+
// source for the named trigger.
|
|
102
|
+
function isAddFailedGhost(job) {
|
|
103
|
+
return !!(job && job.state === 'error' && job.action === 'add');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
module.exports = {
|
|
107
|
+
withTimeout,
|
|
108
|
+
makeGuardedCycle,
|
|
109
|
+
isAddFailedGhost,
|
|
110
|
+
};
|
package/npm/fleet-plans.js
CHANGED
|
@@ -33,6 +33,32 @@ function indexVmsBySessionId(vms) {
|
|
|
33
33
|
return map;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Indexes the reachable VMs by their fleet machine name, so a claim that only
|
|
38
|
+
* carries `claimedBy.machine` (and no resolvable sessionId) can still attribute
|
|
39
|
+
* to a VM. A VM's machine name is `<instancePrefix><n>` (the GCE instance name
|
|
40
|
+
* the editor stamps into CODEYAM_MACHINE), so the map is derived, not probed.
|
|
41
|
+
*
|
|
42
|
+
* This is the fallback for backend/HTTP-"Run" claims: the editor stamps
|
|
43
|
+
* `claimedBy.sessionId` only for a PTY-attached `plan-select`, so a
|
|
44
|
+
* server-driven autonomous claim has an empty sessionId and never resolves via
|
|
45
|
+
* {@link indexVmsBySessionId}. Its machine name always resolves.
|
|
46
|
+
* @param {Array|Object} vms The merged VM states on the dashboard.
|
|
47
|
+
* @param {string} instancePrefix The fleet instance-name prefix (INSTANCE_PREFIX).
|
|
48
|
+
* @returns {Object} A map of machine name -> VM number (n).
|
|
49
|
+
*/
|
|
50
|
+
function indexVmsByMachine(vms, instancePrefix) {
|
|
51
|
+
const map = {};
|
|
52
|
+
if (!vms || !instancePrefix) return map;
|
|
53
|
+
const list = Array.isArray(vms) ? vms : Object.values(vms);
|
|
54
|
+
for (const vm of list) {
|
|
55
|
+
if (vm && vm.n !== undefined) {
|
|
56
|
+
map[`${instancePrefix}${vm.n}`] = vm.n;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return map;
|
|
60
|
+
}
|
|
61
|
+
|
|
36
62
|
/**
|
|
37
63
|
* Computes which dependencies are unsatisfied (i.e. not in the completedSlugs set).
|
|
38
64
|
* @param {string[]} dependsOn Prerequisite slugs.
|
|
@@ -52,9 +78,11 @@ function unsatisfiedDeps(dependsOn, completedSlugs) {
|
|
|
52
78
|
* @param {Object[]} params.claims Array of PlanClaim objects from /api/plan-claims.
|
|
53
79
|
* @param {Set|string[]} params.completedSlugs Completed plan slugs.
|
|
54
80
|
* @param {Object} params.vmsBySessionId Map of sessionId -> VM number.
|
|
81
|
+
* @param {Object} [params.vmsByMachine] Map of machine name -> VM number, the
|
|
82
|
+
* fallback for backend claims whose sessionId is empty.
|
|
55
83
|
* @returns {Object[]} Annotated plan rows.
|
|
56
84
|
*/
|
|
57
|
-
function resolvePlanRows({ plans, claims, completedSlugs, vmsBySessionId }) {
|
|
85
|
+
function resolvePlanRows({ plans, claims, completedSlugs, vmsBySessionId, vmsByMachine }) {
|
|
58
86
|
const list = Array.isArray(plans) ? plans : [];
|
|
59
87
|
const claimsList = Array.isArray(claims) ? claims : [];
|
|
60
88
|
|
|
@@ -76,8 +104,14 @@ function resolvePlanRows({ plans, claims, completedSlugs, vmsBySessionId }) {
|
|
|
76
104
|
if (claim) {
|
|
77
105
|
const claimedBy = claim.claimedBy || claim.claimed_by || {};
|
|
78
106
|
const sessionId = claimedBy.sessionId || claimedBy.session_id || null;
|
|
79
|
-
|
|
80
|
-
|
|
107
|
+
// Prefer the authoritative live sessionId match; fall back to the claim's
|
|
108
|
+
// machine name (backend claims have an empty sessionId, so this is the
|
|
109
|
+
// only key that resolves them to a VM).
|
|
110
|
+
let vm = (sessionId && vmsBySessionId && vmsBySessionId[sessionId] !== undefined) ? vmsBySessionId[sessionId] : null;
|
|
111
|
+
if (vm === null && claimedBy.machine && vmsByMachine && vmsByMachine[claimedBy.machine] !== undefined) {
|
|
112
|
+
vm = vmsByMachine[claimedBy.machine];
|
|
113
|
+
}
|
|
114
|
+
|
|
81
115
|
claimInfo = {
|
|
82
116
|
vm,
|
|
83
117
|
machine: claimedBy.machine || null,
|
|
@@ -236,6 +270,7 @@ function parsePlansSnapshotStdout(stdout) {
|
|
|
236
270
|
|
|
237
271
|
module.exports = {
|
|
238
272
|
indexVmsBySessionId,
|
|
273
|
+
indexVmsByMachine,
|
|
239
274
|
unsatisfiedDeps,
|
|
240
275
|
resolvePlanRows,
|
|
241
276
|
planSourceForProject,
|