@codeyam-editor/codeyam-editor 0.1.4 → 0.1.6
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/cli.js +5 -1
- package/npm/cloud-branch-switch.js +1 -1
- package/npm/cloud.js +3166 -150
- package/npm/codesign-plan.js +68 -0
- package/npm/container.js +3 -0
- package/npm/disk-pressure.js +78 -0
- package/npm/editor-dev.js +96 -15
- package/npm/editor.js +85 -37
- package/npm/empty-project-dir.js +92 -7
- package/npm/fleet-add-failure.js +252 -0
- package/npm/fleet-asset-version.js +37 -0
- package/npm/fleet-auth-health.js +213 -0
- package/npm/fleet-auth.js +6 -1
- package/npm/fleet-build-data.js +109 -5
- package/npm/fleet-cleanup.js +232 -16
- package/npm/fleet-destroy-safety.js +272 -0
- package/npm/fleet-early-roster.js +87 -0
- package/npm/fleet-effect-poll.js +110 -0
- package/npm/fleet-effect-status.js +33 -32
- package/npm/fleet-env.js +86 -0
- package/npm/fleet-ghcr-secret.js +129 -0
- package/npm/fleet-git-maintenance.js +142 -0
- package/npm/fleet-host-op-limiter.js +68 -0
- package/npm/fleet-host.js +74 -0
- package/npm/fleet-iap-token.js +165 -0
- package/npm/fleet-job-badge.js +1 -1
- package/npm/fleet-launchd.js +171 -0
- package/npm/fleet-live-status.js +155 -0
- package/npm/fleet-memory.js +245 -9
- package/npm/fleet-plans.js +233 -4
- package/npm/fleet-pool.js +75 -16
- package/npm/fleet-prewarm.js +241 -0
- package/npm/fleet-project-drift.js +58 -0
- package/npm/fleet-public-url.js +394 -0
- package/npm/fleet-queue.js +95 -1
- package/npm/fleet-reachability-debounce.js +69 -0
- package/npm/fleet-reachability.js +80 -0
- package/npm/fleet-reconcile.js +43 -1
- package/npm/fleet-reset.js +330 -13
- package/npm/fleet-roster.js +6 -8
- package/npm/fleet-running-plan.js +85 -0
- package/npm/fleet-self-update.js +142 -0
- package/npm/fleet-session-token-cache.js +65 -0
- package/npm/fleet-setup-steps.js +112 -0
- package/npm/fleet-sleep-status.js +33 -0
- package/npm/fleet-status-refs.js +96 -0
- package/npm/fleet-strand.js +181 -0
- package/npm/fleet-stuck-status.js +117 -0
- package/npm/fleet-vm-access.js +404 -0
- package/npm/fleet-vm-probe-target.js +282 -0
- package/npm/fleet-vm-readiness.js +237 -0
- package/npm/fleet-vm-slugs.js +116 -0
- package/npm/google-ingress.js +226 -0
- package/npm/ingress-invoke.js +770 -0
- package/npm/launch-config.js +386 -9
- package/npm/scenario-check.js +880 -37
- package/npm/scenario-handlers.js +154 -30
- package/npm/scenario-interactivity.js +145 -6
- package/npm/scenario-issues.js +13 -1
- package/npm/scenario-metrics.js +116 -1
- package/npm/scenario-mocks.js +144 -21
- package/npm/scenario-playwright.js +649 -38
- package/npm/script-drift.js +152 -0
- package/npm/utils.js +365 -52
- package/npm/vm-urls.js +69 -8
- package/package.json +5 -5
- package/ui/dist/assets/ContainerInspector-DgM0pTAx.js +2 -0
- package/ui/dist/assets/EditorShell-B7Q06uJ_.js +252 -0
- package/ui/dist/assets/EditorShell-DP5UY20G.css +1 -0
- package/ui/dist/assets/{Markdown-BHBqz-Mj.js → Markdown-Chgdf3PW.js} +1 -1
- package/ui/dist/assets/ProjectLauncherScreen-BRrEr8Ng.js +1 -0
- package/ui/dist/assets/ScenarioDataPanel-BeQfa28g.js +9 -0
- package/ui/dist/assets/{ScenarioDataPanel-DYP7pi_n.css → ScenarioDataPanel-C1LukYI6.css} +1 -1
- package/ui/dist/assets/ScenarioDataPanelFullPage-BLqRCxQX.js +1 -0
- package/ui/dist/assets/ScenarioRenderer-CIAxfHPh.js +21 -0
- package/ui/dist/assets/StepsSimulator-CymGgfbu.js +1 -0
- package/ui/dist/assets/index-BygCiJSb.css +1 -0
- package/ui/dist/assets/{index-BuK0lizt.js → index-DCHqRrwO.js} +9 -9
- package/ui/dist/assets/{markdown-v0F0UEt3.js → markdown-C9tbsVHX.js} +1 -1
- package/ui/dist/assets/react-nrLBr15I.js +28 -0
- package/ui/dist/assets/useEvents-S3Qk1Y_d.js +1 -0
- package/ui/dist/assets/useServerIdentity-B7rI0u3O.js +1 -0
- package/ui/dist/codeyam-mark.svg +4 -0
- package/ui/dist/codeyam-wordmark.svg +5 -0
- package/ui/dist/index.html +4 -4
- package/ui/dist/open-source/codeyam-counter.png +0 -0
- package/ui/dist/open-source/el-carot.png +0 -0
- package/ui/dist/open-source/tabcommand.png +0 -0
- package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +0 -2
- package/ui/dist/assets/EditorShell-tMMdarZD.js +0 -187
- package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +0 -5
- package/ui/dist/assets/ScenarioDataPanelFullPage-5G6KuZgw.js +0 -1
- package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +0 -1
- package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
- package/ui/dist/assets/index-BGqairFV.css +0 -1
- package/ui/dist/assets/react-CSS0HapR.js +0 -28
- package/ui/dist/assets/useEvents-DggWrtHe.js +0 -1
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Pure decision + bash-script composition for the dashboard's SELF-update action
|
|
3
|
+
// (scripts/operator/fleet-dashboard/server.js requires this). Mirrors the
|
|
4
|
+
// fleet-branch.js / fleet-reset.js pattern: string + decision logic only, no I/O
|
|
5
|
+
// (no git exec, no env detection, no `exec`), so the self-update *recipe* — what
|
|
6
|
+
// the detached bash actually does to pull the dashboard's own checkout to tip and
|
|
7
|
+
// which supervisor it restarts through — stays unit-testable in isolation
|
|
8
|
+
// (npm/fleet-self-update.test.js). server.js owns the I/O: current-branch read,
|
|
9
|
+
// `id -u`, FLEET_ON_GCE detection, and the detached `exec`.
|
|
10
|
+
//
|
|
11
|
+
// The dashboard's Node half is plain source (it `require`s server.js +
|
|
12
|
+
// npm/fleet-*.js from REPO_ROOT at boot, no compile step), so refreshing *it* is
|
|
13
|
+
// just "pull new source + restart the node process". Both supervisors auto-respawn
|
|
14
|
+
// on exit (systemd Restart=always, launchd KeepAlive), so the restart is a clean
|
|
15
|
+
// hand-off. But the dashboard also depends on the Rust `codeyam-editor` binary at
|
|
16
|
+
// runtime — `pollPlans` shells out to `codeyam-editor editor plans-snapshot` for the
|
|
17
|
+
// Queued-Plans origin view — so a self-update that advances editor source past a
|
|
18
|
+
// changed/new subcommand must rebuild the binary too, or the JS moves while a stale
|
|
19
|
+
// binary keeps erroring. buildSelfUpdateScript therefore rebuilds the binary, but
|
|
20
|
+
// only when editor source actually changed across the update and fail-soft so a
|
|
21
|
+
// build problem never blocks the restart.
|
|
22
|
+
|
|
23
|
+
const { cleanBranch } = require('./fleet-branch');
|
|
24
|
+
|
|
25
|
+
// Single-quote a value for safe bash interpolation — identical to server.js's
|
|
26
|
+
// `shq`, duplicated here so this module stays a pure, dependency-light leaf (it
|
|
27
|
+
// must not require the untestable server shell).
|
|
28
|
+
function shq(s) {
|
|
29
|
+
return `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A branch name we're willing to hand to `git checkout` / `git reset --hard`.
|
|
33
|
+
// git's own ref rules are broader, but the dashboard only ever targets ordinary
|
|
34
|
+
// fleet branches, so we accept a conservative charset (letters, digits, and the
|
|
35
|
+
// `._/-` that real branch names use) and reject everything else. This is the
|
|
36
|
+
// belt to shq's suspenders: even though every interpolated value is quoted, an
|
|
37
|
+
// up-front reject turns an injection attempt / typo into a clear `{ok:false}`
|
|
38
|
+
// instead of a script that runs and fails deep in the detached child.
|
|
39
|
+
function isSafeBranch(b) {
|
|
40
|
+
return /^[A-Za-z0-9._/-]+$/.test(b) && !b.includes('..');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Decide whether a self-update request is actionable and on which branch.
|
|
44
|
+
// - `alreadyRunning` (a self-update already in flight) is refused first, so the
|
|
45
|
+
// in-flight guard is part of the tested decision rather than action branching.
|
|
46
|
+
// - A blank requested branch falls back to the dashboard's current branch
|
|
47
|
+
// (the modal prefills the current branch, but an operator can clear it).
|
|
48
|
+
// - An unsafe / empty resulting branch is rejected with a clear `msg`.
|
|
49
|
+
// Pure; no I/O. Returns `{ ok, branch, msg }`. Keeping the guard + resolution +
|
|
50
|
+
// validation here lets server.js's actionUpdateDashboard stay a thin I/O shell.
|
|
51
|
+
function decideSelfUpdate({ requestedBranch, currentBranch, alreadyRunning } = {}) {
|
|
52
|
+
if (alreadyRunning) {
|
|
53
|
+
return { ok: false, msg: 'dashboard update already running' };
|
|
54
|
+
}
|
|
55
|
+
const branch = cleanBranch(requestedBranch) || cleanBranch(currentBranch);
|
|
56
|
+
if (!branch) {
|
|
57
|
+
return { ok: false, msg: 'self-update: no branch given and current branch is unknown' };
|
|
58
|
+
}
|
|
59
|
+
if (!isSafeBranch(branch)) {
|
|
60
|
+
return { ok: false, msg: `self-update: invalid branch name '${branch}'` };
|
|
61
|
+
}
|
|
62
|
+
return { ok: true, branch, msg: `updating dashboard to ${branch}` };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The supervisor-appropriate restart command. The dashboard restarting itself
|
|
66
|
+
// kills the very process serving the request, so this runs inside the detached
|
|
67
|
+
// child (see buildSelfUpdateScript). Selection:
|
|
68
|
+
// - GCE operator host (systemd) → `sudo systemctl restart codeyam-dashboard`
|
|
69
|
+
// (needs a passwordless sudoers drop-in; see the plan's provisioning note).
|
|
70
|
+
// - laptop (launchd) → `launchctl kickstart -k gui/<uid>/<label>`
|
|
71
|
+
// - bare-nohup fallback → `<repoRoot>/scripts/operator/fleet-dashboard/start.sh`
|
|
72
|
+
// The non-GCE path tries launchd first and falls back to start.sh in one `||`
|
|
73
|
+
// chain, so a laptop with no launchd agent (a bare `nohup node server.js`) still
|
|
74
|
+
// restarts. start.sh is idempotent (kills the port, re-execs node).
|
|
75
|
+
function restartCommand({ onGce, launchdUid, label, repoRoot }) {
|
|
76
|
+
if (onGce) return 'sudo systemctl restart codeyam-dashboard';
|
|
77
|
+
const target = shq(`gui/${launchdUid}/${label}`);
|
|
78
|
+
const startSh = shq(`${repoRoot}/scripts/operator/fleet-dashboard/start.sh`);
|
|
79
|
+
return `launchctl kickstart -k ${target} || ${startSh}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Compose the bash the detached child runs: pull the dashboard's own checkout to
|
|
83
|
+
// the tip of `branch`, conditionally rebuild the editor binary, then restart
|
|
84
|
+
// through the environment-appropriate supervisor. Hard-reset semantics
|
|
85
|
+
// (operator's choice): always land exactly on `origin/<branch>`, discarding any
|
|
86
|
+
// uncommitted/divergent state on the dashboard checkout. Bails loudly if the
|
|
87
|
+
// branch doesn't exist on origin (nothing is reset/restarted).
|
|
88
|
+
//
|
|
89
|
+
// Rebuild gating (between the reset and the restart): record the pre-reset HEAD,
|
|
90
|
+
// compare it to the post-reset HEAD restricted to the editor source paths, and
|
|
91
|
+
// rebuild only when something under `crates`/`Cargo.toml`/`Cargo.lock` actually
|
|
92
|
+
// moved — a JS-only update must not pay a multi-minute Rust compile. The rebuild
|
|
93
|
+
// is additionally gated on `codeyam-editor` being on PATH so a host without the
|
|
94
|
+
// toolchain skips cleanly, and is fail-soft (`|| echo …`) so a build failure can't
|
|
95
|
+
// abort the `set -e` script before the restart: a degraded-but-running dashboard
|
|
96
|
+
// beats a wedged one. The restart stays last and unconditional.
|
|
97
|
+
//
|
|
98
|
+
// Every interpolated value is shq-quoted. Pure; no I/O.
|
|
99
|
+
function buildSelfUpdateScript({ repoRoot, branch, onGce, launchdUid, label } = {}) {
|
|
100
|
+
const R = shq(repoRoot);
|
|
101
|
+
const B = shq(branch);
|
|
102
|
+
const originRef = shq(`origin/${branch}`);
|
|
103
|
+
const restart = restartCommand({ onGce, launchdUid, label, repoRoot });
|
|
104
|
+
return [
|
|
105
|
+
'set -e',
|
|
106
|
+
`echo ">> self-update: fetching origin in ${repoRoot}"`,
|
|
107
|
+
`git -C ${R} fetch origin`,
|
|
108
|
+
// Verify origin/<branch> exists BEFORE touching the working tree, so a typo'd
|
|
109
|
+
// / unknown branch bails with nothing reset or restarted.
|
|
110
|
+
`git -C ${R} rev-parse --verify ${originRef} >/dev/null 2>&1 || ` +
|
|
111
|
+
`{ echo "self-update: origin/${branch} not found" >&2; exit 1; }`,
|
|
112
|
+
// Record the editor-source revision BEFORE the reset, so we can tell after
|
|
113
|
+
// whether the editor binary needs rebuilding. Read-only; touches nothing.
|
|
114
|
+
`OLD=$(git -C ${R} rev-parse HEAD)`,
|
|
115
|
+
`echo ">> self-update: checking out ${branch}"`,
|
|
116
|
+
// DWIM creates a local tracking branch when one doesn't exist yet; a no-op
|
|
117
|
+
// when already on it.
|
|
118
|
+
`git -C ${R} checkout ${B}`,
|
|
119
|
+
`echo ">> self-update: hard-reset to origin/${branch} (discards local changes)"`,
|
|
120
|
+
`git -C ${R} reset --hard ${originRef}`,
|
|
121
|
+
`NEW=$(git -C ${R} rev-parse HEAD)`,
|
|
122
|
+
// Rebuild the editor binary, but only when (a) editor source actually moved
|
|
123
|
+
// and (b) the binary is on PATH. The `git diff --quiet … -- crates …` exits 0
|
|
124
|
+
// when nothing under the editor source paths changed → skip (the fast JS-only
|
|
125
|
+
// path). `command -v` skips a toolchain-less host. The rebuild itself is
|
|
126
|
+
// fail-soft so a build failure still falls through to the restart. Each guard
|
|
127
|
+
// sits in an `if`/`elif` condition, where a non-zero exit is allowed under
|
|
128
|
+
// `set -e`.
|
|
129
|
+
`if git -C ${R} diff --quiet "$OLD" "$NEW" -- crates Cargo.toml Cargo.lock; then ` +
|
|
130
|
+
`echo ">> self-update: skipping rebuild (no editor source change)"; ` +
|
|
131
|
+
`elif ! command -v codeyam-editor >/dev/null 2>&1; then ` +
|
|
132
|
+
`echo ">> self-update: skipping rebuild (codeyam-editor not on PATH)"; ` +
|
|
133
|
+
`else echo ">> self-update: editor source changed — rebuilding binary"; ` +
|
|
134
|
+
`codeyam-editor editor rebuild-self || ` +
|
|
135
|
+
`echo ">> self-update: rebuild-self FAILED — continuing to restart with the existing binary" >&2; ` +
|
|
136
|
+
`fi`,
|
|
137
|
+
`echo ">> self-update: restarting dashboard"`,
|
|
138
|
+
restart,
|
|
139
|
+
].join('\n');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = { shq, isSafeBranch, decideSelfUpdate, restartCommand, buildSelfUpdateScript };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Per-VM editor session-token cache for the fleet dashboard's gated probes.
|
|
3
|
+
//
|
|
4
|
+
// The post-501766261 editor runs FAIL-CLOSED: every control-API request must
|
|
5
|
+
// carry that VM's own editor session token in `Authorization`, or the editor
|
|
6
|
+
// answers 401. The dashboard reads each token once over the existing SSH/
|
|
7
|
+
// docker-exec path (server.js supplies the impure `fetchToken`) and caches it
|
|
8
|
+
// per VM — the token is stable for the container's lifetime, so re-reading it
|
|
9
|
+
// on every poll would be a wasted gcloud-ssh fork per VM per cycle.
|
|
10
|
+
//
|
|
11
|
+
// Pure logic only — no I/O, no env reads, no clock — so it stays unit-testable
|
|
12
|
+
// in isolation (npm/fleet-session-token-cache.test.js), exactly like the
|
|
13
|
+
// createIapTokenProvider it mirrors. The caller injects `fetchToken(n)`.
|
|
14
|
+
|
|
15
|
+
/** Build a per-VM session-token cache.
|
|
16
|
+
*
|
|
17
|
+
* `fetchToken(n)` resolves to the VM's session token string (or null/'' when
|
|
18
|
+
* it cannot be read). Returns `{ getToken, invalidate }`:
|
|
19
|
+
*
|
|
20
|
+
* getToken(n) — resolves the cached token, fetching once on a cold entry.
|
|
21
|
+
* Concurrent cold calls for the same VM share ONE in-flight
|
|
22
|
+
* fetch (the poll cycle probes several endpoints per VM at
|
|
23
|
+
* once). A null/empty/failed fetch resolves to null and is
|
|
24
|
+
* NOT cached, so the next poll retries rather than sticking a
|
|
25
|
+
* VM on a missing token.
|
|
26
|
+
* invalidate(n) — drop a VM's cached token so the next getToken re-fetches.
|
|
27
|
+
* Called on a fail-closed 401, which is how a container
|
|
28
|
+
* recreate / token rotation self-heals.
|
|
29
|
+
*/
|
|
30
|
+
function createSessionTokenCache({ fetchToken } = {}) {
|
|
31
|
+
if (typeof fetchToken !== 'function') {
|
|
32
|
+
throw new Error('createSessionTokenCache requires a fetchToken(n) function');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const cache = new Map(); // n -> non-empty token string
|
|
36
|
+
const inFlight = new Map(); // n -> Promise<string|null>
|
|
37
|
+
|
|
38
|
+
async function getToken(n) {
|
|
39
|
+
if (cache.has(n)) return cache.get(n);
|
|
40
|
+
if (inFlight.has(n)) return inFlight.get(n);
|
|
41
|
+
|
|
42
|
+
const p = Promise.resolve()
|
|
43
|
+
.then(() => fetchToken(n))
|
|
44
|
+
.then((tok) => {
|
|
45
|
+
const value = tok == null ? '' : String(tok).trim();
|
|
46
|
+
// Only a real token is cached; a null/empty/errored read degrades to
|
|
47
|
+
// today's behavior (probe with no session token) and retries next poll.
|
|
48
|
+
if (value !== '') cache.set(n, value);
|
|
49
|
+
return value === '' ? null : value;
|
|
50
|
+
})
|
|
51
|
+
.catch(() => null)
|
|
52
|
+
.finally(() => { inFlight.delete(n); });
|
|
53
|
+
|
|
54
|
+
inFlight.set(n, p);
|
|
55
|
+
return p;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function invalidate(n) {
|
|
59
|
+
cache.delete(n);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { getToken, invalidate };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = { createSessionTokenCache };
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// npm/fleet-setup-steps.js — partition a VM's degradedReasons into expected
|
|
4
|
+
// first-run SETUP steps vs genuine ATTENTION problems for the fleet dashboard
|
|
5
|
+
// (vm: Dashboard 'Finishing setup' state for expected add steps).
|
|
6
|
+
//
|
|
7
|
+
// A clean greenfield Add lands with a wall of amber `⚠ Add degraded` badges
|
|
8
|
+
// because every unfinished post-provision wiring step is funneled into one flat
|
|
9
|
+
// `degradedReasons` string array and rendered identically. There is no
|
|
10
|
+
// distinction between *expected first-run setup that just hasn't finished yet*
|
|
11
|
+
// (Claude login not seeded, ingress host-rule not wired, project dir still the
|
|
12
|
+
// scaffold `workspace`, no start command on a brand-new project) and *something
|
|
13
|
+
// that actually broke* (broker death, half-provisioned orphan, off-branch
|
|
14
|
+
// drift). This module draws that line: it parses the existing reason-code
|
|
15
|
+
// prefixes and classifies each reason as a calm `setup` step or an `attention`
|
|
16
|
+
// problem, keeping the rules in a tested pure function the way fleet-job-badge.js
|
|
17
|
+
// / fleet-public-url.js do — server.js stays thin and the card colors finally
|
|
18
|
+
// tell the truth (blue = still wiring up, amber = look at this).
|
|
19
|
+
//
|
|
20
|
+
// Stack note: this is fleet-operator infrastructure (the GCE fleet that runs
|
|
21
|
+
// codeyam-editor itself), GCP-specific by design — the same posture as the
|
|
22
|
+
// scripts it backstops. The classification is pure string parsing, no network,
|
|
23
|
+
// so it unit-tests with no fixtures.
|
|
24
|
+
|
|
25
|
+
// The setup-step codes this module emits. A single source so server.js and the
|
|
26
|
+
// card UI agree on the vocabulary.
|
|
27
|
+
const SETUP_CODES = ['auth', 'ingress', 'project-dir', 'start-command'];
|
|
28
|
+
|
|
29
|
+
// The scaffold default project directory a brand-new (not-yet-created) VM runs
|
|
30
|
+
// under. project-dir-drift AWAY from this is a real problem (two real project
|
|
31
|
+
// names disagree); drift while still ON it is just "the greenfield project
|
|
32
|
+
// hasn't been created yet" — an expected setup step.
|
|
33
|
+
const SCAFFOLD_PROJECT_DIR = 'workspace';
|
|
34
|
+
|
|
35
|
+
// Parse `project-dir-drift (expected: X, running: Y)` into its two names.
|
|
36
|
+
// Returns { expected, running } with null fields when the shape doesn't match.
|
|
37
|
+
function parseDriftReason(reason) {
|
|
38
|
+
const m = /expected:\s*([^,]+?)\s*,\s*running:\s*([^)]+?)\s*\)/.exec(reason);
|
|
39
|
+
return m ? { expected: m[1].trim(), running: m[2].trim() } : { expected: null, running: null };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Partition degradedReasons into { setupSteps, attentionReasons }.
|
|
43
|
+
//
|
|
44
|
+
// setupSteps ordered [{ code, label, detail, action? }] — expected
|
|
45
|
+
// first-run wiring the card renders as a calm checklist.
|
|
46
|
+
// attentionReasons the original reason strings that signal a genuine
|
|
47
|
+
// problem — rendered amber with the existing ⚠ treatment.
|
|
48
|
+
//
|
|
49
|
+
// Unknown reason codes default to attentionReasons: an unrecognized signal is
|
|
50
|
+
// surfaced loudly, never silently hidden in the calm checklist.
|
|
51
|
+
function classifyReasons(degradedReasons = []) {
|
|
52
|
+
const setupSteps = [];
|
|
53
|
+
const attentionReasons = [];
|
|
54
|
+
const list = Array.isArray(degradedReasons) ? degradedReasons : [];
|
|
55
|
+
for (const raw of list) {
|
|
56
|
+
const reason = String(raw);
|
|
57
|
+
if (reason.startsWith('claude-auth-not-seeded')) {
|
|
58
|
+
setupSteps.push({ code: 'auth', label: 'Claude login', detail: reason });
|
|
59
|
+
} else if (reason.startsWith('ingress-unwired')) {
|
|
60
|
+
// System-handled: the dashboard reconcile wires the public URL
|
|
61
|
+
// automatically (incl. down+up self-heal for reused-slug orphans). NO
|
|
62
|
+
// operator `action`/command — render as in-progress, never a to-do.
|
|
63
|
+
setupSteps.push({
|
|
64
|
+
code: 'ingress',
|
|
65
|
+
label: 'Public URL wiring',
|
|
66
|
+
detail: 'Wiring the public URL — this happens automatically.',
|
|
67
|
+
auto: true,
|
|
68
|
+
});
|
|
69
|
+
} else if (reason.startsWith('project-dir-drift')) {
|
|
70
|
+
const { expected, running } = parseDriftReason(reason);
|
|
71
|
+
if (running === SCAFFOLD_PROJECT_DIR) {
|
|
72
|
+
setupSteps.push({
|
|
73
|
+
code: 'project-dir',
|
|
74
|
+
label: expected ? `Create project ${expected}` : 'Create project',
|
|
75
|
+
detail: reason,
|
|
76
|
+
});
|
|
77
|
+
} else {
|
|
78
|
+
// Drift between two REAL project names is a genuine problem, not setup.
|
|
79
|
+
attentionReasons.push(reason);
|
|
80
|
+
}
|
|
81
|
+
} else if (reason.startsWith('start-command')) {
|
|
82
|
+
setupSteps.push({ code: 'start-command', label: 'Start command', detail: reason });
|
|
83
|
+
} else {
|
|
84
|
+
attentionReasons.push(reason);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { setupSteps, attentionReasons };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// True when a VM's reasons are ALL expected setup steps with zero genuine
|
|
91
|
+
// problems — the gate for the calm `finishing-setup` status. Requires at least
|
|
92
|
+
// one setup step (an empty reason list is not "finishing setup", it's just a
|
|
93
|
+
// healthy VM with nothing left to do) and zero attention reasons.
|
|
94
|
+
function hasOnlySetup(degradedReasons = []) {
|
|
95
|
+
const { setupSteps, attentionReasons } = classifyReasons(degradedReasons);
|
|
96
|
+
return setupSteps.length > 0 && attentionReasons.length === 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// True when a VM's reasons are ALL expected setup steps AND every one of them is
|
|
100
|
+
// the SAME given code — e.g. `onlySetupCode(reasons, 'auth')` is the gate for the
|
|
101
|
+
// calm, first-class `needs-login` status: the ONLY outstanding item is Claude
|
|
102
|
+
// login, nothing else. A MIXED set (auth + ingress) is not `onlySetupCode('auth')`
|
|
103
|
+
// — it reads the generic `finishing-setup` instead. Builds on the same classifier
|
|
104
|
+
// as hasOnlySetup, so an empty list or any attention reason is false.
|
|
105
|
+
function onlySetupCode(degradedReasons = [], code) {
|
|
106
|
+
const { setupSteps, attentionReasons } = classifyReasons(degradedReasons);
|
|
107
|
+
return setupSteps.length > 0
|
|
108
|
+
&& attentionReasons.length === 0
|
|
109
|
+
&& setupSteps.every((s) => s.code === code);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
module.exports = { classifyReasons, hasOnlySetup, onlySetupCode, SETUP_CODES, SCAFFOLD_PROJECT_DIR };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Pure status helpers for the Sleep / Wake feature on the Fleet Dashboard
|
|
3
|
+
// (scripts/operator/fleet-dashboard/server.js requires this). Mirrors the
|
|
4
|
+
// pure-module-plus-colocated-vitest-test shape of npm/fleet-effect-status.js:
|
|
5
|
+
// server.js does the impure fact-gathering (the gcloud running-VM list, the
|
|
6
|
+
// per-VM reachability probe); these predicates turn those facts into the two
|
|
7
|
+
// new card statuses without any I/O, so the whole decision is unit-testable.
|
|
8
|
+
|
|
9
|
+
// Map a RUNNING stop/start job to its in-flight status badge. A Sleep click
|
|
10
|
+
// runs `gcloud compute instances stop` as a 'stop' job → 'stopping'; a Wake
|
|
11
|
+
// click runs `...start` as a 'start' job → 'starting'. Returns null for every
|
|
12
|
+
// other action so deriveStatus's existing per-action chain keeps owning it.
|
|
13
|
+
function sleepJobStatus(action) {
|
|
14
|
+
if (action === 'stop') return 'stopping';
|
|
15
|
+
if (action === 'start') return 'starting';
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Classify an UNREACHABLE rostered VM. The dashboard already polls GCP for the
|
|
20
|
+
// set of RUNNING fleet instances (runningFleetVms) on its slow 60s cycle, so we
|
|
21
|
+
// reuse it at zero extra API cost: a VM GCP still lists as running but whose
|
|
22
|
+
// control API we can't reach is genuinely 'unreachable' (booting, wedged, or
|
|
23
|
+
// the cloudflared tunnel is down); one absent from the running list has been
|
|
24
|
+
// stopped to pause billing — 'asleep'. A null/absent list is treated as "not
|
|
25
|
+
// running" so a cold dashboard with no poll yet defaults to the safe 'asleep'
|
|
26
|
+
// rather than masking a stopped VM as a red unreachable.
|
|
27
|
+
function unreachableStatus(n, runningFleetVms) {
|
|
28
|
+
return Array.isArray(runningFleetVms) && runningFleetVms.includes(n)
|
|
29
|
+
? 'unreachable'
|
|
30
|
+
: 'asleep';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { sleepJobStatus, unreachableStatus };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Push-based fleet-status read for the operator dashboard
|
|
3
|
+
// (scripts/operator/fleet-dashboard/server.js requires this).
|
|
4
|
+
//
|
|
5
|
+
// Each VM's editor publishes its own status to a git ref
|
|
6
|
+
// `refs/codeyam/fleet-status/<machine>` on its plan-claim heartbeat (see the
|
|
7
|
+
// Rust `control-api::fleet_status_refs` writer + the `plan_claims_sync`
|
|
8
|
+
// pusher). This module is the READ half: the dashboard fetches that WHOLE
|
|
9
|
+
// namespace in ONE `git fetch` and reads every VM's status from the local
|
|
10
|
+
// mirror — O(1) in fleet size, and crucially spawning ZERO `gcloud ssh`.
|
|
11
|
+
//
|
|
12
|
+
// That is the entire point of the push model: the old gather fanned out one
|
|
13
|
+
// heavyweight `gcloud compute ssh … docker exec` per VM per poll (Python +
|
|
14
|
+
// IAP tunnel, ~100 MB apiece), so monitoring cost O(N) SSH round-trips and
|
|
15
|
+
// the operator host had to be over-provisioned just to stay responsive. One
|
|
16
|
+
// fetch replaces all of it.
|
|
17
|
+
//
|
|
18
|
+
// Pure orchestration with injected I/O (`spawn` for the fetch, `readRefs` for
|
|
19
|
+
// the mirror read) so it stays unit-testable with no network and no git —
|
|
20
|
+
// exactly like the other fleet-* modules. Any real gcloud/SSH I/O is
|
|
21
|
+
// DELIBERATELY absent here; a test asserts no spawn command matches /gcloud/.
|
|
22
|
+
|
|
23
|
+
// Force refspec mirroring the whole fleet-status namespace into the local
|
|
24
|
+
// mirror. The leading `+` prunes a machine that stopped publishing (its ref
|
|
25
|
+
// deleted upstream), so a decommissioned VM's status can't linger. Kept
|
|
26
|
+
// byte-identical to the Rust `FLEET_STATUS_FETCH_REFSPEC` constant.
|
|
27
|
+
const FLEET_STATUS_FETCH_REFSPEC =
|
|
28
|
+
'+refs/codeyam/fleet-status/*:refs/codeyam/fleet-status/*';
|
|
29
|
+
|
|
30
|
+
// Parse `git for-each-ref --format='%(refname)%00%(contents)'` output over the
|
|
31
|
+
// fleet-status namespace into an array of payload objects. A ref whose body is
|
|
32
|
+
// not valid JSON is skipped (never throws) so one corrupt ref can't blind the
|
|
33
|
+
// dashboard to the rest of the fleet. NUL separates the ref name from its
|
|
34
|
+
// single-line JSON body; records are newline-separated.
|
|
35
|
+
function parseForEachRef(stdout) {
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const line of String(stdout || '').split('\n')) {
|
|
38
|
+
if (!line.trim()) continue;
|
|
39
|
+
const sep = line.indexOf('\u0000');
|
|
40
|
+
const json = sep >= 0 ? line.slice(sep + 1) : line;
|
|
41
|
+
try {
|
|
42
|
+
const payload = JSON.parse(json);
|
|
43
|
+
if (payload && typeof payload === 'object') out.push(payload);
|
|
44
|
+
} catch (e) {
|
|
45
|
+
// Skip an unparseable ref body; the rest of the fleet still reads.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Index an array of status payloads by their `machine`, keeping the LAST
|
|
52
|
+
// occurrence for a machine (there should only be one ref per machine, but be
|
|
53
|
+
// deterministic if a duplicate slips in). Payloads with no `machine` are
|
|
54
|
+
// dropped — they can't be attributed to a VM.
|
|
55
|
+
function indexByMachine(payloads) {
|
|
56
|
+
const vms = {};
|
|
57
|
+
for (const p of payloads || []) {
|
|
58
|
+
if (p && p.machine) vms[p.machine] = p;
|
|
59
|
+
}
|
|
60
|
+
return vms;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Read the whole fleet's pushed status in ONE fetch, then index it by machine.
|
|
64
|
+
//
|
|
65
|
+
// spawn(command, args) — run a process (the single git fetch). May return a
|
|
66
|
+
// promise; awaited. NEVER used for gcloud/SSH.
|
|
67
|
+
// readRefs() — return (or resolve to) the parsed status payloads
|
|
68
|
+
// from the local mirror. Injected so tests need no
|
|
69
|
+
// git; production passes a reader that runs
|
|
70
|
+
// `git for-each-ref` + parseForEachRef.
|
|
71
|
+
// remote — remote to fetch from (default 'origin').
|
|
72
|
+
// projectDir — cwd for the fetch, threaded to spawn's options.
|
|
73
|
+
//
|
|
74
|
+
// Returns `{ vms: { <machine>: payload }, count }`. A fetch failure is
|
|
75
|
+
// swallowed (best-effort, same posture as the Rust sync): the read falls back
|
|
76
|
+
// to whatever is already in the local mirror, so a transient network blip
|
|
77
|
+
// degrades to slightly-stale status rather than a blank dashboard.
|
|
78
|
+
async function gatherFleetStatus({ spawn, readRefs, remote = 'origin', projectDir } = {}) {
|
|
79
|
+
if (typeof spawn !== 'function') throw new Error('gatherFleetStatus requires a spawn function');
|
|
80
|
+
if (typeof readRefs !== 'function') throw new Error('gatherFleetStatus requires a readRefs function');
|
|
81
|
+
try {
|
|
82
|
+
await spawn('git', ['fetch', '--no-write-fetch-head', remote, FLEET_STATUS_FETCH_REFSPEC], { cwd: projectDir });
|
|
83
|
+
} catch (e) {
|
|
84
|
+
// Fall through to reading the (possibly stale) local mirror.
|
|
85
|
+
}
|
|
86
|
+
const payloads = await readRefs();
|
|
87
|
+
const vms = indexByMachine(payloads);
|
|
88
|
+
return { vms, count: Object.keys(vms).length };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = {
|
|
92
|
+
FLEET_STATUS_FETCH_REFSPEC,
|
|
93
|
+
parseForEachRef,
|
|
94
|
+
indexByMachine,
|
|
95
|
+
gatherFleetStatus,
|
|
96
|
+
};
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Pure classification + self-heal predicates for the fleet dashboard's
|
|
3
|
+
// launcher-strand detection (vm: Fleet converges onto the launcher-strand fix
|
|
4
|
+
// and self-heals the strand regardless of trigger).
|
|
5
|
+
//
|
|
6
|
+
// A cloud VM can strand off its control port `:14199` in bare Project Launcher
|
|
7
|
+
// mode: the editor re-execs into the launcher on an EPHEMERAL port instead of
|
|
8
|
+
// serving its one /workspace project on the contract port, so the dashboard's
|
|
9
|
+
// local-forward probe gets connection-refused and reports a generic
|
|
10
|
+
// "unreachable" — indistinguishable from a real outage. Diagnosed on VM-4
|
|
11
|
+
// (2026-06-25): an add-time binary that predated the `serve_cwd_directly`
|
|
12
|
+
// launcher-strand guard fell through `decide_launch` to the launcher because a
|
|
13
|
+
// stale `/tmp` test-fixture project padded `registry_count` to 2.
|
|
14
|
+
//
|
|
15
|
+
// The completed predecessor plan fixed the editor CODE PATH (serve the cwd
|
|
16
|
+
// project on :14199); this module is the dashboard-side SAFETY NET that detects
|
|
17
|
+
// the strand CONTINUOUSLY — by its trigger-independent SIGNAL SHAPE, not by what
|
|
18
|
+
// caused it — so a running fleet can't silently sit on the landmine. This is the
|
|
19
|
+
// PURE half (classification + decision), mirroring npm/fleet-public-url.js: the
|
|
20
|
+
// docker-exec / curl I/O lives in scripts/operator/fleet-dashboard/server.js's
|
|
21
|
+
// gatherLauncherStrand, the same gather-edge / pure-half split
|
|
22
|
+
// classifyPublicUrlHealth follows. Unit-tested in npm/fleet-strand.test.js.
|
|
23
|
+
//
|
|
24
|
+
// Stack note: this is fleet-operator infrastructure (the GCE fleet that runs
|
|
25
|
+
// codeyam-editor itself). A project that never joins the fleet never reaches it.
|
|
26
|
+
|
|
27
|
+
// The boot guards whose presence makes a binary safe. Single source so the
|
|
28
|
+
// classifier, the capability field the editor advertises, and any log line
|
|
29
|
+
// agree on the strings.
|
|
30
|
+
// SERVE_CWD_GUARD — safe from the launcher-mode strand (serves its cwd
|
|
31
|
+
// project on the contract port instead of re-execing
|
|
32
|
+
// into the Project Launcher on an ephemeral port).
|
|
33
|
+
// CAPTURE_AUTH_GUARD — enforces the capture-session auth guard, so component
|
|
34
|
+
// captures don't 401 when the editor binds a
|
|
35
|
+
// non-loopback address.
|
|
36
|
+
const SERVE_CWD_GUARD = 'serve_cwd_directly';
|
|
37
|
+
const CAPTURE_AUTH_GUARD = 'capture_session_auth';
|
|
38
|
+
|
|
39
|
+
// Per-guard impact clause folded into the stale reason, so a flagged VM's badge
|
|
40
|
+
// names WHY the missing guard matters (not a generic "stale") and the operator
|
|
41
|
+
// knows the concrete failure to expect.
|
|
42
|
+
const GUARD_IMPACT = {
|
|
43
|
+
[SERVE_CWD_GUARD]: 'will strand in Project Launcher mode on its next re-exec',
|
|
44
|
+
[CAPTURE_AUTH_GUARD]: 'component captures will 401 on a non-loopback bind',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Classify a VM's in-container probe into the launcher-strand vocabulary. The
|
|
48
|
+
* DECISIVE, trigger-independent signal is observable in-VM regardless of what
|
|
49
|
+
* caused the strand (a stale add-time binary, a wiped open-project pointer, any
|
|
50
|
+
* future cause):
|
|
51
|
+
*
|
|
52
|
+
* 'serving' — `editorHealthOk`: the control port answers /api/health,
|
|
53
|
+
* so the editor is serving its project. Nothing to heal.
|
|
54
|
+
* 'launcher-strand'— container up, control port has NO listener, AND a
|
|
55
|
+
* codeyam-editor process is serving the Project Launcher on
|
|
56
|
+
* an ephemeral port. THIS is the strand: :14199 is dark but
|
|
57
|
+
* the editor is alive on the wrong port.
|
|
58
|
+
* 'down' — container up, control port dark, and NO launcher process
|
|
59
|
+
* either: a genuine outage (crashed editor / dead tunnel).
|
|
60
|
+
* Left to the existing unreachable handling — must NOT
|
|
61
|
+
* auto-rebuild, a rebuild is the wrong hammer for a crash.
|
|
62
|
+
* 'unknown' — anything else (container not up, probe failed): absence
|
|
63
|
+
* of evidence, never auto-acts (mirrors
|
|
64
|
+
* classifyPublicUrlHealth's 'unknown' contract).
|
|
65
|
+
*
|
|
66
|
+
* `editorHealthOk` wins first: once the control port answers, the VM is serving
|
|
67
|
+
* no matter what else a slow/racy probe reported. */
|
|
68
|
+
function classifyLauncherStrand({
|
|
69
|
+
controlPortListening,
|
|
70
|
+
containerUp,
|
|
71
|
+
launcherServing,
|
|
72
|
+
editorHealthOk,
|
|
73
|
+
} = {}) {
|
|
74
|
+
if (editorHealthOk) return 'serving';
|
|
75
|
+
if (containerUp && !controlPortListening && launcherServing) return 'launcher-strand';
|
|
76
|
+
if (containerUp && !controlPortListening && !launcherServing) return 'down';
|
|
77
|
+
return 'unknown';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Decide whether a just-landed strand verdict warrants an auto-recovery, the
|
|
81
|
+
* PURE gate the standing self-heal fires through — debounced exactly like
|
|
82
|
+
* shouldAutoRewireIngress so a recovery that doesn't immediately clear can't
|
|
83
|
+
* loop and a single transient poll during a legitimate restart can't act:
|
|
84
|
+
* - `verdict` is 'launcher-strand' (only the strand self-heals; 'down' is a
|
|
85
|
+
* genuine outage the unreachable path owns, 'serving'/'unknown' need nothing),
|
|
86
|
+
* - `strandStreak` has reached `minStreak` consecutive strand polls (a single
|
|
87
|
+
* transient dark-port poll during a restart must not trigger a heavy rebuild),
|
|
88
|
+
* - no recovery is `alreadyRecovering` (one heavy job per VM), AND
|
|
89
|
+
* - the `cooldownMs` window has elapsed since `lastRecoverAt` so a recovery
|
|
90
|
+
* still settling can't re-fire every slow cycle.
|
|
91
|
+
* `hasSession` deliberately does NOT gate: a stranded editor has no live project
|
|
92
|
+
* session to protect (same rationale as the post-reset force=true fallback). A
|
|
93
|
+
* missing/0 `lastRecoverAt` means "never recovered" → the cooldown is satisfied. */
|
|
94
|
+
function shouldAutoRecoverStrand({
|
|
95
|
+
verdict,
|
|
96
|
+
strandStreak,
|
|
97
|
+
alreadyRecovering,
|
|
98
|
+
lastRecoverAt,
|
|
99
|
+
now,
|
|
100
|
+
cooldownMs,
|
|
101
|
+
minStreak = 2,
|
|
102
|
+
} = {}) {
|
|
103
|
+
if (verdict !== 'launcher-strand') return false;
|
|
104
|
+
if (alreadyRecovering) return false;
|
|
105
|
+
if (Number(strandStreak) < Number(minStreak)) return false;
|
|
106
|
+
return (Number(now) - (Number(lastRecoverAt) || 0)) >= Number(cooldownMs);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Choose the recovery SHAPE for a launcher-strand by binary currency. A plain
|
|
110
|
+
* reconfigure KEEPS the editor image, so recreating the container with the SAME
|
|
111
|
+
* pre-guard binary would immediately re-strand — the wrong hammer when a stale
|
|
112
|
+
* binary caused the strand. Returns:
|
|
113
|
+
* 'rebuild' — advance source to origin tip → rebuild-self → restart (the
|
|
114
|
+
* update-reset-everything shape) when the binary is stale OR
|
|
115
|
+
* its currency is UNKNOWN. The dark-control-port strand can't
|
|
116
|
+
* advertise its capability live, and re-stranding on a kept
|
|
117
|
+
* image is the worse failure, so unknown defaults to rebuild.
|
|
118
|
+
* 'reconfigure' — image-kept container recreate, correct ONLY when the running
|
|
119
|
+
* binary is already CURRENT (e.g. a wiped-pointer strand on a
|
|
120
|
+
* fresh binary), where a rebuild would be a needless ~8-min build.
|
|
121
|
+
* `binaryCurrent === true` is the only input that yields 'reconfigure'; null /
|
|
122
|
+
* undefined / false all yield 'rebuild'. */
|
|
123
|
+
function strandRecoveryAction({ binaryCurrent } = {}) {
|
|
124
|
+
return binaryCurrent === true ? 'reconfigure' : 'rebuild';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Classify whether a STILL-SERVING VM's running binary is at risk of the
|
|
128
|
+
* launcher-strand on its next re-exec — the PROACTIVE convergence signal (step
|
|
129
|
+
* 5), distinct from the strand verdict above (which fires only once a VM is
|
|
130
|
+
* ALREADY dark). Whether a binary is guard-capable is answered most robustly by
|
|
131
|
+
* the editor advertising the capability (the `bootGuards` array on the
|
|
132
|
+
* health/session-info payload it already serves) rather than the dashboard
|
|
133
|
+
* guessing from commit ancestry:
|
|
134
|
+
* - `bootGuards` IS an array → authoritative: stale iff it is missing ANY
|
|
135
|
+
* required guard. A binary that has `serve_cwd_directly` yet predates
|
|
136
|
+
* `capture_session_auth` (VM-13) must still be flagged, so a single
|
|
137
|
+
* required guard is not enough — the check is over the whole required set,
|
|
138
|
+
* naming the FIRST missing guard (with its impact clause) in the reason.
|
|
139
|
+
* - `bootGuards` ABSENT (an older binary that predates the field — which are
|
|
140
|
+
* exactly the at-risk ones) → fall back to the build-commit comparison the
|
|
141
|
+
* dashboard already computes: flag stale only when the editor source is
|
|
142
|
+
* `editorBehind > 0` commits behind origin, so a current binary that merely
|
|
143
|
+
* predates the FIELD isn't falsely flagged while a genuinely-behind one is.
|
|
144
|
+
* `requiredGuard` accepts an array (the new multi-guard shape, default
|
|
145
|
+
* `[SERVE_CWD_GUARD, CAPTURE_AUTH_GUARD]`) or a single string (legacy /
|
|
146
|
+
* future-guard reuse). Returns `{ stale, reason }`; `reason` is null when not
|
|
147
|
+
* stale. */
|
|
148
|
+
function classifyBinaryGuard({
|
|
149
|
+
bootGuards,
|
|
150
|
+
editorBehind,
|
|
151
|
+
requiredGuard = [SERVE_CWD_GUARD, CAPTURE_AUTH_GUARD],
|
|
152
|
+
} = {}) {
|
|
153
|
+
const required = Array.isArray(requiredGuard) ? requiredGuard : [requiredGuard];
|
|
154
|
+
if (Array.isArray(bootGuards)) {
|
|
155
|
+
const missing = required.find((guard) => !bootGuards.includes(guard));
|
|
156
|
+
if (!missing) return { stale: false, reason: null };
|
|
157
|
+
const impact = GUARD_IMPACT[missing];
|
|
158
|
+
const because = impact ? ` — ${impact}` : '';
|
|
159
|
+
return {
|
|
160
|
+
stale: true,
|
|
161
|
+
reason: `running binary advertises bootGuards [${bootGuards.join(', ')}] without '${missing}'${because}; rebuild to converge`,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
const behind = Number(editorBehind);
|
|
165
|
+
if (Number.isFinite(behind) && behind > 0) {
|
|
166
|
+
return {
|
|
167
|
+
stale: true,
|
|
168
|
+
reason: `running binary predates the boot-guard capability and editor source is ${behind} commit(s) behind origin; rebuild to converge`,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
return { stale: false, reason: null };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
module.exports = {
|
|
175
|
+
SERVE_CWD_GUARD,
|
|
176
|
+
CAPTURE_AUTH_GUARD,
|
|
177
|
+
classifyLauncherStrand,
|
|
178
|
+
shouldAutoRecoverStrand,
|
|
179
|
+
strandRecoveryAction,
|
|
180
|
+
classifyBinaryGuard,
|
|
181
|
+
};
|