@codeyam-editor/codeyam-editor 0.1.8 → 0.1.10
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 +377 -21
- package/npm/editor-dev.js +24 -2
- package/npm/editor.js +15 -5
- package/npm/fleet-add-failure.js +355 -21
- package/npm/fleet-auth-account.js +61 -0
- package/npm/fleet-auth.js +69 -1
- package/npm/fleet-branch.js +124 -1
- package/npm/fleet-build-data.js +12 -1
- package/npm/fleet-cleanup.js +84 -3
- package/npm/fleet-datastore-health.js +107 -0
- package/npm/fleet-public-url.js +81 -12
- package/npm/fleet-reconcile.js +46 -11
- package/npm/fleet-revision.js +108 -0
- package/npm/fleet-run-setup.js +66 -0
- package/npm/fleet-session-activity.js +162 -0
- package/npm/fleet-vm-readiness.js +57 -16
- package/npm/group-bounded-spawn.js +111 -0
- package/npm/launch-config.js +216 -23
- package/npm/orphan-ssh-reaper.js +81 -0
- package/npm/scenario-check.js +697 -34
- package/npm/scenario-interactivity.js +477 -23
- package/npm/scenario-issues.js +61 -0
- package/npm/scenario-playwright.js +174 -20
- package/npm/utils.js +262 -22
- package/package.json +5 -5
- package/ui/dist/assets/{ContainerInspector-DSVZvEkB.js → ContainerInspector-DiN91foX.js} +1 -1
- package/ui/dist/assets/EditorShell-BEIdHnHd.css +1 -0
- package/ui/dist/assets/EditorShell-CPV5vhjo.js +330 -0
- package/ui/dist/assets/ScenarioDataPanel-DHAHyraq.js +18 -0
- package/ui/dist/assets/{ScenarioDataPanelFullPage-Cyk6efiI.js → ScenarioDataPanelFullPage-CHIWip3n.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-D9a91iwS.js +21 -0
- package/ui/dist/assets/index--cE0hUJw.css +1 -0
- package/ui/dist/assets/{index-BPLKgwon.js → index-sW8wcg7v.js} +9 -9
- package/ui/dist/index.html +2 -2
- package/ui/dist/preview-subdir-demo/index.html +62 -0
- package/ui/dist/preview-subdir-demo/sibling.html +48 -0
- package/ui/dist/assets/EditorShell-DnXdHrar.js +0 -256
- package/ui/dist/assets/EditorShell-FvbXyqqD.css +0 -1
- package/ui/dist/assets/ScenarioDataPanel-6qJE-0lA.js +0 -15
- package/ui/dist/assets/ScenarioRenderer-Uyt54vHI.js +0 -21
- package/ui/dist/assets/index-DKpgJLo3.css +0 -1
package/npm/fleet-reconcile.js
CHANGED
|
@@ -59,16 +59,33 @@ function strandedAdoptDecision({ stranded, streaks, threshold } = {}) {
|
|
|
59
59
|
return { streaks: next, ready };
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
// Each entry maps a degraded-reason shape to the live witness that disconfirms
|
|
63
|
+
// it. Extend the table as new degraded reasons gain a corresponding signal.
|
|
64
|
+
//
|
|
65
|
+
// TWO SHAPES PER CLASS, ON PURPOSE. The dashboard writes its auth reason under
|
|
66
|
+
// the structured `claude-auth-not-seeded:` prefix (server.js, and the prefix
|
|
67
|
+
// two other call sites key on), but the original free-text pattern needs a
|
|
68
|
+
// SPACE — `/claude auth/` never matches the hyphenated prefix. Such reasons
|
|
69
|
+
// cleared only because the canned message happens to contain the word "login";
|
|
70
|
+
// phrased any other way they were retained forever, which is the reported bug
|
|
71
|
+
// surviving in the one form the reconcile was aimed at. Matching the prefix
|
|
72
|
+
// only would be the opposite mistake: reasons like `claude auth copy failed on
|
|
73
|
+
// VM-2 (…)` are already recorded in live snapshots and carry no prefix at all,
|
|
74
|
+
// so both shapes have to match.
|
|
75
|
+
const REASON_WITNESSES = [
|
|
76
|
+
{ match: /^\s*claude-auth-not-seeded\b/i, witness: 'authed' },
|
|
77
|
+
{ match: /claude auth|claude\.json|login/i, witness: 'authed' },
|
|
78
|
+
{ match: /container|never came up|provisioning failed/i, witness: 'reachableServing' },
|
|
79
|
+
];
|
|
80
|
+
|
|
62
81
|
// Map a single degraded reason to the live health signal that resolves it.
|
|
63
82
|
// Returns true only when that signal now passes; a reason with no mapped signal
|
|
64
83
|
// returns false (retained — a genuine config problem, never a false-green).
|
|
65
|
-
// Start with the two observed classes (improve41); extend the table as new
|
|
66
|
-
// degraded reasons gain a corresponding live signal.
|
|
67
84
|
function reasonClearedByHealth(reason, { reachableServing, authed }) {
|
|
68
85
|
const r = String(reason || '');
|
|
69
|
-
|
|
70
|
-
if (
|
|
71
|
-
return
|
|
86
|
+
const entry = REASON_WITNESSES.find((e) => e.match.test(r));
|
|
87
|
+
if (!entry) return false;
|
|
88
|
+
return entry.witness === 'authed' ? !!authed : !!reachableServing;
|
|
72
89
|
}
|
|
73
90
|
|
|
74
91
|
// Given a terminal add-job badge and the VM's live health, return the corrected
|
|
@@ -79,7 +96,14 @@ function reasonClearedByHealth(reason, { reachableServing, authed }) {
|
|
|
79
96
|
// facts (I/O at the edge) and applies the result; the verdict itself is pure.
|
|
80
97
|
// job: { state: 'done'|'error', degraded, degradedReasons }
|
|
81
98
|
// health: { reachable, editorHttpOk, providerAuthState }
|
|
82
|
-
// → null | { state, degraded, degradedReasons, transition }
|
|
99
|
+
// → null | { state, degraded, degradedReasons, retiredReasons, transition }
|
|
100
|
+
//
|
|
101
|
+
// `retiredReasons` names the reasons this verdict just dropped. Without it a
|
|
102
|
+
// retired reason simply vanishes from the card, so an operator who saw amber
|
|
103
|
+
// never learns it resolved — the shell renders these as the same muted
|
|
104
|
+
// "recovered (was: …)" trace the reconciled error badge already leaves. What
|
|
105
|
+
// that trace should SAY is the shell's call; naming what was retired is this
|
|
106
|
+
// module's.
|
|
83
107
|
function reconcileTerminalAddBadge({ job, health } = {}) {
|
|
84
108
|
if (!job || !health) return null;
|
|
85
109
|
const reachableServing = !!(health.reachable && health.editorHttpOk);
|
|
@@ -91,7 +115,13 @@ function reconcileTerminalAddBadge({ job, health } = {}) {
|
|
|
91
115
|
if (job.state === 'error') {
|
|
92
116
|
const reasons = job.degradedReasons || [];
|
|
93
117
|
if (reasons.length === 0 && reachableServing) {
|
|
94
|
-
return {
|
|
118
|
+
return {
|
|
119
|
+
state: 'done',
|
|
120
|
+
degraded: false,
|
|
121
|
+
degradedReasons: [],
|
|
122
|
+
retiredReasons: [],
|
|
123
|
+
transition: 'error→done (editor 200)',
|
|
124
|
+
};
|
|
95
125
|
}
|
|
96
126
|
return null;
|
|
97
127
|
}
|
|
@@ -100,15 +130,20 @@ function reconcileTerminalAddBadge({ job, health } = {}) {
|
|
|
100
130
|
// passes; retain the rest. Drop `degraded` only when no reasons remain.
|
|
101
131
|
if (job.state === 'done' && job.degraded) {
|
|
102
132
|
const reasons = job.degradedReasons || [];
|
|
103
|
-
const remaining =
|
|
104
|
-
|
|
105
|
-
const
|
|
133
|
+
const remaining = [];
|
|
134
|
+
const retired = [];
|
|
135
|
+
for (const reason of reasons) {
|
|
136
|
+
const bucket = reasonClearedByHealth(reason, { reachableServing, authed }) ? retired : remaining;
|
|
137
|
+
bucket.push(reason);
|
|
138
|
+
}
|
|
139
|
+
if (retired.length === 0) return null; // nothing cleared → no-op
|
|
106
140
|
return {
|
|
107
141
|
state: 'done',
|
|
108
142
|
degraded: remaining.length > 0,
|
|
109
143
|
degradedReasons: remaining,
|
|
144
|
+
retiredReasons: retired,
|
|
110
145
|
transition: remaining.length > 0
|
|
111
|
-
? `degraded: cleared ${
|
|
146
|
+
? `degraded: cleared ${retired.length} reason(s), ${remaining.length} remain`
|
|
112
147
|
: 'degraded→clear (health restored)',
|
|
113
148
|
};
|
|
114
149
|
}
|
package/npm/fleet-revision.js
CHANGED
|
@@ -97,8 +97,116 @@ function projectDashboardRevision(memo = {}) {
|
|
|
97
97
|
return { ...memo, level: classifyDashboardStaleness(memo) };
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/** Is the CLI BINARY the dashboard shells out to as current as the checkout?
|
|
101
|
+
*
|
|
102
|
+
* The sibling question to `classifyDashboardStaleness`, and until 2026-08-21
|
|
103
|
+
* nobody was asking it. The operator host's checkout self-updates hourly while
|
|
104
|
+
* the `codeyam-editor` binary beside it never did, so the control plane drifted
|
|
105
|
+
* a little further behind every hour until a CLI-visible schema change broke it
|
|
106
|
+
* outright: an `editor-shipped` glossarySkip reason merged at 05:12 could not be
|
|
107
|
+
* parsed by a binary built 2026-07-19, and every CLI invocation the dashboard
|
|
108
|
+
* made died at config load.
|
|
109
|
+
*
|
|
110
|
+
* Why this needs its own signal rather than riding on `behind`: NOTHING that
|
|
111
|
+
* normally means "down" was down. `dashboard-bes` stayed HEALTHY, `/healthz`
|
|
112
|
+
* answered in 0.8 ms, all 14 VMs read reachable, and the checkout itself was at
|
|
113
|
+
* tip — so a commits-behind chip reading 0 was correct AND useless. Two
|
|
114
|
+
* artifacts deploy on this host and only one of them was ever measured.
|
|
115
|
+
*
|
|
116
|
+
* Compares git SHAs, not versions: the published artifact stamps the source
|
|
117
|
+
* commit it was built from (`org.opencontainers.image.revision`), and a semver
|
|
118
|
+
* moves far more slowly than the schema does — `0.1.7` was both the working and
|
|
119
|
+
* the broken binary.
|
|
120
|
+
*
|
|
121
|
+
* Levels, deliberately the same four words `classifyDashboardStaleness` uses so
|
|
122
|
+
* the two chips read as one family:
|
|
123
|
+
* 'stale' — both SHAs known and different. The CLI predates the checkout;
|
|
124
|
+
* a schema change away from empty panels.
|
|
125
|
+
* 'current' — both known and equal.
|
|
126
|
+
* 'unknown' — cannot compare AND something failed. Includes the incident's
|
|
127
|
+
* own worst case: a binary too stale to answer what it is fails
|
|
128
|
+
* the very read that would have proven it stale, so "I could not
|
|
129
|
+
* tell" must never round down to 'current'.
|
|
130
|
+
* 'unchecked' — cannot compare and nothing failed (the boot window before the
|
|
131
|
+
* first slow-cycle read). Renders nothing.
|
|
132
|
+
*
|
|
133
|
+
* Pure; no I/O. */
|
|
134
|
+
function classifyBinaryStaleness({ installed, tip, error } = {}) {
|
|
135
|
+
const sha = (v) => ((typeof v === 'string' && v.trim()) ? v.trim() : null);
|
|
136
|
+
const a = sha(installed);
|
|
137
|
+
const b = sha(tip);
|
|
138
|
+
if (a === null || b === null) return error ? 'unknown' : 'unchecked';
|
|
139
|
+
return a === b ? 'current' : 'stale';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The `/data` view of the binary memo, with the verdict resolved server-side.
|
|
143
|
+
*
|
|
144
|
+
* Same split, for the same reason, as `projectDashboardRevision`: `server.js`
|
|
145
|
+
* has top-level side effects on require, so a projection defined there can
|
|
146
|
+
* never be executed by a test and ships its bugs green.
|
|
147
|
+
*
|
|
148
|
+
* Pure; no I/O. */
|
|
149
|
+
function projectBinaryRevision(memo = {}) {
|
|
150
|
+
return { ...memo, level: classifyBinaryStaleness(memo) };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Why did the CLI shell-out fail — and is it the nameable one?
|
|
154
|
+
*
|
|
155
|
+
* The 2026-08-21 outage surfaced ONLY as a truncated `queueFreshness.error`
|
|
156
|
+
* reading `" Fix the file and re-run."` — two lines of tail from a multi-line
|
|
157
|
+
* diagnostic, with the sentence naming the cause among the lines that got cut.
|
|
158
|
+
* An operator looking at empty Queue and Plans panels had to infer that the CLI
|
|
159
|
+
* was broken at all, and nothing on the page said so.
|
|
160
|
+
*
|
|
161
|
+
* So classify the FULL stderr before it is tailed. Two phrasings both mean
|
|
162
|
+
* "this binary cannot read this checkout's config", and BOTH must match:
|
|
163
|
+
* the current wording, and the pre-2026-08-19 `Fix the file and re-run` — which
|
|
164
|
+
* is not legacy trivia but the single most likely text to see here, because the
|
|
165
|
+
* binary emitting it is by definition an old one.
|
|
166
|
+
*
|
|
167
|
+
* Levels:
|
|
168
|
+
* 'ok' — no error.
|
|
169
|
+
* 'config' — the binary cannot read `.codeyam/editor.json`. Almost always a
|
|
170
|
+
* CLI older than the checkout; the file is usually correct and
|
|
171
|
+
* editing it is the wrong move, which is why the recovery text
|
|
172
|
+
* says so out loud.
|
|
173
|
+
* 'failing' — the CLI failed for some other reason (git credentials, a
|
|
174
|
+
* timeout). Still worth showing, still not this condition.
|
|
175
|
+
*
|
|
176
|
+
* Pure; no I/O. */
|
|
177
|
+
function classifyCliFailure(text) {
|
|
178
|
+
const s = (typeof text === 'string') ? text : '';
|
|
179
|
+
if (!s.trim()) return 'ok';
|
|
180
|
+
if (/cannot read [^\n]*editor\.json/i.test(s) || /Fix the file and re-run/i.test(s)) return 'config';
|
|
181
|
+
return 'failing';
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The `/data` view of CLI health, with the verdict and its recovery resolved
|
|
185
|
+
* server-side — so the panel renders a named condition instead of leaving the
|
|
186
|
+
* operator to infer one from a blank list.
|
|
187
|
+
*
|
|
188
|
+
* `recovery` is only populated for 'config', where the right action is
|
|
189
|
+
* genuinely non-obvious and the intuitive one (edit the file) is wrong.
|
|
190
|
+
*
|
|
191
|
+
* Pure; no I/O. */
|
|
192
|
+
function projectCliHealth({ error } = {}) {
|
|
193
|
+
const level = classifyCliFailure(error);
|
|
194
|
+
return {
|
|
195
|
+
level,
|
|
196
|
+
broken: level !== 'ok',
|
|
197
|
+
error: (typeof error === 'string' && error.trim()) ? error.trim() : null,
|
|
198
|
+
recovery: level === 'config'
|
|
199
|
+
? 'The codeyam-editor binary is older than the checkout it is reading — do NOT edit .codeyam/editor.json. Refresh the binary: scripts/operator/refresh-operator-repo.sh'
|
|
200
|
+
: null,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
100
204
|
module.exports = {
|
|
101
205
|
mergeDashboardRevision,
|
|
102
206
|
classifyDashboardStaleness,
|
|
103
207
|
projectDashboardRevision,
|
|
208
|
+
classifyBinaryStaleness,
|
|
209
|
+
projectBinaryRevision,
|
|
210
|
+
classifyCliFailure,
|
|
211
|
+
projectCliHealth,
|
|
104
212
|
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Pure builder for the fleet dashboard's in-container `npm run setup` snippet
|
|
3
|
+
// (scripts/operator/fleet-dashboard/server.js requires this). String
|
|
4
|
+
// construction only — no I/O, no child processes — so the snippet's CONTENT is
|
|
5
|
+
// unit-testable in isolation (npm/fleet-run-setup.test.js), exactly like
|
|
6
|
+
// fleet-env.js. server.js itself has no unit-test harness, which is why this
|
|
7
|
+
// lives here rather than inline in actionRunSetup.
|
|
8
|
+
//
|
|
9
|
+
// Named for the run-setup ACTION, not "setup" generally: npm/fleet-setup-steps.js
|
|
10
|
+
// already exists and is a different concern (it is bound as `fsetup` in
|
|
11
|
+
// server.js, so a `fleet-setup.js` here collided on both the filename and the
|
|
12
|
+
// import identifier).
|
|
13
|
+
|
|
14
|
+
// Seconds before a prompting or hanging setup is killed. Matches the
|
|
15
|
+
// bootstrap's SETUP_TIMEOUT default so both paths time-box identically; the
|
|
16
|
+
// dashboard job budget (11 min) is deliberately larger, to cover this plus ssh.
|
|
17
|
+
const SETUP_TIMEOUT_SECS = 600;
|
|
18
|
+
|
|
19
|
+
// How many trailing lines of setup's own output ride on the CY_DEGRADED line.
|
|
20
|
+
// The marker is parsed line-by-line by the job-output reader, so these are
|
|
21
|
+
// flattened onto one line — keep it small enough to stay readable on a card.
|
|
22
|
+
const FAILURE_TAIL_LINES = 3;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build the bash snippet that runs the project's `npm run setup` inside VM-n's
|
|
26
|
+
* container and reports what actually broke.
|
|
27
|
+
*
|
|
28
|
+
* The failure line carries the setup command's OWN trailing output. It used to
|
|
29
|
+
* hardcode "set env (e.g. DATABASE_URL) via Env, then retry Run setup" — a
|
|
30
|
+
* guess, and on VM-11 (2026-08-26) a wrong one: DATABASE_URL was present in
|
|
31
|
+
* /workspace/.env the whole time and the real failure was that Postgres was not
|
|
32
|
+
* installed. The failing script had already printed a precise, actionable
|
|
33
|
+
* message and the wrapper talked over it, sending the operator to configure env
|
|
34
|
+
* that was already correct. Report what broke; do not theorize about it.
|
|
35
|
+
*
|
|
36
|
+
* Exits 0 even on failure, emitting a `CY_DEGRADED` marker line instead — so
|
|
37
|
+
* the cause persists on the VM card rather than vanishing as a transient error.
|
|
38
|
+
*
|
|
39
|
+
* @param {number|string} n VM number, used only to name the VM in the message.
|
|
40
|
+
* @returns {string} bash snippet suitable for `inContainer(n, …)`.
|
|
41
|
+
*/
|
|
42
|
+
function buildRunSetupScript(n) {
|
|
43
|
+
return [
|
|
44
|
+
'cd /workspace || { echo "no /workspace in container"; exit 1; }',
|
|
45
|
+
`setup="$(node -p "(require('./package.json').scripts||{}).setup||''" 2>/dev/null || true)"`,
|
|
46
|
+
'if [ -z "$setup" ]; then echo "no \'setup\' script in package.json — nothing to run"; exit 0; fi',
|
|
47
|
+
`echo "running 'npm run setup' (script: $setup; non-interactive, ${SETUP_TIMEOUT_SECS}s timeout)"`,
|
|
48
|
+
// tee so the full output still streams into the job log, while the tail is
|
|
49
|
+
// retained for the CY_DEGRADED line. ${PIPESTATUS[0]} is setup's exit code,
|
|
50
|
+
// not tee's — without it a failing setup would report success.
|
|
51
|
+
'log="$(mktemp)"',
|
|
52
|
+
`timeout ${SETUP_TIMEOUT_SECS} npm run setup </dev/null 2>&1 | tee "$log"; rc=\${PIPESTATUS[0]}`,
|
|
53
|
+
'if [ "$rc" -ne 0 ]; then',
|
|
54
|
+
` why="$(grep -v '^[[:space:]]*$' "$log" | tail -n ${FAILURE_TAIL_LINES} | tr '\\n' '|' | sed 's/|$//; s/|/ | /g')"`,
|
|
55
|
+
` [ -n "$why" ] || why="(no output — likely killed by the ${SETUP_TIMEOUT_SECS}s timeout)"`,
|
|
56
|
+
` echo "CY_DEGRADED 'npm run setup' failed (exit $rc) on VM-${n}, in the container as root: $why"`,
|
|
57
|
+
'fi',
|
|
58
|
+
'rm -f "$log"',
|
|
59
|
+
].join('\n');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = {
|
|
63
|
+
buildRunSetupScript,
|
|
64
|
+
SETUP_TIMEOUT_SECS,
|
|
65
|
+
FAILURE_TAIL_LINES,
|
|
66
|
+
};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Pure session-activity classification and stickiness for the Fleet Dashboard
|
|
3
|
+
// (scripts/operator/fleet-dashboard/server.js requires this). Given the raw
|
|
4
|
+
// per-poll `hasSession` / `sessionIdleSeconds` the editor's /api/session-info
|
|
5
|
+
// reports, decide what the card renders and what the maintenance gate reads. No
|
|
6
|
+
// I/O — server.js does the polling and threads the state.
|
|
7
|
+
//
|
|
8
|
+
// The problem this fixes (observed live 2026-08-30, VMs 1 and 9): `hasSession`
|
|
9
|
+
// is a bare boolean with no notion of activity age, so a session wedged two days
|
|
10
|
+
// ago is indistinguishable from one someone is actively driving. Both hold a
|
|
11
|
+
// live agent process, so both report `true`, and every refresh was refused with
|
|
12
|
+
// the same `BLOCKED: VM-N has a live session`. The only way through was
|
|
13
|
+
// `--force` — the same flag used to step over genuinely live work — which is how
|
|
14
|
+
// `--force` stops meaning anything. `hasSession` also flapped `false` → `true`
|
|
15
|
+
// between polls minutes apart, so even an operator watching the card could not
|
|
16
|
+
// tell the two apart by eye.
|
|
17
|
+
//
|
|
18
|
+
// Two independent fixes live here, deliberately separate:
|
|
19
|
+
//
|
|
20
|
+
// debounceHasSession — stops the flap. A value that alternates between
|
|
21
|
+
// adjacent polls is not a usable gate input at any
|
|
22
|
+
// age, so this is fixed first and on its own.
|
|
23
|
+
// classifySessionActivity — adds the age dimension the boolean lacks.
|
|
24
|
+
//
|
|
25
|
+
// Asymmetric by design, mirroring npm/fleet-broker-debounce.js: only the flip to
|
|
26
|
+
// `false` (no session) is debounced. `true` takes effect immediately, because
|
|
27
|
+
// the dangerous error is proceeding over live work — we never delay RAISING the
|
|
28
|
+
// block, only delay clearing it. An unreachable editor (`hasSession` neither
|
|
29
|
+
// true nor false) proves nothing and breaks a pending clear-streak rather than
|
|
30
|
+
// advancing it, the same "absence of evidence is never down" rule
|
|
31
|
+
// fleet-broker-status.js follows.
|
|
32
|
+
//
|
|
33
|
+
// State shape carried across polls: { hasSession, clearStreak, lastConfirmed }.
|
|
34
|
+
// hasSession — the debounced value the card and the gate read this poll.
|
|
35
|
+
// clearStreak — how many consecutive raw `false` polls we have seen.
|
|
36
|
+
// lastConfirmed — the last raw boolean we actually proved; held during a
|
|
37
|
+
// pending (sub-threshold) clear-streak so the gate keeps
|
|
38
|
+
// blocking through a blip.
|
|
39
|
+
|
|
40
|
+
// ~9s at the dashboard's 3s poll cadence, matching DOWN_DEBOUNCE_THRESHOLD in
|
|
41
|
+
// npm/fleet-broker-debounce.js. Named so retuning is a one-line edit.
|
|
42
|
+
const CLEAR_DEBOUNCE_THRESHOLD = 3;
|
|
43
|
+
|
|
44
|
+
// A full day of zero on-disk activity. Chosen to sit well clear of the longest
|
|
45
|
+
// legitimate idle a live session has: an agent parked at a confirmation gate
|
|
46
|
+
// overnight is waiting for its operator, not wedged, and forcing past it costs
|
|
47
|
+
// them their place. The observed wedge was two days cold, so this catches it
|
|
48
|
+
// with a wide margin rather than racing it.
|
|
49
|
+
const STALE_SESSION_IDLE_SECONDS = 24 * 60 * 60;
|
|
50
|
+
|
|
51
|
+
function debounceHasSession(prev, rawHasSession, opts) {
|
|
52
|
+
const threshold = (opts && opts.threshold) || CLEAR_DEBOUNCE_THRESHOLD;
|
|
53
|
+
const lastConfirmed = prev ? prev.lastConfirmed : undefined;
|
|
54
|
+
|
|
55
|
+
// Confirmed present: a session is live right now. Render immediately, reset
|
|
56
|
+
// the streak, and remember it as the last confirmed value.
|
|
57
|
+
if (rawHasSession === true) {
|
|
58
|
+
return { hasSession: true, clearStreak: 0, lastConfirmed: true };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Neither true nor false — the editor was unreachable or answered no
|
|
62
|
+
// session-info at all, so we proved nothing. Never let that advance the
|
|
63
|
+
// clear-streak toward "no session"; hold what we last proved.
|
|
64
|
+
if (rawHasSession !== false) {
|
|
65
|
+
return {
|
|
66
|
+
hasSession: lastConfirmed === true,
|
|
67
|
+
clearStreak: 0,
|
|
68
|
+
lastConfirmed,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Raw `false`: only believed once it persists. Count the streak.
|
|
73
|
+
const clearStreak = (prev ? prev.clearStreak : 0) + 1;
|
|
74
|
+
if (clearStreak >= threshold) {
|
|
75
|
+
return { hasSession: false, clearStreak, lastConfirmed: false };
|
|
76
|
+
}
|
|
77
|
+
// Pending (sub-threshold) clear: hold the last confirmed value, so a single
|
|
78
|
+
// dropped poll cannot open a window for maintenance to step over live work.
|
|
79
|
+
return {
|
|
80
|
+
hasSession: lastConfirmed === true,
|
|
81
|
+
clearStreak,
|
|
82
|
+
lastConfirmed,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Classify a VM's session into the verdict the card renders and the maintenance
|
|
87
|
+
// gate branches on:
|
|
88
|
+
//
|
|
89
|
+
// 'none' — no session at all. Maintenance proceeds; nothing to step over.
|
|
90
|
+
// 'active' — a session with recent activity. Blocks exactly as it does
|
|
91
|
+
// today; only `--force` steps over it.
|
|
92
|
+
// 'stale' — a session idle beyond the threshold. Still blocks, but is
|
|
93
|
+
// eligible for the narrower stale-session override.
|
|
94
|
+
// 'unknown' — a session whose age could not be determined, because the editor
|
|
95
|
+
// is too old to report `sessionIdleSeconds` or could read no
|
|
96
|
+
// evidence. Treated as POSSIBLY LIVE and blocks like 'active'.
|
|
97
|
+
//
|
|
98
|
+
// That last mapping is the load-bearing one. An unknown age must never resolve
|
|
99
|
+
// toward 'stale': the override exists to skip work nobody is doing, and handing
|
|
100
|
+
// it out on absent evidence would step over live sessions on exactly the VMs
|
|
101
|
+
// whose reporting is broken. `classify_vm`'s `reachable` tri-state in
|
|
102
|
+
// scripts/operator/refresh-client-vm-editor.sh is the cautionary precedent —
|
|
103
|
+
// collapsing its unknown to a definite verdict once refused all 13 VMs at once
|
|
104
|
+
// on evidence that had never been gathered.
|
|
105
|
+
function classifySessionActivity(input) {
|
|
106
|
+
const hasSession = !!(input && input.hasSession);
|
|
107
|
+
if (!hasSession) return 'none';
|
|
108
|
+
|
|
109
|
+
const idleSeconds = input.idleSeconds;
|
|
110
|
+
if (typeof idleSeconds !== 'number' || !Number.isFinite(idleSeconds)) {
|
|
111
|
+
return 'unknown';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const threshold =
|
|
115
|
+
typeof input.thresholdSeconds === 'number' && Number.isFinite(input.thresholdSeconds)
|
|
116
|
+
? input.thresholdSeconds
|
|
117
|
+
: STALE_SESSION_IDLE_SECONDS;
|
|
118
|
+
|
|
119
|
+
return idleSeconds >= threshold ? 'stale' : 'active';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Render an idle age as the short human string the card and the refusal message
|
|
123
|
+
// both show ("2d", "5h", "3m", "12s"). Returns 'unknown' when there is no age,
|
|
124
|
+
// so a caller never has to decide how to word an absent value — and so the
|
|
125
|
+
// operator sees the word rather than a blank that reads as zero.
|
|
126
|
+
function formatIdleAge(idleSeconds) {
|
|
127
|
+
if (typeof idleSeconds !== 'number' || !Number.isFinite(idleSeconds) || idleSeconds < 0) {
|
|
128
|
+
return 'unknown';
|
|
129
|
+
}
|
|
130
|
+
const s = Math.floor(idleSeconds);
|
|
131
|
+
if (s < 60) return `${s}s`;
|
|
132
|
+
if (s < 3600) return `${Math.floor(s / 60)}m`;
|
|
133
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h`;
|
|
134
|
+
return `${Math.floor(s / 86400)}d`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Does this VM read as WEDGED rather than merely busy? A card showing
|
|
138
|
+
// `status: building` with no feature named and the workflow still at step 1 is
|
|
139
|
+
// describing a session that never got started, and when its last on-disk
|
|
140
|
+
// activity is also days old there is nothing left that "busy" explains. This is
|
|
141
|
+
// the combination VMs 1 and 9 presented, and the card rendered it as work in
|
|
142
|
+
// progress.
|
|
143
|
+
//
|
|
144
|
+
// Deliberately narrower than `classifySessionActivity(...) === 'stale'`: a
|
|
145
|
+
// long-running step that is genuinely mid-feature is stale-but-working, and
|
|
146
|
+
// labelling it wedged would train operators to ignore the label.
|
|
147
|
+
function isWedgedShape(input) {
|
|
148
|
+
if (!input) return false;
|
|
149
|
+
if (classifySessionActivity(input) !== 'stale') return false;
|
|
150
|
+
if (input.status !== 'building') return false;
|
|
151
|
+
if (input.feature) return false;
|
|
152
|
+
return input.step === 1 || input.step === undefined || input.step === null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = {
|
|
156
|
+
debounceHasSession,
|
|
157
|
+
classifySessionActivity,
|
|
158
|
+
formatIdleAge,
|
|
159
|
+
isWedgedShape,
|
|
160
|
+
CLEAR_DEBOUNCE_THRESHOLD,
|
|
161
|
+
STALE_SESSION_IDLE_SECONDS,
|
|
162
|
+
};
|
|
@@ -71,6 +71,12 @@
|
|
|
71
71
|
// 'cli-missing' — the editor is up but the build-agent CLI isn't installed
|
|
72
72
|
// (`providerAuth.cliInstalled === false`): a provisioning
|
|
73
73
|
// fault, not a usable VM.
|
|
74
|
+
// 'cli-broken' — the CLI IS installed but will not execute
|
|
75
|
+
// (`providerAuth.cliRunnable === false`): a provisioning
|
|
76
|
+
// fault whose remedy is repair/reinstall, not a fresh
|
|
77
|
+
// install and not re-authentication. Distinct from
|
|
78
|
+
// 'cli-missing' because sending an operator to "install it"
|
|
79
|
+
// when it is already installed wastes the incident.
|
|
74
80
|
// 'auth-unsettled' — the auth probe is wedged at `unknown` (timing out — the
|
|
75
81
|
// exact VM5 auth shape): we can't even tell if login is
|
|
76
82
|
// needed. A provisioning fault distinct from a clean
|
|
@@ -89,6 +95,7 @@ const READINESS_STATES = [
|
|
|
89
95
|
'broker-down',
|
|
90
96
|
'ws-dead',
|
|
91
97
|
'cli-missing',
|
|
98
|
+
'cli-broken',
|
|
92
99
|
'auth-unsettled',
|
|
93
100
|
'needs-login',
|
|
94
101
|
'ready',
|
|
@@ -100,23 +107,41 @@ const fpub = require('./fleet-public-url');
|
|
|
100
107
|
|
|
101
108
|
// Classify the build-agent usability axis from the editor's own `providerAuth`
|
|
102
109
|
// subtree (vm: Readiness Verifies Real Usability). control-api emits the SAME
|
|
103
|
-
// `providerAuth` (with `cliInstalled` + settled `state`) on BOTH
|
|
104
|
-
// /api/session-info via build_provider_auth_json, so the
|
|
105
|
-
// straight off the gathered VM record — no re-derivation.
|
|
110
|
+
// `providerAuth` (with `cliInstalled` + `cliRunnable` + settled `state`) on BOTH
|
|
111
|
+
// /api/config and /api/session-info via build_provider_auth_json, so the
|
|
112
|
+
// dashboard reads it straight off the gathered VM record — no re-derivation.
|
|
113
|
+
// Returns one verdict:
|
|
106
114
|
// 'cli-missing' — `cliInstalled === false`: a provisioning fault.
|
|
107
|
-
// '
|
|
108
|
-
//
|
|
109
|
-
// '
|
|
115
|
+
// 'cli-broken' — CLI installed but `cliRunnable === false`: it resolves
|
|
116
|
+
// and will not execute. A provisioning fault.
|
|
117
|
+
// 'auth-unsettled' — CLI installed and runnable (or unprobed) but
|
|
118
|
+
// `state === 'unknown'` (probe wedged / timing out — the
|
|
119
|
+
// VM5 shape): a provisioning fault.
|
|
120
|
+
// 'needs-login' — CLI usable, `state === 'needs-login'`: a LEGITIMATE,
|
|
110
121
|
// actionable state, not a fault.
|
|
111
|
-
// 'ready' — CLI
|
|
122
|
+
// 'ready' — CLI usable, `state === 'authenticated'`: usable.
|
|
112
123
|
// 'unknown' — providerAuth absent (older editor / probe never landed),
|
|
113
124
|
// or any other shape: absence of evidence, never a fault.
|
|
114
|
-
// Only an EXPLICIT `cliInstalled === false` / `
|
|
115
|
-
// missing fields stay 'unknown' so an older
|
|
125
|
+
// Only an EXPLICIT `cliInstalled === false` / `cliRunnable === false` /
|
|
126
|
+
// `state === 'unknown'` is a fault; missing fields stay 'unknown' so an older
|
|
127
|
+
// editor never reads as broken.
|
|
128
|
+
//
|
|
129
|
+
// The `cli-broken` check sits BEFORE the state checks deliberately, and that
|
|
130
|
+
// ordering IS the fix. A CLI that will not execute cannot have authenticated,
|
|
131
|
+
// so `state: 'authenticated'` alongside `cliRunnable: false` is not a
|
|
132
|
+
// contradiction to be resolved in the state's favour — it is precisely the
|
|
133
|
+
// false green observed on VM-2 and VM-7 through the whole 2026-08-19/20 npm
|
|
134
|
+
// publish race, where the card read `cliInstalled: true, state: "authenticated"`
|
|
135
|
+
// while both boxes were unusable and the breakage was found only by ssh.
|
|
136
|
+
//
|
|
137
|
+
// `cliRunnable` is deliberately NOT required to be `true` to proceed: an older
|
|
138
|
+
// editor server omits it entirely (`undefined`), and treating that as a fault
|
|
139
|
+
// would mark every not-yet-upgraded VM in the fleet broken.
|
|
116
140
|
function classifyAgentUsability(providerAuth) {
|
|
117
141
|
if (!providerAuth || typeof providerAuth !== 'object') return 'unknown';
|
|
118
142
|
if (providerAuth.cliInstalled === false) return 'cli-missing';
|
|
119
143
|
if (providerAuth.cliInstalled !== true) return 'unknown';
|
|
144
|
+
if (providerAuth.cliRunnable === false) return 'cli-broken';
|
|
120
145
|
if (providerAuth.state === 'unknown') return 'auth-unsettled';
|
|
121
146
|
if (providerAuth.state === 'needs-login') return 'needs-login';
|
|
122
147
|
if (providerAuth.state === 'authenticated') return 'ready';
|
|
@@ -126,8 +151,13 @@ function classifyAgentUsability(providerAuth) {
|
|
|
126
151
|
// Map the agent-usability verdict to the tri-state the rostering gate
|
|
127
152
|
// (fleet-cleanup's reapVerdict) consumes, mirroring `projectWired`'s
|
|
128
153
|
// true|false|null contract so a flaky probe can never strand a healthy VM:
|
|
129
|
-
// false — a PROVISIONING FAULT ('cli-missing' / '
|
|
130
|
-
// not usable, so don't roster it as
|
|
154
|
+
// false — a PROVISIONING FAULT ('cli-missing' / 'cli-broken' /
|
|
155
|
+
// 'auth-unsettled'): the box is not usable, so don't roster it as
|
|
156
|
+
// healthy (the VM5 false-green). 'cli-broken' joins the fault set
|
|
157
|
+
// because a CLI that will not execute makes the VM exactly as
|
|
158
|
+
// unusable as one that was never installed — the two differ only in
|
|
159
|
+
// the remedy an operator should reach for, not in whether the box can
|
|
160
|
+
// do work.
|
|
131
161
|
// true — usable enough to roster: 'ready', or a legitimate 'needs-login'
|
|
132
162
|
// (the user must auth, but that's an expected state, not a
|
|
133
163
|
// half-provisioned box that should be held back).
|
|
@@ -135,7 +165,13 @@ function classifyAgentUsability(providerAuth) {
|
|
|
135
165
|
// evidence must NEVER block rostering, exactly like reapVerdict's null
|
|
136
166
|
// tolerance for projectWired.
|
|
137
167
|
function agentRosterReady(verdict) {
|
|
138
|
-
if (
|
|
168
|
+
if (
|
|
169
|
+
verdict === 'cli-missing' ||
|
|
170
|
+
verdict === 'cli-broken' ||
|
|
171
|
+
verdict === 'auth-unsettled'
|
|
172
|
+
) {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
139
175
|
if (verdict === 'ready' || verdict === 'needs-login') return true;
|
|
140
176
|
return null;
|
|
141
177
|
}
|
|
@@ -206,12 +242,17 @@ function deriveVmReadiness(v, ctx) {
|
|
|
206
242
|
if (v.publicWsHealth === 'unwired' && !(bh && bh.editor === 'healthy')) return 'ws-dead';
|
|
207
243
|
}
|
|
208
244
|
// 6. Agent usability: the box is up and the WS path carries frames, but the
|
|
209
|
-
// build agent itself may not be usable — CLI not installed,
|
|
210
|
-
// probe wedged at 'unknown'. A legitimate
|
|
211
|
-
// actionable state (Authenticate),
|
|
212
|
-
// Absence of providerAuth (older
|
|
245
|
+
// build agent itself may not be usable — CLI not installed, CLI installed
|
|
246
|
+
// but not executable, or the auth probe wedged at 'unknown'. A legitimate
|
|
247
|
+
// 'needs-login' surfaces as its own actionable state (Authenticate),
|
|
248
|
+
// distinct from a provisioning fault. Absence of providerAuth (older
|
|
249
|
+
// editor / unprobed) falls through to ready.
|
|
213
250
|
const usability = classifyAgentUsability(v.providerAuth);
|
|
214
251
|
if (usability === 'cli-missing') return 'cli-missing';
|
|
252
|
+
// Surfaced as its own readiness state rather than folded into 'cli-missing':
|
|
253
|
+
// the card's whole job here is to send an operator to the right remedy, and
|
|
254
|
+
// "reinstall the broken CLI" is not "install the missing one".
|
|
255
|
+
if (usability === 'cli-broken') return 'cli-broken';
|
|
215
256
|
if (usability === 'auth-unsettled') return 'auth-unsettled';
|
|
216
257
|
if (usability === 'needs-login') return 'needs-login';
|
|
217
258
|
// 6b. Preview-host IAP gap: the editor surface grants access (the URL routes
|