@codeyam-editor/codeyam-editor 0.1.6 → 0.1.8
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.js +308 -52
- package/npm/compose-conflict.js +59 -0
- package/npm/fleet-add-failure.js +174 -18
- package/npm/fleet-cleanup.js +129 -0
- package/npm/fleet-job-badge.js +28 -12
- package/npm/fleet-memory.js +73 -4
- package/npm/fleet-probe-transport.js +155 -0
- package/npm/fleet-queue.js +141 -0
- package/npm/fleet-revision.js +104 -0
- package/npm/fleet-running-plan.js +37 -7
- package/npm/fleet-vm-access.js +162 -0
- package/npm/ingress-invoke.js +46 -2
- package/npm/launch-config.js +163 -4
- package/npm/scenario-check.js +63 -19
- package/npm/scenario-handlers.js +18 -0
- package/npm/scenario-issues.js +11 -0
- package/npm/scenario-mocks.js +245 -39
- package/npm/scenario-playwright.js +114 -22
- package/package.json +5 -5
- package/ui/dist/assets/{ContainerInspector-DgM0pTAx.js → ContainerInspector-DSVZvEkB.js} +1 -1
- package/ui/dist/assets/EditorShell-DnXdHrar.js +256 -0
- package/ui/dist/assets/EditorShell-FvbXyqqD.css +1 -0
- package/ui/dist/assets/ProjectLauncherScreen-E0NYYiwy.js +1 -0
- package/ui/dist/assets/ScenarioDataPanel-6qJE-0lA.js +15 -0
- package/ui/dist/assets/{ScenarioDataPanelFullPage-BLqRCxQX.js → ScenarioDataPanelFullPage-Cyk6efiI.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-Uyt54vHI.js +21 -0
- package/ui/dist/assets/{index-DCHqRrwO.js → index-BPLKgwon.js} +9 -9
- package/ui/dist/assets/index-DKpgJLo3.css +1 -0
- package/ui/dist/assets/useServerIdentity-CVuHTzjg.js +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/EditorShell-B7Q06uJ_.js +0 -252
- package/ui/dist/assets/EditorShell-DP5UY20G.css +0 -1
- package/ui/dist/assets/ProjectLauncherScreen-BRrEr8Ng.js +0 -1
- package/ui/dist/assets/ScenarioDataPanel-BeQfa28g.js +0 -9
- package/ui/dist/assets/ScenarioRenderer-CIAxfHPh.js +0 -21
- package/ui/dist/assets/index-BygCiJSb.css +0 -1
- package/ui/dist/assets/useServerIdentity-B7rI0u3O.js +0 -1
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// The fleet dashboard's two LB-path HTTP probe transports, extracted from
|
|
3
|
+
// scripts/operator/fleet-dashboard/server.js so their SOCKET LIFETIME is
|
|
4
|
+
// testable.
|
|
5
|
+
//
|
|
6
|
+
// The undici contract, which is the whole reason this module exists: a `fetch`
|
|
7
|
+
// response body is a stream that holds its connection open until it is read to
|
|
8
|
+
// completion or explicitly cancelled. IGNORING `res.body` is NOT the same as
|
|
9
|
+
// releasing it — the socket stays checked out of the agent's pool, and once the
|
|
10
|
+
// peer sends FIN it parks in CLOSE-WAIT forever. Node's `http` module is the
|
|
11
|
+
// opposite (an unread response pauses the socket, hence the `res.resume(); //
|
|
12
|
+
// drain so the socket can close` in server.js's localGetJson), and that
|
|
13
|
+
// asymmetry is what got missed when the fetch path was added.
|
|
14
|
+
//
|
|
15
|
+
// On 2026-08-01 that leaked 10,749 CLOSE-WAIT sockets to the fleet LB
|
|
16
|
+
// (8.228.227.92:443) holding 128MB of unread IAP-login HTML in kernel receive
|
|
17
|
+
// queues — ~460 sockets/hour, a ~1-day fuse — driving the dashboard cgroup into
|
|
18
|
+
// swap-thrash until the node event loop stalled in `D` state and dashboard-bes
|
|
19
|
+
// went UNHEALTHY while systemd still reported the unit active.
|
|
20
|
+
//
|
|
21
|
+
// So: EVERY return path here consumes or cancels the body. `fetch` is injected
|
|
22
|
+
// rather than closed over, so a test can drive every branch with a body that
|
|
23
|
+
// records whether it was cancelled.
|
|
24
|
+
|
|
25
|
+
const fpt = require('./fleet-vm-probe-target');
|
|
26
|
+
|
|
27
|
+
/** Release the connection behind a response we are not going to read.
|
|
28
|
+
*
|
|
29
|
+
* Swallows its own failure deliberately. `cancel()` rejects when the body was
|
|
30
|
+
* already consumed or already errored — in both cases the connection is
|
|
31
|
+
* released anyway, so there is nothing to recover from and nothing to report.
|
|
32
|
+
* Letting it throw would be actively harmful: these calls sit inside the
|
|
33
|
+
* transports' `try`, so a rejection would be caught by the transport's own
|
|
34
|
+
* catch and misclassify a perfectly good HTTP response as `unreachable`.
|
|
35
|
+
*/
|
|
36
|
+
async function disposeBody(res) {
|
|
37
|
+
try {
|
|
38
|
+
await res.body?.cancel();
|
|
39
|
+
} catch {
|
|
40
|
+
// Already consumed, already errored, or no body at all — connection is
|
|
41
|
+
// released either way.
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** GET JSON from a resolved LB target.
|
|
46
|
+
*
|
|
47
|
+
* Uses redirect:'manual' so IAP's 302→accounts.google.com surfaces as a
|
|
48
|
+
* classifiable response instead of the fetch silently following it and handing
|
|
49
|
+
* us a login page that fails JSON parsing — which would read as "VM is down"
|
|
50
|
+
* when the real fault is our token. That 302 carries a full HTML login page,
|
|
51
|
+
* which is exactly why the non-ok return below must dispose of the body.
|
|
52
|
+
*
|
|
53
|
+
* `deps` injects the transport's collaborators for tests: `fetchImpl` (default
|
|
54
|
+
* global fetch) and `warn` (default console.warn).
|
|
55
|
+
*/
|
|
56
|
+
async function lbGetJson(target, timeout, deps = {}) {
|
|
57
|
+
const { fetchImpl = globalThis.fetch, warn = console.warn } = deps;
|
|
58
|
+
const ctl = new AbortController();
|
|
59
|
+
const timer = setTimeout(() => ctl.abort(), timeout);
|
|
60
|
+
try {
|
|
61
|
+
const res = await fetchImpl(target.url, {
|
|
62
|
+
headers: target.headers,
|
|
63
|
+
redirect: 'manual',
|
|
64
|
+
signal: ctl.signal,
|
|
65
|
+
});
|
|
66
|
+
const outcome = fpt.classifyProbeResponse({
|
|
67
|
+
status: res.status,
|
|
68
|
+
location: res.headers.get('location'),
|
|
69
|
+
});
|
|
70
|
+
if (outcome !== 'ok') {
|
|
71
|
+
// 401 and 403 share one outcome but have opposite fixes (OAuth client vs
|
|
72
|
+
// IAM grant). Name which one we hit, so the next misconfig is diagnosable
|
|
73
|
+
// from the log instead of a live curl against the backend.
|
|
74
|
+
const why = fpt.describeAuthMisconfig(res.status);
|
|
75
|
+
if (why) warn(`probe ${target.url}: HTTP ${res.status} — ${why}`);
|
|
76
|
+
await disposeBody(res);
|
|
77
|
+
return { outcome, json: null, status: res.status };
|
|
78
|
+
}
|
|
79
|
+
// `status` rides on every return so a caller can NAME the HTTP status in a
|
|
80
|
+
// fault warning. Without it a reachable-but-unusable body was indiscernible
|
|
81
|
+
// from a dead VM in the log — see probeBodyFault.
|
|
82
|
+
//
|
|
83
|
+
// Both branches below are body-safe without an explicit cancel: `res.text()`
|
|
84
|
+
// reads the stream to completion, so the connection is already released by
|
|
85
|
+
// the time JSON.parse runs or throws.
|
|
86
|
+
try { return { outcome: 'ok', json: JSON.parse(await res.text()), status: res.status }; }
|
|
87
|
+
catch { return { outcome: 'unreachable', json: null, error: 'bad-json', status: res.status }; }
|
|
88
|
+
} catch (err) {
|
|
89
|
+
// No response to dispose of — the request never produced one.
|
|
90
|
+
return {
|
|
91
|
+
outcome: 'unreachable',
|
|
92
|
+
json: null,
|
|
93
|
+
error: err && err.name === 'AbortError' ? 'timeout' : 'error',
|
|
94
|
+
};
|
|
95
|
+
} finally {
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Reachability-only transport for the PUBLIC preview ingress over the IAP-LB
|
|
101
|
+
* seam.
|
|
102
|
+
*
|
|
103
|
+
* Unlike lbGetJson it does not PARSE a body — the preview root returns HTML, so
|
|
104
|
+
* routing it through lbGetJson would fail-close on the non-JSON parse. It must
|
|
105
|
+
* still DISPOSE of that body on every path; "does not parse" was historically
|
|
106
|
+
* written as "does not touch", and that is precisely what leaked.
|
|
107
|
+
*
|
|
108
|
+
* Classifies purely on the HTTP response:
|
|
109
|
+
* any non-auth HTTP response (200, or even 404/502 — the LB routed to a
|
|
110
|
+
* backend that answered) → true (ingress wired);
|
|
111
|
+
* auth-misconfigured (302→Google login / 401 / 403) → null (UNKNOWN, never
|
|
112
|
+
* false): the preview backend is a SEPARATE IAP backend
|
|
113
|
+
* service whose audience/accessor may differ from the
|
|
114
|
+
* editor backend the operator token is minted for, so an
|
|
115
|
+
* auth challenge here proves nothing about the URL wiring;
|
|
116
|
+
* transport error / timeout → false (nothing answered).
|
|
117
|
+
*
|
|
118
|
+
* Uses redirect:'manual' so an IAP login 302 surfaces as classifiable instead
|
|
119
|
+
* of fetch silently following it to a login page.
|
|
120
|
+
*/
|
|
121
|
+
async function lbProbeReachable(target, timeout, deps = {}) {
|
|
122
|
+
const { fetchImpl = globalThis.fetch, warn = console.warn } = deps;
|
|
123
|
+
const ctl = new AbortController();
|
|
124
|
+
const timer = setTimeout(() => ctl.abort(), timeout);
|
|
125
|
+
try {
|
|
126
|
+
const res = await fetchImpl(target.url, {
|
|
127
|
+
headers: target.headers,
|
|
128
|
+
redirect: 'manual',
|
|
129
|
+
signal: ctl.signal,
|
|
130
|
+
});
|
|
131
|
+
const outcome = fpt.classifyProbeResponse({
|
|
132
|
+
status: res.status,
|
|
133
|
+
location: res.headers.get('location'),
|
|
134
|
+
});
|
|
135
|
+
if (outcome === 'auth-misconfigured') {
|
|
136
|
+
const why = fpt.describeAuthMisconfig(res.status);
|
|
137
|
+
if (why) warn(`preview ingress ${target.url}: HTTP ${res.status} — ${why}`);
|
|
138
|
+
await disposeBody(res);
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
await disposeBody(res);
|
|
142
|
+
return true;
|
|
143
|
+
} catch {
|
|
144
|
+
// No response to dispose of — the request never produced one.
|
|
145
|
+
return false;
|
|
146
|
+
} finally {
|
|
147
|
+
clearTimeout(timer);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
module.exports = {
|
|
152
|
+
disposeBody,
|
|
153
|
+
lbGetJson,
|
|
154
|
+
lbProbeReachable,
|
|
155
|
+
};
|
package/npm/fleet-queue.js
CHANGED
|
@@ -126,9 +126,150 @@ function reconcileQueueDraft(draftOrder, liveOrder) {
|
|
|
126
126
|
return out;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
// Consecutive failed reads before the panel stops trusting its mirror. At the
|
|
130
|
+
// default QUEUE_POLL_MS of 5000 this is ~15s of sustained failure — long enough
|
|
131
|
+
// that the ordinary 20s-timeout blip the next tick clears only ever reaches
|
|
132
|
+
// 'degraded', short enough that a real credential outage is flagged in seconds.
|
|
133
|
+
// Named rather than inlined so the verdict and the tooltip copy read the same
|
|
134
|
+
// number from one place.
|
|
135
|
+
const QUEUE_STALE_AFTER_FAILURES = 3;
|
|
136
|
+
|
|
137
|
+
/** Merge one commit-queue poll result over the previous memo.
|
|
138
|
+
*
|
|
139
|
+
* THE RULE: a failed read RETAINS the previous `entries` AND the previous
|
|
140
|
+
* `readAt`, and records `error`. Retaining the entries is deliberate — a stale
|
|
141
|
+
* order beats a blank panel for an operator mid-triage. Leaving `readAt`
|
|
142
|
+
* untouched is what makes the staleness legible: it is the ONLY signal that
|
|
143
|
+
* distinguishes a frozen view from a live one.
|
|
144
|
+
*
|
|
145
|
+
* Advancing `readAt` on a failure is precisely the bug this exists to prevent.
|
|
146
|
+
* On 2026-08-14 /etc/codeyam/git-credentials on the operator host went to 0
|
|
147
|
+
* bytes and every origin git operation began failing; the queue panel went on
|
|
148
|
+
* naming VM-5 as HEAD for 3.5 hours after VM-5 had released, while the real
|
|
149
|
+
* queue was VM-2 -> VM-8 -> VM-4. Nothing on the page distinguished it from
|
|
150
|
+
* live. This is the same lesson mergeDashboardRevision records from
|
|
151
|
+
* 2026-07-31, applied to the second poller.
|
|
152
|
+
*
|
|
153
|
+
* A previous `readAt` of null (no successful read has EVER happened) stays
|
|
154
|
+
* null and is not coerced to a timestamp — "never read" and "read a moment
|
|
155
|
+
* ago" must stay distinguishable, the same reason mergeDashboardRevision
|
|
156
|
+
* refuses to push a null `behind` through Number().
|
|
157
|
+
*
|
|
158
|
+
* Success is the absence of `error`. Pure; no I/O. */
|
|
159
|
+
function mergeQueuePoll({ prev, entries, readAt, error } = {}) {
|
|
160
|
+
const base = (prev && typeof prev === 'object') ? prev : {};
|
|
161
|
+
const str = (v) => ((typeof v === 'string' && v) ? v : null);
|
|
162
|
+
const prevEntries = Array.isArray(base.entries) ? base.entries : [];
|
|
163
|
+
const prevReadAt = str(base.readAt);
|
|
164
|
+
const prevFailures = (typeof base.consecutiveFailures === 'number'
|
|
165
|
+
&& Number.isFinite(base.consecutiveFailures) && base.consecutiveFailures >= 0)
|
|
166
|
+
? base.consecutiveFailures : 0;
|
|
167
|
+
const err = str(error);
|
|
168
|
+
if (err) {
|
|
169
|
+
return {
|
|
170
|
+
entries: prevEntries,
|
|
171
|
+
readAt: prevReadAt,
|
|
172
|
+
error: err,
|
|
173
|
+
consecutiveFailures: prevFailures + 1,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
entries: Array.isArray(entries) ? entries : [],
|
|
178
|
+
readAt: str(readAt) || prevReadAt,
|
|
179
|
+
error: null,
|
|
180
|
+
consecutiveFailures: 0,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** What does the current queue memo mean for the operator?
|
|
185
|
+
*
|
|
186
|
+
* Levels:
|
|
187
|
+
* 'fresh' — the last read succeeded. Render exactly as before; every
|
|
188
|
+
* control stays live.
|
|
189
|
+
* 'degraded' — erroring, but under QUEUE_STALE_AFTER_FAILURES consecutive
|
|
190
|
+
* failures. A blip. Noted quietly, controls stay live — a
|
|
191
|
+
* single 20s-timeout hiccup that the next tick clears must not
|
|
192
|
+
* cry wolf or lock the operator out of their own queue.
|
|
193
|
+
* 'stale' — erroring at or past the threshold. The mirror is not to be
|
|
194
|
+
* trusted and writes computed against it are blocked.
|
|
195
|
+
* 'unknown' — erroring and NO read has ever succeeded (the boot window with
|
|
196
|
+
* broken credentials). Worth saying out loud: an empty-looking
|
|
197
|
+
* queue here means "we cannot see the queue", not "go".
|
|
198
|
+
*
|
|
199
|
+
* AGE IS DELIBERATELY NOT AN INPUT. A genuinely quiet queue is not a broken
|
|
200
|
+
* one, so escalation keys on consecutive read FAILURES only; age is carried by
|
|
201
|
+
* projectQueueFreshness purely to phrase the message ("last read 3h 12m ago").
|
|
202
|
+
* An hours-old `readAt` with no error is 'fresh' and must stay that way. Taking
|
|
203
|
+
* a clock here would imply otherwise, so the signature omits one — the same
|
|
204
|
+
* take-only-what-you-use shape as classifyDashboardStaleness.
|
|
205
|
+
*
|
|
206
|
+
* Pure; no I/O. */
|
|
207
|
+
function classifyQueueFreshness({ readAt, error, consecutiveFailures } = {}) {
|
|
208
|
+
if (!(typeof error === 'string' && error)) return 'fresh';
|
|
209
|
+
if (!(typeof readAt === 'string' && readAt)) return 'unknown';
|
|
210
|
+
const failures = (typeof consecutiveFailures === 'number' && Number.isFinite(consecutiveFailures))
|
|
211
|
+
? consecutiveFailures : 0;
|
|
212
|
+
return failures >= QUEUE_STALE_AFTER_FAILURES ? 'stale' : 'degraded';
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The `/data` view of the queue memo, with the freshness VERDICT resolved
|
|
216
|
+
* server-side.
|
|
217
|
+
*
|
|
218
|
+
* The panel is then a pure renderer. Two consumers re-deriving "is this stale"
|
|
219
|
+
* from a raw timestamp would drift, and `public/index.html` is an inline
|
|
220
|
+
* browser script with no module boundary — any decision left in it could only
|
|
221
|
+
* ever be source-scan tested, which is how it ships bugs green. Same split, and
|
|
222
|
+
* same reason, as projectDashboardRevision.
|
|
223
|
+
*
|
|
224
|
+
* `now` is injected (epoch ms) rather than read from the clock so the
|
|
225
|
+
* projection stays pure and its tests deterministic. `ageSec` is null when no
|
|
226
|
+
* successful read has happened or no clock was supplied.
|
|
227
|
+
*
|
|
228
|
+
* Pure; no I/O. */
|
|
229
|
+
function projectQueueFreshness(memo = {}, now = null) {
|
|
230
|
+
const at = (typeof memo.readAt === 'string' && memo.readAt) ? Date.parse(memo.readAt) : NaN;
|
|
231
|
+
const clock = (typeof now === 'number' && Number.isFinite(now)) ? now : null;
|
|
232
|
+
const ageSec = (Number.isFinite(at) && clock !== null)
|
|
233
|
+
? Math.max(0, Math.floor((clock - at) / 1000))
|
|
234
|
+
: null;
|
|
235
|
+
return { ...memo, level: classifyQueueFreshness(memo), ageSec };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The operator-facing reason a queue WRITE must be refused, or null when writes
|
|
239
|
+
* are allowed.
|
|
240
|
+
*
|
|
241
|
+
* A write computed against a mirror known to be frozen is never the right
|
|
242
|
+
* action, and the silent-no-op outcome is worse than a refusal: a Release
|
|
243
|
+
* fires at a uuid that no longer holds the head, reports "released head
|
|
244
|
+
* bb65addf", changes nothing, and leaves the real head held. A reorder is
|
|
245
|
+
* worse still — computeReorderTargets validates the posted order against the
|
|
246
|
+
* very mirror that is wrong, so a doomed reorder looks perfectly valid.
|
|
247
|
+
*
|
|
248
|
+
* The verbatim git error is interpolated rather than summarised to "poll
|
|
249
|
+
* failed". On 2026-08-14 the entire recovery was legible from one line —
|
|
250
|
+
* `could not read Username for 'https://github.com'` — and truncating it would
|
|
251
|
+
* have cost the same 3.5 hours.
|
|
252
|
+
*
|
|
253
|
+
* Lives here rather than in server.js so the refusal COPY is directly
|
|
254
|
+
* assertable: server.js has top-level side effects on require, so a message
|
|
255
|
+
* built there could only ever be source-scan tested. Pure; no I/O. */
|
|
256
|
+
function queueWriteBlockReason(memo = {}) {
|
|
257
|
+
const level = classifyQueueFreshness(memo);
|
|
258
|
+
if (level !== 'stale' && level !== 'unknown') return null;
|
|
259
|
+
const why = (typeof memo.error === 'string' && memo.error) ? ` (${memo.error})` : '';
|
|
260
|
+
return level === 'unknown'
|
|
261
|
+
? `commit queue has never been read successfully${why} — cannot act on an unknown queue`
|
|
262
|
+
: `commit queue read is failing${why} — the displayed order cannot be trusted; fix the read before changing the queue`;
|
|
263
|
+
}
|
|
264
|
+
|
|
129
265
|
module.exports = {
|
|
130
266
|
decideAutoReleaseGoneHead,
|
|
131
267
|
computeReorderTargets,
|
|
132
268
|
nudgeQueueOrder,
|
|
133
269
|
reconcileQueueDraft,
|
|
270
|
+
mergeQueuePoll,
|
|
271
|
+
classifyQueueFreshness,
|
|
272
|
+
projectQueueFreshness,
|
|
273
|
+
queueWriteBlockReason,
|
|
274
|
+
QUEUE_STALE_AFTER_FAILURES,
|
|
134
275
|
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Pure helpers for the fleet dashboard's DEPLOYED-REVISION staleness signal
|
|
3
|
+
// (scripts/operator/fleet-dashboard/server.js requires this; unit tests in
|
|
4
|
+
// npm/fleet-revision.test.js).
|
|
5
|
+
//
|
|
6
|
+
// The dashboard header has always shown the branch name, which says nothing
|
|
7
|
+
// about WHICH COMMIT of that branch is serving. On 2026-07-31 the operator
|
|
8
|
+
// host's /opt/codeyam-editor sat 104 commits behind origin/editor-improvements-66,
|
|
9
|
+
// so the deployed dashboard predated its own memory fix and was
|
|
10
|
+
// indistinguishable from one at tip — the third recurrence of a landed fix
|
|
11
|
+
// surviving because the served checkout was behind (the June VM-5 slug bug was
|
|
12
|
+
// the same shape).
|
|
13
|
+
//
|
|
14
|
+
// server.js owns the I/O — the bounded `git fetch` / `rev-parse` / `rev-list`
|
|
15
|
+
// children and the slow-cycle memo. The two rules that actually matter are here
|
|
16
|
+
// and are pure: how a failed check merges over the last good one, and what
|
|
17
|
+
// verdict a given count means. Both were previously inline in an async IIFE
|
|
18
|
+
// that no test could reach.
|
|
19
|
+
|
|
20
|
+
/** Merge one revision-check result over the previous memo.
|
|
21
|
+
*
|
|
22
|
+
* THE RULE: a failed check RETAINS the previous `behind` and records `error` —
|
|
23
|
+
* it never reports zero. This is what makes the staleness chip sticky. A
|
|
24
|
+
* broken `git fetch` that reset the count to 0 would present a stale dashboard
|
|
25
|
+
* as current, which is precisely the failure this whole signal exists to catch;
|
|
26
|
+
* degrading to "last known count, plus a note that the check is failing" is
|
|
27
|
+
* the honest answer.
|
|
28
|
+
*
|
|
29
|
+
* A `behind` that is absent, negative, or unparseable counts as no reading and
|
|
30
|
+
* falls back to the previous value the same way. `sha` and `branch` likewise
|
|
31
|
+
* retain their last known values rather than blanking the header on a hiccup.
|
|
32
|
+
*
|
|
33
|
+
* A previous `behind` of null (never successfully counted) stays null — it is
|
|
34
|
+
* deliberately NOT coerced through Number(), because `Number(null) === 0` is
|
|
35
|
+
* the exact coercion that made "no reading" and "zero" indistinguishable in
|
|
36
|
+
* readCgroupMemory.
|
|
37
|
+
*
|
|
38
|
+
* Pure; no I/O. */
|
|
39
|
+
function mergeDashboardRevision({ prev, sha, branch, behind, checkedAt, error } = {}) {
|
|
40
|
+
const base = (prev && typeof prev === 'object') ? prev : {};
|
|
41
|
+
const str = (next, fallback) => ((typeof next === 'string' && next) ? next : (typeof fallback === 'string' && fallback ? fallback : null));
|
|
42
|
+
// Strictly a number: a string or null is NOT coerced, so a caller cannot
|
|
43
|
+
// accidentally turn "no reading" into 0 the way Number(null) would.
|
|
44
|
+
const count = (v) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : null);
|
|
45
|
+
const counted = count(behind);
|
|
46
|
+
return {
|
|
47
|
+
sha: str(sha, base.sha),
|
|
48
|
+
branch: str(branch, base.branch),
|
|
49
|
+
behind: counted === null ? count(base.behind) : counted,
|
|
50
|
+
checkedAt: str(checkedAt, base.checkedAt),
|
|
51
|
+
error: str(error, null),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** What does the current revision memo mean for the operator?
|
|
56
|
+
*
|
|
57
|
+
* Split out from the header chip so the verdict is a readable table rather
|
|
58
|
+
* than branches buried in string concatenation — the same reason
|
|
59
|
+
* operatorAuthRecovery was split out of operatorAuthBannerHtml, and the same
|
|
60
|
+
* reason resolveCgroupMemoryDir was split out of readCgroupMemory: logic that
|
|
61
|
+
* can only be reached through a browser inline script or a real cgroup
|
|
62
|
+
* filesystem is logic that ships its bugs green.
|
|
63
|
+
*
|
|
64
|
+
* Levels:
|
|
65
|
+
* 'stale' — behind > 0. Landed fixes are not live; the chip warns.
|
|
66
|
+
* 'current' — behind === 0. Clears the chip, and clears it even when the
|
|
67
|
+
* latest check errored: the count reaching zero is the ONLY
|
|
68
|
+
* thing that clears the warning, and once it has, a later
|
|
69
|
+
* failed fetch has no stale count to keep showing.
|
|
70
|
+
* 'unknown' — no count has ever succeeded AND the check is failing. Worth
|
|
71
|
+
* saying out loud rather than rendering the reassuring nothing
|
|
72
|
+
* that an at-tip dashboard renders.
|
|
73
|
+
* 'unchecked' — no count yet and nothing has failed (the boot window before
|
|
74
|
+
* the first slow-cycle refresh). Renders nothing.
|
|
75
|
+
*
|
|
76
|
+
* Pure; no I/O. */
|
|
77
|
+
function classifyDashboardStaleness({ behind, error } = {}) {
|
|
78
|
+
const counted = (typeof behind === 'number' && Number.isFinite(behind) && behind >= 0) ? behind : null;
|
|
79
|
+
if (counted === null) return error ? 'unknown' : 'unchecked';
|
|
80
|
+
return counted > 0 ? 'stale' : 'current';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The `/data` view of the revision memo, with the staleness VERDICT resolved
|
|
84
|
+
* server-side.
|
|
85
|
+
*
|
|
86
|
+
* The chip is then a pure renderer: `public/index.html` is an inline browser
|
|
87
|
+
* script with no module boundary, so any decision left in it could only ever
|
|
88
|
+
* be source-scan tested — assertions on text rather than on behavior.
|
|
89
|
+
*
|
|
90
|
+
* Lives here rather than in `server.js` for the same reason
|
|
91
|
+
* `classifyDashboardStaleness` does: `server.js` has top-level side effects on
|
|
92
|
+
* require (createServer/listen/setInterval), so nothing there can be executed
|
|
93
|
+
* by a test and a projection defined inside it ships its bugs green.
|
|
94
|
+
*
|
|
95
|
+
* Pure; no I/O. */
|
|
96
|
+
function projectDashboardRevision(memo = {}) {
|
|
97
|
+
return { ...memo, level: classifyDashboardStaleness(memo) };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = {
|
|
101
|
+
mergeDashboardRevision,
|
|
102
|
+
classifyDashboardStaleness,
|
|
103
|
+
projectDashboardRevision,
|
|
104
|
+
};
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
// Resolve the running-plan fields a fleet card shows — feature, step, mode,
|
|
4
|
-
// selectedPlan, holderSessionId — from the two
|
|
5
|
-
// the last-known-good state.
|
|
4
|
+
// selectedPlan, holderSessionId, startedAt, featureStartedAt — from the two
|
|
5
|
+
// independent per-VM probes plus the last-known-good state.
|
|
6
|
+
//
|
|
7
|
+
// The two timestamps ride here because they share the restart problem below and
|
|
8
|
+
// nothing else resolves them: `startedAt` / `featureStartedAt` drive the card's
|
|
9
|
+
// "on step" / "on feature" tenure badges AND the server-side slow/stuck/needs-you
|
|
10
|
+
// verdict (fleet-stuck-status.js deriveAttention keys on `startedAt`). A null
|
|
11
|
+
// degrades that verdict to level:'ok', needsYou:false — so a VM whose editor
|
|
12
|
+
// restarted was silently exempt from the very detection it most needed.
|
|
6
13
|
//
|
|
7
14
|
// Why this exists: the card used to hang these fields off `/api/session-info`
|
|
8
15
|
// alone (spread as `...info` in server.js). But a VM whose editor server
|
|
@@ -35,10 +42,11 @@
|
|
|
35
42
|
// @param {?Object} params.info Parsed `/api/session-info` body (carries
|
|
36
43
|
// `reachable`), or null on a failed/timed-out probe.
|
|
37
44
|
// @param {?Object} params.prev The previous `state.vms[n]` (carries the
|
|
38
|
-
// last-known feature/step/mode/selectedPlan/holderSessionId
|
|
39
|
-
// on the first poll.
|
|
45
|
+
// last-known feature/step/mode/selectedPlan/holderSessionId/startedAt/
|
|
46
|
+
// featureStartedAt). null/undefined on the first poll.
|
|
40
47
|
// @returns {{feature: ?string, step: ?number, mode: ?string,
|
|
41
|
-
// selectedPlan: ?string, holderSessionId: ?string
|
|
48
|
+
// selectedPlan: ?string, holderSessionId: ?string, startedAt: ?string,
|
|
49
|
+
// featureStartedAt: ?string}}
|
|
42
50
|
function resolveRunningPlan({ step, info, prev } = {}) {
|
|
43
51
|
const s = step || {};
|
|
44
52
|
const i = info || {};
|
|
@@ -56,6 +64,10 @@ function resolveRunningPlan({ step, info, prev } = {}) {
|
|
|
56
64
|
mode: p.mode || null,
|
|
57
65
|
selectedPlan: p.selectedPlan || null,
|
|
58
66
|
holderSessionId: p.holderSessionId || null,
|
|
67
|
+
// Carried verbatim like the rest: a single timed-out cycle must not blink
|
|
68
|
+
// the tenure badges off and reset the stuck timer back to zero.
|
|
69
|
+
startedAt: p.startedAt || null,
|
|
70
|
+
featureStartedAt: p.featureStartedAt || null,
|
|
59
71
|
};
|
|
60
72
|
}
|
|
61
73
|
|
|
@@ -67,18 +79,36 @@ function resolveRunningPlan({ step, info, prev } = {}) {
|
|
|
67
79
|
const samePlan = freshSelectedPlan != null && freshSelectedPlan === (p.selectedPlan || null);
|
|
68
80
|
const carry = samePlan ? p : {};
|
|
69
81
|
|
|
82
|
+
// The module's precedence rule, in ONE place: /api/step (session-independent)
|
|
83
|
+
// > /api/session-info > same-plan carry. Four fields resolve exactly this way,
|
|
84
|
+
// so hoisting it keeps a future precedence change from being applied to some
|
|
85
|
+
// of them and not others. The two fields that deviate stay longhand below, and
|
|
86
|
+
// their being longhand is the signal that they deviate: `step` needs an
|
|
87
|
+
// explicit null check (0 is a real step number, and `||` would swallow it),
|
|
88
|
+
// and holderSessionId deliberately skips session-info, which never carries it.
|
|
89
|
+
const pick = (key) => s[key] || i[key] || carry[key] || null;
|
|
90
|
+
|
|
70
91
|
const stepNum =
|
|
71
92
|
s.step != null ? s.step : i.step != null ? i.step : carry.step != null ? carry.step : null;
|
|
72
93
|
|
|
73
94
|
return {
|
|
74
|
-
feature:
|
|
95
|
+
feature: pick("feature"),
|
|
75
96
|
step: stepNum,
|
|
76
|
-
mode:
|
|
97
|
+
mode: pick("mode"),
|
|
77
98
|
selectedPlan: freshSelectedPlan || carry.selectedPlan || null,
|
|
78
99
|
// session-info never carries holderSessionId — it comes from /api/step
|
|
79
100
|
// (editor-step.json) or the same-plan carry. This is what lets a claim map
|
|
80
101
|
// to the executing VM after an editor restart drops the live sessionId.
|
|
81
102
|
holderSessionId: s.holderSessionId || carry.holderSessionId || null,
|
|
103
|
+
// Workflow tenure timers. /api/step wins because EditorStepState serializes
|
|
104
|
+
// `started_at` (non-optional, camelCase) straight out of editor-step.json —
|
|
105
|
+
// session-independent, so it survives the editor restart that makes
|
|
106
|
+
// session-info report hasSession:false and omit them entirely. The same-plan
|
|
107
|
+
// `carry` applies unchanged: a completed plan's startedAt must never be
|
|
108
|
+
// pinned onto the next plan, or the badges would show a tenure the new plan
|
|
109
|
+
// has not accrued.
|
|
110
|
+
startedAt: pick("startedAt"),
|
|
111
|
+
featureStartedAt: pick("featureStartedAt"),
|
|
82
112
|
};
|
|
83
113
|
}
|
|
84
114
|
|
package/npm/fleet-vm-access.js
CHANGED
|
@@ -215,6 +215,161 @@ function noDefaultOperatorDegradedReasons({ ok, action, lbMode, resolvedOperator
|
|
|
215
215
|
];
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
/** The BLOCKED/Next-valid-action refusal for an Add that would create an
|
|
219
|
+
* IAP-gated VM authorizing NOBODY — the front-door counterpart to
|
|
220
|
+
* `noDefaultOperatorDegradedReasons`, which can only warn AFTER the VM exists.
|
|
221
|
+
* Fires only in LB mode (`lbMode` — a fleet domain is configured, so the VM's
|
|
222
|
+
* public URL is IAP-gated) on a numbered fleet VM (`vmNumber != null`) when the
|
|
223
|
+
* intended principal set is empty (`intendedCount === 0`): no `--vm-access`
|
|
224
|
+
* collaborators AND no default operator from any of the three tiers. The check
|
|
225
|
+
* depends only on env + config, never on GCP state, so the caller can run it in
|
|
226
|
+
* the add's preflight and refuse before an instance exists — provisioning a
|
|
227
|
+
* machine nobody can open, then revoking the slot's prior tenant, is what
|
|
228
|
+
* happened to VMs 4/5/6 on 2026-08-09.
|
|
229
|
+
*
|
|
230
|
+
* Names all THREE resolution tiers because which one to populate depends on the
|
|
231
|
+
* host: an automation-SA session (the dashboard) can only be fixed by env or
|
|
232
|
+
* cloud.json, while a human gcloud session resolves tier 3 on its own and never
|
|
233
|
+
* reaches this refusal. Returns null when the Add is fine (so the call site is a
|
|
234
|
+
* bare `if (msg) throw`), else the two-line recovery pair. Pure — the resolve
|
|
235
|
+
* happens at the call site and only its COUNT is passed in. */
|
|
236
|
+
function unauthorizableAddRefusal({ lbMode, vmNumber, intendedCount } = {}) {
|
|
237
|
+
if (!lbMode || vmNumber == null || intendedCount !== 0) return null;
|
|
238
|
+
return (
|
|
239
|
+
`BLOCKED: this Add would create an IAP-gated VM (VM-${vmNumber}) that authorizes NOBODY.\n`
|
|
240
|
+
+ ` ${DEFAULT_OPERATOR_ENV} is unset, .codeyam/cloud.json has no \`vmAccessDefault\`,\n`
|
|
241
|
+
+ ' and the active gcloud identity is a service account (skipped by design).\n'
|
|
242
|
+
+ 'Next valid action: add `"vmAccessDefault": "user:you@example.com"` to\n'
|
|
243
|
+
+ ' .codeyam/cloud.json (durable, no env re-bake), or pass --vm-access.'
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Is this IAM principal a service account — i.e. authorizes no HUMAN? Matches
|
|
248
|
+
* both the typed form gcloud returns on a policy read (`serviceAccount:x@…`) and
|
|
249
|
+
* a bare `*.gserviceaccount.com` address, so the read-back below cannot be
|
|
250
|
+
* satisfied by the automation SA that `iap-access.sh` unions into every grant.
|
|
251
|
+
* Read-path semantics, like parseProjectMembers: it inspects whatever IAM
|
|
252
|
+
* already holds rather than validating a write, so it never throws. */
|
|
253
|
+
function isServiceAccountPrincipal(principal) {
|
|
254
|
+
const p = String(principal == null ? '' : principal).trim().toLowerCase();
|
|
255
|
+
if (p === '') return false;
|
|
256
|
+
return p.startsWith('serviceaccount:') || /gserviceaccount\.com$/.test(p);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Does a finished job warrant the post-add IAP read-back at all? Only a SUCCESSFUL
|
|
260
|
+
* `add` in LB mode has an IAP-gated public URL whose reachability is worth
|
|
261
|
+
* proving — a failed job, another action, or legacy quick-tunnel mode each have
|
|
262
|
+
* nothing to read back, and flagging them would bury a real failure under a wrong
|
|
263
|
+
* one. Split out so the caller can skip the (gcloud) policy read entirely on the
|
|
264
|
+
* cheap gates, and so that decision is stated in exactly one place instead of
|
|
265
|
+
* being duplicated at the call site and inside the verdict below.
|
|
266
|
+
*
|
|
267
|
+
* `wiringInFlight` is the fourth gate and the one that fixes the 2026-08-11 race:
|
|
268
|
+
* the IAP grant is the LAST step of the detached `wire-add-tail` that the add path
|
|
269
|
+
* spawns, and that tail runs for another 5–10 minutes after the Add settles at
|
|
270
|
+
* container-healthy. A read-back fired while the tail is alive is therefore racing
|
|
271
|
+
* the very step it checks for — it is GUARANTEED to observe zero accessor bindings
|
|
272
|
+
* on a perfectly healthy VM, which is exactly how healthy VM-4 and VM-7 were failed
|
|
273
|
+
* with "authorizes no human". A live tail means the grant provably has not run yet,
|
|
274
|
+
* so there is nothing to read back. Defaults false, so an omitted fact behaves
|
|
275
|
+
* exactly as before this input existed. */
|
|
276
|
+
function shouldReadBackIapAccess({ ok, action, lbMode, wiringInFlight } = {}) {
|
|
277
|
+
return !!ok && action === 'add' && !!lbMode && !wiringInFlight;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Is `job` still the job a post-add read-back was started for? The policy read is
|
|
281
|
+
* async, so a newer job can replace it mid-flight; downgrading THAT job would
|
|
282
|
+
* report a stale verdict against work it never describes. */
|
|
283
|
+
function readbackAppliesToJob(job) {
|
|
284
|
+
return !!job && job.action === 'add' && job.state === 'done';
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Post-add read-back verdict: after a successful LB-mode Add, does VM-N's LIVE
|
|
288
|
+
* IAP policy authorize at least one non-service-account principal? This is the
|
|
289
|
+
* only thing that PROVES the end state is reachable — every other signal in the
|
|
290
|
+
* add path reports intent (what we meant to grant), and on 2026-08-09 intent and
|
|
291
|
+
* outcome disagreed: the job reported `done` while the URL authorized nobody.
|
|
292
|
+
*
|
|
293
|
+
* `iapMembers` is the union of accessor members across surfaces
|
|
294
|
+
* (`aggregateIapMembers(...).all`); `null` means a surface's read ERRORED, the
|
|
295
|
+
* same absence-of-evidence posture aggregateIapMembers itself takes.
|
|
296
|
+
*
|
|
297
|
+
* Reported as a discriminated outcome, so a caller can tell the four cases apart
|
|
298
|
+
* instead of collapsing three of them onto `null`:
|
|
299
|
+
*
|
|
300
|
+
* 'skipped' the check does not apply (not an ok LB-mode add, or the add's
|
|
301
|
+
* detached wiring is still in flight). Nothing to do.
|
|
302
|
+
* 'undetermined' a surface's policy read ERRORED (`iapMembers` is not an array).
|
|
303
|
+
* This is absence of evidence, NOT a pass: the caller must
|
|
304
|
+
* re-arm and re-check rather than concluding. Collapsing it onto
|
|
305
|
+
* "authorized" is how VM-5 settled `done` on 2026-08-11 with its
|
|
306
|
+
* access never actually proven — a flaky gcloud call under load
|
|
307
|
+
* silently skipped the only check that proves reachability.
|
|
308
|
+
* 'authorized' at least one non-service-account principal holds an accessor
|
|
309
|
+
* binding. The end state is provably reachable by a human.
|
|
310
|
+
* 'unauthorized' the policy was read and authorizes no human. `reason` carries
|
|
311
|
+
* the BLOCKED / `Next valid action:` remedy.
|
|
312
|
+
*
|
|
313
|
+
* `reason` is non-null only for 'unauthorized'. Pure — the gcloud read is the
|
|
314
|
+
* caller's. */
|
|
315
|
+
function iapReadbackOutcome({ ok, action, lbMode, wiringInFlight, iapMembers, vmNumber } = {}) {
|
|
316
|
+
if (!shouldReadBackIapAccess({ ok, action, lbMode, wiringInFlight })) {
|
|
317
|
+
return { outcome: 'skipped', reason: null };
|
|
318
|
+
}
|
|
319
|
+
if (!Array.isArray(iapMembers)) return { outcome: 'undetermined', reason: null };
|
|
320
|
+
if (iapMembers.some((p) => !isServiceAccountPrincipal(p))) return { outcome: 'authorized', reason: null };
|
|
321
|
+
const held = iapMembers.length > 0 ? iapMembers.join(', ') : '(none)';
|
|
322
|
+
return {
|
|
323
|
+
outcome: 'unauthorized',
|
|
324
|
+
reason: (
|
|
325
|
+
`BLOCKED: VM-${vmNumber} is up but its IAP policy authorizes no human — the public URL\n`
|
|
326
|
+
+ ` returns "Error code 51" for everyone. Accessor bindings held: ${held}.\n`
|
|
327
|
+
+ `Next valid action: configure a default operator (${DEFAULT_OPERATOR_ENV} or\n`
|
|
328
|
+
+ ' .codeyam/cloud.json `vmAccessDefault`), then run\n'
|
|
329
|
+
+ ` VM_ACCESS=user:you@example.com bash scripts/gcp/iap-access.sh ${vmNumber} grant`
|
|
330
|
+
),
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Timing policy for the DEFERRED post-add read-back: on this attempt, is there
|
|
335
|
+
* anything worth reading yet? Separate from shouldReadBackIapAccess, which asks
|
|
336
|
+
* whether the check applies AT ALL — this asks whether now is the moment.
|
|
337
|
+
*
|
|
338
|
+
* wiringInFlight the add's detached wiring tail is still running, and the IAP
|
|
339
|
+
* grant is its LAST step. Reading now can only observe the
|
|
340
|
+
* grant not having happened yet, so wait.
|
|
341
|
+
* wiringFailed the tail dropped its failed marker. A blown deadline is
|
|
342
|
+
* EVIDENCE, not a reason to keep waiting: read now and let a
|
|
343
|
+
* genuinely unauthorized VM escalate instead of waiting forever.
|
|
344
|
+
* budgetSpent the caller's total wait has elapsed. Read regardless — a
|
|
345
|
+
* crashed tail that never unlinked its marker must not defer the
|
|
346
|
+
* check indefinitely, and the classifier still renders the card
|
|
347
|
+
* calm while the wiring reads live.
|
|
348
|
+
*
|
|
349
|
+
* Returns 'defer' (re-arm and ask again later) or 'read' (do the policy read). */
|
|
350
|
+
function iapReadbackDeferral({ wiringInFlight, wiringFailed, budgetSpent } = {}) {
|
|
351
|
+
if (budgetSpent || wiringFailed) return 'read';
|
|
352
|
+
return wiringInFlight ? 'defer' : 'read';
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** What should the caller DO with an iapReadbackOutcome? Pure so the one rule that
|
|
356
|
+
* cost us a silently-skipped check is stated and tested in one place:
|
|
357
|
+
*
|
|
358
|
+
* An 'undetermined' read (a gcloud call that errored under load) must DEFER, never
|
|
359
|
+
* settle. Treating it as a pass is how VM-5 reached `done` on 2026-08-11 with its
|
|
360
|
+
* reachability never actually proven — the check that exists to catch an
|
|
361
|
+
* unreachable VM was skipped entirely by one flaky call. It settles only once the
|
|
362
|
+
* wait budget is spent, and even then it settles rather than failing: absence of
|
|
363
|
+
* evidence must not manufacture a failed Add either.
|
|
364
|
+
*
|
|
365
|
+
* Returns 'fail' (downgrade the job with the reason), 'defer' (re-arm and
|
|
366
|
+
* re-check), or 'settle' (leave the job alone). */
|
|
367
|
+
function iapReadbackNextAction({ outcome, budgetSpent } = {}) {
|
|
368
|
+
if (outcome === 'unauthorized') return 'fail';
|
|
369
|
+
if (outcome === 'undetermined') return budgetSpent ? 'settle' : 'defer';
|
|
370
|
+
return 'settle';
|
|
371
|
+
}
|
|
372
|
+
|
|
218
373
|
/** Union the default-operator principal(s) into an access list, deduped and
|
|
219
374
|
* order-preserving (explicit collaborators first, the operator appended if not
|
|
220
375
|
* already present). Both inputs are normalized. The result is what gets GRANTED;
|
|
@@ -391,6 +546,13 @@ module.exports = {
|
|
|
391
546
|
resolveDefaultOperator,
|
|
392
547
|
defaultCloudConfigRead,
|
|
393
548
|
noDefaultOperatorDegradedReasons,
|
|
549
|
+
unauthorizableAddRefusal,
|
|
550
|
+
isServiceAccountPrincipal,
|
|
551
|
+
shouldReadBackIapAccess,
|
|
552
|
+
readbackAppliesToJob,
|
|
553
|
+
iapReadbackOutcome,
|
|
554
|
+
iapReadbackDeferral,
|
|
555
|
+
iapReadbackNextAction,
|
|
394
556
|
withDefaultOperator,
|
|
395
557
|
parseProjectMembers,
|
|
396
558
|
reconcileVmAccess,
|