@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.
- package/npm/cloud.js +377 -21
- package/npm/editor-dev.js +24 -2
- package/npm/editor.js +15 -5
- package/npm/fleet-add-failure.js +355 -21
- package/npm/fleet-auth-account.js +61 -0
- package/npm/fleet-auth.js +69 -1
- package/npm/fleet-branch.js +124 -1
- package/npm/fleet-build-data.js +12 -1
- package/npm/fleet-cleanup.js +84 -3
- package/npm/fleet-datastore-health.js +107 -0
- package/npm/fleet-public-url.js +81 -12
- package/npm/fleet-reconcile.js +46 -11
- package/npm/fleet-revision.js +108 -0
- package/npm/fleet-run-setup.js +66 -0
- package/npm/fleet-session-activity.js +162 -0
- package/npm/fleet-vm-readiness.js +57 -16
- package/npm/group-bounded-spawn.js +111 -0
- package/npm/launch-config.js +216 -23
- package/npm/orphan-ssh-reaper.js +81 -0
- package/npm/scenario-check.js +697 -34
- package/npm/scenario-interactivity.js +477 -23
- package/npm/scenario-issues.js +61 -0
- package/npm/scenario-playwright.js +174 -20
- package/npm/utils.js +262 -22
- package/package.json +5 -5
- package/ui/dist/assets/{ContainerInspector-DSVZvEkB.js → ContainerInspector-DiN91foX.js} +1 -1
- package/ui/dist/assets/EditorShell-BEIdHnHd.css +1 -0
- package/ui/dist/assets/EditorShell-CPV5vhjo.js +330 -0
- package/ui/dist/assets/ScenarioDataPanel-DHAHyraq.js +18 -0
- package/ui/dist/assets/{ScenarioDataPanelFullPage-Cyk6efiI.js → ScenarioDataPanelFullPage-CHIWip3n.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-D9a91iwS.js +21 -0
- package/ui/dist/assets/index--cE0hUJw.css +1 -0
- package/ui/dist/assets/{index-BPLKgwon.js → index-sW8wcg7v.js} +9 -9
- package/ui/dist/index.html +2 -2
- package/ui/dist/preview-subdir-demo/index.html +62 -0
- package/ui/dist/preview-subdir-demo/sibling.html +48 -0
- package/ui/dist/assets/EditorShell-DnXdHrar.js +0 -256
- package/ui/dist/assets/EditorShell-FvbXyqqD.css +0 -1
- package/ui/dist/assets/ScenarioDataPanel-6qJE-0lA.js +0 -15
- package/ui/dist/assets/ScenarioRenderer-Uyt54vHI.js +0 -21
- 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 };
|
package/npm/launch-config.js
CHANGED
|
@@ -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
|
-
* (
|
|
410
|
-
|
|
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
|
-
/**
|
|
417
|
-
*
|
|
418
|
-
*
|
|
419
|
-
|
|
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 =
|
|
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 `
|
|
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. `
|
|
436
|
-
*
|
|
437
|
-
*
|
|
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.
|
|
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
|
-
|
|
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).
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
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
|
-
|
|
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 };
|