@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 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,
@@ -1,13 +1,14 @@
1
1
  "use strict";
2
2
 
3
3
  /**
4
- * Launcher-side mirror of `is_empty_project_dir` in
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; this JS mirror
7
- * decides the same for the launcher's post-launch tail banner. Keep
8
- * both in sync — the test `empty-project-dir.test.js` and Rust's
9
- * `is_empty_project_dir_ignores_hidden_entries` /
10
- * `is_empty_project_dir_detects_visible_entry` pin the contract.
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
- module.exports = { isEmptyProjectDir };
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
+ };
@@ -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
- const vm = (sessionId && vmsBySessionId && vmsBySessionId[sessionId] !== undefined) ? vmsBySessionId[sessionId] : null;
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,