@codeyam-editor/codeyam-editor 0.1.0-staging.01efa1a
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/cli.js +19 -0
- package/npm/cloud-branch-switch.js +442 -0
- package/npm/cloud.js +2184 -0
- package/npm/container.js +54 -0
- package/npm/editor-dev.js +459 -0
- package/npm/editor.js +272 -0
- package/npm/empty-project-dir.js +33 -0
- package/npm/fleet-auth.js +111 -0
- package/npm/fleet-branch.js +49 -0
- package/npm/fleet-broker-death.js +44 -0
- package/npm/fleet-broker-debounce.js +63 -0
- package/npm/fleet-broker-status.js +45 -0
- package/npm/fleet-build-data.js +81 -0
- package/npm/fleet-cleanup.js +297 -0
- package/npm/fleet-effect-status.js +179 -0
- package/npm/fleet-env.js +125 -0
- package/npm/fleet-grouping.js +56 -0
- package/npm/fleet-job-badge.js +83 -0
- package/npm/fleet-memory.js +157 -0
- package/npm/fleet-plans.js +96 -0
- package/npm/fleet-pool-policy.js +78 -0
- package/npm/fleet-pool.js +401 -0
- package/npm/fleet-queue.js +40 -0
- package/npm/fleet-reconcile.js +93 -0
- package/npm/fleet-reset.js +222 -0
- package/npm/fleet-roster.js +81 -0
- package/npm/launch-config.js +717 -0
- package/npm/open.js +15 -0
- package/npm/preflight-guidance.js +77 -0
- package/npm/scenario-check.js +604 -0
- package/npm/scenario-handlers.js +82 -0
- package/npm/scenario-interactivity.js +245 -0
- package/npm/scenario-issues.js +35 -0
- package/npm/scenario-metrics.js +108 -0
- package/npm/scenario-mocks.js +107 -0
- package/npm/scenario-playwright.js +534 -0
- package/npm/utils.js +823 -0
- package/npm/vm-urls.js +117 -0
- package/package.json +32 -0
- package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +2 -0
- package/ui/dist/assets/EditorShell-By-2rl7D.js +187 -0
- package/ui/dist/assets/Markdown-BHBqz-Mj.js +1 -0
- package/ui/dist/assets/ScenarioDataPanel-Cg8zOFNZ.js +5 -0
- package/ui/dist/assets/ScenarioDataPanel-DYP7pi_n.css +32 -0
- package/ui/dist/assets/ScenarioDataPanelFullPage-WOUYbba7.js +1 -0
- package/ui/dist/assets/ScenarioRenderer-CBTlIVPs.js +1 -0
- package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +1 -0
- package/ui/dist/assets/index-DzoF9RKw.js +27 -0
- package/ui/dist/assets/index-ckz94rKI.css +1 -0
- package/ui/dist/assets/markdown-v0F0UEt3.js +37 -0
- package/ui/dist/assets/react-CSS0HapR.js +28 -0
- package/ui/dist/assets/useEvents-DggWrtHe.js +1 -0
- package/ui/dist/assets/xterm--24IGk-x.js +9 -0
- package/ui/dist/favicon.ico +0 -0
- package/ui/dist/favicon.png +0 -0
- package/ui/dist/index.html +16 -0
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// npm/fleet-pool.js — the warm-VM-pool state layer (improve38).
|
|
4
|
+
//
|
|
5
|
+
// Standing up a VM on demand pays for an image pull, a branch rebrand and —
|
|
6
|
+
// the slow part — an interactive auth approval. The warm pool removes that
|
|
7
|
+
// latency from the request path: keep N pre-provisioned, pre-authed, READY
|
|
8
|
+
// idle VMs and CLAIM them in seconds (rebrand-only, no OAuth). This module is
|
|
9
|
+
// the state seam for that pool — it answers "which VMs are idle-and-claimable
|
|
10
|
+
// vs already-assigned" and performs the ATOMIC claim that guarantees two
|
|
11
|
+
// operators never grab the same VM.
|
|
12
|
+
//
|
|
13
|
+
// Why a SEPARATE file from the roster (`fleet-vms.json`): the roster is a
|
|
14
|
+
// shared contract — a plain JSON array of VM-number integers that the
|
|
15
|
+
// dashboard, vm-watchdog, cloudflared-monitor and the bash publishers all
|
|
16
|
+
// read. Widening it to carry per-VM status would break every reader (they
|
|
17
|
+
// `jq -r '.[]'` it as scalars). So pool membership lives alongside the roster
|
|
18
|
+
// in `.codeyam/logs/fleet-pool.json`, keyed by VM number, and the roster keeps
|
|
19
|
+
// meaning "these VM numbers exist". A VM can be in the roster without being in
|
|
20
|
+
// the pool (e.g. an operator's hand-driven VM); the pool only tracks the warm
|
|
21
|
+
// set.
|
|
22
|
+
//
|
|
23
|
+
// On-disk shape (tolerant: a missing or malformed file is the empty pool):
|
|
24
|
+
// { "vms": { "<n>": { status, branch, freshAt, claimedAt } } }
|
|
25
|
+
// status: "pool" idle, claim-ready
|
|
26
|
+
// "assigned" claimed by an operator, rebranded to `branch`
|
|
27
|
+
// branch: the branch the VM is on (base branch while pooled)
|
|
28
|
+
// freshAt: ISO timestamp of the last successful auth/health refresh —
|
|
29
|
+
// drives evict-before-claim of a member whose token lapsed
|
|
30
|
+
// claimedAt: ISO timestamp of the claim (null while pooled)
|
|
31
|
+
//
|
|
32
|
+
// The pure functions (normalize / select / refill / staleness) operate on a
|
|
33
|
+
// plain state object and return a new one — no I/O — so they unit-test without
|
|
34
|
+
// touching disk. readState/writeState/withLock add the atomic file layer, and
|
|
35
|
+
// main() is the CLI seam the bash orchestrators (fleet-claim.sh,
|
|
36
|
+
// fleet-pool-keeper.sh) call instead of re-implementing the logic in shell.
|
|
37
|
+
|
|
38
|
+
const fs = require('fs');
|
|
39
|
+
const path = require('path');
|
|
40
|
+
|
|
41
|
+
const { rootDir } = require('./utils');
|
|
42
|
+
|
|
43
|
+
const POOL_STATE_FILE = process.env.POOL_STATE_FILE
|
|
44
|
+
|| path.join(rootDir, '.codeyam', 'logs', 'fleet-pool.json');
|
|
45
|
+
|
|
46
|
+
const POOL = 'pool';
|
|
47
|
+
const ASSIGNED = 'assigned';
|
|
48
|
+
|
|
49
|
+
// ── Pure state helpers ──────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
// Coerce arbitrary parsed JSON into the canonical `{ vms: { <n>: entry } }`
|
|
52
|
+
// shape. Drops entries whose key is not a positive integer or whose status is
|
|
53
|
+
// unrecognized, so a truncated write or a hand-edit never feeds a malformed
|
|
54
|
+
// entry into the claim path. Returned object is a fresh copy — callers mutate
|
|
55
|
+
// it freely.
|
|
56
|
+
function normalizeState(raw) {
|
|
57
|
+
const out = { vms: {} };
|
|
58
|
+
const vms = raw && typeof raw === 'object' ? raw.vms : null;
|
|
59
|
+
if (!vms || typeof vms !== 'object') return out;
|
|
60
|
+
for (const key of Object.keys(vms)) {
|
|
61
|
+
const n = Number(key);
|
|
62
|
+
if (!Number.isInteger(n) || n <= 0) continue;
|
|
63
|
+
const e = vms[key] || {};
|
|
64
|
+
const status = e.status === ASSIGNED ? ASSIGNED : e.status === POOL ? POOL : null;
|
|
65
|
+
if (!status) continue;
|
|
66
|
+
out.vms[n] = {
|
|
67
|
+
status,
|
|
68
|
+
branch: typeof e.branch === 'string' ? e.branch : null,
|
|
69
|
+
freshAt: typeof e.freshAt === 'string' ? e.freshAt : null,
|
|
70
|
+
claimedAt: typeof e.claimedAt === 'string' ? e.claimedAt : null,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// VM numbers in a given status, sorted ascending. Ascending order makes claim
|
|
77
|
+
// selection deterministic (lowest-numbered idle VM first) so tests and two
|
|
78
|
+
// concurrent callers agree on which VM a claim takes.
|
|
79
|
+
function membersByStatus(state, status) {
|
|
80
|
+
return Object.keys(state.vms)
|
|
81
|
+
.map(Number)
|
|
82
|
+
.filter((n) => state.vms[n].status === status)
|
|
83
|
+
.sort((a, b) => a - b);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function poolMembers(state) {
|
|
87
|
+
return membersByStatus(state, POOL);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function assignedMembers(state) {
|
|
91
|
+
return membersByStatus(state, ASSIGNED);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// True when a pool entry's auth/health is older than `ttlMs` (its token may
|
|
95
|
+
// have lapsed). An entry with no `freshAt` is treated as stale — "freshness
|
|
96
|
+
// not proven" fails closed, mirroring fleet-ready's auth gate. Only meaningful
|
|
97
|
+
// for pooled VMs; an assigned VM is the operator's concern, not the keeper's.
|
|
98
|
+
function isStale(entry, nowMs, ttlMs) {
|
|
99
|
+
if (!entry || entry.status !== POOL) return false;
|
|
100
|
+
if (!entry.freshAt) return true;
|
|
101
|
+
const t = Date.parse(entry.freshAt);
|
|
102
|
+
if (Number.isNaN(t)) return true;
|
|
103
|
+
return nowMs - t > ttlMs;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Pool VM numbers whose freshness has lapsed — the evict-before-claim set the
|
|
107
|
+
// keeper drops (and replaces) so a claim never hands a caller a stale VM.
|
|
108
|
+
function staleMembers(state, nowMs, ttlMs) {
|
|
109
|
+
return poolMembers(state).filter((n) => isStale(state.vms[n], nowMs, ttlMs));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// How many NEW pool VMs the keeper must provision to reach `target` idle
|
|
113
|
+
// members. Assigned VMs do not count toward the pool — once claimed they're
|
|
114
|
+
// gone from the warm set until released — so a claim naturally triggers a
|
|
115
|
+
// refill. Never negative (an over-target pool is shrunk by policy, not here).
|
|
116
|
+
function refillCount(state, target) {
|
|
117
|
+
const have = poolMembers(state).length;
|
|
118
|
+
return Math.max(0, target - have);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The heart of the claim: deterministically move up to `count` idle pool VMs to
|
|
122
|
+
// `assigned` on `branch`. Pure — returns { next, claimed, shortfall } without
|
|
123
|
+
// touching disk; the caller persists `next` under the lock. Lowest-numbered
|
|
124
|
+
// VMs are taken first. `shortfall` (requested minus granted) is the signal the
|
|
125
|
+
// orchestrator uses to fall back to on-demand provisioning when the pool can't
|
|
126
|
+
// satisfy the whole request — an empty pool yields claimed=[] , shortfall=count
|
|
127
|
+
// rather than an error.
|
|
128
|
+
function selectForClaim(state, count, branch, nowIso) {
|
|
129
|
+
const next = normalizeState(state);
|
|
130
|
+
const idle = poolMembers(next);
|
|
131
|
+
const take = idle.slice(0, Math.max(0, count));
|
|
132
|
+
for (const n of take) {
|
|
133
|
+
next.vms[n] = {
|
|
134
|
+
...next.vms[n],
|
|
135
|
+
status: ASSIGNED,
|
|
136
|
+
branch,
|
|
137
|
+
claimedAt: nowIso,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
return { next, claimed: take, shortfall: Math.max(0, count - take.length) };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Register (or overwrite) a VM in the pool state. The keeper calls this after
|
|
144
|
+
// provisioning a fresh READY VM (status=pool) and the claim path can re-add an
|
|
145
|
+
// assigned VM. `freshAt` defaults to now for a pool member so a just-verified
|
|
146
|
+
// VM isn't immediately stale.
|
|
147
|
+
function applyAdd(state, n, status, branch, nowIso) {
|
|
148
|
+
const next = normalizeState(state);
|
|
149
|
+
const st = status === ASSIGNED ? ASSIGNED : POOL;
|
|
150
|
+
next.vms[n] = {
|
|
151
|
+
status: st,
|
|
152
|
+
branch: branch || null,
|
|
153
|
+
freshAt: st === POOL ? nowIso : (next.vms[n] && next.vms[n].freshAt) || null,
|
|
154
|
+
claimedAt: st === ASSIGNED ? nowIso : null,
|
|
155
|
+
};
|
|
156
|
+
return next;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Return an assigned VM to the idle pool (operator finished with it). A no-op
|
|
160
|
+
// for an absent VM. `freshAt` is reset to now: a just-released VM was live and
|
|
161
|
+
// authed, so it's claim-ready again without waiting for the keeper's refresh.
|
|
162
|
+
function applyRelease(state, n, nowIso) {
|
|
163
|
+
const next = normalizeState(state);
|
|
164
|
+
if (!next.vms[n]) return next;
|
|
165
|
+
next.vms[n] = {
|
|
166
|
+
...next.vms[n],
|
|
167
|
+
status: POOL,
|
|
168
|
+
freshAt: nowIso,
|
|
169
|
+
claimedAt: null,
|
|
170
|
+
};
|
|
171
|
+
return next;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Drop a VM from the pool state entirely (the keeper evicts a stale/unhealthy
|
|
175
|
+
// member before replacing it). A no-op for an absent VM.
|
|
176
|
+
function applyEvict(state, n) {
|
|
177
|
+
const next = normalizeState(state);
|
|
178
|
+
delete next.vms[n];
|
|
179
|
+
return next;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Stamp a pool member as freshly auth/health-verified. The keeper calls this
|
|
183
|
+
// after a successful credential refresh so the member's eviction clock resets.
|
|
184
|
+
function applyMarkFresh(state, n, nowIso) {
|
|
185
|
+
const next = normalizeState(state);
|
|
186
|
+
if (!next.vms[n]) return next;
|
|
187
|
+
next.vms[n] = { ...next.vms[n], freshAt: nowIso };
|
|
188
|
+
return next;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── Atomic file layer ───────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
// Synchronous millisecond sleep without a busy CPU spin — used by withLock's
|
|
194
|
+
// retry. Atomics.wait blocks the (single) thread for `ms`; a fresh 4-byte
|
|
195
|
+
// SharedArrayBuffer that nothing else holds means the wait always times out
|
|
196
|
+
// rather than being woken early.
|
|
197
|
+
function sleepSync(ms) {
|
|
198
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Run `fn` while holding a cross-PROCESS mutex around the pool-state file, so a
|
|
202
|
+
// read-modify-write (the claim) is atomic even when two operators run
|
|
203
|
+
// `fleet-claim` at the same moment. The lock is a directory next to the state
|
|
204
|
+
// file: `mkdir` is atomic on POSIX, so exactly one process wins the create and
|
|
205
|
+
// the rest retry. A lock older than `staleMs` is assumed abandoned (a crashed
|
|
206
|
+
// claimer) and stolen, so a dead process can't wedge the pool forever.
|
|
207
|
+
function withLock(fn, { retries = 100, delayMs = 25, staleMs = 30000 } = {}) {
|
|
208
|
+
const lockDir = `${POOL_STATE_FILE}.lock`;
|
|
209
|
+
fs.mkdirSync(path.dirname(POOL_STATE_FILE), { recursive: true });
|
|
210
|
+
let held = false;
|
|
211
|
+
for (let i = 0; i < retries && !held; i += 1) {
|
|
212
|
+
try {
|
|
213
|
+
fs.mkdirSync(lockDir);
|
|
214
|
+
held = true;
|
|
215
|
+
} catch (err) {
|
|
216
|
+
if (err.code !== 'EEXIST') throw err;
|
|
217
|
+
// Steal an abandoned lock (claimer crashed mid-operation).
|
|
218
|
+
try {
|
|
219
|
+
const age = Date.now() - fs.statSync(lockDir).mtimeMs;
|
|
220
|
+
if (age > staleMs) {
|
|
221
|
+
fs.rmdirSync(lockDir);
|
|
222
|
+
continue; // retry the mkdir immediately
|
|
223
|
+
}
|
|
224
|
+
} catch { /* lock vanished between stat and now — just retry */ }
|
|
225
|
+
sleepSync(delayMs);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (!held) throw new Error(`fleet-pool: could not acquire lock ${lockDir} after ${retries} tries`);
|
|
229
|
+
try {
|
|
230
|
+
return fn();
|
|
231
|
+
} finally {
|
|
232
|
+
try { fs.rmdirSync(lockDir); } catch { /* already gone */ }
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Read + normalize the pool state. A missing or malformed file is the empty
|
|
237
|
+
// pool `{ vms: {} }` (the documented tolerant contract), so a truncated write
|
|
238
|
+
// never throws into the keeper's reconcile loop.
|
|
239
|
+
function readState() {
|
|
240
|
+
try {
|
|
241
|
+
return normalizeState(JSON.parse(fs.readFileSync(POOL_STATE_FILE, 'utf8')));
|
|
242
|
+
} catch {
|
|
243
|
+
return { vms: {} };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Persist `state` atomically: write a pid-tagged temp file then `rename` over
|
|
248
|
+
// the target so a concurrent reader never observes a half-written file (same
|
|
249
|
+
// discipline as fleet-roster.writeRoster). Returns the normalized state.
|
|
250
|
+
function writeState(state) {
|
|
251
|
+
const next = normalizeState(state);
|
|
252
|
+
fs.mkdirSync(path.dirname(POOL_STATE_FILE), { recursive: true });
|
|
253
|
+
const tmp = `${POOL_STATE_FILE}.${process.pid}.tmp`;
|
|
254
|
+
fs.writeFileSync(tmp, JSON.stringify(next));
|
|
255
|
+
fs.renameSync(tmp, POOL_STATE_FILE);
|
|
256
|
+
return next;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
module.exports = {
|
|
260
|
+
POOL_STATE_FILE,
|
|
261
|
+
POOL,
|
|
262
|
+
ASSIGNED,
|
|
263
|
+
normalizeState,
|
|
264
|
+
poolMembers,
|
|
265
|
+
assignedMembers,
|
|
266
|
+
isStale,
|
|
267
|
+
staleMembers,
|
|
268
|
+
refillCount,
|
|
269
|
+
selectForClaim,
|
|
270
|
+
applyAdd,
|
|
271
|
+
applyRelease,
|
|
272
|
+
applyEvict,
|
|
273
|
+
applyMarkFresh,
|
|
274
|
+
withLock,
|
|
275
|
+
readState,
|
|
276
|
+
writeState,
|
|
277
|
+
main,
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
// ── CLI seam ────────────────────────────────────────────────────────────────
|
|
281
|
+
// The bash orchestrators call these subcommands instead of re-deriving the
|
|
282
|
+
// logic in shell. Each mutating subcommand runs its read-modify-write inside
|
|
283
|
+
// withLock so concurrent callers serialize. Output is line-oriented and
|
|
284
|
+
// machine-parseable (space-separated VM numbers / a single integer) so the
|
|
285
|
+
// shell side stays a thin `$(node fleet-pool.js ...)` capture.
|
|
286
|
+
|
|
287
|
+
function parsePoolFlags(argv) {
|
|
288
|
+
const flags = {};
|
|
289
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
290
|
+
const a = argv[i];
|
|
291
|
+
if (a.startsWith('--')) {
|
|
292
|
+
const key = a.slice(2);
|
|
293
|
+
const val = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[(i += 1)] : 'true';
|
|
294
|
+
flags[key] = val;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return flags;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function main(argv) {
|
|
301
|
+
const [cmd, ...rest] = argv;
|
|
302
|
+
const flags = parsePoolFlags(rest);
|
|
303
|
+
const now = flags.now || new Date().toISOString();
|
|
304
|
+
|
|
305
|
+
switch (cmd) {
|
|
306
|
+
case 'list':
|
|
307
|
+
process.stdout.write(`${JSON.stringify(readState(), null, 2)}\n`);
|
|
308
|
+
return 0;
|
|
309
|
+
case 'pool-members':
|
|
310
|
+
process.stdout.write(`${poolMembers(readState()).join(' ')}\n`);
|
|
311
|
+
return 0;
|
|
312
|
+
case 'assigned-members':
|
|
313
|
+
process.stdout.write(`${assignedMembers(readState()).join(' ')}\n`);
|
|
314
|
+
return 0;
|
|
315
|
+
case 'refill-count': {
|
|
316
|
+
const target = Number(flags.target);
|
|
317
|
+
if (!Number.isInteger(target) || target < 0) {
|
|
318
|
+
process.stderr.write('fleet-pool: refill-count needs --target <non-negative int>\n');
|
|
319
|
+
return 2;
|
|
320
|
+
}
|
|
321
|
+
process.stdout.write(`${refillCount(readState(), target)}\n`);
|
|
322
|
+
return 0;
|
|
323
|
+
}
|
|
324
|
+
case 'stale': {
|
|
325
|
+
const ttl = Number(flags['ttl-seconds']);
|
|
326
|
+
if (!Number.isInteger(ttl) || ttl < 0) {
|
|
327
|
+
process.stderr.write('fleet-pool: stale needs --ttl-seconds <non-negative int>\n');
|
|
328
|
+
return 2;
|
|
329
|
+
}
|
|
330
|
+
const out = staleMembers(readState(), Date.parse(now), ttl * 1000);
|
|
331
|
+
process.stdout.write(`${out.join(' ')}\n`);
|
|
332
|
+
return 0;
|
|
333
|
+
}
|
|
334
|
+
case 'claim': {
|
|
335
|
+
const count = Number(flags.count);
|
|
336
|
+
const branch = flags.branch;
|
|
337
|
+
if (!Number.isInteger(count) || count <= 0 || !branch || branch === 'true') {
|
|
338
|
+
process.stderr.write('fleet-pool: claim needs --count <pos int> --branch <name>\n');
|
|
339
|
+
return 2;
|
|
340
|
+
}
|
|
341
|
+
const result = withLock(() => {
|
|
342
|
+
const { next, claimed, shortfall } = selectForClaim(readState(), count, branch, now);
|
|
343
|
+
writeState(next);
|
|
344
|
+
return { claimed, shortfall };
|
|
345
|
+
});
|
|
346
|
+
// VM numbers on stdout (the orchestrator rebrands each); shortfall on
|
|
347
|
+
// stderr so an empty/short pool is visible without polluting the capture.
|
|
348
|
+
process.stdout.write(`${result.claimed.join(' ')}\n`);
|
|
349
|
+
if (result.shortfall > 0) {
|
|
350
|
+
process.stderr.write(`fleet-pool: shortfall ${result.shortfall} (pool could not satisfy full claim)\n`);
|
|
351
|
+
}
|
|
352
|
+
return 0;
|
|
353
|
+
}
|
|
354
|
+
case 'add': {
|
|
355
|
+
const vm = Number(flags.vm);
|
|
356
|
+
if (!Number.isInteger(vm) || vm <= 0) {
|
|
357
|
+
process.stderr.write('fleet-pool: add needs --vm <pos int>\n');
|
|
358
|
+
return 2;
|
|
359
|
+
}
|
|
360
|
+
const status = flags.status === ASSIGNED ? ASSIGNED : POOL;
|
|
361
|
+
withLock(() => writeState(applyAdd(readState(), vm, status, flags.branch, now)));
|
|
362
|
+
return 0;
|
|
363
|
+
}
|
|
364
|
+
case 'release': {
|
|
365
|
+
const vm = Number(flags.vm);
|
|
366
|
+
if (!Number.isInteger(vm) || vm <= 0) {
|
|
367
|
+
process.stderr.write('fleet-pool: release needs --vm <pos int>\n');
|
|
368
|
+
return 2;
|
|
369
|
+
}
|
|
370
|
+
withLock(() => writeState(applyRelease(readState(), vm, now)));
|
|
371
|
+
return 0;
|
|
372
|
+
}
|
|
373
|
+
case 'evict': {
|
|
374
|
+
const vm = Number(flags.vm);
|
|
375
|
+
if (!Number.isInteger(vm) || vm <= 0) {
|
|
376
|
+
process.stderr.write('fleet-pool: evict needs --vm <pos int>\n');
|
|
377
|
+
return 2;
|
|
378
|
+
}
|
|
379
|
+
withLock(() => writeState(applyEvict(readState(), vm)));
|
|
380
|
+
return 0;
|
|
381
|
+
}
|
|
382
|
+
case 'mark-fresh': {
|
|
383
|
+
const vm = Number(flags.vm);
|
|
384
|
+
if (!Number.isInteger(vm) || vm <= 0) {
|
|
385
|
+
process.stderr.write('fleet-pool: mark-fresh needs --vm <pos int>\n');
|
|
386
|
+
return 2;
|
|
387
|
+
}
|
|
388
|
+
withLock(() => writeState(applyMarkFresh(readState(), vm, now)));
|
|
389
|
+
return 0;
|
|
390
|
+
}
|
|
391
|
+
default:
|
|
392
|
+
process.stderr.write(
|
|
393
|
+
'fleet-pool: usage: <list|pool-members|assigned-members|refill-count|stale|claim|add|release|evict|mark-fresh> [--flags]\n',
|
|
394
|
+
);
|
|
395
|
+
return 2;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (require.main === module) {
|
|
400
|
+
process.exit(main(process.argv.slice(2)));
|
|
401
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Pure helpers for the fleet dashboard's commit-queue auto-release logic
|
|
3
|
+
// (scripts/operator/fleet-dashboard/server.js requires this). The decision
|
|
4
|
+
// of whether to auto-release a queue head lives here so server.js's
|
|
5
|
+
// orchestration stays thin and this piece can be unit-tested in isolation
|
|
6
|
+
// (npm/fleet-queue.test.js).
|
|
7
|
+
//
|
|
8
|
+
// Auto-release fires only on the deterministic "owning VM left the fleet"
|
|
9
|
+
// signal — a VM that is absent from the roster AND has no in-flight
|
|
10
|
+
// provisioning job. The ambiguous "in roster but unreachable" case is
|
|
11
|
+
// explicitly out of scope: a transient blip could trigger a spurious
|
|
12
|
+
// release. That case is surfaced as a badge for the operator to act on.
|
|
13
|
+
|
|
14
|
+
/** Decide whether the current queue head should be auto-released.
|
|
15
|
+
*
|
|
16
|
+
* Conditions that must ALL hold:
|
|
17
|
+
* 1. The head entry's `vm` field is non-null (the machine name matched the
|
|
18
|
+
* fleet's INSTANCE_PREFIX-…-<n> shape via vmFromMachine). An
|
|
19
|
+
* unattributable head (vm===null) is never auto-released.
|
|
20
|
+
* 2. That VM number is absent from the live `vms` roster array.
|
|
21
|
+
* 3. There is no in-flight or queued provisioning job for that number
|
|
22
|
+
* (guards the Add→addVm gap where a VM is being created but not yet
|
|
23
|
+
* rostered). Also guards known-orphan VM numbers.
|
|
24
|
+
*
|
|
25
|
+
* Returns { release: false } or { release: true, uuid, vm, reason }.
|
|
26
|
+
* Pure; no I/O.
|
|
27
|
+
*/
|
|
28
|
+
function decideAutoReleaseGoneHead({ queue, vms, jobs, orphans }) {
|
|
29
|
+
const head = queue && queue[0];
|
|
30
|
+
if (!head || head.vm == null) return { release: false };
|
|
31
|
+
const n = head.vm;
|
|
32
|
+
if (vms.includes(n)) return { release: false };
|
|
33
|
+
const job = jobs[n];
|
|
34
|
+
const provisioning = job && (job.state === 'queued' || job.state === 'running')
|
|
35
|
+
&& /add|cloud:up/.test(job.action || '');
|
|
36
|
+
if (provisioning || orphans[n]) return { release: false };
|
|
37
|
+
return { release: true, uuid: head.uuid, vm: n, reason: `owning VM-${n} left the fleet` };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { decideAutoReleaseGoneHead };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// npm/fleet-reconcile.js — pure reconciliation helpers for the fleet dashboard's
|
|
4
|
+
// "unrostered VM" safety net (improve38). The dashboard lists running GCP
|
|
5
|
+
// instances and surfaces any that aren't in the roster (provisioned out-of-band
|
|
6
|
+
// — raw `gcloud`, or a `cloud:up` whose roster write failed). The gcloud I/O
|
|
7
|
+
// lives in server.js (the untestable server shell); the pure parse + set
|
|
8
|
+
// difference live here so they're unit-tested without invoking gcloud.
|
|
9
|
+
|
|
10
|
+
// Parse `gcloud compute instances list --format='value(name)'` output into the
|
|
11
|
+
// sorted, de-duped fleet VM numbers it names — instances whose name matches
|
|
12
|
+
// `<prefix><n>`. Non-matching lines (other instances, blank lines, gcloud/SSH
|
|
13
|
+
// warning noise) are ignored.
|
|
14
|
+
function parseRunningFleetVms(stdout, prefix) {
|
|
15
|
+
const re = new RegExp(`^${String(prefix).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\d+)$`);
|
|
16
|
+
const ns = String(stdout || '')
|
|
17
|
+
.split('\n')
|
|
18
|
+
.map((line) => {
|
|
19
|
+
const m = line.trim().match(re);
|
|
20
|
+
return m ? Number(m[1]) : null;
|
|
21
|
+
})
|
|
22
|
+
.filter((n) => n != null);
|
|
23
|
+
return [...new Set(ns)].sort((a, b) => a - b);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Set difference: the `running` VM numbers that are NOT already accounted for in
|
|
27
|
+
// `shown` (the roster plus any provisioning/orphan numbers already rendered as a
|
|
28
|
+
// card). Returns a sorted, de-duped array — the numbers that need an Adopt card.
|
|
29
|
+
function computeUnrostered(running, shown) {
|
|
30
|
+
const accounted = new Set(shown);
|
|
31
|
+
return [...new Set(running)]
|
|
32
|
+
.filter((n) => !accounted.has(n))
|
|
33
|
+
.sort((a, b) => a - b);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Map a single degraded reason to the live health signal that resolves it.
|
|
37
|
+
// Returns true only when that signal now passes; a reason with no mapped signal
|
|
38
|
+
// returns false (retained — a genuine config problem, never a false-green).
|
|
39
|
+
// Start with the two observed classes (improve41); extend the table as new
|
|
40
|
+
// degraded reasons gain a corresponding live signal.
|
|
41
|
+
function reasonClearedByHealth(reason, { reachableServing, authed }) {
|
|
42
|
+
const r = String(reason || '');
|
|
43
|
+
if (/claude auth|claude\.json|login/i.test(r)) return authed;
|
|
44
|
+
if (/container|never came up|provisioning failed/i.test(r)) return reachableServing;
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Given a terminal add-job badge and the VM's live health, return the corrected
|
|
49
|
+
// badge — or null when nothing changes. Conservative by construction: clears a
|
|
50
|
+
// degraded reason only when its live health signal now passes; flips a bare
|
|
51
|
+
// `error` add to `done` when the VM is reachable + serving; leaves any reason
|
|
52
|
+
// with no health signal intact (no false-green). The poll loop supplies the live
|
|
53
|
+
// facts (I/O at the edge) and applies the result; the verdict itself is pure.
|
|
54
|
+
// job: { state: 'done'|'error', degraded, degradedReasons }
|
|
55
|
+
// health: { reachable, editorHttpOk, providerAuthState }
|
|
56
|
+
// → null | { state, degraded, degradedReasons, transition }
|
|
57
|
+
function reconcileTerminalAddBadge({ job, health } = {}) {
|
|
58
|
+
if (!job || !health) return null;
|
|
59
|
+
const reachableServing = !!(health.reachable && health.editorHttpOk);
|
|
60
|
+
const authed = health.providerAuthState === 'authenticated';
|
|
61
|
+
|
|
62
|
+
// Bare `error` add (no degraded reasons): flip to `done` once the VM is
|
|
63
|
+
// reachable + serving — the VM3 case (a manual `cloud:up` recovered the editor
|
|
64
|
+
// outside the dashboard's view).
|
|
65
|
+
if (job.state === 'error') {
|
|
66
|
+
const reasons = job.degradedReasons || [];
|
|
67
|
+
if (reasons.length === 0 && reachableServing) {
|
|
68
|
+
return { state: 'done', degraded: false, degradedReasons: [], transition: 'error→done (editor 200)' };
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Terminal `done` that is degraded: clear each reason whose live signal now
|
|
74
|
+
// passes; retain the rest. Drop `degraded` only when no reasons remain.
|
|
75
|
+
if (job.state === 'done' && job.degraded) {
|
|
76
|
+
const reasons = job.degradedReasons || [];
|
|
77
|
+
const remaining = reasons.filter((reason) => !reasonClearedByHealth(reason, { reachableServing, authed }));
|
|
78
|
+
if (remaining.length === reasons.length) return null; // nothing cleared → no-op
|
|
79
|
+
const cleared = reasons.length - remaining.length;
|
|
80
|
+
return {
|
|
81
|
+
state: 'done',
|
|
82
|
+
degraded: remaining.length > 0,
|
|
83
|
+
degradedReasons: remaining,
|
|
84
|
+
transition: remaining.length > 0
|
|
85
|
+
? `degraded: cleared ${cleared} reason(s), ${remaining.length} remain`
|
|
86
|
+
: 'degraded→clear (health restored)',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = { parseRunningFleetVms, computeUnrostered, reconcileTerminalAddBadge };
|