@codeyam-editor/codeyam-editor 0.1.3 → 0.1.5
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-branch-switch.js +1 -1
- package/npm/cloud.js +1693 -96
- package/npm/container.js +14 -0
- package/npm/disk-pressure.js +167 -0
- package/npm/editor-dev.js +107 -15
- package/npm/editor.js +97 -37
- package/npm/fleet-auth-health.js +75 -0
- package/npm/fleet-auth.js +6 -1
- package/npm/fleet-build-data.js +130 -0
- package/npm/fleet-cleanup.js +145 -7
- package/npm/fleet-destroy-safety.js +55 -0
- package/npm/fleet-effect-status.js +33 -32
- package/npm/fleet-host.js +74 -0
- package/npm/fleet-job-badge.js +46 -2
- package/npm/fleet-launchd.js +171 -0
- package/npm/fleet-live-status.js +123 -0
- package/npm/fleet-plans.js +231 -0
- package/npm/fleet-prewarm.js +165 -0
- package/npm/fleet-public-url.js +160 -0
- package/npm/fleet-queue.js +38 -1
- package/npm/fleet-reconcile.js +100 -1
- package/npm/fleet-reset.js +298 -13
- package/npm/fleet-roster.js +6 -8
- package/npm/fleet-self-update.js +142 -0
- package/npm/fleet-setup-steps.js +105 -0
- package/npm/fleet-sleep-status.js +33 -0
- package/npm/fleet-strand.js +150 -0
- package/npm/fleet-stuck-status.js +111 -0
- package/npm/fleet-vm-access.js +234 -0
- package/npm/fleet-vm-readiness.js +104 -0
- package/npm/fleet-vm-slugs.js +116 -0
- package/npm/google-ingress.js +211 -0
- package/npm/ingress-invoke.js +292 -0
- package/npm/launch-config.js +310 -4
- package/npm/scenario-check.js +684 -33
- package/npm/scenario-handlers.js +94 -12
- package/npm/scenario-metrics.js +116 -1
- package/npm/scenario-mocks.js +20 -0
- package/npm/scenario-playwright.js +366 -29
- package/npm/script-drift.js +81 -0
- package/npm/utils.js +453 -43
- package/npm/vm-urls.js +69 -8
- package/package.json +5 -5
- package/ui/dist/assets/ContainerInspector-D7VZkkaj.js +2 -0
- package/ui/dist/assets/EditorShell-DHhIaotd.css +1 -0
- package/ui/dist/assets/EditorShell-r-796N_W.js +191 -0
- package/ui/dist/assets/{Markdown-BHBqz-Mj.js → Markdown-BoLHHFXH.js} +1 -1
- package/ui/dist/assets/ProjectLauncherScreen-Ck-XWkQN.js +1 -0
- package/ui/dist/assets/ScenarioDataPanel-B9iDSUpA.js +5 -0
- package/ui/dist/assets/ScenarioDataPanelFullPage-CBLTGC4-.js +1 -0
- package/ui/dist/assets/ScenarioRenderer-BhLfT-nX.js +21 -0
- package/ui/dist/assets/StepsSimulator-BFldWyoX.js +1 -0
- package/ui/dist/assets/index-CIXZkwfB.css +1 -0
- package/ui/dist/assets/{index-KDFAgt2e.js → index-QsOiOG9x.js} +9 -9
- package/ui/dist/assets/useEvents-DHkLJZR4.js +1 -0
- package/ui/dist/codeyam-mark.svg +4 -0
- package/ui/dist/codeyam-wordmark.svg +5 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +0 -2
- package/ui/dist/assets/EditorShell-CGsBSA6u.js +0 -186
- package/ui/dist/assets/ScenarioDataPanel-nuLXJzLf.js +0 -5
- package/ui/dist/assets/ScenarioDataPanelFullPage-Bnghtj8a.js +0 -1
- package/ui/dist/assets/ScenarioRenderer-B_Za9jL8.js +0 -1
- package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
- package/ui/dist/assets/index-Bih6HJ_u.css +0 -1
- package/ui/dist/assets/useEvents-DggWrtHe.js +0 -1
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Pure helpers for running a VM `add` provision as its OWN transient launchd
|
|
3
|
+
// job (`com.codeyam.fleet-add-vm-<n>`) — improve46 Phase 3. The provision job
|
|
4
|
+
// is a SIBLING of `com.codeyam.fleet-dashboard` rather than a `setsid` child of
|
|
5
|
+
// it, so a `launchctl bootout` of the dashboard label (which tears down the
|
|
6
|
+
// whole dashboard launchd job tree regardless of session/process-group) has no
|
|
7
|
+
// authority over the provision. Phases 1 (signal-kill reclassification) and 2
|
|
8
|
+
// (the reinstall in-flight-add guard) still compose with this — Phase 3 reduces
|
|
9
|
+
// how often a bootout reaches the provision at all.
|
|
10
|
+
//
|
|
11
|
+
// All the string-building and parsing lives here so server.js can stay thin and
|
|
12
|
+
// these pieces are unit-testable WITHOUT launchctl (npm/fleet-launchd.test.js) —
|
|
13
|
+
// the same decomposition `npm/fleet-cleanup.js` uses for the reap brain.
|
|
14
|
+
//
|
|
15
|
+
// Stack assumption: launchd is macOS-only. server.js gates the whole own-job
|
|
16
|
+
// path on platform + a `launchctl` probe and falls back to the existing `setsid`
|
|
17
|
+
// detached spawn on cloud containers / CI / Linux, where these helpers are never
|
|
18
|
+
// reached. They are pure either way.
|
|
19
|
+
|
|
20
|
+
// The launchd label for VM-n's transient add-provision job. ONE label per VM
|
|
21
|
+
// number so the re-add collision guard and the completion teardown each address
|
|
22
|
+
// exactly one job. Mirrors the `com.codeyam.fleet-*` label family used by the
|
|
23
|
+
// dashboard's other launchd agents (see scripts/operator/launchd-reinstall.sh).
|
|
24
|
+
function addVmLabel(n) {
|
|
25
|
+
return `com.codeyam.fleet-add-vm-${n}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The file the transient job's stdout/stderr are redirected to (the plist's
|
|
29
|
+
// Standard{Out,Error}Path). The dashboard tails this for the in-progress card's
|
|
30
|
+
// live log when the launchd path is taken — the equivalent of the `setsid`
|
|
31
|
+
// path's stdout pipe, which a separate launchd job can't share. Kept DISTINCT
|
|
32
|
+
// from `/tmp/dash-add-vm-<n>.log` (the addVm script's own `cloud:up` redirect)
|
|
33
|
+
// so the provision shell's progress echoes ("provisioning…", "VM-n provisioned")
|
|
34
|
+
// aren't interleaved with the cloud:up build output.
|
|
35
|
+
function addVmJobLogPath(n) {
|
|
36
|
+
return `/tmp/dash-add-vm-${n}.provision.log`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// XML-escape a string for safe inclusion in a plist `<string>` body. Plists are
|
|
40
|
+
// XML, so the five predefined entities must be escaped or a path/env value with
|
|
41
|
+
// `&`/`<`/`>`/quotes would corrupt the plist. Pure.
|
|
42
|
+
function xmlEscape(s) {
|
|
43
|
+
return String(s == null ? '' : s)
|
|
44
|
+
.replace(/&/g, '&')
|
|
45
|
+
.replace(/</g, '<')
|
|
46
|
+
.replace(/>/g, '>')
|
|
47
|
+
.replace(/"/g, '"')
|
|
48
|
+
.replace(/'/g, ''');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Build a transient launchd plist (XML text) that runs `programArguments` ONCE
|
|
52
|
+
// under `label`. RunAtLoad=true so `launchctl bootstrap` fires it immediately;
|
|
53
|
+
// KeepAlive=false so launchd NEVER relaunches the provision after it exits (a
|
|
54
|
+
// one-shot job, not a daemon). stdout/stderr go to `logPath` so the dashboard
|
|
55
|
+
// can tail the live card. `environment` (the bootstrap form env) and
|
|
56
|
+
// `workingDirectory` are optional. Pure — caller writes the returned text to a
|
|
57
|
+
// temp file and bootstraps it.
|
|
58
|
+
function buildAddVmPlist({ label, programArguments, logPath, workingDirectory, environment } = {}) {
|
|
59
|
+
const args = (Array.isArray(programArguments) ? programArguments : [])
|
|
60
|
+
.map((a) => ` <string>${xmlEscape(a)}</string>`)
|
|
61
|
+
.join('\n');
|
|
62
|
+
const lines = [
|
|
63
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
64
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
65
|
+
'<plist version="1.0">',
|
|
66
|
+
'<dict>',
|
|
67
|
+
' <key>Label</key>',
|
|
68
|
+
` <string>${xmlEscape(label)}</string>`,
|
|
69
|
+
' <key>ProgramArguments</key>',
|
|
70
|
+
' <array>',
|
|
71
|
+
args,
|
|
72
|
+
' </array>',
|
|
73
|
+
' <key>RunAtLoad</key>',
|
|
74
|
+
' <true/>',
|
|
75
|
+
' <key>KeepAlive</key>',
|
|
76
|
+
' <false/>',
|
|
77
|
+
];
|
|
78
|
+
if (logPath) {
|
|
79
|
+
lines.push(' <key>StandardOutPath</key>', ` <string>${xmlEscape(logPath)}</string>`);
|
|
80
|
+
lines.push(' <key>StandardErrorPath</key>', ` <string>${xmlEscape(logPath)}</string>`);
|
|
81
|
+
}
|
|
82
|
+
if (workingDirectory) {
|
|
83
|
+
lines.push(' <key>WorkingDirectory</key>', ` <string>${xmlEscape(workingDirectory)}</string>`);
|
|
84
|
+
}
|
|
85
|
+
const env = environment && typeof environment === 'object' ? environment : null;
|
|
86
|
+
if (env && Object.keys(env).length > 0) {
|
|
87
|
+
lines.push(' <key>EnvironmentVariables</key>', ' <dict>');
|
|
88
|
+
for (const [k, v] of Object.entries(env)) {
|
|
89
|
+
lines.push(` <key>${xmlEscape(k)}</key>`, ` <string>${xmlEscape(v)}</string>`);
|
|
90
|
+
}
|
|
91
|
+
lines.push(' </dict>');
|
|
92
|
+
}
|
|
93
|
+
lines.push('</dict>', '</plist>', '');
|
|
94
|
+
return lines.join('\n');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Parse the integer pid from `launchctl print gui/$UID/<label>` output. While
|
|
98
|
+
// the job's process is alive the block contains a `pid = N` line; this resolves
|
|
99
|
+
// it so the dashboard records the REAL provision pid into the sidecar `meta.pid`
|
|
100
|
+
// (the field `reattachVerdict`'s `kill -0` re-attach reads) rather than the
|
|
101
|
+
// launchctl invocation's own pid. Returns the integer pid, or null when there's
|
|
102
|
+
// no live process line (job not started yet, already exited, or junk). `pid = 0`
|
|
103
|
+
// is treated as no-pid. Pure: takes the text so it's testable without launchctl.
|
|
104
|
+
function parseLaunchdPrintPid(text) {
|
|
105
|
+
const m = String(text == null ? '' : text).match(/^\s*pid\s*=\s*(\d+)\b/m);
|
|
106
|
+
if (!m) return null;
|
|
107
|
+
const pid = Number(m[1]);
|
|
108
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Filter a captured transcript down to the live-card tail: drop the noisy
|
|
112
|
+
// gcloud/IAP/NumPy lines and keep the last `maxLines`. Pure — the same shaping
|
|
113
|
+
// startJob's `tailLog` applies to the setsid stdout pipe, extracted so the
|
|
114
|
+
// launchd path's logfile tail and the setsid path share ONE tested filter
|
|
115
|
+
// instead of duplicating the regex + slice.
|
|
116
|
+
function filterTailLog(text, maxLines = 14) {
|
|
117
|
+
return String(text == null ? '' : text).split('\n')
|
|
118
|
+
.filter((l) => !/WARNING|NumPy|google\.com\/iap/.test(l))
|
|
119
|
+
.join('\n').trim().split('\n').slice(-maxLines).join('\n');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// True when a `spawnSync` result represents a successful launchctl invocation:
|
|
123
|
+
// a defined result with a zero exit status and no spawn error (ENOENT etc.).
|
|
124
|
+
// Pure — lets the bootstrap/`load -w` success checks be one tested predicate.
|
|
125
|
+
function launchctlSpawnSucceeded(result) {
|
|
126
|
+
return !!result && result.status === 0 && !result.error;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Decide what the launchd add-completion watcher should do on one tick, from the
|
|
130
|
+
// durable signals it gathers: the exit marker's code (or null when absent),
|
|
131
|
+
// whether the launchd process is still alive, and how many consecutive ticks it
|
|
132
|
+
// has been gone. Pure state machine — the I/O (reading the marker, `kill -0`,
|
|
133
|
+
// tailing the log) stays in the server.js watcher; this owns the decision so it
|
|
134
|
+
// is exhaustively testable without launchd.
|
|
135
|
+
// - marker present → done, that exit code (the normal path)
|
|
136
|
+
// - process gone for `graceTicks` → done, exit 1 (EXIT trap never wrote a
|
|
137
|
+
// marker — resolve the card as a failure
|
|
138
|
+
// rather than tailing forever)
|
|
139
|
+
// - process gone, under grace → keep waiting, bump goneTicks
|
|
140
|
+
// - process alive → keep waiting, reset goneTicks
|
|
141
|
+
function launchdWatchVerdict({ markerExitCode, pidAlive, goneTicks = 0, graceTicks = 3 } = {}) {
|
|
142
|
+
if (markerExitCode != null) return { done: true, exitCode: markerExitCode, goneTicks: 0 };
|
|
143
|
+
if (!pidAlive) {
|
|
144
|
+
const next = goneTicks + 1;
|
|
145
|
+
if (next >= graceTicks) return { done: true, exitCode: 1, goneTicks: next };
|
|
146
|
+
return { done: false, goneTicks: next };
|
|
147
|
+
}
|
|
148
|
+
return { done: false, goneTicks: 0 };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Decide whether the own-launchd-job path should be taken for a spawn. Pure
|
|
152
|
+
// gate: it's taken only for a `detached` add (the sole caller that passes
|
|
153
|
+
// `detached`) AND on a host where `launchctl` is actually present. Everything
|
|
154
|
+
// else — a non-detached job, or a host with no launchctl (cloud container / CI /
|
|
155
|
+
// Linux) — falls back to the existing `setsid` detached spawn. Centralised here
|
|
156
|
+
// so the gate is one tested predicate, not an inline `&&` at the spawn site.
|
|
157
|
+
function shouldUseLaunchd({ detached, hasLaunchctl } = {}) {
|
|
158
|
+
return !!detached && !!hasLaunchctl;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
module.exports = {
|
|
162
|
+
addVmLabel,
|
|
163
|
+
addVmJobLogPath,
|
|
164
|
+
xmlEscape,
|
|
165
|
+
buildAddVmPlist,
|
|
166
|
+
parseLaunchdPrintPid,
|
|
167
|
+
filterTailLog,
|
|
168
|
+
launchctlSpawnSucceeded,
|
|
169
|
+
launchdWatchVerdict,
|
|
170
|
+
shouldUseLaunchd,
|
|
171
|
+
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Pure session/broker live-status derivation for the Fleet Dashboard's per-VM
|
|
3
|
+
// status LABEL (scripts/operator/fleet-dashboard/server.js requires this). This
|
|
4
|
+
// is the trailing decision of deriveStatus, after every heavy-job / orphan /
|
|
5
|
+
// failed-badge branch has been ruled out: given the reachable VM's session and
|
|
6
|
+
// broker signals, is it building, waiting, or genuinely idle?
|
|
7
|
+
//
|
|
8
|
+
// The bug this fixes (observed live 2026-06-20, VM-1): the label was derived
|
|
9
|
+
// SOLELY from `hasSession` — the editor server's own in-process build-session
|
|
10
|
+
// flag. But a Claude agent's lifetime is owned by the PTY broker, deliberately
|
|
11
|
+
// DECOUPLED from the editor-server lifetime (see docs/process-model.md). After a
|
|
12
|
+
// server restart, or for a broker-resumed/detached agent the server didn't spawn
|
|
13
|
+
// itself, `hasSession` is false while a fully-live agent keeps working in the
|
|
14
|
+
// broker. The card fell through to 'idle' even though the broker dot — driven by
|
|
15
|
+
// the same `brokerStatus` — already showed 'agent-live'. The label and the dot
|
|
16
|
+
// disagreed.
|
|
17
|
+
//
|
|
18
|
+
// The fix consults the already-debounced broker tri-state (from
|
|
19
|
+
// fleet-broker-status.js, threaded in by the caller) so a broker-held live agent
|
|
20
|
+
// reads as working, not idle. `awaitingUserInput` is honored independent of
|
|
21
|
+
// `hasSession`, so a broker-live agent blocked on input reads 'waiting'.
|
|
22
|
+
//
|
|
23
|
+
// The terminal-done refinement (observed live 2026-06-21): a broker-live agent
|
|
24
|
+
// whose WORKFLOW reached its terminal step is parked alive at its prompt but is
|
|
25
|
+
// NOT building — the editor masks the feature and reports `needsCleanup: true`
|
|
26
|
+
// (session_info_response.rs). Without this signal the card showed an active-green
|
|
27
|
+
// "Building" border on a finished, idle VM. We discriminate on `needsCleanup`
|
|
28
|
+
// (a positive terminal signal) rather than feature-emptiness (also briefly true
|
|
29
|
+
// at session START, which would mislabel a just-starting agent as done). This
|
|
30
|
+
// only ever DOWNGRADES a would-be-'building' verdict to 'session-done'; a
|
|
31
|
+
// mid-feature post-restart agent is not terminal (`needsCleanup` falsy) and stays
|
|
32
|
+
// 'building', preserving the 2026-06-20 decoupled-agent fix above.
|
|
33
|
+
//
|
|
34
|
+
// Returns 'waiting' | 'building' | 'session-done' | 'idle' for a reachable VM, or
|
|
35
|
+
// `null` when the VM is unreachable — the caller maps null to its own
|
|
36
|
+
// asleep/unreachable verdict (it owns the running-fleet snapshot this module
|
|
37
|
+
// deliberately does not touch) and otherwise passes the verdict through verbatim
|
|
38
|
+
// as the card's `status`, so 'session-done' must match the card's status class.
|
|
39
|
+
// Precedence is preserved: an unreachable VM returns null regardless of a stale
|
|
40
|
+
// 'agent-live', so the broker branch only ever upgrades a would-be-idle verdict.
|
|
41
|
+
function liveSessionStatus(v, brokerStatus) {
|
|
42
|
+
// Unreachable: we polled nothing reliable. Defer to the caller's
|
|
43
|
+
// asleep/unreachable mapping — a stale broker signal must not mask it.
|
|
44
|
+
if (!v.reachable) return null;
|
|
45
|
+
if (v.hasSession && v.awaitingUserInput) return 'waiting';
|
|
46
|
+
if (v.hasSession) return 'building';
|
|
47
|
+
// No editor-owned session, but a live agent may still be working in the PTY
|
|
48
|
+
// broker (decoupled lifetime — see docs/process-model.md). The broker
|
|
49
|
+
// tri-state is already debounced upstream; agent-live ⇒ working, not idle.
|
|
50
|
+
if (brokerStatus === 'agent-live') {
|
|
51
|
+
if (v.awaitingUserInput) return 'waiting';
|
|
52
|
+
// The agent is parked alive in the broker's build slot, but its workflow
|
|
53
|
+
// reached the terminal step (editor reports needsCleanup) — finished and
|
|
54
|
+
// idle, not building. A mid-feature post-restart agent is NOT terminal and
|
|
55
|
+
// still reads 'building' (preserves the 2026-06-20 decoupled-agent fix).
|
|
56
|
+
if (v.needsCleanup) return 'session-done';
|
|
57
|
+
return 'building';
|
|
58
|
+
}
|
|
59
|
+
return 'idle';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Distinguish a freshly-rostered VM whose editor is still STARTING UP — a normal
|
|
63
|
+
// cold boot that hasn't bound :14199 yet, or (the VM-2 case) the ~10–15-min
|
|
64
|
+
// self-host `rebuild-self --force-in-container` compile that recompiles the
|
|
65
|
+
// editor binary from source before the server can listen — from a genuinely DEAD
|
|
66
|
+
// one. deriveStatus (scripts/operator/fleet-dashboard/server.js) calls this only
|
|
67
|
+
// after it has already established the VM is rostered, unreachable, and GCP still
|
|
68
|
+
// lists it RUNNING — i.e. exactly the case that otherwise falls through to the
|
|
69
|
+
// red 'unreachable' that made a successful Add look like a failure.
|
|
70
|
+
//
|
|
71
|
+
// Returns 'editor-starting' when EITHER the VM was launched within the grace
|
|
72
|
+
// window (`recentlyLaunched` — the cheap common case, no SSH needed) OR an
|
|
73
|
+
// in-container probe has CONFIRMED a first-run build is still running
|
|
74
|
+
// (`probeBuilding === true`), which lets the long self-host compile keep reading
|
|
75
|
+
// "Starting editor…" past the window with no fragile fixed timeout. Returns null
|
|
76
|
+
// otherwise so deriveStatus runs its existing unreachable path unchanged — an
|
|
77
|
+
// honest red card the moment the grace window lapses with no confirmed build. A
|
|
78
|
+
// DISTINCT key from the wake-path 'starting' (fleet-sleep-status.js) so the two
|
|
79
|
+
// in-progress states never collide. Pure; the caller owns the time + SSH I/O.
|
|
80
|
+
function classifyStartingVsUnreachable({ recentlyLaunched = false, probeBuilding = null } = {}) {
|
|
81
|
+
if (recentlyLaunched || probeBuilding === true) return 'editor-starting';
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Classify the in-container startup probe (server.js's editorStartingInContainer
|
|
86
|
+
// one-IAP-SSH round-trip) into the tri-state the verdict cache stores and
|
|
87
|
+
// classifyStartingVsUnreachable consumes. Mirrors fleet-cleanup.js's
|
|
88
|
+
// classifyProjectWiring shape: takes the raw `{ stdout, err }` and parses the
|
|
89
|
+
// three `---CY---`-separated segments the probe emits — the `docker ps` status
|
|
90
|
+
// line, the count of listeners on :14199, and the count of live first-run build
|
|
91
|
+
// processes (`rebuild-self` / `cargo build`):
|
|
92
|
+
// true — container up, :14199 NOT yet bound, and a build process is alive ⇒
|
|
93
|
+
// still building (keeps the card calm-blue past the grace window).
|
|
94
|
+
// false — container up but :14199 already bound (the normal reachable path
|
|
95
|
+
// will win) OR up-and-idle with no build process (let the card flip to
|
|
96
|
+
// honest 'unreachable'); also a clean response with the container down.
|
|
97
|
+
// null — an ssh/docker blip (`err`) ⇒ inconclusive; never upgrades a
|
|
98
|
+
// past-window card (stays honest), re-probed next poll.
|
|
99
|
+
function classifyStartupProbe({ stdout, err } = {}) {
|
|
100
|
+
if (err) return null;
|
|
101
|
+
const parts = String(stdout == null ? '' : stdout).split('---CY---').map((s) => s.trim());
|
|
102
|
+
const containerUp = /^Up\b/m.test(parts[0] || '');
|
|
103
|
+
const portBound = (Number((parts[1] || '0').trim()) || 0) > 0;
|
|
104
|
+
const buildProcAlive = (Number((parts[2] || '0').trim()) || 0) > 0;
|
|
105
|
+
if (!containerUp) return false; // clean response, container down → not building
|
|
106
|
+
if (portBound) return false; // already serving — the reachable path owns it
|
|
107
|
+
return buildProcAlive ? true : false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Pure gate deciding whether pollStartupProbes should fire the (costly) IAP-SSH
|
|
111
|
+
// build probe for a given VM this tick. We only probe a VM that is PLAUSIBLY
|
|
112
|
+
// still starting — present, unreachable, GCP still lists it RUNNING, NOT already
|
|
113
|
+
// owned by an active heavy job, and either recently launched OR already confirmed
|
|
114
|
+
// building on a prior tick (so a ~15-min self-host compile keeps being probed
|
|
115
|
+
// past the grace window with no fixed cap). A healthy/reachable VM, an
|
|
116
|
+
// asleep/vanished one, or a long-idle one that simply went unreachable is never
|
|
117
|
+
// probed. Pure so pollStartupProbes stays a thin SSH loop and this decision
|
|
118
|
+
// matrix is exhaustively unit-testable without booting the server.
|
|
119
|
+
function shouldProbeStartup({ vmPresent = false, reachable = false, gcpRunning = false, jobActive = false, recentlyLaunched = false, wasBuilding = false } = {}) {
|
|
120
|
+
return vmPresent && !reachable && gcpRunning && !jobActive && (recentlyLaunched || wasBuilding);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = { liveSessionStatus, classifyStartingVsUnreachable, classifyStartupProbe, shouldProbeStartup };
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Indexes the currently-reachable VMs by their live sessionId.
|
|
5
|
+
* @param {Array|Object} vms The merged VM states on the dashboard.
|
|
6
|
+
* @returns {Object} A map of sessionId -> VM number (n).
|
|
7
|
+
*/
|
|
8
|
+
function indexVmsBySessionId(vms) {
|
|
9
|
+
const map = {};
|
|
10
|
+
if (!vms) return map;
|
|
11
|
+
const list = Array.isArray(vms) ? vms : Object.values(vms);
|
|
12
|
+
for (const vm of list) {
|
|
13
|
+
if (vm && vm.sessionId && vm.n !== undefined) {
|
|
14
|
+
map[vm.sessionId] = vm.n;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return map;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Computes which dependencies are unsatisfied (i.e. not in the completedSlugs set).
|
|
22
|
+
* @param {string[]} dependsOn Prerequisite slugs.
|
|
23
|
+
* @param {Set|string[]} completedSlugs Set or array of completed plan slugs.
|
|
24
|
+
* @returns {string[]} The unsatisfied dependency slugs.
|
|
25
|
+
*/
|
|
26
|
+
function unsatisfiedDeps(dependsOn, completedSlugs) {
|
|
27
|
+
const deps = Array.isArray(dependsOn) ? dependsOn : [];
|
|
28
|
+
const completedSet = completedSlugs instanceof Set ? completedSlugs : new Set(completedSlugs || []);
|
|
29
|
+
return deps.filter(dep => !completedSet.has(dep));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolves claims and dependency states for each queued plan.
|
|
34
|
+
* @param {Object} params
|
|
35
|
+
* @param {Object[]} params.plans Array of PlanSummary objects from /api/plans.
|
|
36
|
+
* @param {Object[]} params.claims Array of PlanClaim objects from /api/plan-claims.
|
|
37
|
+
* @param {Set|string[]} params.completedSlugs Completed plan slugs.
|
|
38
|
+
* @param {Object} params.vmsBySessionId Map of sessionId -> VM number.
|
|
39
|
+
* @returns {Object[]} Annotated plan rows.
|
|
40
|
+
*/
|
|
41
|
+
function resolvePlanRows({ plans, claims, completedSlugs, vmsBySessionId }) {
|
|
42
|
+
const list = Array.isArray(plans) ? plans : [];
|
|
43
|
+
const claimsList = Array.isArray(claims) ? claims : [];
|
|
44
|
+
|
|
45
|
+
return list.map(plan => {
|
|
46
|
+
// 1. Extract plan fields
|
|
47
|
+
const slug = plan.slug;
|
|
48
|
+
const title = plan.title;
|
|
49
|
+
const mode = plan.mode || null;
|
|
50
|
+
const order = plan.order != null ? plan.order : null;
|
|
51
|
+
const prefix = plan.prefix || null;
|
|
52
|
+
|
|
53
|
+
// 2. Resolve dependency-blocked state
|
|
54
|
+
const dependsOn = Array.isArray(plan.depends_on) ? plan.depends_on : (plan.dependsOn ? plan.dependsOn : []);
|
|
55
|
+
const blockedBy = unsatisfiedDeps(dependsOn, completedSlugs);
|
|
56
|
+
|
|
57
|
+
// 3. Resolve claim state
|
|
58
|
+
const claim = claimsList.find(c => c.slug === slug);
|
|
59
|
+
let claimInfo = null;
|
|
60
|
+
if (claim) {
|
|
61
|
+
const claimedBy = claim.claimedBy || claim.claimed_by || {};
|
|
62
|
+
const sessionId = claimedBy.sessionId || claimedBy.session_id || null;
|
|
63
|
+
const vm = (sessionId && vmsBySessionId && vmsBySessionId[sessionId] !== undefined) ? vmsBySessionId[sessionId] : null;
|
|
64
|
+
|
|
65
|
+
claimInfo = {
|
|
66
|
+
vm,
|
|
67
|
+
machine: claimedBy.machine || null,
|
|
68
|
+
agent: claimedBy.agentProvider || claimedBy.agent_provider || null,
|
|
69
|
+
sessionAlive: claim.sessionAlive !== undefined ? claim.sessionAlive : (claim.session_alive !== undefined ? claim.session_alive : null),
|
|
70
|
+
source: claim.source || null,
|
|
71
|
+
currentStep: claimedBy.currentStep || claimedBy.current_step || null,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
slug,
|
|
77
|
+
title,
|
|
78
|
+
mode,
|
|
79
|
+
order,
|
|
80
|
+
prefix,
|
|
81
|
+
blockedBy,
|
|
82
|
+
claim: claimInfo
|
|
83
|
+
};
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const REQUIRED_PLAN_ROW_FIELDS = [
|
|
88
|
+
'slug', 'title', 'mode', 'order', 'prefix', 'blockedBy', 'claim'
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Decides which data source a project's Queued Plans panel should read from.
|
|
93
|
+
* The dashboard's own project (codeyam-editor) reads plans fresh from origin
|
|
94
|
+
* via the laptop checkout; every other project keeps the VM probe.
|
|
95
|
+
* @param {Object} params
|
|
96
|
+
* @param {string} params.project The project name for the panel.
|
|
97
|
+
* @param {?string} params.selfHostProject The dashboard's own project name.
|
|
98
|
+
* @returns {'origin'|'vm'} The source to use.
|
|
99
|
+
*/
|
|
100
|
+
function planSourceForProject({ project, selfHostProject }) {
|
|
101
|
+
return selfHostProject && project === selfHostProject ? 'origin' : 'vm';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Builds the static (non-ticking) part of a Queued Plans panel header label so
|
|
106
|
+
* the data source and its staleness risk are always visible. Origin-sourced
|
|
107
|
+
* panels name the branch + short sha; VM-sourced panels carry the "may lag
|
|
108
|
+
* origin" caveat. The relative age ("· 8s ago") is appended by the frontend's
|
|
109
|
+
* own ago ticker, so this returns the static prefix only.
|
|
110
|
+
* @param {Object} params
|
|
111
|
+
* @param {'origin'|'vm'} params.source Where the panel's data came from.
|
|
112
|
+
* @param {?string} params.ref The git ref (e.g. "origin/<branch>") for origin.
|
|
113
|
+
* @param {?string} params.sha The full commit sha for origin.
|
|
114
|
+
* @param {?number} params.fromVm The representative VM number for vm source.
|
|
115
|
+
* @param {?('stale-binary'|'fetch-failed'|'parse-error'|'unknown')} params.originError
|
|
116
|
+
* When the self-host origin path was *attempted* and failed, the classified
|
|
117
|
+
* reason it fell back to the VM probe. Absent/null for ordinary VM-sourced
|
|
118
|
+
* projects (which were never meant to read origin).
|
|
119
|
+
* @returns {string} The static header annotation.
|
|
120
|
+
*/
|
|
121
|
+
function planPanelSourceLabel({ source, ref, sha, fromVm, originError }) {
|
|
122
|
+
if (source === 'origin') {
|
|
123
|
+
const shortSha = sha ? String(sha).slice(0, 7) : '?';
|
|
124
|
+
const where = ref || 'origin';
|
|
125
|
+
return `${where} @ ${shortSha}`;
|
|
126
|
+
}
|
|
127
|
+
// VM-sourced: surface the residual lag risk the origin path eliminates.
|
|
128
|
+
const vm = (fromVm !== null && fromVm !== undefined) ? `VM-${fromVm}` : 'VM';
|
|
129
|
+
if (originError) {
|
|
130
|
+
// The origin path was attempted and failed: this is a *degraded* panel, not
|
|
131
|
+
// a normal VM-sourced one. Name the failure class so it is self-diagnosing.
|
|
132
|
+
return `via ${vm} · origin unavailable: ${originErrorPhrase(originError)}`;
|
|
133
|
+
}
|
|
134
|
+
return `via ${vm} · may lag origin`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Maps a classified origin-failure bucket to a short operator-facing phrase for
|
|
139
|
+
* the panel header. Mirrors the bucket set of classifyOriginError; an unknown
|
|
140
|
+
* bucket degrades to the generic "origin read failed" phrase.
|
|
141
|
+
* @param {string} originError The classified bucket.
|
|
142
|
+
* @returns {string} A short human-readable phrase.
|
|
143
|
+
*/
|
|
144
|
+
function originErrorPhrase(originError) {
|
|
145
|
+
switch (originError) {
|
|
146
|
+
case 'stale-binary':
|
|
147
|
+
return 'stale editor binary';
|
|
148
|
+
case 'fetch-failed':
|
|
149
|
+
return 'git fetch failed';
|
|
150
|
+
case 'parse-error':
|
|
151
|
+
return 'bad snapshot output';
|
|
152
|
+
default:
|
|
153
|
+
return 'origin read failed';
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Classifies a caught origin-path error message into a small, stable set of
|
|
159
|
+
* buckets the operator can act on, rather than surfacing a noisy raw error
|
|
160
|
+
* (which can carry paths/branch names) in the panel header. Pure so it is
|
|
161
|
+
* unit-testable.
|
|
162
|
+
* - `unrecognized subcommand` → 'stale-binary' (CLI predates the command)
|
|
163
|
+
* - leading `git fetch origin` → 'fetch-failed' (couldn't reach origin)
|
|
164
|
+
* - no-JSON / malformed snapshot → 'parse-error' (plans-snapshot output)
|
|
165
|
+
* - anything else → 'unknown'
|
|
166
|
+
* @param {?string} message The caught error's message text.
|
|
167
|
+
* @returns {'stale-binary'|'fetch-failed'|'parse-error'|'unknown'}
|
|
168
|
+
*/
|
|
169
|
+
function classifyOriginError(message) {
|
|
170
|
+
const msg = typeof message === 'string' ? message : '';
|
|
171
|
+
if (msg.includes('unrecognized subcommand')) return 'stale-binary';
|
|
172
|
+
if (/^git fetch origin\b/.test(msg)) return 'fetch-failed';
|
|
173
|
+
if (msg.includes('plans-snapshot produced no JSON') ||
|
|
174
|
+
msg.includes('plans-snapshot JSON parse')) return 'parse-error';
|
|
175
|
+
return 'unknown';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* True when an origin-fetch error means the branch no longer exists on the
|
|
180
|
+
* remote — it was merged and deleted, so `git fetch origin <branch>` fails with
|
|
181
|
+
* git's `couldn't find remote ref <branch>`. This is NOT a transient fetch
|
|
182
|
+
* failure (network / auth): the branch is *gone*, so re-fetching it every poll
|
|
183
|
+
* cycle is pure noise. The dashboard uses this to record the branch as gone,
|
|
184
|
+
* fall back to the VM probe quietly, and stop logging an error every cycle —
|
|
185
|
+
* the difference between a healthy fleet that *reads* broken in the log and one
|
|
186
|
+
* that reads calm. Pure so it is unit-testable.
|
|
187
|
+
* @param {?string} message The caught fetch-error message text.
|
|
188
|
+
* @returns {boolean}
|
|
189
|
+
*/
|
|
190
|
+
function isRemoteRefGone(message) {
|
|
191
|
+
const msg = typeof message === 'string' ? message : '';
|
|
192
|
+
return /couldn't find remote ref/i.test(msg);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Parses the stdout of `editor plans-snapshot --format json` into the snapshot
|
|
197
|
+
* envelope the dashboard consumes. The pure half of snapshotPlansFromOrigin:
|
|
198
|
+
* extracts the JSON object (the CLI may prefix a dev-build banner), parses it,
|
|
199
|
+
* and normalizes missing arrays to []. Throws a descriptive error on no-JSON
|
|
200
|
+
* or malformed-JSON so the caller can fall back to the VM probe.
|
|
201
|
+
* @param {string} stdout The plans-snapshot CLI stdout.
|
|
202
|
+
* @returns {{ref: string, sha: string, plans: Object[], completedSlugs: string[]}}
|
|
203
|
+
*/
|
|
204
|
+
function parsePlansSnapshotStdout(stdout) {
|
|
205
|
+
const m = stdout && stdout.match(/\{[\s\S]*\}/);
|
|
206
|
+
if (!m) throw new Error('plans-snapshot produced no JSON');
|
|
207
|
+
let data;
|
|
208
|
+
try {
|
|
209
|
+
data = JSON.parse(m[0]);
|
|
210
|
+
} catch (e) {
|
|
211
|
+
throw new Error(`plans-snapshot JSON parse: ${e.message}`);
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
ref: data.ref,
|
|
215
|
+
sha: data.sha,
|
|
216
|
+
plans: data.plans || [],
|
|
217
|
+
completedSlugs: data.completedSlugs || [],
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
module.exports = {
|
|
222
|
+
indexVmsBySessionId,
|
|
223
|
+
unsatisfiedDeps,
|
|
224
|
+
resolvePlanRows,
|
|
225
|
+
planSourceForProject,
|
|
226
|
+
planPanelSourceLabel,
|
|
227
|
+
classifyOriginError,
|
|
228
|
+
isRemoteRefGone,
|
|
229
|
+
parsePlansSnapshotStdout,
|
|
230
|
+
REQUIRED_PLAN_ROW_FIELDS
|
|
231
|
+
};
|