@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,130 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Assemble the per-VM wire object the dashboard's /data response carries to the
|
|
4
|
+
// browser. This is the single source of truth for a card's transport shape: the
|
|
5
|
+
// card UI (scripts/operator/fleet-dashboard/public/index.html) reads `v.<field>`
|
|
6
|
+
// off each entry, so every field the card renders MUST be emitted here.
|
|
7
|
+
//
|
|
8
|
+
// It was split out of server.js's buildData() so the shape is unit-testable.
|
|
9
|
+
// The regression that motivated the split: `currentProvider` (the VM's
|
|
10
|
+
// configured build agent) was merged onto the poll state AND read by the card to
|
|
11
|
+
// render the 🤖 agent badge, but it was silently missing from this envelope — so
|
|
12
|
+
// it lived in server memory and never reached the browser, and the badge never
|
|
13
|
+
// appeared. A hand-maintained object literal inside a 197KB monolith has no
|
|
14
|
+
// guard against that drift; a pure function does.
|
|
15
|
+
//
|
|
16
|
+
// `v` is the merged poll state (state.vms[n]). `ctx` carries the derived,
|
|
17
|
+
// cross-VM, and side-effecting values buildData computes per VM (status,
|
|
18
|
+
// debounced broker tri-state, reconciled job, queue position, launch cfg, …) —
|
|
19
|
+
// the things this pure function can't know on its own.
|
|
20
|
+
function buildVmEnvelope(v, ctx) {
|
|
21
|
+
return {
|
|
22
|
+
n: ctx.n,
|
|
23
|
+
url: v.url || null,
|
|
24
|
+
reachable: !!v.reachable,
|
|
25
|
+
hasSession: !!v.hasSession,
|
|
26
|
+
// Three-state: true = broker confirmed up, false = confirmed down, null =
|
|
27
|
+
// editor unreachable so broker state is unknown. Never conflate "down" with
|
|
28
|
+
// "unreachable" — that mismatch is what made the card show "broker down"
|
|
29
|
+
// while a direct pgrep proved the broker was up.
|
|
30
|
+
brokerLive: v.reachable ? !!v.agentBrokerLive : null,
|
|
31
|
+
// Broker tri-state: 'down' | 'idle' | 'agent-live' | 'unknown' (derived
|
|
32
|
+
// upstream, debounced). Richer than brokerLive's is-an-agent-live boolean.
|
|
33
|
+
brokerStatus: ctx.brokerStatus,
|
|
34
|
+
awaitingInput: !!v.awaitingUserInput,
|
|
35
|
+
// True when the editor reports the build workflow reached its terminal step
|
|
36
|
+
// and is parked awaiting cleanup (session_info_response.rs emits it at
|
|
37
|
+
// step >= total). Flows onto poll state via the `...info` spread in server.js
|
|
38
|
+
// exactly like `feature`/`awaitingUserInput`; defaulted false here so a legacy
|
|
39
|
+
// editor that predates the field degrades to today's behavior. Consumed by
|
|
40
|
+
// fleet-live-status.js to read a parked-but-done agent as calm 'session-done'
|
|
41
|
+
// instead of active-green 'building'.
|
|
42
|
+
needsCleanup: !!v.needsCleanup,
|
|
43
|
+
feature: v.feature || null,
|
|
44
|
+
step: v.step != null ? v.step : null,
|
|
45
|
+
mode: v.mode || null,
|
|
46
|
+
label: v.label || null,
|
|
47
|
+
status: ctx.status,
|
|
48
|
+
orphan: ctx.orphan || null,
|
|
49
|
+
degradedReasons: ctx.degradedReasons || [],
|
|
50
|
+
// Derived presentation split of degradedReasons (vm: Dashboard 'Finishing
|
|
51
|
+
// setup' state): `setupSteps` are expected first-run wiring rendered as a
|
|
52
|
+
// calm blue checklist, `attentionReasons` are genuine problems kept in the
|
|
53
|
+
// amber ⚠ treatment. `degradedReasons` is left intact for the many places
|
|
54
|
+
// that still consume the flat list (filters, sort, reconcile); these two are
|
|
55
|
+
// purely additive. Both default to [] so a legacy/healthy VM renders neither
|
|
56
|
+
// block. See npm/fleet-setup-steps.js::classifyReasons.
|
|
57
|
+
setupSteps: Array.isArray(ctx.setupSteps) ? ctx.setupSteps : [],
|
|
58
|
+
attentionReasons: Array.isArray(ctx.attentionReasons) ? ctx.attentionReasons : [],
|
|
59
|
+
isHead: !!ctx.isHead,
|
|
60
|
+
// True when this VM holds the queue head but is unreachable (operator
|
|
61
|
+
// decides manually whether to release it).
|
|
62
|
+
headHolderUnreachable: !!ctx.headHolderUnreachable,
|
|
63
|
+
queuePos: ctx.queuePos != null ? ctx.queuePos : null,
|
|
64
|
+
queueLen: ctx.queueLen,
|
|
65
|
+
job: ctx.job || null,
|
|
66
|
+
config: ctx.config || null,
|
|
67
|
+
project: ctx.project != null ? ctx.project : null,
|
|
68
|
+
variant: ctx.variant != null ? ctx.variant : null,
|
|
69
|
+
cardIdentity: ctx.cardIdentity,
|
|
70
|
+
brokerHistory: Array.isArray(v.brokerHistory) ? v.brokerHistory : [],
|
|
71
|
+
brokerDeath: ctx.brokerDeath,
|
|
72
|
+
// Auth state + reason + token expiry. Only surfaced when the editor is
|
|
73
|
+
// reachable (an unreachable VM's cached auth is stale and misleading).
|
|
74
|
+
providerAuth: v.reachable && v.providerAuth ? v.providerAuth : null,
|
|
75
|
+
effects: v.effects || null,
|
|
76
|
+
// The VM's live-configured build agent (lowercase provider), reported by
|
|
77
|
+
// /api/session-info and merged (sticky) onto the poll state. Drives the
|
|
78
|
+
// card's 🤖 agent badge. null for an older VM whose editor predates the
|
|
79
|
+
// field (the card omits the badge rather than rendering a blank chip).
|
|
80
|
+
currentProvider: v.currentProvider || null,
|
|
81
|
+
// Raw workflow timestamps (ISO) surfaced by /api/session-info and merged onto
|
|
82
|
+
// the poll state via the `...info` spread, exactly like feature/step/label.
|
|
83
|
+
// The card live-ticks the elapsed minutes off these between polls. null for a
|
|
84
|
+
// legacy editor that predates the fields, or a VM with no active step.
|
|
85
|
+
startedAt: v.startedAt || null,
|
|
86
|
+
featureStartedAt: v.featureStartedAt || null,
|
|
87
|
+
// The server-computed stuck / needs-you verdict (npm/fleet-stuck-status.js):
|
|
88
|
+
// { stepElapsedMs, level: 'ok'|'slow'|'stuck', needsYou, needsYouElapsedMs }.
|
|
89
|
+
// The authoritative level only changes on poll; the card colors the step
|
|
90
|
+
// tenure badge by it. null when buildData couldn't compute one.
|
|
91
|
+
attention: ctx.attention || null,
|
|
92
|
+
// Per-VM card readiness derived upstream (npm/fleet-vm-readiness.js): one of
|
|
93
|
+
// READINESS_STATES. The card colors the border and GATES the editor link on
|
|
94
|
+
// it — a non-'ready' VM never presents its public URL as live, because the
|
|
95
|
+
// LB would 503. Computed in buildData where the broker tri-state / job / LB
|
|
96
|
+
// mode are on hand, then carried here so the browser needs no extra request.
|
|
97
|
+
readiness: ctx.readiness || null,
|
|
98
|
+
// The raw LB backend-health map { editor, preview } the readiness derivation
|
|
99
|
+
// consumed, surfaced so the card can show a precise tooltip (which surface
|
|
100
|
+
// is unwired). null in legacy mode or before the first slow probe lands.
|
|
101
|
+
backendHealth: v.backendHealth || null,
|
|
102
|
+
// The per-VM IAP access whitelist (the principals explicitly granted
|
|
103
|
+
// resource-level access to THIS VM, from the fleet-vm-access sidecar). Merged
|
|
104
|
+
// onto the poll state at server.js, it drives the card's 🔐 access line and
|
|
105
|
+
// the Manage access modal's "this VM only" list. It was previously dropped
|
|
106
|
+
// here — the exact currentProvider-style envelope drift this module guards
|
|
107
|
+
// against — so the line and list rendered empty even when the sidecar held
|
|
108
|
+
// principals. Defaults to [] for a VM with no list.
|
|
109
|
+
vmAccess: Array.isArray(v.vmAccess) ? v.vmAccess : [],
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// The fields the card UI depends on being present in every /data VM entry. This
|
|
114
|
+
// is the contract `buildVmEnvelope` must satisfy: the regression that prompted
|
|
115
|
+
// this module was a field the card read but the envelope didn't emit, so the
|
|
116
|
+
// test asserts the envelope keys are a superset of this list. Adding a field the
|
|
117
|
+
// card renders means adding it here AND in buildVmEnvelope — the test fails loud
|
|
118
|
+
// if you do one without the other.
|
|
119
|
+
const REQUIRED_CARD_FIELDS = [
|
|
120
|
+
'n', 'url', 'reachable', 'hasSession', 'brokerLive', 'brokerStatus',
|
|
121
|
+
'awaitingInput', 'needsCleanup', 'feature', 'step', 'mode', 'label', 'status', 'orphan',
|
|
122
|
+
'degradedReasons', 'setupSteps', 'attentionReasons',
|
|
123
|
+
'isHead', 'headHolderUnreachable', 'queuePos', 'queueLen',
|
|
124
|
+
'job', 'config', 'project', 'variant', 'cardIdentity', 'brokerHistory',
|
|
125
|
+
'brokerDeath', 'providerAuth', 'effects', 'currentProvider',
|
|
126
|
+
'readiness', 'backendHealth', 'vmAccess',
|
|
127
|
+
'startedAt', 'featureStartedAt', 'attention',
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
module.exports = { buildVmEnvelope, REQUIRED_CARD_FIELDS };
|
package/npm/fleet-cleanup.js
CHANGED
|
@@ -175,13 +175,28 @@ function clearExitMarker(dir, n) {
|
|
|
175
175
|
catch (_) { /* missing is fine */ }
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
+
// True when an integer exit code is the shell's "killed by signal N" encoding,
|
|
179
|
+
// i.e. 128 + N for N in 1..37 — the realistic kills being 143 (SIGTERM, the
|
|
180
|
+
// `launchctl bootout` graceful teardown), 137 (SIGKILL), 130 (SIGINT) and 129
|
|
181
|
+
// (SIGHUP). Pure boundary check: 128 itself is NOT a signal kill (it's `exit
|
|
182
|
+
// 128`, an ordinary failure code), so the range is the OPEN interval
|
|
183
|
+
// (128, 165]. Anything outside it — a small `exit 1` from the addVm script's own
|
|
184
|
+
// failure branches, 0, NaN, null — is not a signal kill. The split that matters:
|
|
185
|
+
// "did a signal kill the provision shell" (interrupted → reconcile) vs "did the
|
|
186
|
+
// provision logic itself decide it failed" (real error). Exported so the matrix
|
|
187
|
+
// is unit-testable directly.
|
|
188
|
+
function isSignalKillExit(code) {
|
|
189
|
+
return Number.isInteger(code) && code > 128 && code <= 165;
|
|
190
|
+
}
|
|
191
|
+
|
|
178
192
|
// Decide how a snapshot-restored `add` should be re-attached after a dashboard
|
|
179
193
|
// restart, BEFORE any gcloud probe. Pure: takes the durable signals the boot
|
|
180
194
|
// path gathers — whether the exit marker exists, its recorded code, and whether
|
|
181
195
|
// the detached provision child is still alive (recorded pid, `kill -0`).
|
|
182
196
|
// verdict when dispatch
|
|
183
197
|
// 'done' marker present, exit code 0 roster + recordLaunch, clear sidecar/marker
|
|
184
|
-
// '
|
|
198
|
+
// 'reconcile' marker present, SIGNAL-kill code (128<c≤165) interrupted, not failed — gcloud probe decides
|
|
199
|
+
// 'error' marker present, other non-zero exit code surface the real failure, clear sidecar/marker
|
|
185
200
|
// 'running' no marker yet, child still alive RE-ATTACH: keep polling, do NOT mark error
|
|
186
201
|
// 'reconcile' child gone, no usable marker fall through to fc.reapVerdict (gcloud probe)
|
|
187
202
|
// This is the durable fix's brain: a provision that survived the kill (still
|
|
@@ -192,20 +207,65 @@ function clearExitMarker(dir, n) {
|
|
|
192
207
|
// `reconcile` hands that exact (exists, containerUp, projectWired) decision to
|
|
193
208
|
// fc.reapVerdict. A marker whose first line isn't an integer is treated as
|
|
194
209
|
// absent (parseExitMarker → null) so a corrupt marker reconciles rather than
|
|
195
|
-
// asserting a bogus code.
|
|
210
|
+
// asserting a bogus code.
|
|
211
|
+
//
|
|
212
|
+
// SIGNAL-kill codes (improve46): `setsid` shields the detached provision from a
|
|
213
|
+
// dashboard-process-GROUP SIGKILL (the 2GB launchd hard cap), but NOT from a
|
|
214
|
+
// `launchctl bootout`, which tears down the whole launchd job tree regardless of
|
|
215
|
+
// session/process-group. A graceful SIGTERM then reaches the "detached" child and
|
|
216
|
+
// its EXIT trap records 143 (128+SIGTERM) into the marker. That is an INTERRUPTED
|
|
217
|
+
// add, not a failed one — the manual re-add succeeds in seconds off the image the
|
|
218
|
+
// interrupted build already left on the VM. So a signal-kill code routes to
|
|
219
|
+
// 'reconcile' (let the gcloud probe read ground truth and roster / recover) rather
|
|
220
|
+
// than 'error' (a false add-failed that throws away a resumable build). A genuine
|
|
221
|
+
// provision failure exits with a small non-zero code (e.g. `exit 1`), which STILL
|
|
222
|
+
// classifies 'error'. The matrix is exhaustively tested without a live VM.
|
|
196
223
|
function reattachVerdict({ markerPresent, exitCode, childAlive } = {}) {
|
|
197
224
|
if (markerPresent) {
|
|
198
225
|
// Guard the null/undefined coercion BEFORE Number() — `Number(null)` is 0,
|
|
199
226
|
// which would masquerade as a clean exit. A marker with no parseable code
|
|
200
227
|
// (half-written / corrupt) must reconcile, not assert success.
|
|
201
228
|
const code = exitCode == null ? NaN : Number(exitCode);
|
|
202
|
-
if (Number.isInteger(code))
|
|
229
|
+
if (Number.isInteger(code)) {
|
|
230
|
+
if (code === 0) return 'done';
|
|
231
|
+
// A signal killed the provision shell (e.g. bootout SIGTERM → 143): the add
|
|
232
|
+
// was interrupted, not failed — reconcile against real VM state instead of
|
|
233
|
+
// surfacing a false add-failed and discarding a resumable build.
|
|
234
|
+
if (isSignalKillExit(code)) return 'reconcile';
|
|
235
|
+
return 'error';
|
|
236
|
+
}
|
|
203
237
|
return 'reconcile'; // marker present but no parseable code — let gcloud decide
|
|
204
238
|
}
|
|
205
239
|
if (childAlive) return 'running';
|
|
206
240
|
return 'reconcile';
|
|
207
241
|
}
|
|
208
242
|
|
|
243
|
+
// improve45 — map a reattachVerdict tag to the dashboard's NEXT action, now that
|
|
244
|
+
// a clean exit marker is NECESSARY BUT NOT SUFFICIENT to roster. A detached
|
|
245
|
+
// `cloud:up` can exit 0 AFTER the image build but BEFORE `docker compose up`
|
|
246
|
+
// ever runs — an ssh broken-pipe or dashboard SIGKILL landing in that window,
|
|
247
|
+
// with the wrapping EXIT trap still recording 0 (the live VM2 signature: built
|
|
248
|
+
// image, repo cloned, ZERO editor containers). So 'done' no longer means "roster
|
|
249
|
+
// it"; it means "confirm the container is actually up first." Both the clean-exit
|
|
250
|
+
// ('done') and the no-usable-marker ('reconcile') cases therefore route through
|
|
251
|
+
// the SAME gcloud container + wiring probe (reconcileViaGcloud → fc.reapVerdict),
|
|
252
|
+
// which rosters only a genuinely-up + wired add and routes a half-provisioned one
|
|
253
|
+
// to recover-bootstrap. 'error' (real non-zero exit) and 'running' (child still
|
|
254
|
+
// alive) are unchanged. reattachVerdict stays the honest classifier of the
|
|
255
|
+
// child's exit SIGNAL; this helper expresses how that signal is CONSUMED now that
|
|
256
|
+
// exit-0 no longer implies rostering. Pure: server.js maps the returned action to
|
|
257
|
+
// its side-effecting step, so the matrix is exhaustively testable without a VM.
|
|
258
|
+
// verdict next step meaning
|
|
259
|
+
// 'done' 'probe' confirm the container before rostering
|
|
260
|
+
// 'reconcile' 'probe' no usable marker — let the gcloud probe decide
|
|
261
|
+
// 'error' 'surface-error' real failure — show add-failed with the code
|
|
262
|
+
// 'running' 'watch' child still alive — keep polling for the marker
|
|
263
|
+
function reattachNextStep(verdict) {
|
|
264
|
+
if (verdict === 'error') return 'surface-error';
|
|
265
|
+
if (verdict === 'running') return 'watch';
|
|
266
|
+
return 'probe'; // 'done' and 'reconcile' both confirm via the gcloud probe
|
|
267
|
+
}
|
|
268
|
+
|
|
209
269
|
// Anti-loop budget for the `recover-bootstrap` path. Pure: given the count of
|
|
210
270
|
// recoveries ALREADY persisted on the sidecar, return the next attempt number,
|
|
211
271
|
// whether the reap should proceed (re-run cloud:up) or treat the VM as
|
|
@@ -271,11 +331,87 @@ function orphanedAddsToReconcile({ jobs, sidecarKeys } = {}) {
|
|
|
271
331
|
// posture). "Busy" (a live session or an in-flight add to this number) also
|
|
272
332
|
// holds the entry for an operator rather than auto-removing — the shouldn't-
|
|
273
333
|
// happen-but-fail-safe case where a vanished instance still reports a session.
|
|
274
|
-
|
|
334
|
+
// verdict when dispatch
|
|
335
|
+
// 'cleanup' instance gone AND the VM is idle runDestroyCleanup
|
|
336
|
+
// 'leave' exists uncertain (null), OR the VM is busy do nothing this pass
|
|
337
|
+
// 'keep' instance still exists do nothing (healthy)
|
|
338
|
+
// 'reset' existing idle instance unreachable too long trigger auto-reset
|
|
339
|
+
// Fail-safe by construction: cleanup fires ONLY on a DEFINITIVE absence
|
|
340
|
+
// (exists === false) — a flaky/transient describe (null) is always 'leave', so a
|
|
341
|
+
// gcloud blip can never evict a healthy VM (mirroring reapVerdict's `leave`
|
|
342
|
+
// posture). "Busy" (a live session or an in-flight add to this number) also
|
|
343
|
+
// holds the entry for an operator rather than auto-removing — the shouldn't-
|
|
344
|
+
// happen-but-fail-safe case where a vanished instance still reports a session.
|
|
345
|
+
// auto-reset escalation (vm: OOM Wedge Prevention):
|
|
346
|
+
// When the VM has been unreachable for at least RESET_UNREACHABLE_THRESHOLD
|
|
347
|
+
// (e.g. 200, which is roughly 10 minutes at a 3s poll rate), we issue a 'reset'
|
|
348
|
+
// verdict. This auto-reset is guarded and bounded by a cooldown window (in ms)
|
|
349
|
+
// and a max attempts limit to prevent infinite reboot loops.
|
|
350
|
+
//
|
|
351
|
+
// false-unreachable reboot safety (2026-06-25): "unreachable" as the dashboard
|
|
352
|
+
// sees it means the LOCALLY-FORWARDED port is dark — which is ALSO the signature
|
|
353
|
+
// of an operator-side fault (the laptop's gcloud auth expired, the ssh -L tunnel
|
|
354
|
+
// zombied, a laptop network blip) where the VM itself is perfectly healthy.
|
|
355
|
+
// Rebooting a healthy production VM off that signal interrupts a live session.
|
|
356
|
+
// So three operator-fault guards are layered BEFORE the cooldown/attempts budget
|
|
357
|
+
// (a reset is a last resort, never the response to a dark local port). Each is
|
|
358
|
+
// OPT-IN: it only changes the verdict when the caller supplies the fact, so
|
|
359
|
+
// legacy callers (and the OOM-wedge tests) that pass none keep the old behavior.
|
|
360
|
+
// operatorAuthDegraded our own gcloud auth is broken → EVERY VM reads
|
|
361
|
+
// unreachable; rebooting them all is exactly wrong → keep
|
|
362
|
+
// fleetWideUnreachable a simultaneous N-VM "outage" is the operator (auth/
|
|
363
|
+
// tunnel/network), not N dead VMs → keep
|
|
364
|
+
// gcpReachable GCP-side confirmation: true ⇒ the VM answers from
|
|
365
|
+
// GCP's vantage, so the dark local port is a laptop
|
|
366
|
+
// tunnel fault → 'respawn' (never reset); null ⇒ the
|
|
367
|
+
// GCP-side probe was inconclusive → keep (don't escalate
|
|
368
|
+
// a destructive reset on absence of evidence); false ⇒
|
|
369
|
+
// genuinely unreachable from GCP too → fall through to
|
|
370
|
+
// the cooldown/attempts budget and reset as before.
|
|
371
|
+
function reconcileRosteredVm({ exists, hasSession, hasPendingAdd, streak, resetHistory, threshold, cooldownMs, maxAttempts, operatorAuthDegraded, fleetWideUnreachable, gcpReachable } = {}) {
|
|
275
372
|
if (exists === null || exists === undefined) return "leave";
|
|
276
|
-
if (exists)
|
|
277
|
-
|
|
278
|
-
|
|
373
|
+
if (!exists) {
|
|
374
|
+
if (hasSession || hasPendingAdd) return "leave";
|
|
375
|
+
return "cleanup";
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// If the VM exists, check if it's dead-unreachable and eligible for auto-reset.
|
|
379
|
+
if (streak !== undefined && threshold !== undefined) {
|
|
380
|
+
if (streak >= threshold) {
|
|
381
|
+
if (hasSession || hasPendingAdd) {
|
|
382
|
+
// Do NOT auto-reset a VM that is busy (holding a live session or an in-flight add)
|
|
383
|
+
return "keep";
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Operator-fault guards — layered before the cooldown/attempts budget so a
|
|
387
|
+
// laptop-side fault never even consumes a reset attempt.
|
|
388
|
+
if (operatorAuthDegraded) return "keep";
|
|
389
|
+
if (fleetWideUnreachable) return "keep";
|
|
390
|
+
if (gcpReachable === true) return "respawn";
|
|
391
|
+
if (gcpReachable === null) return "keep";
|
|
392
|
+
|
|
393
|
+
const history = resetHistory || { count: 0, lastResetAt: 0 };
|
|
394
|
+
const attempts = history.count || 0;
|
|
395
|
+
const limit = maxAttempts !== undefined ? maxAttempts : 3;
|
|
396
|
+
|
|
397
|
+
if (attempts >= limit) {
|
|
398
|
+
// Budget exhausted (VM wedges repeatedly) -> park (needs-manual-recovery status)
|
|
399
|
+
return "keep";
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const lastReset = history.lastResetAt || 0;
|
|
403
|
+
const cooldown = cooldownMs !== undefined ? cooldownMs : 600000; // default 10 minutes
|
|
404
|
+
const now = Date.now();
|
|
405
|
+
if (now - lastReset < cooldown) {
|
|
406
|
+
// Within cooldown window -> do not reset again yet
|
|
407
|
+
return "keep";
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return "reset";
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return "keep";
|
|
279
415
|
}
|
|
280
416
|
|
|
281
417
|
module.exports = {
|
|
@@ -293,5 +429,7 @@ module.exports = {
|
|
|
293
429
|
parseExitMarker,
|
|
294
430
|
readExitMarker,
|
|
295
431
|
clearExitMarker,
|
|
432
|
+
isSignalKillExit,
|
|
296
433
|
reattachVerdict,
|
|
434
|
+
reattachNextStep,
|
|
297
435
|
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Pure decision + formatting helpers for the fleet dashboard's destroy/deroster
|
|
3
|
+
// false-unreachable safety (2026-06-26 incident, where a reauth-triggered
|
|
4
|
+
// false-unreachable cascade permanently deleted VM2/3/4). The dashboard's
|
|
5
|
+
// server.js gathers the live operator-fault facts (state.operatorAuthDegraded +
|
|
6
|
+
// fleetWideUnreachableNow) and runs the side effects (spawn the delete, log,
|
|
7
|
+
// stop+deroster); the DECISIONS those facts drive — "is this destroy blocked,
|
|
8
|
+
// and why" and "what does the attribution log line read" — live here so they
|
|
9
|
+
// are unit-testable in isolation, the same pure/IO split npm/fleet-auth-health.js
|
|
10
|
+
// (classifyOperatorAuth) and npm/fleet-cleanup.js (reconcileRosteredVm) follow.
|
|
11
|
+
//
|
|
12
|
+
// Pure logic only — no I/O, no env reads, no clock, no global state.
|
|
13
|
+
|
|
14
|
+
// Classify whether a destructive removal (hard destroy or --shrink) must be
|
|
15
|
+
// REFUSED because the operator's own side is faulted, from the two booleans
|
|
16
|
+
// server.js computes. Returns { blocked, reason }: blocked is true when the
|
|
17
|
+
// operator's gcloud auth is degraded OR a fleet-wide unreachable spike is in
|
|
18
|
+
// progress — the signature of a laptop-side fault (which makes EVERY VM read
|
|
19
|
+
// unreachable), NOT N genuinely-dead VMs. authDegraded takes precedence in the
|
|
20
|
+
// reason because it is the upstream cause (a broken operator auth is what
|
|
21
|
+
// produces the fleet-wide false-unreachable spike in the first place), so the
|
|
22
|
+
// message points at the thing to fix.
|
|
23
|
+
// authDegraded state.operatorAuthDegraded — operator gcloud auth is broken
|
|
24
|
+
// fleetWide fleetWideUnreachableNow() — N+ VMs unreachable at once
|
|
25
|
+
function classifyDestroyBlock({ authDegraded, fleetWide } = {}) {
|
|
26
|
+
if (authDegraded) {
|
|
27
|
+
return { blocked: true, reason: 'operator gcloud auth degraded' };
|
|
28
|
+
}
|
|
29
|
+
if (fleetWide) {
|
|
30
|
+
return { blocked: true, reason: 'fleet-wide unreachable spike in progress (operator-side fault signature)' };
|
|
31
|
+
}
|
|
32
|
+
return { blocked: false, reason: null };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Build the single attributable destroy-decision log line. Mirrors the
|
|
36
|
+
// performAutoReset "confirmed … not fleet-wide" template so every destroy —
|
|
37
|
+
// proceed OR refused, across every trigger source — is diagnosable from one
|
|
38
|
+
// grep. `remote` ({ ip, ua }) is the caller attribution whose absence made the
|
|
39
|
+
// 2026-06-26 delete impossible to trace back to its trigger; null/absent renders
|
|
40
|
+
// as `n/a` (e.g. the shrink / reap paths have no HTTP caller). All the flag
|
|
41
|
+
// fields are normalized to real booleans so a missing field never reads as
|
|
42
|
+
// `undefined` in the line.
|
|
43
|
+
function formatDestroyDecision(info = {}) {
|
|
44
|
+
const remote = info.remote ? `${info.remote.ip || '?'}/${info.remote.ua || '?'}` : 'n/a';
|
|
45
|
+
return (
|
|
46
|
+
`destroy-decision: VM-${info.n} source=${info.source} force=${!!info.force} ` +
|
|
47
|
+
`override=${!!info.overrideSafety} remote=${remote} hasSession=${!!info.hasSession} ` +
|
|
48
|
+
`authDegraded=${!!info.authDegraded} fleetWide=${!!info.fleetWide} → ${info.outcome}`
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = {
|
|
53
|
+
classifyDestroyBlock,
|
|
54
|
+
formatDestroyDecision,
|
|
55
|
+
};
|
|
@@ -58,12 +58,10 @@ function resetReason({ agent, session, dirty, behind, codeyam }) {
|
|
|
58
58
|
// meaningless for them).
|
|
59
59
|
const ACTION_IDS = [
|
|
60
60
|
'update-client-source',
|
|
61
|
-
'update-editor-source',
|
|
62
|
-
'reset-editor-source',
|
|
63
61
|
'reset-client',
|
|
62
|
+
'end-session',
|
|
64
63
|
'rebuild-binary',
|
|
65
64
|
'update-reset-everything',
|
|
66
|
-
'run-setup',
|
|
67
65
|
];
|
|
68
66
|
|
|
69
67
|
// facts (a plain object gathered by server.js — no I/O):
|
|
@@ -105,13 +103,6 @@ function computeButtonEffects(facts) {
|
|
|
105
103
|
? { effect: 'changes', reason: behindReason(client.behind, f.clientBranch) }
|
|
106
104
|
: { effect: 'noop', reason: 'up to date with ' + originRef(f.clientBranch) };
|
|
107
105
|
|
|
108
|
-
// (2) Update editor — advance the editor source the binary builds from.
|
|
109
|
-
const updateEditor = editor == null
|
|
110
|
-
? { effect: 'unknown', reason: 'editor state not checked' }
|
|
111
|
-
: editor.behind > 0
|
|
112
|
-
? { effect: 'changes', reason: behindReason(editor.behind, f.editorBranch) }
|
|
113
|
-
: { effect: 'noop', reason: 'up to date with ' + originRef(f.editorBranch) };
|
|
114
|
-
|
|
115
106
|
// (5) Rebuild binary — advances source then compiles. Changes iff the running
|
|
116
107
|
// binary's build commit differs from the editor origin tip. Unknown when we
|
|
117
108
|
// don't have both commits to compare.
|
|
@@ -121,25 +112,38 @@ function computeButtonEffects(facts) {
|
|
|
121
112
|
? { effect: 'changes', reason: `binary at ${short(editor.buildCommit)}, ${originRef(f.editorBranch)} at ${short(editor.originSha)}` }
|
|
122
113
|
: { effect: 'noop', reason: 'binary built from ' + originRef(f.editorBranch) + ' tip' };
|
|
123
114
|
|
|
124
|
-
//
|
|
125
|
-
// .codeyam. No-op ONLY when clean at origin tip with no session.
|
|
115
|
+
// Reset editor (editor-state reset) — discards uncommitted + session, advances
|
|
116
|
+
// source, wipes .codeyam. No-op ONLY when clean at origin tip with no session.
|
|
117
|
+
// INTERNAL-ONLY: the standalone "Reset editor" button was removed; this
|
|
118
|
+
// predicate survives purely as an input to the "Reset all + rebuild" composite
|
|
119
|
+
// below (which still runs resetEditorStateScript), so it is NOT returned/keyed.
|
|
126
120
|
const resetEditor = editor == null
|
|
127
121
|
? { effect: 'unknown', reason: 'editor state not checked' }
|
|
128
122
|
: (brokerLive || hasSession || editor.dirty || editor.behind > 0 || codeyamDirty)
|
|
129
123
|
? { effect: 'changes', reason: resetReason({ agent: brokerLive, session: hasSession, dirty: editor.dirty, behind: editor.behind > 0, codeyam: codeyamDirty }) }
|
|
130
124
|
: { effect: 'noop', reason: 'already clean at origin tip, no session' };
|
|
131
125
|
|
|
132
|
-
// (
|
|
133
|
-
//
|
|
126
|
+
// (2) Reset client — hard-clean /workspace + .codeyam, drop the session, now
|
|
127
|
+
// ADVANCING to origin tip. Since the reset advances source, a VM behind origin
|
|
128
|
+
// would be changed by a re-run, so `behind` IS part of this predicate.
|
|
134
129
|
const resetClient = client == null
|
|
135
130
|
? { effect: 'unknown', reason: 'client state not checked' }
|
|
136
|
-
: (brokerLive || client.dirty || codeyamDirty || hasSession)
|
|
137
|
-
? { effect: 'changes', reason: resetReason({ agent: brokerLive, session: hasSession, dirty: client.dirty, behind:
|
|
138
|
-
: { effect: 'noop', reason: 'already clean, no session' };
|
|
131
|
+
: (brokerLive || client.dirty || client.behind > 0 || codeyamDirty || hasSession)
|
|
132
|
+
? { effect: 'changes', reason: resetReason({ agent: brokerLive, session: hasSession, dirty: client.dirty, behind: client.behind > 0, codeyam: codeyamDirty }) }
|
|
133
|
+
: { effect: 'noop', reason: 'already clean at origin tip, no session' };
|
|
139
134
|
|
|
140
|
-
// (
|
|
141
|
-
//
|
|
142
|
-
//
|
|
135
|
+
// (2b) End session — kills the live agent + clears session state. The ONLY
|
|
136
|
+
// thing it touches is the running session, so it changes the VM iff a live
|
|
137
|
+
// agent or a registered session exists; otherwise it's a clean no-op. Unlike
|
|
138
|
+
// the resets it ignores dirty/behind/.codeyam — it neither advances source nor
|
|
139
|
+
// wipes state.
|
|
140
|
+
const endSession = (brokerLive || hasSession)
|
|
141
|
+
? { effect: 'changes', reason: resetReason({ agent: brokerLive, session: hasSession }) }
|
|
142
|
+
: { effect: 'noop', reason: 'no active session' };
|
|
143
|
+
|
|
144
|
+
// (4) Reset all + rebuild — composite. Changes iff ANY component would
|
|
145
|
+
// change: rebuild-binary, the editor-state reset (internal resetEditor
|
|
146
|
+
// predicate above), or the client being behind/dirty. Reason summarizes parts.
|
|
143
147
|
const everything = (() => {
|
|
144
148
|
if (editor == null || client == null) {
|
|
145
149
|
return { effect: 'unknown', reason: 'state not checked' };
|
|
@@ -157,23 +161,20 @@ function computeButtonEffects(facts) {
|
|
|
157
161
|
return { effect: 'noop', reason: 'everything already at origin tip, no session' };
|
|
158
162
|
})();
|
|
159
163
|
|
|
160
|
-
// (run-setup) Honest soft signal — there is no cheap upstream proof that
|
|
161
|
-
// `npm run setup` would change nothing (DB/migration state isn't
|
|
162
|
-
// introspected). `recommended` when a reset/update landed after the last
|
|
163
|
-
// successful setup; otherwise `unknown`. Never a confident green no-op.
|
|
164
|
-
const runSetup = f.setupRanSince
|
|
165
|
-
? { effect: 'unknown', reason: 'no setup-affecting change since last setup' }
|
|
166
|
-
: { effect: 'recommended', reason: 'a reset/update landed since the last setup' };
|
|
167
|
-
|
|
168
164
|
return {
|
|
169
165
|
'update-client-source': updateClient,
|
|
170
|
-
'update-editor-source': updateEditor,
|
|
171
|
-
'reset-editor-source': resetEditor,
|
|
172
166
|
'reset-client': resetClient,
|
|
167
|
+
'end-session': endSession,
|
|
173
168
|
'rebuild-binary': rebuild,
|
|
174
169
|
'update-reset-everything': everything,
|
|
175
|
-
'run-setup': runSetup,
|
|
176
170
|
};
|
|
177
171
|
}
|
|
178
172
|
|
|
179
|
-
|
|
173
|
+
// Decide whether a Live Preview SSH forward needs to be re-wired (improve41)
|
|
174
|
+
function needsRewire(forwardAppPort, polledAppPort) {
|
|
175
|
+
if (typeof polledAppPort !== 'number' || isNaN(polledAppPort)) return false;
|
|
176
|
+
if (!forwardAppPort) return false;
|
|
177
|
+
return forwardAppPort !== polledAppPort;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
module.exports = { computeButtonEffects, ACTION_IDS, needsRewire };
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Pure operator-host placement resolution for the fleet dashboard
|
|
3
|
+
// (vm-dashboard-on-gce).
|
|
4
|
+
//
|
|
5
|
+
// The dashboard runs in two homes: the operator's laptop (legacy fallback) and a
|
|
6
|
+
// dedicated GCE "operator host" (the primary path). Three things differ by home —
|
|
7
|
+
// how gcloud authenticates, what address the dashboard binds, and how a branch
|
|
8
|
+
// pin is read from the env — and each used to be an inline ternary at the top of
|
|
9
|
+
// server.js's 200KB monolith, untestable. This module is the single home for
|
|
10
|
+
// those decisions, so server.js delegates the RULE and keeps only the I/O, the
|
|
11
|
+
// same split npm/fleet-branch.js (resolveFleetBranch) follows.
|
|
12
|
+
//
|
|
13
|
+
// Pure logic only — no I/O, no env reads beyond the explicit `env` argument a
|
|
14
|
+
// caller passes in — so it stays unit-testable in isolation (npm/fleet-host.test.js).
|
|
15
|
+
|
|
16
|
+
// Env var the systemd unit sets on the GCE operator host. Its presence (truthy)
|
|
17
|
+
// is what flips the dashboard from the laptop defaults to the on-GCE defaults.
|
|
18
|
+
const FLEET_ON_GCE_ENV = 'FLEET_ON_GCE';
|
|
19
|
+
|
|
20
|
+
/** True when the dashboard is running on the GCE operator host, read from the
|
|
21
|
+
* `FLEET_ON_GCE` env. Accepts the conventional truthy spellings `1` / `true` /
|
|
22
|
+
* `yes` (case-insensitive, trimmed); everything else — unset, empty, `0`,
|
|
23
|
+
* `false`, garbage — is false, so a laptop run (which never sets it) stays on
|
|
24
|
+
* the loopback + named-configuration defaults. */
|
|
25
|
+
function onGce(env = {}) {
|
|
26
|
+
return /^(1|true|yes)$/i.test(String(env[FLEET_ON_GCE_ENV] || '').trim());
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Resolve the gcloud invocation prefix the dashboard's `${GCLOUD}` chokepoint
|
|
30
|
+
* uses. On the GCE operator host the attached SA authenticates from instance
|
|
31
|
+
* metadata, so a plain `gcloud` is correct (no key file, no named config). Off
|
|
32
|
+
* GCE (laptop) it pins the non-expiring SA configuration with
|
|
33
|
+
* `--configuration=<config>` so background loops survive OAuth-token expiry.
|
|
34
|
+
* Returns `'gcloud'` on GCE; throws if a non-GCE call omits the config name,
|
|
35
|
+
* since silently emitting a bare `gcloud` there would run on the operator's
|
|
36
|
+
* personal account — the exact regression the chokepoint exists to prevent. */
|
|
37
|
+
function gcloudCommand({ onGce: gce, config } = {}) {
|
|
38
|
+
if (gce) return 'gcloud';
|
|
39
|
+
const c = typeof config === 'string' ? config.trim() : '';
|
|
40
|
+
if (!c) {
|
|
41
|
+
throw new Error('gcloudCommand requires a config name when not on GCE');
|
|
42
|
+
}
|
|
43
|
+
return `gcloud --configuration=${c}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Resolve the address the dashboard binds. An explicit `bind` (the DASH_BIND
|
|
47
|
+
* override) always wins. Otherwise the host serves the LB's zonal NEG so it must
|
|
48
|
+
* bind the instance's internal NIC (`0.0.0.0`, firewalled to the LB + IAP
|
|
49
|
+
* ranges); a laptop run binds loopback only (`127.0.0.1`). A blank/whitespace
|
|
50
|
+
* override counts as absent so a set-but-empty env doesn't bind to an empty
|
|
51
|
+
* string. */
|
|
52
|
+
function dashboardBind({ onGce: gce, bind } = {}) {
|
|
53
|
+
const explicit = typeof bind === 'string' ? bind.trim() : '';
|
|
54
|
+
if (explicit) return explicit;
|
|
55
|
+
return gce ? '0.0.0.0' : '127.0.0.1';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Resolve the explicit branch pin from the env, or null when unpinned. The
|
|
59
|
+
* operator-host name is `FLEET_BRANCH`; `BRANCH` is the legacy laptop alias and
|
|
60
|
+
* is honored as a fallback. A blank/whitespace value is "no pin" (so the
|
|
61
|
+
* dashboard tracks live git HEAD via resolveFleetBranch). Returns the trimmed
|
|
62
|
+
* pin or null. */
|
|
63
|
+
function branchPin(env = {}) {
|
|
64
|
+
const pin = String(env.FLEET_BRANCH || env.BRANCH || '').trim();
|
|
65
|
+
return pin === '' ? null : pin;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = {
|
|
69
|
+
FLEET_ON_GCE_ENV,
|
|
70
|
+
onGce,
|
|
71
|
+
gcloudCommand,
|
|
72
|
+
dashboardBind,
|
|
73
|
+
branchPin,
|
|
74
|
+
};
|
package/npm/fleet-job-badge.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// npm/fleet-job-badge.js — pure verdict for the fleet dashboard's failed-job
|
|
4
4
|
// badge reconciliation (improve39). A VM's last terminal-error heavy job
|
|
5
|
-
// (add/error, reset-all error, reset-
|
|
5
|
+
// (add/error, reset-all error, reset-client error, …) used to stick on the
|
|
6
6
|
// dashboard card indefinitely — even after the VM was recovered and is healthy
|
|
7
7
|
// again — because the card renders the last job's error with no check against
|
|
8
8
|
// the VM's live state, so a recovered VM keeps screaming a red error and
|
|
@@ -36,4 +36,48 @@ function shouldReconcileFailedBadge({ job, reachable, drifted, offBranch, degrad
|
|
|
36
36
|
return true;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
// Given a reconcile verdict (from shouldReconcileFailedBadge) and whether the VM
|
|
40
|
+
// is now idle, decide whether the recovered badge should be FULLY CLEARED — the
|
|
41
|
+
// job entry dropped from state.jobs so the card goes clean — rather than merely
|
|
42
|
+
// muted as a "recovered (was: …)" note (improve41).
|
|
43
|
+
//
|
|
44
|
+
// badgeReconciled the shouldReconcileFailedBadge verdict for this VM
|
|
45
|
+
// idle the VM answered this poll, is not running a build session,
|
|
46
|
+
// and its broker is not actively serving an agent
|
|
47
|
+
//
|
|
48
|
+
// A recovered VM that is also idle has demonstrably finished recovering, so the
|
|
49
|
+
// stale error is dropped entirely. A recovered-but-not-yet-idle VM (mid-build,
|
|
50
|
+
// or a poll hasn't confirmed idle) keeps the muted note so the prior log stays
|
|
51
|
+
// visible during that transient window. Never clears a badge that wasn't first
|
|
52
|
+
// judged recovered — an unreachable / drifted / off-branch error stays surfaced.
|
|
53
|
+
function shouldClearReconciledBadge({ badgeReconciled, idle } = {}) {
|
|
54
|
+
return !!badgeReconciled && !!idle;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Pure guard for the operator "Clear badge" action (improve41). Decides whether
|
|
58
|
+
// VM-n's persisted job entry may be dropped after an out-of-band recovery,
|
|
59
|
+
// independent of the server's state / fs I/O so the cases are unit-tested
|
|
60
|
+
// without a live dashboard. The shell (server.js) supplies the live signals and
|
|
61
|
+
// turns the verdict into the {ok,msg} response + the actual delete + snapshot.
|
|
62
|
+
//
|
|
63
|
+
// rostered the VM number is in the live roster
|
|
64
|
+
// job the carried job record ({ state, ... }) or null/undefined
|
|
65
|
+
//
|
|
66
|
+
// Only a TERMINAL job (error/done) on a rostered VM may be cleared, OR a terminal
|
|
67
|
+
// failed add (add/error) on a non-rostered VM (which deletes the card + add-N sidecar):
|
|
68
|
+
// any other job state / non-rostered combination is not clearable.
|
|
69
|
+
// Returns { ok, reason, cleanup } — reason is a stable code the shell maps to an
|
|
70
|
+
// operator message, and cleanup is the cleanup action to perform ('add-failed' vs 'badge-only').
|
|
71
|
+
function canClearBadge({ rostered, job } = {}) {
|
|
72
|
+
if (!rostered) {
|
|
73
|
+
if (job && job.action === 'add' && job.state === 'error') {
|
|
74
|
+
return { ok: true, reason: null, cleanup: 'add-failed' };
|
|
75
|
+
}
|
|
76
|
+
return { ok: false, reason: 'not-rostered' };
|
|
77
|
+
}
|
|
78
|
+
if (!job) return { ok: false, reason: 'no-badge' };
|
|
79
|
+
if (job.state !== 'error' && job.state !== 'done') return { ok: false, reason: 'job-active' };
|
|
80
|
+
return { ok: true, reason: null, cleanup: 'badge-only' };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = { shouldReconcileFailedBadge, shouldClearReconciledBadge, canClearBadge };
|