@codeyam-editor/codeyam-editor 0.1.8 → 0.1.10

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.
Files changed (41) hide show
  1. package/npm/cloud.js +377 -21
  2. package/npm/editor-dev.js +24 -2
  3. package/npm/editor.js +15 -5
  4. package/npm/fleet-add-failure.js +355 -21
  5. package/npm/fleet-auth-account.js +61 -0
  6. package/npm/fleet-auth.js +69 -1
  7. package/npm/fleet-branch.js +124 -1
  8. package/npm/fleet-build-data.js +12 -1
  9. package/npm/fleet-cleanup.js +84 -3
  10. package/npm/fleet-datastore-health.js +107 -0
  11. package/npm/fleet-public-url.js +81 -12
  12. package/npm/fleet-reconcile.js +46 -11
  13. package/npm/fleet-revision.js +108 -0
  14. package/npm/fleet-run-setup.js +66 -0
  15. package/npm/fleet-session-activity.js +162 -0
  16. package/npm/fleet-vm-readiness.js +57 -16
  17. package/npm/group-bounded-spawn.js +111 -0
  18. package/npm/launch-config.js +216 -23
  19. package/npm/orphan-ssh-reaper.js +81 -0
  20. package/npm/scenario-check.js +697 -34
  21. package/npm/scenario-interactivity.js +477 -23
  22. package/npm/scenario-issues.js +61 -0
  23. package/npm/scenario-playwright.js +174 -20
  24. package/npm/utils.js +262 -22
  25. package/package.json +5 -5
  26. package/ui/dist/assets/{ContainerInspector-DSVZvEkB.js → ContainerInspector-DiN91foX.js} +1 -1
  27. package/ui/dist/assets/EditorShell-BEIdHnHd.css +1 -0
  28. package/ui/dist/assets/EditorShell-CPV5vhjo.js +330 -0
  29. package/ui/dist/assets/ScenarioDataPanel-DHAHyraq.js +18 -0
  30. package/ui/dist/assets/{ScenarioDataPanelFullPage-Cyk6efiI.js → ScenarioDataPanelFullPage-CHIWip3n.js} +1 -1
  31. package/ui/dist/assets/ScenarioRenderer-D9a91iwS.js +21 -0
  32. package/ui/dist/assets/index--cE0hUJw.css +1 -0
  33. package/ui/dist/assets/{index-BPLKgwon.js → index-sW8wcg7v.js} +9 -9
  34. package/ui/dist/index.html +2 -2
  35. package/ui/dist/preview-subdir-demo/index.html +62 -0
  36. package/ui/dist/preview-subdir-demo/sibling.html +48 -0
  37. package/ui/dist/assets/EditorShell-DnXdHrar.js +0 -256
  38. package/ui/dist/assets/EditorShell-FvbXyqqD.css +0 -1
  39. package/ui/dist/assets/ScenarioDataPanel-6qJE-0lA.js +0 -15
  40. package/ui/dist/assets/ScenarioRenderer-Uyt54vHI.js +0 -21
  41. package/ui/dist/assets/index-DKpgJLo3.css +0 -1
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * group-bounded-spawn.js — run a command synchronously under a wall-clock budget
5
+ * so that when the budget trips, the command's WHOLE process tree dies with it.
6
+ *
7
+ * `spawnSync`'s `timeout` signals only the direct child. `gcloud compute ssh` is a
8
+ * Python wrapper that runs `/usr/bin/ssh` as its own child, so a timed-out probe
9
+ * killed the wrapper and orphaned the ssh: re-parented to PID 1 and alive for tens
10
+ * of minutes against a 2-minute budget (twelve of them on one laptop, driving load
11
+ * to 14.8 — plan fleet--a-timed-out-gcloud-probe-leaks-its-ssh-session).
12
+ *
13
+ * The fix routes the spawn through this same file run as a tiny runner. The runner
14
+ * stays in the caller's process group, so a terminal Ctrl-C still reaches it; it
15
+ * starts the real command as the leader of a NEW process group, and on any
16
+ * terminating signal — including the SIGTERM spawnSync's own timeout sends — takes
17
+ * that entire group down before exiting with the same signal. The caller therefore
18
+ * sees exactly the result a plain spawnSync timeout produces (`status: null`,
19
+ * `signal: "SIGTERM"`, `error.code: "ETIMEDOUT"`), so isSpawnTimeout and
20
+ * formatGcloudFailure read it unchanged.
21
+ */
22
+
23
+ const childProcess = require("child_process");
24
+ const { reconcileGroupKillTarget } = require("./ingress-invoke");
25
+
26
+ // Between the polite group SIGTERM and the SIGKILL that guarantees nothing
27
+ // survives. Long enough for ssh to restore a raw-mode terminal on SIGTERM.
28
+ const GROUP_KILL_GRACE_MS = 500;
29
+ const TERMINATING_SIGNALS = ["SIGTERM", "SIGINT", "SIGHUP"];
30
+
31
+ /** Drop-in for `spawnSync(command, args, opts)` whose `timeout` (and any
32
+ * terminating signal) reaches every descendant, not only the direct child. */
33
+ function spawnGroupBoundedSync(command, args, opts = {}) {
34
+ // `childProcess.spawnSync` is resolved at call time, not destructured at load,
35
+ // so a caller that swaps it (cloud.test.js does) sees every spawn.
36
+ // Platform assumption: process groups are POSIX. Windows has no group to
37
+ // signal, so it keeps plain spawnSync semantics (direct child only).
38
+ if (process.platform === "win32") return childProcess.spawnSync(command, args, opts);
39
+ return childProcess.spawnSync(process.execPath, [__filename, command, ...args], opts);
40
+ }
41
+
42
+ function killGroup(leaderPid, signal) {
43
+ // The shared target derivation refuses pid <= 1, where a negative kill would
44
+ // signal every process this user owns.
45
+ const target = reconcileGroupKillTarget(leaderPid);
46
+ if (target == null) return;
47
+ try {
48
+ process.kill(target, signal);
49
+ } catch {
50
+ // The group is already gone — nothing left to reap.
51
+ }
52
+ }
53
+
54
+ /** Exit the runner the way the wrapped command ended, so spawnSync's result
55
+ * mirrors a direct spawn. A signal is re-raised on ourselves with its handler
56
+ * removed, which is what makes `result.signal` read true. */
57
+ function exitLike(code, signal) {
58
+ if (signal) {
59
+ for (const s of TERMINATING_SIGNALS) process.removeAllListeners(s);
60
+ process.kill(process.pid, signal);
61
+ return;
62
+ }
63
+ process.exit(code == null ? 1 : code);
64
+ }
65
+
66
+ /** Take the whole group down: a polite SIGTERM so ssh can restore a raw-mode
67
+ * terminal, a SIGKILL after the grace so nothing survives, then exit with the
68
+ * signal that started it. */
69
+ function beginGroupTermination(child, signal) {
70
+ killGroup(child.pid, "SIGTERM");
71
+ setTimeout(() => {
72
+ killGroup(child.pid, "SIGKILL");
73
+ exitLike(null, signal);
74
+ }, GROUP_KILL_GRACE_MS);
75
+ }
76
+
77
+ /** Runner body: `node group-bounded-spawn.js <command> [args...]`. */
78
+ function runAsGroupLeader(command, args) {
79
+ const child = childProcess.spawn(command, args, { stdio: "inherit", detached: true });
80
+ let terminating = false;
81
+
82
+ for (const sig of TERMINATING_SIGNALS) {
83
+ process.on(sig, () => {
84
+ if (terminating) return;
85
+ terminating = true;
86
+ beginGroupTermination(child, sig);
87
+ });
88
+ }
89
+
90
+ child.on("error", (err) => {
91
+ // ENOENT and friends: the command never started. 127 is the shell's
92
+ // "command not found", and the message lands on stderr for the caller.
93
+ process.stderr.write(`${err.message}\n`);
94
+ process.exit(127);
95
+ });
96
+ child.on("exit", (code, signal) => {
97
+ // A termination in flight finishes on its own grace timer.
98
+ if (terminating) return;
99
+ // The leader died by a signal nobody sent through us (OOM, an external
100
+ // kill): its descendants are the same orphans, so take them down too.
101
+ if (signal) killGroup(child.pid, "SIGKILL");
102
+ exitLike(code, signal);
103
+ });
104
+ }
105
+
106
+ if (require.main === module) {
107
+ const [command, ...args] = process.argv.slice(2);
108
+ runAsGroupLeader(command, args);
109
+ }
110
+
111
+ module.exports = { spawnGroupBoundedSync };
@@ -11,6 +11,7 @@
11
11
  const { CANONICAL_EDITOR_REPO, EDITOR_VARIANTS, repoBasename, AGENT_ENV_VARS } = require("./cloud");
12
12
  const { normalizeVmSlug } = require("./vm-urls");
13
13
  const { normalizeAccessList } = require("./fleet-vm-access");
14
+ const { normalizeRepoUrl } = require("./fleet-env");
14
15
 
15
16
  /** The build agents a VM's editor terminal can run, as the keys of cloud.js's
16
17
  * AGENT_ENV_VARS. Deriving the selectable set from that map keeps it a single
@@ -119,6 +120,79 @@ function validateLaunchConfig(cfg) {
119
120
  return { ok: true };
120
121
  }
121
122
 
123
+ /** The branch refs a launch config asks git to resolve, as
124
+ * `[{ field, repo, branch }]` — one entry per branch-carrying field that is
125
+ * actually populated. This is the input to a ref-existence preflight; it lives
126
+ * beside normalizeLaunchConfig (rather than in a second parser at the add
127
+ * endpoint or in cloud.js) because it reads the SAME canonical field names that
128
+ * normalizer produces, so a field rename can never leave the two disagreeing.
129
+ *
130
+ * BOTH fields are reported, deliberately. In the 2026-08-25 incident the dead
131
+ * ref sat in `clientBranch` AND `editorBranch`, and it was `clientBranch` that
132
+ * actually killed the bootstrap — an editor-only check would have missed it.
133
+ *
134
+ * A greenfield add (`newProjectName`, no `clientRepo`) has no client repo to
135
+ * resolve a branch against, so its clientBranch is skipped rather than probed
136
+ * against a repo URL that does not exist. `editorRepo` is passed in because the
137
+ * launch config carries an editor BRANCH but not an editor REPO — the repo is
138
+ * the caller's (cloud:up resolves it via resolveEditorConfig; the dashboard
139
+ * holds it in REPO). Pure; no I/O. */
140
+ function branchRefTargets(cfg, { editorRepo } = {}) {
141
+ const c = cfg || {};
142
+ const targets = [];
143
+ if (c.clientRepo && c.clientBranch) {
144
+ targets.push({ field: "clientBranch", repo: c.clientRepo, branch: c.clientBranch });
145
+ }
146
+ if (editorRepo && c.editorBranch) {
147
+ targets.push({ field: "editorBranch", repo: editorRepo, branch: c.editorBranch });
148
+ }
149
+ return targets;
150
+ }
151
+
152
+ /** Partition branchRefTargets by whether each ref still resolves, using an
153
+ * INJECTED probe so the decision logic is unit-tested with no network and no
154
+ * git. `refExists({ repo, branch })` returns true when the ref is present,
155
+ * false when it is provably gone.
156
+ *
157
+ * A probe that cannot reach the remote at all must NOT report false — a network
158
+ * or credential fault is not evidence a branch was deleted, and treating it as
159
+ * such would refuse every add the moment GitHub blipped. The probe signals that
160
+ * by returning `null`/`undefined`, which this treats as "unknown, let it
161
+ * through": the classifier arm downstream still catches a genuinely dead ref,
162
+ * at the cost this preflight normally saves. Fail-open is the right default
163
+ * here precisely because the preflight is an OPTIMIZATION over a failure that
164
+ * is already handled, never the only line of defence. Pure; no I/O. */
165
+ function missingBranchRefs(targets, refExists) {
166
+ const missing = [];
167
+ for (const t of targets || []) {
168
+ if (refExists(t) === false) missing.push(t);
169
+ }
170
+ return missing;
171
+ }
172
+
173
+ /** Operator-facing copy for a failed branch-ref preflight. States the OUTCOME
174
+ * first (nothing was created), then the mechanism, then the remedy — and never
175
+ * the "a partial instance may exist — reconcile or destroy it" language the
176
+ * generic provisioning failure uses, which is what made the real incident
177
+ * confusing: at preflight time no instance, disk, NEG or backend service has
178
+ * been created, so there is nothing to reconcile and nothing to destroy.
179
+ *
180
+ * Names which FIELD carried the dead ref, because the two are configured in
181
+ * different places and an operator who only corrects one re-issues a request
182
+ * that fails identically. Pure; no I/O. */
183
+ function missingBranchRefMessage(missing) {
184
+ const list = (missing || [])
185
+ .map((m) => `${m.field}: branch '${m.branch}' is not on ${m.repo}`)
186
+ .join("; ");
187
+ return (
188
+ `Branch no longer exists on origin — ${list}. ` +
189
+ "Nothing was provisioned: this is checked before any instance is created, so " +
190
+ "there is no VM, disk or backend service to clean up. This repo prunes branches " +
191
+ "when they merge, so a stored branch name expires silently. Re-add on a live " +
192
+ "branch (the current fleet branch, or any branch that still exists on origin)."
193
+ );
194
+ }
195
+
122
196
  /** Pre-flight the operator-host CONFIG a dashboard Add depends on, BEFORE
123
197
  * spawning the ~20-min cloud:up. Pure classifier: it takes the ALREADY-resolved
124
198
  * zone, project, and GitHub token (the side-effecting resolution — env lookups,
@@ -406,21 +480,51 @@ function queuedStatusForAction(action) {
406
480
  /** Push a launch entry onto the newest-first history, capped to `cap`. No
407
481
  * cross-VM dedup at write time — the full log is what powers per-VM config
408
482
  * lookups (configForVm); the "recent launches" picker de-dups at read time
409
- * (distinctConfigs). Returns a new array; does not mutate the input. */
410
- function pushHistory(history, entry, cap = 30) {
483
+ * (distinctProjectConfigs). Returns a new array; does not mutate the input.
484
+ *
485
+ * The cap is deliberately generous: this log is the ONLY source the picker can
486
+ * draw distinct projects from, so it bounds how far back "recent projects" can
487
+ * reach, and one project relaunched in a burst must not be able to evict every
488
+ * other project from the window. Entries are ~390 bytes, so 100 of them is
489
+ * ~39 KB — no meaningful cost, and it also lengthens the reach of the per-VM
490
+ * lookups (configForVm / latestLaunchTs) for VMs not relaunched recently. */
491
+ function pushHistory(history, entry, cap = 100) {
411
492
  const arr = Array.isArray(history) ? history.slice() : [];
412
493
  arr.unshift(entry);
413
494
  return arr.slice(0, Math.max(1, cap));
414
495
  }
415
496
 
416
- /** Distinct configs, newest-first, for the "recent launches" picker (so the
417
- * same config launched on several VMs shows once — the most-recent
418
- * occurrence). */
419
- function distinctConfigs(history, limit = 12) {
497
+ /** The identity the "recent launches" picker de-dups on: WHICH PROJECT a launch
498
+ * was against, not which exact configuration it used.
499
+ *
500
+ * Per-launch and per-VM fields (clientBranch, editorVariant, editorBranch,
501
+ * agent, vmSlug, vmAccess) must NOT participate: they are the variant details
502
+ * the operator edits in the form *after* picking a project, so keying on them
503
+ * fragments one hot project into many near-identical rows and crowds every
504
+ * other project off the list.
505
+ *
506
+ * Repo identity goes through `normalizeRepoUrl` (fleet-env.js), so the scp-like
507
+ * and https spellings of one repo — and a `.git` suffix — collapse to one row.
508
+ * `repoBasename` is deliberately NOT the key: two owners' repos can share a
509
+ * basename. Greenfield launches key on the project name, so two different
510
+ * greenfield projects stay two rows. Normalizes through EMPTY_CONFIG so a
511
+ * legacy history entry with missing fields cannot throw. Pure; no I/O. */
512
+ function launchProjectKey(config) {
513
+ const cfg = { ...EMPTY_CONFIG, ...(config || {}) };
514
+ if (cfg.clientRepo) return `repo:${normalizeRepoUrl(cfg.clientRepo)}`;
515
+ if (cfg.newProjectName) return `new:${String(cfg.newProjectName).trim().toLowerCase()}`;
516
+ return "none";
517
+ }
518
+
519
+ /** Newest config per PROJECT, newest-first, for the "recent launches" picker —
520
+ * repo identity or greenfield name (launchProjectKey), so one project launched
521
+ * many times shows once, carrying its most recent launch as the row's prefill.
522
+ * `limit` bounds the rows returned. */
523
+ function distinctProjectConfigs(history, limit = 12) {
420
524
  const seen = new Set();
421
525
  const out = [];
422
526
  for (const e of Array.isArray(history) ? history : []) {
423
- const k = JSON.stringify(e && e.config);
527
+ const k = launchProjectKey(e && e.config);
424
528
  if (seen.has(k)) continue;
425
529
  seen.add(k);
426
530
  out.push(e);
@@ -429,12 +533,12 @@ function distinctConfigs(history, limit = 12) {
429
533
  return out;
430
534
  }
431
535
 
432
- /** Display-side companion to `distinctConfigs`: derive the two label strings the
536
+ /** Display-side companion to `distinctProjectConfigs`: derive the two label strings the
433
537
  * "recent launches" picker renders for one config, so two configs that differ
434
538
  * only by build agent (or greenfield project name) look different instead of
435
- * collapsing to identical rows. `distinctConfigs` de-dups on the FULL config, so
436
- * the rows are genuinely distinct launches — the old inline label just never
437
- * surfaced the distinguishing fields (agent, greenfield name).
539
+ * collapsing to identical rows. `distinctProjectConfigs` de-dups on project identity,
540
+ * so each row is one project carrying its most recent launch — and these labels
541
+ * are what say which branch / editor variant / agent that launch used.
438
542
  *
439
543
  * Returns { primary, secondary }:
440
544
  * - primary: greenfield (`!clientRepo && newProjectName`) → `new: <name>` so
@@ -522,11 +626,42 @@ function recentlyLaunchedWithin(ts, nowMs, graceMs) {
522
626
  * variant(@branch) fallback.
523
627
  * - source "live" when editorIdentity drove editorLabel, else
524
628
  * "history" — the affordance the card uses to mark inferred
525
- * data. Pure; no I/O. */
629
+ * data.
630
+ * - deployedBranch the branch the fleet ADVANCED this VM to, from the editor's
631
+ * `editorDeployment` (which reads the record the refresh script
632
+ * writes at install time); null when the editor reports none.
633
+ * - deployedTag the full artifact tag behind that branch, for the hover.
634
+ * - deployedSource "live" when the editor reported a deployment, else
635
+ * "unknown".
636
+ * - deployedState the card's three-way presentation verdict, resolved HERE
637
+ * rather than in the browser so the rule is unit-tested in one
638
+ * place and index.html carries no copy of it (the compromise
639
+ * groupVmsByProject's own comment apologizes for, avoidable
640
+ * here because this value rides in the /data payload):
641
+ * "agrees" deployment and provenance name the same branch
642
+ * "differs" they disagree — the EXPECTED plan-only-retag
643
+ * state, to be shown as a pair, never badged
644
+ * "unknown" no deployment reported
645
+ *
646
+ * editorLabel and deployedBranch answer DIFFERENT questions and are routinely
647
+ * expected to disagree. editorLabel is build provenance — what source the
648
+ * binary was compiled from — and stays exactly as it was; it is the right
649
+ * answer to "what code is this actually running?" and is what fragility #33's
650
+ * fix was built for. deployedBranch is the deployment target. After a plan-only
651
+ * advance, where fleet-advance-to.sh correctly retags the base branch's CI
652
+ * binary, provenance `main` + deployment `editor-improvements-78` is the
653
+ * CORRECT state, so the card must present the pair rather than badge it.
654
+ *
655
+ * A missing deployment resolves to null / "unknown" and is NEVER filled in from
656
+ * cfg.editorBranch, though that value is right there: it is written at
657
+ * reconfigure and is not updated by a binary refresh, so it is stale for
658
+ * precisely this question — the same live-over-history discipline the rest of
659
+ * this function applies, and the same `source` honesty. Pure; no I/O. */
526
660
  function resolveCardIdentity(live, historyCfg) {
527
661
  const cfg = historyCfg || {};
528
662
  const lcp = live && live.clientProject;
529
663
  const lei = live && live.editorIdentity;
664
+ const led = live && live.editorDeployment;
530
665
 
531
666
  const historyName = cfg.clientRepo ? repoBasename(cfg.clientRepo) : (cfg.newProjectName || "");
532
667
  const project = (lcp && lcp.name) || historyName || null;
@@ -547,7 +682,24 @@ function resolveCardIdentity(live, historyCfg) {
547
682
  source = "history";
548
683
  }
549
684
 
550
- return { project, clientBranch, editorLabel, source };
685
+ const deployedBranch = (led && led.branch) || null;
686
+ const deployedTag = (led && led.tag) || null;
687
+ const deployedSource = deployedBranch || deployedTag ? "live" : "unknown";
688
+ let deployedState;
689
+ if (!deployedBranch) deployedState = "unknown";
690
+ else if (deployedBranch === editorLabel) deployedState = "agrees";
691
+ else deployedState = "differs";
692
+
693
+ return {
694
+ project,
695
+ clientBranch,
696
+ editorLabel,
697
+ source,
698
+ deployedBranch,
699
+ deployedTag,
700
+ deployedSource,
701
+ deployedState,
702
+ };
551
703
  }
552
704
 
553
705
  /** Sticky-merge a VM poll's `clientProject` against the last-known-good value
@@ -798,13 +950,29 @@ function decideStartJob({ existing, requested } = {}) {
798
950
  * Modes "token" and "none" carry no claude auth, so the caller marks the VM
799
951
  * add-degraded with a `claude /login` remediation. `reachableDonors` must be
800
952
  * pre-filtered to VMs that actually answered (a destroyed-but-rostered orphan
801
- * is not a donor — fragility #24). Pure; no I/O. */
802
- function resolveCredsSource({ reachableDonors = [], claudeCredsSrc = "", githubToken = "" } = {}) {
803
- const donor = Array.isArray(reachableDonors) && reachableDonors.length ? reachableDonors[0] : null;
804
- if (donor != null) return { mode: "donor", donor };
805
- if (claudeCredsSrc) return { mode: "host-stash", donor: null };
806
- if (githubToken) return { mode: "token", donor: null };
807
- return { mode: "none", donor: null };
953
+ * is not a donor — fragility #24).
954
+ *
955
+ * `preferredDonor` (the reachable VM holding the operator's chosen Claude
956
+ * account) wins over `reachableDonors[0]` when it is itself reachable. When a
957
+ * `requestedAccount` was asked for, the result also carries
958
+ * `requestedAccountUnavailable` — true when no preferred donor could honor it —
959
+ * so the caller can raise a degraded reason without re-deriving why. With no
960
+ * account requested the result shape is unchanged. Pure; no I/O. */
961
+ function resolveCredsSource({
962
+ reachableDonors = [],
963
+ preferredDonor = null,
964
+ requestedAccount = "",
965
+ claudeCredsSrc = "",
966
+ githubToken = "",
967
+ } = {}) {
968
+ const donors = Array.isArray(reachableDonors) ? reachableDonors : [];
969
+ const preferred = preferredDonor != null && donors.includes(preferredDonor) ? preferredDonor : null;
970
+ const extra = requestedAccount ? { requestedAccountUnavailable: preferred == null } : {};
971
+ const donor = preferred != null ? preferred : (donors.length ? donors[0] : null);
972
+ if (donor != null) return { mode: "donor", donor, ...extra };
973
+ if (claudeCredsSrc) return { mode: "host-stash", donor: null, ...extra };
974
+ if (githubToken) return { mode: "token", donor: null, ...extra };
975
+ return { mode: "none", donor: null, ...extra };
808
976
  }
809
977
 
810
978
  /** True when the resolved creds `mode` (see `resolveCredsSource`) carries a real
@@ -917,7 +1085,12 @@ function verifyBeforeMoveSink(
917
1085
  * can extend host-stash with a sibling onboarding-stash env knob.
918
1086
  *
919
1087
  * Pure; no I/O. */
920
- function buildCredsSteps({ mode, refInst = "", inst, container, gcloud, zone, n, claudeCredsSrc = "" } = {}) {
1088
+ function buildCredsSteps({ mode, refInst = "", inst, container, gcloud, zone, n, claudeCredsSrc = "", account = "" } = {}) {
1089
+ // The donor's Claude account, named in the add log so it says WHICH account
1090
+ // was seeded, not only which VM it came from. Reduced to email-safe characters
1091
+ // because it is interpolated into a double-quoted shell `echo`.
1092
+ const acct = String(account).replace(/[^A-Za-z0-9@._+-]/g, "");
1093
+ const acctNote = acct ? ` (account ${acct})` : "";
921
1094
  // The `.credentials.json` copy additionally validates the donor's credential
922
1095
  // shape (positive `expiresAt`, non-empty tokens) before overwriting — a
923
1096
  // non-empty-but-corrupt donor must not clobber a working target. The
@@ -932,7 +1105,7 @@ function buildCredsSteps({ mode, refInst = "", inst, container, gcloud, zone, n,
932
1105
  return [
933
1106
  `echo "copying git creds from ${refInst}..."`,
934
1107
  `${gcloud} compute ssh ${refInst} --tunnel-through-iap --zone=${zone} --command='cat $HOME/.codeyam-cloud-git-credentials' 2>/dev/null | ${gcloud} compute ssh ${inst} --tunnel-through-iap --zone=${zone} --command='umask 077; cat > $HOME/.codeyam-cloud-git-credentials' 2>/dev/null || echo "CY_DEGRADED git creds copy failed on VM-${n} — re-copy creds from a live donor (the agent can't git pull/push until then)"`,
935
- `echo "copying claude auth from ${refInst} (container->container)..."`,
1108
+ `echo "copying claude auth from ${refInst}${acctNote} (container->container)..."`,
936
1109
  `${gcloud} compute ssh ${refInst} --tunnel-through-iap --zone=${zone} --command='docker exec ${container} base64 /root/.claude/.credentials.json' 2>/dev/null | ${gcloud} compute ssh ${inst} --tunnel-through-iap --zone=${zone} --command=${_shq(claudeSink)} 2>/dev/null && echo "claude auth copied" || echo "CY_DEGRADED claude auth copy failed on VM-${n} (empty/unreadable/malformed donor — existing creds left untouched, not overwritten with a corrupt credential) — ${loginHint}"`,
937
1110
  `echo "copying claude.json (onboarding state) from ${refInst} (container->container)..."`,
938
1111
  `${gcloud} compute ssh ${refInst} --tunnel-through-iap --zone=${zone} --command='docker exec ${container} base64 /root/.claude.json' 2>/dev/null | ${gcloud} compute ssh ${inst} --tunnel-through-iap --zone=${zone} --command=${_shq(claudeJsonSink)} 2>/dev/null && echo "claude.json copied" || echo "CY_DEGRADED claude.json copy failed on VM-${n} — first interactive claude launch may show 'Select login method' menu; ${loginHint} once to complete onboarding"`,
@@ -954,6 +1127,21 @@ function buildCredsSteps({ mode, refInst = "", inst, container, gcloud, zone, n,
954
1127
  ];
955
1128
  }
956
1129
 
1130
+ /** The CY_DEGRADED reason for an Add whose requested Claude account no
1131
+ * reachable VM holds: names the account asked for and what was seeded instead
1132
+ * (the fallback donor's account, the host stash's account, or none for the
1133
+ * token/none modes), plus the remediation. Fall back loudly, never silently —
1134
+ * the dashboard lifts this line onto the new VM's card as an amber badge. The
1135
+ * caller shell-quotes it. Pure. */
1136
+ function accountFallbackMessage({ requested = "", seeded = "", mode = "none", donor = null, inst = "" } = {}) {
1137
+ const fallback = {
1138
+ donor: donor != null ? `VM-${donor}'s account` : "an unknown account",
1139
+ "host-stash": "the host credential stash's account",
1140
+ };
1141
+ const got = seeded || fallback[mode] || "no Claude account";
1142
+ return `CY_DEGRADED requested Claude account ${requested} is not on any reachable VM — seeded ${got} instead; run claude /login on ${inst} to switch it to ${requested}`;
1143
+ }
1144
+
957
1145
  /** Build the two host-side shell snippets that PRESERVE the container's
958
1146
  * /root/.claude.json (claude onboarding state) across a non-self-host Rebuild's
959
1147
  * container recreate. The whole /root/.claude DIRECTORY is bind-mounted from the
@@ -1215,6 +1403,9 @@ module.exports = {
1215
1403
  EMPTY_CONFIG,
1216
1404
  normalizeLaunchConfig,
1217
1405
  validateLaunchConfig,
1406
+ branchRefTargets,
1407
+ missingBranchRefs,
1408
+ missingBranchRefMessage,
1218
1409
  addPreflight,
1219
1410
  isVariantProvisionable,
1220
1411
  isVariantRebuildable,
@@ -1227,7 +1418,8 @@ module.exports = {
1227
1418
  HEAVY_ACTIONS,
1228
1419
  queuedStatusForAction,
1229
1420
  pushHistory,
1230
- distinctConfigs,
1421
+ launchProjectKey,
1422
+ distinctProjectConfigs,
1231
1423
  recentLaunchDisplay,
1232
1424
  defaultConfig,
1233
1425
  configForVm,
@@ -1242,6 +1434,7 @@ module.exports = {
1242
1434
  resolveCredsSource,
1243
1435
  claudeAuthSeeded,
1244
1436
  buildCredsSteps,
1437
+ accountFallbackMessage,
1245
1438
  verifyBeforeMoveSink,
1246
1439
  buildClaudeJsonPreserveSteps,
1247
1440
  buildEnvApplyCommand,
@@ -0,0 +1,81 @@
1
+ /**
2
+ * orphan-ssh-reaper.js — sweep the fleet SSH sessions a timed-out gcloud probe
3
+ * already leaked, before this machine's next sweep adds to them.
4
+ *
5
+ * group-bounded-spawn.js stops NEW leaks. A machine that ran the sweep before
6
+ * that fix may still carry orphans: `gcloud compute ssh` requests a TTY (`-t`),
7
+ * so an orphaned session has no EOF to notice and no parent to report to — it
8
+ * simply waits, and they accumulate rather than drain.
9
+ *
10
+ * A process is reaped only when BOTH hold:
11
+ * - it is an `ssh` carrying the fleet's `HostKeyAlias=compute.` signature
12
+ * (gcloud sets the alias to `compute.<instance-id>`), which separates it
13
+ * from any other ssh the operator runs; AND
14
+ * - it has been re-parented to PID 1. A live sweep's in-flight ssh still has
15
+ * its gcloud parent, so the command line alone is never enough.
16
+ * Limitation: on a Linux host with a user-session subreaper, orphans re-parent
17
+ * to that subreaper instead of PID 1 and are left alone — the safe direction.
18
+ */
19
+
20
+ const { spawnSync } = require("child_process");
21
+
22
+ const FLEET_SSH_SIGNATURE = /HostKeyAlias=compute\./;
23
+ // The ssh binary itself (`ssh …` or `/usr/bin/ssh …`) — never the gcloud
24
+ // wrapper, whose command line reads `… gcloud.py compute ssh …`.
25
+ const SSH_BINARY = /(^|\/)ssh\s/;
26
+
27
+ /** Parse `ps -A -o pid=,ppid=,command=` stdout into `{ pid, ppid, command }`
28
+ * rows, skipping anything malformed. Pure. */
29
+ function parsePsRows(stdout) {
30
+ return String(stdout || "")
31
+ .split("\n")
32
+ .map((line) => /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line))
33
+ .filter(Boolean)
34
+ .map((m) => ({ pid: Number(m[1]), ppid: Number(m[2]), command: m[3] }));
35
+ }
36
+
37
+ /** Whether one `ps` row is a leaked fleet ssh session. Pure. */
38
+ function isOrphanedFleetSsh(row) {
39
+ return (
40
+ row.ppid === 1 &&
41
+ SSH_BINARY.test(row.command) &&
42
+ FLEET_SSH_SIGNATURE.test(row.command)
43
+ );
44
+ }
45
+
46
+ function defaultRunPs() {
47
+ return spawnSync("ps", ["-A", "-o", "pid=,ppid=,command="], { encoding: "utf8" });
48
+ }
49
+
50
+ /** SIGKILL every orphaned fleet ssh and report each one. Returns the reaped
51
+ * rows. Best-effort: a `ps` failure logs and reaps nothing, never throws. */
52
+ function reapOrphanedFleetSsh({
53
+ runPs = defaultRunPs,
54
+ kill = (pid, signal) => process.kill(pid, signal),
55
+ log = (line) => console.error(line),
56
+ } = {}) {
57
+ // Platform assumption: PID-1 re-parenting and `ps -o` are POSIX.
58
+ if (process.platform === "win32") return [];
59
+ const res = runPs();
60
+ if (res.error || res.status !== 0) {
61
+ const why = (res.error && res.error.message) || `ps exited ${res.status}`;
62
+ log(` orphaned-ssh sweep skipped: could not list processes (${why})`);
63
+ return [];
64
+ }
65
+ const reaped = [];
66
+ for (const row of parsePsRows(res.stdout).filter(isOrphanedFleetSsh)) {
67
+ try {
68
+ kill(row.pid, "SIGKILL");
69
+ } catch {
70
+ continue; // exited between the listing and the kill
71
+ }
72
+ reaped.push(row);
73
+ log(` reaped orphaned fleet ssh pid=${row.pid} :: ${row.command.slice(0, 120)}`);
74
+ }
75
+ if (reaped.length) {
76
+ log(` orphaned-ssh sweep: reaped ${reaped.length} leaked session(s) left by earlier timed-out probes`);
77
+ }
78
+ return reaped;
79
+ }
80
+
81
+ module.exports = { parsePsRows, isOrphanedFleetSsh, reapOrphanedFleetSsh };