@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
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Pure session/broker live-status derivation for the Fleet Dashboard's per-VM
|
|
3
|
+
// status LABEL (scripts/operator/fleet-dashboard/server.js requires this). This
|
|
4
|
+
// is the trailing decision of deriveStatus, after every heavy-job / orphan /
|
|
5
|
+
// failed-badge branch has been ruled out: given the reachable VM's session and
|
|
6
|
+
// broker signals, is it building, waiting, or genuinely idle?
|
|
7
|
+
//
|
|
8
|
+
// The bug this fixes (observed live 2026-06-20, VM-1): the label was derived
|
|
9
|
+
// SOLELY from `hasSession` — the editor server's own in-process build-session
|
|
10
|
+
// flag. But a Claude agent's lifetime is owned by the PTY broker, deliberately
|
|
11
|
+
// DECOUPLED from the editor-server lifetime (see docs/process-model.md). After a
|
|
12
|
+
// server restart, or for a broker-resumed/detached agent the server didn't spawn
|
|
13
|
+
// itself, `hasSession` is false while a fully-live agent keeps working in the
|
|
14
|
+
// broker. The card fell through to 'idle' even though the broker dot — driven by
|
|
15
|
+
// the same `brokerStatus` — already showed 'agent-live'. The label and the dot
|
|
16
|
+
// disagreed.
|
|
17
|
+
//
|
|
18
|
+
// The fix consults the already-debounced broker tri-state (from
|
|
19
|
+
// fleet-broker-status.js, threaded in by the caller) so a broker-held live agent
|
|
20
|
+
// reads as working, not idle. `awaitingUserInput` is honored independent of
|
|
21
|
+
// `hasSession`, so a broker-live agent blocked on input reads 'waiting'.
|
|
22
|
+
//
|
|
23
|
+
// The terminal-done refinement (observed live 2026-06-21): a broker-live agent
|
|
24
|
+
// whose WORKFLOW reached its terminal step is parked alive at its prompt but is
|
|
25
|
+
// NOT building — the editor masks the feature and reports `needsCleanup: true`
|
|
26
|
+
// (session_info_response.rs). Without this signal the card showed an active-green
|
|
27
|
+
// "Building" border on a finished, idle VM. We discriminate on `needsCleanup`
|
|
28
|
+
// (a positive terminal signal) rather than feature-emptiness (also briefly true
|
|
29
|
+
// at session START, which would mislabel a just-starting agent as done). This
|
|
30
|
+
// only ever DOWNGRADES a would-be-'building' verdict to 'session-done'; a
|
|
31
|
+
// mid-feature post-restart agent is not terminal (`needsCleanup` falsy) and stays
|
|
32
|
+
// 'building', preserving the 2026-06-20 decoupled-agent fix above.
|
|
33
|
+
//
|
|
34
|
+
// Returns 'waiting' | 'building' | 'session-done' | 'idle' for a reachable VM, or
|
|
35
|
+
// `null` when the VM is unreachable — the caller maps null to its own
|
|
36
|
+
// asleep/unreachable verdict (it owns the running-fleet snapshot this module
|
|
37
|
+
// deliberately does not touch) and otherwise passes the verdict through verbatim
|
|
38
|
+
// as the card's `status`, so 'session-done' must match the card's status class.
|
|
39
|
+
// Precedence is preserved: an unreachable VM returns null regardless of a stale
|
|
40
|
+
// 'agent-live', so the broker branch only ever upgrades a would-be-idle verdict.
|
|
41
|
+
function liveSessionStatus(v, brokerStatus) {
|
|
42
|
+
// Unreachable: we polled nothing reliable. Defer to the caller's
|
|
43
|
+
// asleep/unreachable mapping — a stale broker signal must not mask it.
|
|
44
|
+
if (!v.reachable) return null;
|
|
45
|
+
if (v.hasSession && v.awaitingUserInput) return 'waiting';
|
|
46
|
+
if (v.hasSession) return 'building';
|
|
47
|
+
// No editor-owned session, but a live agent may still be working in the PTY
|
|
48
|
+
// broker (decoupled lifetime — see docs/process-model.md). The broker
|
|
49
|
+
// tri-state is already debounced upstream; agent-live ⇒ working, not idle.
|
|
50
|
+
if (brokerStatus === 'agent-live') {
|
|
51
|
+
if (v.awaitingUserInput) return 'waiting';
|
|
52
|
+
// The agent is parked alive in the broker's build slot, but its workflow
|
|
53
|
+
// reached the terminal step (editor reports needsCleanup) — finished and
|
|
54
|
+
// idle, not building. A mid-feature post-restart agent is NOT terminal and
|
|
55
|
+
// still reads 'building' (preserves the 2026-06-20 decoupled-agent fix).
|
|
56
|
+
if (v.needsCleanup) return 'session-done';
|
|
57
|
+
return 'building';
|
|
58
|
+
}
|
|
59
|
+
return 'idle';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Distinguish a freshly-rostered VM whose editor is still STARTING UP — a normal
|
|
63
|
+
// cold boot that hasn't bound :14199 yet, or (the VM-2 case) the ~10–15-min
|
|
64
|
+
// self-host `rebuild-self --force-in-container` compile that recompiles the
|
|
65
|
+
// editor binary from source before the server can listen — from a genuinely DEAD
|
|
66
|
+
// one. deriveStatus (scripts/operator/fleet-dashboard/server.js) calls this only
|
|
67
|
+
// after it has already established the VM is rostered, unreachable, and GCP still
|
|
68
|
+
// lists it RUNNING — i.e. exactly the case that otherwise falls through to the
|
|
69
|
+
// red 'unreachable' that made a successful Add look like a failure.
|
|
70
|
+
//
|
|
71
|
+
// Returns 'editor-starting' when EITHER the VM was launched within the grace
|
|
72
|
+
// window (`recentlyLaunched` — the cheap common case, no SSH needed) OR an
|
|
73
|
+
// in-container probe has CONFIRMED a first-run build is still running
|
|
74
|
+
// (`probeBuilding === true`), which lets the long self-host compile keep reading
|
|
75
|
+
// "Starting editor…" past the window with no fragile fixed timeout. Returns null
|
|
76
|
+
// otherwise so deriveStatus runs its existing unreachable path unchanged — an
|
|
77
|
+
// honest red card the moment the grace window lapses with no confirmed build. A
|
|
78
|
+
// DISTINCT key from the wake-path 'starting' (fleet-sleep-status.js) so the two
|
|
79
|
+
// in-progress states never collide. Pure; the caller owns the time + SSH I/O.
|
|
80
|
+
function classifyStartingVsUnreachable({ recentlyLaunched = false, probeBuilding = null } = {}) {
|
|
81
|
+
if (recentlyLaunched || probeBuilding === true) return 'editor-starting';
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Classify the in-container startup probe (server.js's editorStartingInContainer
|
|
86
|
+
// one-IAP-SSH round-trip) into the tri-state the verdict cache stores and
|
|
87
|
+
// classifyStartingVsUnreachable consumes. Mirrors fleet-cleanup.js's
|
|
88
|
+
// classifyProjectWiring shape: takes the raw `{ stdout, err }` and parses the
|
|
89
|
+
// three `---CY---`-separated segments the probe emits — the `docker ps` status
|
|
90
|
+
// line, the count of listeners on :14199, and the count of live first-run build
|
|
91
|
+
// processes (`rebuild-self` / `cargo build`):
|
|
92
|
+
// true — container up, :14199 NOT yet bound, and a build process is alive ⇒
|
|
93
|
+
// still building (keeps the card calm-blue past the grace window).
|
|
94
|
+
// false — container up but :14199 already bound (the normal reachable path
|
|
95
|
+
// will win) OR up-and-idle with no build process (let the card flip to
|
|
96
|
+
// honest 'unreachable'); also a clean response with the container down.
|
|
97
|
+
// null — an ssh/docker blip (`err`) ⇒ inconclusive; never upgrades a
|
|
98
|
+
// past-window card (stays honest), re-probed next poll.
|
|
99
|
+
function classifyStartupProbe({ stdout, err } = {}) {
|
|
100
|
+
if (err) return null;
|
|
101
|
+
const parts = String(stdout == null ? '' : stdout).split('---CY---').map((s) => s.trim());
|
|
102
|
+
const containerUp = /^Up\b/m.test(parts[0] || '');
|
|
103
|
+
const portBound = (Number((parts[1] || '0').trim()) || 0) > 0;
|
|
104
|
+
const buildProcAlive = (Number((parts[2] || '0').trim()) || 0) > 0;
|
|
105
|
+
if (!containerUp) return false; // clean response, container down → not building
|
|
106
|
+
if (portBound) return false; // already serving — the reachable path owns it
|
|
107
|
+
return buildProcAlive ? true : false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Pure gate deciding whether pollStartupProbes should fire the (costly) IAP-SSH
|
|
111
|
+
// build probe for a given VM this tick. We only probe a VM that is PLAUSIBLY
|
|
112
|
+
// still starting — present, unreachable, GCP still lists it RUNNING, NOT already
|
|
113
|
+
// owned by an active heavy job, and either recently launched OR already confirmed
|
|
114
|
+
// building on a prior tick (so a ~15-min self-host compile keeps being probed
|
|
115
|
+
// past the grace window with no fixed cap). A healthy/reachable VM, an
|
|
116
|
+
// asleep/vanished one, or a long-idle one that simply went unreachable is never
|
|
117
|
+
// probed. Pure so pollStartupProbes stays a thin SSH loop and this decision
|
|
118
|
+
// matrix is exhaustively unit-testable without booting the server.
|
|
119
|
+
function shouldProbeStartup({ vmPresent = false, reachable = false, gcpRunning = false, jobActive = false, recentlyLaunched = false, wasBuilding = false } = {}) {
|
|
120
|
+
return vmPresent && !reachable && gcpRunning && !jobActive && (recentlyLaunched || wasBuilding);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = { liveSessionStatus, classifyStartingVsUnreachable, classifyStartupProbe, shouldProbeStartup };
|
package/npm/fleet-plans.js
CHANGED
|
@@ -88,9 +88,144 @@ const REQUIRED_PLAN_ROW_FIELDS = [
|
|
|
88
88
|
'slug', 'title', 'mode', 'order', 'prefix', 'blockedBy', 'claim'
|
|
89
89
|
];
|
|
90
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Decides which data source a project's Queued Plans panel should read from.
|
|
93
|
+
* The dashboard's own project (codeyam-editor) reads plans fresh from origin
|
|
94
|
+
* via the laptop checkout; every other project keeps the VM probe.
|
|
95
|
+
* @param {Object} params
|
|
96
|
+
* @param {string} params.project The project name for the panel.
|
|
97
|
+
* @param {?string} params.selfHostProject The dashboard's own project name.
|
|
98
|
+
* @returns {'origin'|'vm'} The source to use.
|
|
99
|
+
*/
|
|
100
|
+
function planSourceForProject({ project, selfHostProject }) {
|
|
101
|
+
return selfHostProject && project === selfHostProject ? 'origin' : 'vm';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Builds the static (non-ticking) part of a Queued Plans panel header label so
|
|
106
|
+
* the data source and its staleness risk are always visible. Origin-sourced
|
|
107
|
+
* panels name the branch + short sha; VM-sourced panels carry the "may lag
|
|
108
|
+
* origin" caveat. The relative age ("· 8s ago") is appended by the frontend's
|
|
109
|
+
* own ago ticker, so this returns the static prefix only.
|
|
110
|
+
* @param {Object} params
|
|
111
|
+
* @param {'origin'|'vm'} params.source Where the panel's data came from.
|
|
112
|
+
* @param {?string} params.ref The git ref (e.g. "origin/<branch>") for origin.
|
|
113
|
+
* @param {?string} params.sha The full commit sha for origin.
|
|
114
|
+
* @param {?number} params.fromVm The representative VM number for vm source.
|
|
115
|
+
* @param {?('stale-binary'|'fetch-failed'|'parse-error'|'unknown')} params.originError
|
|
116
|
+
* When the self-host origin path was *attempted* and failed, the classified
|
|
117
|
+
* reason it fell back to the VM probe. Absent/null for ordinary VM-sourced
|
|
118
|
+
* projects (which were never meant to read origin).
|
|
119
|
+
* @returns {string} The static header annotation.
|
|
120
|
+
*/
|
|
121
|
+
function planPanelSourceLabel({ source, ref, sha, fromVm, originError }) {
|
|
122
|
+
if (source === 'origin') {
|
|
123
|
+
const shortSha = sha ? String(sha).slice(0, 7) : '?';
|
|
124
|
+
const where = ref || 'origin';
|
|
125
|
+
return `${where} @ ${shortSha}`;
|
|
126
|
+
}
|
|
127
|
+
// VM-sourced: surface the residual lag risk the origin path eliminates.
|
|
128
|
+
const vm = (fromVm !== null && fromVm !== undefined) ? `VM-${fromVm}` : 'VM';
|
|
129
|
+
if (originError) {
|
|
130
|
+
// The origin path was attempted and failed: this is a *degraded* panel, not
|
|
131
|
+
// a normal VM-sourced one. Name the failure class so it is self-diagnosing.
|
|
132
|
+
return `via ${vm} · origin unavailable: ${originErrorPhrase(originError)}`;
|
|
133
|
+
}
|
|
134
|
+
return `via ${vm} · may lag origin`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Maps a classified origin-failure bucket to a short operator-facing phrase for
|
|
139
|
+
* the panel header. Mirrors the bucket set of classifyOriginError; an unknown
|
|
140
|
+
* bucket degrades to the generic "origin read failed" phrase.
|
|
141
|
+
* @param {string} originError The classified bucket.
|
|
142
|
+
* @returns {string} A short human-readable phrase.
|
|
143
|
+
*/
|
|
144
|
+
function originErrorPhrase(originError) {
|
|
145
|
+
switch (originError) {
|
|
146
|
+
case 'stale-binary':
|
|
147
|
+
return 'stale editor binary';
|
|
148
|
+
case 'fetch-failed':
|
|
149
|
+
return 'git fetch failed';
|
|
150
|
+
case 'parse-error':
|
|
151
|
+
return 'bad snapshot output';
|
|
152
|
+
default:
|
|
153
|
+
return 'origin read failed';
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Classifies a caught origin-path error message into a small, stable set of
|
|
159
|
+
* buckets the operator can act on, rather than surfacing a noisy raw error
|
|
160
|
+
* (which can carry paths/branch names) in the panel header. Pure so it is
|
|
161
|
+
* unit-testable.
|
|
162
|
+
* - `unrecognized subcommand` → 'stale-binary' (CLI predates the command)
|
|
163
|
+
* - leading `git fetch origin` → 'fetch-failed' (couldn't reach origin)
|
|
164
|
+
* - no-JSON / malformed snapshot → 'parse-error' (plans-snapshot output)
|
|
165
|
+
* - anything else → 'unknown'
|
|
166
|
+
* @param {?string} message The caught error's message text.
|
|
167
|
+
* @returns {'stale-binary'|'fetch-failed'|'parse-error'|'unknown'}
|
|
168
|
+
*/
|
|
169
|
+
function classifyOriginError(message) {
|
|
170
|
+
const msg = typeof message === 'string' ? message : '';
|
|
171
|
+
if (msg.includes('unrecognized subcommand')) return 'stale-binary';
|
|
172
|
+
if (/^git fetch origin\b/.test(msg)) return 'fetch-failed';
|
|
173
|
+
if (msg.includes('plans-snapshot produced no JSON') ||
|
|
174
|
+
msg.includes('plans-snapshot JSON parse')) return 'parse-error';
|
|
175
|
+
return 'unknown';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* True when an origin-fetch error means the branch no longer exists on the
|
|
180
|
+
* remote — it was merged and deleted, so `git fetch origin <branch>` fails with
|
|
181
|
+
* git's `couldn't find remote ref <branch>`. This is NOT a transient fetch
|
|
182
|
+
* failure (network / auth): the branch is *gone*, so re-fetching it every poll
|
|
183
|
+
* cycle is pure noise. The dashboard uses this to record the branch as gone,
|
|
184
|
+
* fall back to the VM probe quietly, and stop logging an error every cycle —
|
|
185
|
+
* the difference between a healthy fleet that *reads* broken in the log and one
|
|
186
|
+
* that reads calm. Pure so it is unit-testable.
|
|
187
|
+
* @param {?string} message The caught fetch-error message text.
|
|
188
|
+
* @returns {boolean}
|
|
189
|
+
*/
|
|
190
|
+
function isRemoteRefGone(message) {
|
|
191
|
+
const msg = typeof message === 'string' ? message : '';
|
|
192
|
+
return /couldn't find remote ref/i.test(msg);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Parses the stdout of `editor plans-snapshot --format json` into the snapshot
|
|
197
|
+
* envelope the dashboard consumes. The pure half of snapshotPlansFromOrigin:
|
|
198
|
+
* extracts the JSON object (the CLI may prefix a dev-build banner), parses it,
|
|
199
|
+
* and normalizes missing arrays to []. Throws a descriptive error on no-JSON
|
|
200
|
+
* or malformed-JSON so the caller can fall back to the VM probe.
|
|
201
|
+
* @param {string} stdout The plans-snapshot CLI stdout.
|
|
202
|
+
* @returns {{ref: string, sha: string, plans: Object[], completedSlugs: string[]}}
|
|
203
|
+
*/
|
|
204
|
+
function parsePlansSnapshotStdout(stdout) {
|
|
205
|
+
const m = stdout && stdout.match(/\{[\s\S]*\}/);
|
|
206
|
+
if (!m) throw new Error('plans-snapshot produced no JSON');
|
|
207
|
+
let data;
|
|
208
|
+
try {
|
|
209
|
+
data = JSON.parse(m[0]);
|
|
210
|
+
} catch (e) {
|
|
211
|
+
throw new Error(`plans-snapshot JSON parse: ${e.message}`);
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
ref: data.ref,
|
|
215
|
+
sha: data.sha,
|
|
216
|
+
plans: data.plans || [],
|
|
217
|
+
completedSlugs: data.completedSlugs || [],
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
91
221
|
module.exports = {
|
|
92
222
|
indexVmsBySessionId,
|
|
93
223
|
unsatisfiedDeps,
|
|
94
224
|
resolvePlanRows,
|
|
225
|
+
planSourceForProject,
|
|
226
|
+
planPanelSourceLabel,
|
|
227
|
+
classifyOriginError,
|
|
228
|
+
isRemoteRefGone,
|
|
229
|
+
parsePlansSnapshotStdout,
|
|
95
230
|
REQUIRED_PLAN_ROW_FIELDS
|
|
96
231
|
};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// npm/fleet-prewarm.js — pure verdicts for the fleet dashboard's concurrent
|
|
4
|
+
// cold-add pre-warm (vm--concurrent-add-prewarm-branch-image).
|
|
5
|
+
//
|
|
6
|
+
// When several VMs are added at once on a branch whose prebuilt image is not yet
|
|
7
|
+
// published, EVERY VM compiles the editor from source (~10 min each) in
|
|
8
|
+
// parallel — none has finished compiling-and-publishing before its siblings
|
|
9
|
+
// start, so the self-feeding fast path (publish_compiled_binary in
|
|
10
|
+
// scripts/gcp-bootstrap.sh) never gets a chance to prime. The fix is to bring up
|
|
11
|
+
// ONE "pacer" VM first, wait until it has published the branch binary (or
|
|
12
|
+
// settled on a definite won't-publish), then release the rest so they pull it.
|
|
13
|
+
//
|
|
14
|
+
// This module holds the *decisions* — pace-or-fan-out, which tags constitute a
|
|
15
|
+
// usable image, parsing the bootstrap's machine markers, and the push-token
|
|
16
|
+
// diagnostic — so each case is unit-tested without a live VM or registry. The
|
|
17
|
+
// I/O (the manifest probe, reading the add log, threading the held queue) lives
|
|
18
|
+
// in the server / cloud.js shells that call these.
|
|
19
|
+
|
|
20
|
+
// Machine-detectable markers gcp-bootstrap.sh emits into the cloud:up add log
|
|
21
|
+
// (streamed to /tmp/dash-add-vm-<n>.log). They are stable contracts the
|
|
22
|
+
// dashboard greps for — keep them in sync with scripts/gcp-bootstrap.sh.
|
|
23
|
+
const MARKER_PUBLISHED = 'CY_BINARY_PUBLISHED'; // pacer published the branch binary; held adds can pull it
|
|
24
|
+
const MARKER_PUBLISH_SKIPPED = 'CY_BINARY_PUBLISH_SKIPPED'; // pacer will NOT publish (no token / no branch / failure)
|
|
25
|
+
const MARKER_PATH_PULL = 'CY_PROVISION_PATH pull'; // this VM pulled a prebuilt image (~2 min fast path)
|
|
26
|
+
const MARKER_PATH_COMPILE = 'CY_PROVISION_PATH compile'; // this VM compiled from source (~10 min slow path)
|
|
27
|
+
|
|
28
|
+
// Mirror scripts/gcp-bootstrap.sh's BRANCH_SLUG: '/' → '-', lowercased, capped
|
|
29
|
+
// at 128 chars. The dashboard and the VM MUST agree on the slug or the probe
|
|
30
|
+
// checks different tags than the bootstrap pulls. Empty / missing branch → the
|
|
31
|
+
// universal "main" slug, matching the bootstrap's detached-HEAD fallback.
|
|
32
|
+
function branchSlug(branch) {
|
|
33
|
+
if (!branch || typeof branch !== 'string') return 'main';
|
|
34
|
+
const slug = branch.replace(/\//g, '-').toLowerCase().slice(0, 128);
|
|
35
|
+
return slug || 'main';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The tag chain that constitutes a "usable" prebuilt editor image, in the same
|
|
39
|
+
// precedence the dev bootstrap pulls (build_editor_image in gcp-bootstrap.sh):
|
|
40
|
+
// 1. cloud-smoke:<slug>-latest — the branch base image
|
|
41
|
+
// 2. cloud-smoke:main-latest — the universal CI fallback (skipped when
|
|
42
|
+
// the branch already IS main, no duplicate)
|
|
43
|
+
// 3. binary:<slug>-latest — the branch binary overlay
|
|
44
|
+
// If a manifest HEAD on ANY of these succeeds, a cold add can pull instead of
|
|
45
|
+
// compiling, so no pacer is needed. Registry defaults to the same
|
|
46
|
+
// CODEYAM_REGISTRY the bootstrap reads.
|
|
47
|
+
function usableImageTags({ registry, branch } = {}) {
|
|
48
|
+
const reg = registry || 'ghcr.io/codeyam-ai';
|
|
49
|
+
const slug = branchSlug(branch);
|
|
50
|
+
const tags = [`${reg}/codeyam-editor-cloud-smoke:${slug}-latest`];
|
|
51
|
+
if (slug !== 'main') tags.push(`${reg}/codeyam-editor-cloud-smoke:main-latest`);
|
|
52
|
+
tags.push(`${reg}/codeyam-editor-binary:${slug}-latest`);
|
|
53
|
+
return tags;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The core verdict: should a batch add pace one VM ahead of the rest?
|
|
57
|
+
//
|
|
58
|
+
// count how many VMs the operator asked to add
|
|
59
|
+
// hasUsableImage the probe result — true (image present), false (definitely
|
|
60
|
+
// absent), or null (couldn't determine: no token, probe error)
|
|
61
|
+
// hasPushToken a write:packages token is present, so the pacer CAN publish
|
|
62
|
+
//
|
|
63
|
+
// Pace ONLY when it strictly pays AND can work:
|
|
64
|
+
// - count > 1 — a single add is never serialized (never slower)
|
|
65
|
+
// - hasUsableImage === false — known-absent; an existing image means all N can
|
|
66
|
+
// pull concurrently, and `null` (unknown) falls
|
|
67
|
+
// back to today's fan-out rather than risk a
|
|
68
|
+
// 10-min serialization that buys nothing
|
|
69
|
+
// - hasPushToken — without it the pacer can't publish, so pacing would
|
|
70
|
+
// just serialize N source compiles for no gain
|
|
71
|
+
// Any miss → fan out all N immediately (today's behavior, strictly additive).
|
|
72
|
+
function decideBatchPrewarm({ count, hasUsableImage, hasPushToken } = {}) {
|
|
73
|
+
const k = Math.round(Number(count) || 0);
|
|
74
|
+
if (k <= 1) return { pace: false, reason: 'single add — never serialized' };
|
|
75
|
+
if (hasUsableImage === true)
|
|
76
|
+
return { pace: false, reason: 'usable prebuilt image exists — all pull concurrently' };
|
|
77
|
+
if (hasUsableImage !== false)
|
|
78
|
+
return { pace: false, reason: 'image presence undetermined — fan out (no worse than today)' };
|
|
79
|
+
if (!hasPushToken)
|
|
80
|
+
return { pace: false, reason: 'no push token — pacer could not publish, fan out' };
|
|
81
|
+
return { pace: true, reason: `pacing 1 of ${k} — branch image absent, will publish for the rest` };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Has the pacer's add log settled to a definite publish outcome the held batch
|
|
85
|
+
// can act on? Returns 'published' (binary is now pullable — release and pull),
|
|
86
|
+
// 'skipped' (pacer will NOT publish — release; they recompile, today's
|
|
87
|
+
// behavior), or null (still in flight — keep waiting). A SKIPPED that is later
|
|
88
|
+
// followed by a PUBLISHED still reports published (the success wins), so marker
|
|
89
|
+
// ordering can't strand the batch on a transient earlier skip.
|
|
90
|
+
function detectPublishSettled(logText) {
|
|
91
|
+
if (!logText || typeof logText !== 'string') return null;
|
|
92
|
+
if (logText.includes(MARKER_PUBLISHED)) return 'published';
|
|
93
|
+
if (logText.includes(MARKER_PUBLISH_SKIPPED)) return 'skipped';
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The pure release decision for the pre-warm hold (startPrewarmWatch's per-tick
|
|
98
|
+
// verdict): given the pacer's current signals, should the held batch be released
|
|
99
|
+
// now, and why? Returns a human reason string to release, or null to keep
|
|
100
|
+
// waiting. Releases on the FIRST of: a settled publish marker, the pacer job
|
|
101
|
+
// terminating without one, or the bounded timeout — so a publish failure or a
|
|
102
|
+
// stuck pacer can never strand the batch.
|
|
103
|
+
//
|
|
104
|
+
// settled detectPublishSettled() result: 'published' | 'skipped' | null
|
|
105
|
+
// pacerJob the pacer add job record ({ state, ... }) or null/undefined
|
|
106
|
+
// elapsedMs ms since the watch started
|
|
107
|
+
// timeoutMs the bounded fallback after which the batch releases regardless
|
|
108
|
+
function prewarmReleaseDecision({ settled, pacerJob, elapsedMs, timeoutMs } = {}) {
|
|
109
|
+
if (settled) return `pacer ${settled}`;
|
|
110
|
+
const st = pacerJob && pacerJob.state;
|
|
111
|
+
if (st === 'done' || st === 'error') return `pacer ${st}`;
|
|
112
|
+
if (Number(elapsedMs) > Number(timeoutMs)) return 'timeout';
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Which provision path did a VM take, parsed from its add log's path marker?
|
|
117
|
+
// Returns 'pull' (~2 min prebuilt pull), 'compile' (~10 min source compile), or
|
|
118
|
+
// null (not yet determined). The LAST marker wins — a VM that tries a pull,
|
|
119
|
+
// misses, then falls back to a compile correctly reads 'compile'.
|
|
120
|
+
function provisionPath(logText) {
|
|
121
|
+
if (!logText || typeof logText !== 'string') return null;
|
|
122
|
+
const pull = logText.lastIndexOf(MARKER_PATH_PULL);
|
|
123
|
+
const compile = logText.lastIndexOf(MARKER_PATH_COMPILE);
|
|
124
|
+
if (pull < 0 && compile < 0) return null;
|
|
125
|
+
return compile > pull ? 'compile' : 'pull';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Operator-facing one-liner for a provision path, for the add card sub-status.
|
|
129
|
+
// null path → null (render nothing).
|
|
130
|
+
function provisionPathLabel(path) {
|
|
131
|
+
if (path === 'pull') return 'pulling prebuilt image (~2 min)';
|
|
132
|
+
if (path === 'compile') return 'compiling from source (~10 min)';
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The push-token diagnostic surfaced on the dashboard. Without a write:packages
|
|
137
|
+
// token the self-feeding publish is silently off, so EVERY cold add recompiles
|
|
138
|
+
// (~10 min) and the pacer can't help either — a state the operator must see, not
|
|
139
|
+
// discover from a buried per-VM log line.
|
|
140
|
+
function pushTokenDiagnostic({ hasPushToken } = {}) {
|
|
141
|
+
if (hasPushToken) return { warn: false, message: null };
|
|
142
|
+
return {
|
|
143
|
+
warn: true,
|
|
144
|
+
message:
|
|
145
|
+
'Self-feeding disabled: no CODEYAM_GHCR_PUSH_TOKEN (write:packages). ' +
|
|
146
|
+
'Cold adds will always recompile the editor from source (~10 min each) ' +
|
|
147
|
+
'and concurrent batches cannot pre-warm. Install a write-scoped token and ' +
|
|
148
|
+
'reinstall the fleet agents to enable the fast path.',
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = {
|
|
153
|
+
MARKER_PUBLISHED,
|
|
154
|
+
MARKER_PUBLISH_SKIPPED,
|
|
155
|
+
MARKER_PATH_PULL,
|
|
156
|
+
MARKER_PATH_COMPILE,
|
|
157
|
+
branchSlug,
|
|
158
|
+
usableImageTags,
|
|
159
|
+
decideBatchPrewarm,
|
|
160
|
+
detectPublishSettled,
|
|
161
|
+
prewarmReleaseDecision,
|
|
162
|
+
provisionPath,
|
|
163
|
+
provisionPathLabel,
|
|
164
|
+
pushTokenDiagnostic,
|
|
165
|
+
};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Pure classification + reason-string derivation for the dashboard's public-URL
|
|
3
|
+
// reachability probe (vm: Dashboard validates the public ingress URL, not just
|
|
4
|
+
// the tunnel).
|
|
5
|
+
//
|
|
6
|
+
// The fleet dashboard probes each VM's editor through the local SSH tunnel and,
|
|
7
|
+
// in Google-LB mode, the per-VM backend SERVICE health (gcloud get-health) —
|
|
8
|
+
// but never the public `<slug>.editor.<domain>` URL the card advertises. VM5
|
|
9
|
+
// (2026-06-19) sat "healthy" on every one of those signals while the advertised
|
|
10
|
+
// URL returned 503 "no healthy upstream" for hours: a reused-slug Add aborted on
|
|
11
|
+
// a `Duplicate path matcher` collision before binding the host rule, so the
|
|
12
|
+
// hostname fell through to the default backend. A backend-health probe is
|
|
13
|
+
// structurally blind to a missing host rule; only an end-to-end GET of the
|
|
14
|
+
// advertised hostname catches it.
|
|
15
|
+
//
|
|
16
|
+
// This module is the PURE half — parsing + string derivation, no network —
|
|
17
|
+
// mirroring classifyBackendHealth's `absent|unknown|unhealthy|healthy`
|
|
18
|
+
// contract. The HTTPS GET I/O lives in server.js's gatherPublicUrlHealth, the
|
|
19
|
+
// same split gatherBackendHealth / classifyBackendHealth already follow. Unit-
|
|
20
|
+
// tested in npm/fleet-public-url.test.js.
|
|
21
|
+
//
|
|
22
|
+
// Stack note: this is fleet-operator infrastructure (the GCE fleet that runs
|
|
23
|
+
// codeyam-editor itself), GCP / HTTPS-LB-specific by design — the same posture
|
|
24
|
+
// as the scripts it backstops. A project that never joins the fleet never sets
|
|
25
|
+
// FLEET_DOMAIN and never reaches this probe.
|
|
26
|
+
|
|
27
|
+
// The Google HTTPS-LB "no backend answered" body signature: GFE emits this when
|
|
28
|
+
// a host rule routes nowhere (or to a backendless default). A single source so
|
|
29
|
+
// the classifier and any future log line agree on the string.
|
|
30
|
+
const NO_HEALTHY_UPSTREAM_RE = /no healthy upstream/i;
|
|
31
|
+
|
|
32
|
+
/** Classify a public-URL probe result into the ingress-wiring vocabulary,
|
|
33
|
+
* following classifyBackendHealth's contract — absence of evidence is never a
|
|
34
|
+
* problem state:
|
|
35
|
+
*
|
|
36
|
+
* 'unknown' — a network / TLS / timeout / DNS error (no statusCode): the
|
|
37
|
+
* probe couldn't reach a verdict. NEVER a degraded state, exactly
|
|
38
|
+
* like classifyBackendHealth's 'unknown'. Also covers an
|
|
39
|
+
* unexpected non-503 HTTP error (a 404/500 from the editor
|
|
40
|
+
* itself) — that means the host rule DID route, just not to a
|
|
41
|
+
* happy response, so it is neither 'unwired' nor a clean 'wired'.
|
|
42
|
+
* 'unwired' — the LB answered but no backend is bound to the advertised host:
|
|
43
|
+
* a 503, or a recognizable "no healthy upstream" default-backend
|
|
44
|
+
* body. THIS is the missing-host-rule signal a backend-health
|
|
45
|
+
* probe cannot see (the VM5 case).
|
|
46
|
+
* 'wired' — any 2xx / 3xx: the editor answered (a 200, or its login 302
|
|
47
|
+
* redirect — the real VM5-healthy shape). The host rule routes.
|
|
48
|
+
*/
|
|
49
|
+
function classifyPublicUrlHealth({ statusCode, body, error } = {}) {
|
|
50
|
+
if (error || statusCode == null) return 'unknown';
|
|
51
|
+
const code = Number(statusCode);
|
|
52
|
+
if (code === 503) return 'unwired';
|
|
53
|
+
if (NO_HEALTHY_UPSTREAM_RE.test(String(body || ''))) return 'unwired';
|
|
54
|
+
if (code >= 200 && code < 400) return 'wired';
|
|
55
|
+
// Any other 4xx/5xx is the editor answering with an error of its own — the
|
|
56
|
+
// host rule routes, so it is NOT 'unwired'; but it is not a clean success
|
|
57
|
+
// either. Treat as 'unknown' (absence of the unwired signal) so we never raise
|
|
58
|
+
// a false ingress-unwired reason on it.
|
|
59
|
+
return 'unknown';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The sticky `ingress-unwired` degraded-reason string — the single source for
|
|
63
|
+
* both the dashboard card reason and any log line. Embeds the advertised URL
|
|
64
|
+
* and the EXACT re-wire command an operator runs to fix it, so the amber card
|
|
65
|
+
* is self-documenting (the surface VM5 should have shown for hours instead of
|
|
66
|
+
* reading healthy). */
|
|
67
|
+
function ingressUnwiredReason({ url, domain, slug, n } = {}) {
|
|
68
|
+
const target = url || `(vm-${n} editor URL)`;
|
|
69
|
+
const slugEnv = slug ? ` VM_SLUG=${slug}` : '';
|
|
70
|
+
const rewire = `FLEET_DOMAIN=${domain}${slugEnv} bash scripts/gcp/vm-ingress.sh ${n} up`;
|
|
71
|
+
return `ingress-unwired: ${target} returns 503 (host rule missing). Re-wire: ${rewire}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Decide whether a just-landed public-URL verdict warrants an auto-rewire
|
|
75
|
+
* self-heal. PURE — the dashboard reads the live VM state (I/O at the edge) and
|
|
76
|
+
* passes the facts in; the gate logic itself is unit-testable without network
|
|
77
|
+
* or server state. Fires only for a GENUINE missing-host-rule case:
|
|
78
|
+
* - `verdict` is 'unwired' (the URL 503s), AND
|
|
79
|
+
* - `reachable` (the tunnel is up — a down VM is not a missing-host-rule
|
|
80
|
+
* case; re-wiring it accomplishes nothing), AND
|
|
81
|
+
* - `backendEditorHealth` is 'healthy' (the backend SERVICE is fine, so only
|
|
82
|
+
* the host rule is missing — the VM5 host-rule-drift case) OR 'absent' (the
|
|
83
|
+
* per-VM backend service was NEVER created — the interrupted-add VM-4 case,
|
|
84
|
+
* where a SIGTERM killed `backend-services create` mid-wiring) OR
|
|
85
|
+
* 'unhealthy' *with* `backendEditorBackendless` true (the BES EXISTS but has
|
|
86
|
+
* ZERO NEG backends attached — the concurrent-add editor-band race, where the
|
|
87
|
+
* backend slipped its NEG attachment but the BES object landed). All three
|
|
88
|
+
* are recoverable by a single `vm-ingress.sh up`, which is
|
|
89
|
+
* describe-before-create idempotent and creates/attaches only the missing
|
|
90
|
+
* pieces. A backendless BES classifies as 'unhealthy' (get-health returns
|
|
91
|
+
* empty, indistinguishable from a still-cold backend) yet will NEVER converge
|
|
92
|
+
* on its own — it has no endpoint to become healthy — so it MUST re-wire, not
|
|
93
|
+
* wait. 'unknown' (a transient gcloud/auth error — absence of evidence must
|
|
94
|
+
* never trigger a re-wire storm) and a GENUINELY-converging 'unhealthy' (a
|
|
95
|
+
* backend IS attached, health still flipping — `backendEditorBackendless`
|
|
96
|
+
* false: let it converge, don't churn the LB) stay EXCLUDED, AND
|
|
97
|
+
* - no re-wire is already `inFlight` (one per VM), AND
|
|
98
|
+
* - the debounce window has elapsed since `lastRewireAt`.
|
|
99
|
+
* The debounce is load-bearing: GCP host-rule changes take minutes to roll out
|
|
100
|
+
* to all GFEs (VM5's CORRECT rule 503'd for ~minutes before flipping), so a
|
|
101
|
+
* per-cycle re-fire would storm the LB while a good change is still
|
|
102
|
+
* propagating. A missing/0 `lastRewireAt` means "never re-wired" → the window
|
|
103
|
+
* is satisfied. */
|
|
104
|
+
function shouldAutoRewireIngress({
|
|
105
|
+
verdict,
|
|
106
|
+
reachable,
|
|
107
|
+
backendEditorHealth,
|
|
108
|
+
backendEditorBackendless,
|
|
109
|
+
inFlight,
|
|
110
|
+
lastRewireAt,
|
|
111
|
+
now,
|
|
112
|
+
debounceMs,
|
|
113
|
+
} = {}) {
|
|
114
|
+
if (verdict !== 'unwired') return false;
|
|
115
|
+
if (!reachable) return false;
|
|
116
|
+
// 'healthy' = host-rule drift (VM5); 'absent' = backend never created
|
|
117
|
+
// (interrupted-add VM-4); 'unhealthy' + backendless = BES exists with zero NEG
|
|
118
|
+
// backends (concurrent-add editor-band race) — it can't converge unaided, so it
|
|
119
|
+
// re-wires too. A genuinely-converging 'unhealthy' (backend attached, health
|
|
120
|
+
// flipping → backendless false) and 'unknown' stay excluded.
|
|
121
|
+
const rewirable =
|
|
122
|
+
backendEditorHealth === 'healthy' ||
|
|
123
|
+
backendEditorHealth === 'absent' ||
|
|
124
|
+
(backendEditorHealth === 'unhealthy' && backendEditorBackendless === true);
|
|
125
|
+
if (!rewirable) return false;
|
|
126
|
+
if (inFlight) return false;
|
|
127
|
+
return (Number(now) - (Number(lastRewireAt) || 0)) >= Number(debounceMs);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Classify a `gcloud compute backend-services describe --format=value(backends[].group)`
|
|
131
|
+
* result into the attachment vocabulary — the signal `get-health` cannot give,
|
|
132
|
+
* because an empty health result is ambiguous between "no backend attached" and
|
|
133
|
+
* "a backend attached but still health-pending":
|
|
134
|
+
*
|
|
135
|
+
* 'backendless' — the BES EXISTS but has ZERO NEG backends attached (the
|
|
136
|
+
* describe printed empty / whitespace-only output). This BES
|
|
137
|
+
* will NEVER converge on its own — there is no endpoint to
|
|
138
|
+
* become healthy — so it is re-wirable, feeding
|
|
139
|
+
* shouldAutoRewireIngress's `backendEditorBackendless` branch
|
|
140
|
+
* (the concurrent-add editor-band slip).
|
|
141
|
+
* 'attached' — the describe printed at least one backend group, OR it
|
|
142
|
+
* ERRORED. An error is treated as 'attached' (absence of
|
|
143
|
+
* evidence): a NotFound is the 'absent' case classifyBackendHealth
|
|
144
|
+
* already owns, and a transient gcloud/auth failure must NEVER
|
|
145
|
+
* manufacture a 'backendless' verdict that storms the LB with a
|
|
146
|
+
* re-wire. Pure (no I/O), so the empty-backends classification is
|
|
147
|
+
* unit-testable without gcloud — the I/O lives in server.js's
|
|
148
|
+
* gatherEditorBackendless, the same split classifyPublicUrlHealth
|
|
149
|
+
* follows. */
|
|
150
|
+
function classifyBackendAttachment(err, stdout) {
|
|
151
|
+
if (err) return 'attached';
|
|
152
|
+
return String(stdout == null ? '' : stdout).trim() === '' ? 'backendless' : 'attached';
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = {
|
|
156
|
+
classifyPublicUrlHealth,
|
|
157
|
+
ingressUnwiredReason,
|
|
158
|
+
shouldAutoRewireIngress,
|
|
159
|
+
classifyBackendAttachment,
|
|
160
|
+
};
|
package/npm/fleet-queue.js
CHANGED
|
@@ -37,4 +37,41 @@ function decideAutoReleaseGoneHead({ queue, vms, jobs, orphans }) {
|
|
|
37
37
|
return { release: true, uuid: head.uuid, vm: n, reason: `owning VM-${n} left the fleet` };
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
/** Compute the set-priority writes a reorder requires.
|
|
41
|
+
*
|
|
42
|
+
* A reorder POSTs a desired uuid order; the queue's priority convention is
|
|
43
|
+
* first=N … last=1 (higher priority sorts earlier). This maps the desired
|
|
44
|
+
* order onto target priorities, drops the entries already at their target
|
|
45
|
+
* priority (no-ops — no `set-priority` shell-out needed), and sorts the
|
|
46
|
+
* remaining writes intended-HEAD-first so the head lands first even if a
|
|
47
|
+
* later write fails. `current` is the authoritative queue ([{uuid,priority}]);
|
|
48
|
+
* `order` is the desired uuid sequence.
|
|
49
|
+
*
|
|
50
|
+
* Returns one of:
|
|
51
|
+
* { ok: false, reason: 'empty' } — no order supplied
|
|
52
|
+
* { ok: false, reason: 'stale' } — order doesn't match current set
|
|
53
|
+
* { ok: true, targets: [{uuid, prio}] } — writes to apply (possibly [])
|
|
54
|
+
* Pure; no I/O.
|
|
55
|
+
*/
|
|
56
|
+
function computeReorderTargets({ order, current }) {
|
|
57
|
+
const ord = (order || []).map((s) => String(s).trim()).filter(Boolean);
|
|
58
|
+
const cur = current || [];
|
|
59
|
+
const curUuids = cur.map((e) => e.uuid);
|
|
60
|
+
if (!ord.length) return { ok: false, reason: 'empty' };
|
|
61
|
+
// Same membership, same length — a reorder, not an add/remove. Anything else
|
|
62
|
+
// means the queue changed underneath the operator since they last saw it.
|
|
63
|
+
if (ord.length !== curUuids.length || !ord.every((u) => curUuids.includes(u))) {
|
|
64
|
+
return { ok: false, reason: 'stale' };
|
|
65
|
+
}
|
|
66
|
+
const N = ord.length;
|
|
67
|
+
const targets = ord
|
|
68
|
+
.map((uuid, i) => ({ uuid, prio: N - i })) // first=N … last=1
|
|
69
|
+
.filter(({ uuid, prio }) => {
|
|
70
|
+
const e = cur.find((x) => x.uuid === uuid);
|
|
71
|
+
return !e || e.priority !== prio; // skip no-ops
|
|
72
|
+
})
|
|
73
|
+
.sort((a, b) => b.prio - a.prio); // intended HEAD first
|
|
74
|
+
return { ok: true, targets };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { decideAutoReleaseGoneHead, computeReorderTargets };
|