@codeyam-editor/codeyam-editor 0.1.4 → 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 +1415 -73
- package/npm/container.js +3 -0
- package/npm/disk-pressure.js +78 -0
- package/npm/editor-dev.js +96 -15
- package/npm/editor.js +85 -37
- package/npm/fleet-auth-health.js +75 -0
- package/npm/fleet-auth.js +6 -1
- package/npm/fleet-build-data.js +51 -2
- package/npm/fleet-cleanup.js +118 -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 +1 -1
- package/npm/fleet-launchd.js +171 -0
- package/npm/fleet-live-status.js +123 -0
- package/npm/fleet-plans.js +135 -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 +43 -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 +204 -3
- package/npm/scenario-check.js +603 -32
- package/npm/scenario-handlers.js +78 -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 +329 -50
- 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-BuK0lizt.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-tMMdarZD.js +0 -187
- package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +0 -5
- package/ui/dist/assets/ScenarioDataPanelFullPage-5G6KuZgw.js +0 -1
- package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +0 -1
- package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
- package/ui/dist/assets/index-BGqairFV.css +0 -1
- package/ui/dist/assets/useEvents-DggWrtHe.js +0 -1
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,14 +207,33 @@ 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';
|
|
@@ -297,11 +331,87 @@ function orphanedAddsToReconcile({ jobs, sidecarKeys } = {}) {
|
|
|
297
331
|
// posture). "Busy" (a live session or an in-flight add to this number) also
|
|
298
332
|
// holds the entry for an operator rather than auto-removing — the shouldn't-
|
|
299
333
|
// happen-but-fail-safe case where a vanished instance still reports a session.
|
|
300
|
-
|
|
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 } = {}) {
|
|
301
372
|
if (exists === null || exists === undefined) return "leave";
|
|
302
|
-
if (exists)
|
|
303
|
-
|
|
304
|
-
|
|
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";
|
|
305
415
|
}
|
|
306
416
|
|
|
307
417
|
module.exports = {
|
|
@@ -319,6 +429,7 @@ module.exports = {
|
|
|
319
429
|
parseExitMarker,
|
|
320
430
|
readExitMarker,
|
|
321
431
|
clearExitMarker,
|
|
432
|
+
isSignalKillExit,
|
|
322
433
|
reattachVerdict,
|
|
323
434
|
reattachNextStep,
|
|
324
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
|
|
@@ -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
|
+
};
|