@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.
Files changed (97) hide show
  1. package/npm/cli.js +5 -1
  2. package/npm/cloud-branch-switch.js +1 -1
  3. package/npm/cloud.js +3166 -150
  4. package/npm/codesign-plan.js +68 -0
  5. package/npm/container.js +3 -0
  6. package/npm/disk-pressure.js +78 -0
  7. package/npm/editor-dev.js +96 -15
  8. package/npm/editor.js +85 -37
  9. package/npm/empty-project-dir.js +92 -7
  10. package/npm/fleet-add-failure.js +252 -0
  11. package/npm/fleet-asset-version.js +37 -0
  12. package/npm/fleet-auth-health.js +213 -0
  13. package/npm/fleet-auth.js +6 -1
  14. package/npm/fleet-build-data.js +109 -5
  15. package/npm/fleet-cleanup.js +232 -16
  16. package/npm/fleet-destroy-safety.js +272 -0
  17. package/npm/fleet-early-roster.js +87 -0
  18. package/npm/fleet-effect-poll.js +110 -0
  19. package/npm/fleet-effect-status.js +33 -32
  20. package/npm/fleet-env.js +86 -0
  21. package/npm/fleet-ghcr-secret.js +129 -0
  22. package/npm/fleet-git-maintenance.js +142 -0
  23. package/npm/fleet-host-op-limiter.js +68 -0
  24. package/npm/fleet-host.js +74 -0
  25. package/npm/fleet-iap-token.js +165 -0
  26. package/npm/fleet-job-badge.js +1 -1
  27. package/npm/fleet-launchd.js +171 -0
  28. package/npm/fleet-live-status.js +155 -0
  29. package/npm/fleet-memory.js +245 -9
  30. package/npm/fleet-plans.js +233 -4
  31. package/npm/fleet-pool.js +75 -16
  32. package/npm/fleet-prewarm.js +241 -0
  33. package/npm/fleet-project-drift.js +58 -0
  34. package/npm/fleet-public-url.js +394 -0
  35. package/npm/fleet-queue.js +95 -1
  36. package/npm/fleet-reachability-debounce.js +69 -0
  37. package/npm/fleet-reachability.js +80 -0
  38. package/npm/fleet-reconcile.js +43 -1
  39. package/npm/fleet-reset.js +330 -13
  40. package/npm/fleet-roster.js +6 -8
  41. package/npm/fleet-running-plan.js +85 -0
  42. package/npm/fleet-self-update.js +142 -0
  43. package/npm/fleet-session-token-cache.js +65 -0
  44. package/npm/fleet-setup-steps.js +112 -0
  45. package/npm/fleet-sleep-status.js +33 -0
  46. package/npm/fleet-status-refs.js +96 -0
  47. package/npm/fleet-strand.js +181 -0
  48. package/npm/fleet-stuck-status.js +117 -0
  49. package/npm/fleet-vm-access.js +404 -0
  50. package/npm/fleet-vm-probe-target.js +282 -0
  51. package/npm/fleet-vm-readiness.js +237 -0
  52. package/npm/fleet-vm-slugs.js +116 -0
  53. package/npm/google-ingress.js +226 -0
  54. package/npm/ingress-invoke.js +770 -0
  55. package/npm/launch-config.js +386 -9
  56. package/npm/scenario-check.js +880 -37
  57. package/npm/scenario-handlers.js +154 -30
  58. package/npm/scenario-interactivity.js +145 -6
  59. package/npm/scenario-issues.js +13 -1
  60. package/npm/scenario-metrics.js +116 -1
  61. package/npm/scenario-mocks.js +144 -21
  62. package/npm/scenario-playwright.js +649 -38
  63. package/npm/script-drift.js +152 -0
  64. package/npm/utils.js +365 -52
  65. package/npm/vm-urls.js +69 -8
  66. package/package.json +5 -5
  67. package/ui/dist/assets/ContainerInspector-DgM0pTAx.js +2 -0
  68. package/ui/dist/assets/EditorShell-B7Q06uJ_.js +252 -0
  69. package/ui/dist/assets/EditorShell-DP5UY20G.css +1 -0
  70. package/ui/dist/assets/{Markdown-BHBqz-Mj.js → Markdown-Chgdf3PW.js} +1 -1
  71. package/ui/dist/assets/ProjectLauncherScreen-BRrEr8Ng.js +1 -0
  72. package/ui/dist/assets/ScenarioDataPanel-BeQfa28g.js +9 -0
  73. package/ui/dist/assets/{ScenarioDataPanel-DYP7pi_n.css → ScenarioDataPanel-C1LukYI6.css} +1 -1
  74. package/ui/dist/assets/ScenarioDataPanelFullPage-BLqRCxQX.js +1 -0
  75. package/ui/dist/assets/ScenarioRenderer-CIAxfHPh.js +21 -0
  76. package/ui/dist/assets/StepsSimulator-CymGgfbu.js +1 -0
  77. package/ui/dist/assets/index-BygCiJSb.css +1 -0
  78. package/ui/dist/assets/{index-BuK0lizt.js → index-DCHqRrwO.js} +9 -9
  79. package/ui/dist/assets/{markdown-v0F0UEt3.js → markdown-C9tbsVHX.js} +1 -1
  80. package/ui/dist/assets/react-nrLBr15I.js +28 -0
  81. package/ui/dist/assets/useEvents-S3Qk1Y_d.js +1 -0
  82. package/ui/dist/assets/useServerIdentity-B7rI0u3O.js +1 -0
  83. package/ui/dist/codeyam-mark.svg +4 -0
  84. package/ui/dist/codeyam-wordmark.svg +5 -0
  85. package/ui/dist/index.html +4 -4
  86. package/ui/dist/open-source/codeyam-counter.png +0 -0
  87. package/ui/dist/open-source/el-carot.png +0 -0
  88. package/ui/dist/open-source/tabcommand.png +0 -0
  89. package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +0 -2
  90. package/ui/dist/assets/EditorShell-tMMdarZD.js +0 -187
  91. package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +0 -5
  92. package/ui/dist/assets/ScenarioDataPanelFullPage-5G6KuZgw.js +0 -1
  93. package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +0 -1
  94. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
  95. package/ui/dist/assets/index-BGqairFV.css +0 -1
  96. package/ui/dist/assets/react-CSS0HapR.js +0 -28
  97. 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
+ };