@codeyam-editor/codeyam-editor 0.1.1 → 0.1.2

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.
Files changed (46) hide show
  1. package/npm/cli.js +5 -2
  2. package/npm/cloud-branch-switch.js +442 -0
  3. package/npm/cloud.js +1521 -0
  4. package/npm/container.js +22 -3
  5. package/npm/editor-dev.js +286 -36
  6. package/npm/editor.js +98 -35
  7. package/npm/empty-project-dir.js +33 -0
  8. package/npm/fleet-auth.js +111 -0
  9. package/npm/fleet-cleanup.js +124 -0
  10. package/npm/fleet-env.js +125 -0
  11. package/npm/fleet-memory.js +127 -0
  12. package/npm/fleet-pool-policy.js +78 -0
  13. package/npm/fleet-pool.js +401 -0
  14. package/npm/fleet-queue.js +40 -0
  15. package/npm/fleet-reconcile.js +36 -0
  16. package/npm/fleet-reset.js +144 -0
  17. package/npm/fleet-roster.js +81 -0
  18. package/npm/launch-config.js +544 -0
  19. package/npm/preflight-guidance.js +68 -0
  20. package/npm/scenario-check.js +298 -14
  21. package/npm/scenario-handlers.js +16 -5
  22. package/npm/scenario-interactivity.js +199 -0
  23. package/npm/scenario-metrics.js +16 -2
  24. package/npm/scenario-mocks.js +62 -25
  25. package/npm/scenario-playwright.js +169 -6
  26. package/npm/utils.js +507 -21
  27. package/npm/vm-urls.js +117 -0
  28. package/package.json +8 -7
  29. package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +2 -0
  30. package/ui/dist/assets/EditorShell-DWaeBxgj.js +186 -0
  31. package/ui/dist/assets/Markdown-BHBqz-Mj.js +1 -0
  32. package/ui/dist/assets/ScenarioDataPanel-DYP7pi_n.css +32 -0
  33. package/ui/dist/assets/ScenarioDataPanel-zJSXtXL4.js +5 -0
  34. package/ui/dist/assets/ScenarioDataPanelFullPage-C0YHQB-T.js +1 -0
  35. package/ui/dist/assets/ScenarioRenderer-CgDxuigm.js +1 -0
  36. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +1 -0
  37. package/ui/dist/assets/index-BUYNFqKK.js +27 -0
  38. package/ui/dist/assets/index-DVr5rY2d.css +1 -0
  39. package/ui/dist/assets/markdown-v0F0UEt3.js +37 -0
  40. package/ui/dist/assets/react-CSS0HapR.js +28 -0
  41. package/ui/dist/assets/useEvents-DggWrtHe.js +1 -0
  42. package/ui/dist/assets/xterm--24IGk-x.js +9 -0
  43. package/ui/dist/favicon.ico +0 -0
  44. package/ui/dist/favicon.png +0 -0
  45. package/ui/dist/index.html +16 -0
  46. package/npm/postinstall.js +0 -132
@@ -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,36 @@
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
+ module.exports = { parseRunningFleetVms, computeUnrostered };
@@ -0,0 +1,144 @@
1
+ 'use strict';
2
+ // Pure bash-script builders for the fleet dashboard's RESET actions
3
+ // (scripts/operator/fleet-dashboard/server.js requires this). String
4
+ // construction only — no I/O, no gcloud/docker exec — so the reset *recipe*
5
+ // (what the remote bash actually does) stays unit-testable in isolation
6
+ // (npm/fleet-reset.test.js), exactly like launch-config.js / fleet-env.js.
7
+ // The dispatch (gcloud ssh → docker exec → startJob) stays in server.js as
8
+ // platform-glue; only the decision-bearing script shape lives here.
9
+
10
+ // The `.codeyam` deep-clean preserve→pause→wipe→restore sequence, shared by every
11
+ // reset that hard-cleans a /workspace. `git reset --hard` + `session-reset`
12
+ // leave the git-IGNORED runtime state behind (workflow step files,
13
+ // scenario-*.js, server-state, logs, caches), so we explicitly wipe and
14
+ // restore the tracked `.codeyam`. Two gitignored files MUST survive the wipe:
15
+ // - editor.local.json — previewOrigin + the agent API key (a blind wipe
16
+ // breaks Live Preview + auth).
17
+ // - server-state.json — the running pid + control port the restart's verify
18
+ // reads.
19
+ // Both are cp'd aside, `.codeyam` is wiped, restored from the branch (tracked,
20
+ // e.g. self-host) or re-`init`'d (greenfield, where `.codeyam` is gitignored),
21
+ // then the two files are copied back. Returned as step strings the caller
22
+ // joins into its own `&&`-chain (the surrounding `( …; true )` guards keep each
23
+ // best-effort cp from breaking the chain when a file is absent).
24
+ //
25
+ // First step: release any commit-queue entries this VM owns. The wipe
26
+ // regenerates `.codeyam/run/queue-origin-id`, which would otherwise orphan
27
+ // the prior session's entry (different origin_id, same machine — invisible
28
+ // to the two-key adoption path until the next enqueue's same-machine guard
29
+ // reaps it). Routing through `branch-queue release-mine` makes the
30
+ // queue-side cleanup intentional and visible in the queue log instead.
31
+ // Best-effort: a no-remote / queue-disabled project legitimately exits
32
+ // non-zero, and we should not block the reset.
33
+ //
34
+ // Between the cp-asides and the `rm -rf` we cooperatively pause the editor
35
+ // server (`stop-server --hold-for-restart`). Without that, the server keeps
36
+ // writing to `.codeyam/logs/editor-server.log` and `.codeyam/run/` between
37
+ // when `rm -rf` enumerates the dir and when it tries to `rmdir` the empty
38
+ // directory — it surfaces as `rm: cannot remove '.codeyam': Directory not
39
+ // empty`, which the dashboard reports as `error`. SIGUSR2 closes listeners +
40
+ // stops the dev-server and parks the PID; the parent script's trailing
41
+ // `restart-server --force-in-container` then resurrects via the existing
42
+ // SIGUSR1 in-place reexec. Fail-soft `|| true` so a server that's already
43
+ // dead (e.g. the reset is rerun after a partial failure) doesn't block the
44
+ // chain.
45
+ function codeyamDeepCleanSteps(E) {
46
+ return [
47
+ `( ${E} editor branch-queue release-mine --yes 2>/dev/null; true )`,
48
+ '( cp .codeyam/editor.local.json /tmp/cy-elj.bak 2>/dev/null; true )',
49
+ '( cp .codeyam/server-state.json /tmp/cy-ss.bak 2>/dev/null; true )',
50
+ `(${E} editor stop-server --hold-for-restart --force-in-container || true)`,
51
+ 'rm -rf .codeyam',
52
+ `( git checkout HEAD -- .codeyam 2>/dev/null || ${E} init )`,
53
+ '( test -f /tmp/cy-elj.bak && mkdir -p .codeyam && cp /tmp/cy-elj.bak .codeyam/editor.local.json && rm -f /tmp/cy-elj.bak; true )',
54
+ '( test -f /tmp/cy-ss.bak && mkdir -p .codeyam && cp /tmp/cy-ss.bak .codeyam/server-state.json && rm -f /tmp/cy-ss.bak; true )',
55
+ ];
56
+ }
57
+
58
+ // Build the in-container bash for "Reset client" — hard-clean the CLIENT
59
+ // project (/workspace) on ANY VM shape. The decisions encoded here:
60
+ // - Reset to HEAD, NOT origin: `git reset --hard` (bare) discards uncommitted
61
+ // churn but PRESERVES committed work. Advancing to origin tip is the
62
+ // separate "Update client" action — this must never `git fetch`/reset to
63
+ // `origin/<branch>`.
64
+ // - `git reset --hard` leaves untracked files, so node_modules + a per-VM
65
+ // .env survive (no forced re-install / lost secrets).
66
+ // - Stop the broker FIRST so abandoned agent WIP can't be re-written to disk
67
+ // mid-reset; drop a stale LOCAL credential.helper (fragility #29) so the
68
+ // re-seed + the agent's git ops fall back to the working system helper.
69
+ // - Re-seed the git credential cache AFTER the restart so the agent can
70
+ // pull/push immediately; the re-seed is a self-contained subshell whose
71
+ // own `|| echo` never masks an earlier &&-chain failure.
72
+ // `E` is the in-container editor binary; `gitReseedBody` is the re-seed bash
73
+ // body (passed in so this stays a pure function of its inputs).
74
+ function resetClientScript({ E, gitReseedBody }) {
75
+ return [
76
+ 'cd /workspace',
77
+ `command -v ${E} >/dev/null`,
78
+ 'echo ">> [1/5] stopping agent session"',
79
+ `(${E} editor pty-broker stop || true)`,
80
+ '(git config --local --unset-all credential.helper || true)',
81
+ 'echo ">> [2/5] discarding uncommitted changes (git reset --hard to HEAD; keeps committed work + untracked files)"',
82
+ 'git reset --hard',
83
+ 'echo ">> [3/5] deep-cleaning .codeyam (clears ignored runtime state; keeps editor.local.json + server-state.json)"',
84
+ ...codeyamDeepCleanSteps(E),
85
+ `${E} editor session-reset`,
86
+ 'echo ">> [4/5] restarting server to respawn the broker (no rebuild)"',
87
+ `(${E} editor restart-server --force-in-container || true)`,
88
+ 'echo ">> [5/5] re-seeding git credential cache"',
89
+ `( ( ${gitReseedBody} ) || echo "WARN git cache re-seed failed" )`,
90
+ ].join(' && ');
91
+ }
92
+
93
+ // Build the in-container bash for "Reset editor" — the editor-state recipe:
94
+ // stop the agent, drop a stale local credential.helper, advance the VM's branch
95
+ // to origin tip (skipped on a greenfield local-only repo), deep-clean .codeyam
96
+ // (preserving editor.local.json + server-state.json via codeyamDeepCleanSteps),
97
+ // run session-reset, and restart the server. This is the SAME &&-chain
98
+ // previously inlined in actionResetEditorSource in scripts/operator/
99
+ // fleet-dashboard/server.js — extracted so the standalone Reset editor action
100
+ // AND the composite Update & Reset Everything action share one definition and
101
+ // it gets unit-test coverage like the other reset builders.
102
+ //
103
+ // Binary-existence gate FIRST so a broken binary aborts BEFORE the destructive
104
+ // `git reset --hard` / `rm -rf .codeyam`. The composite action relies on this
105
+ // same gate as its safety contract — a failed rebuild that leaves no binary
106
+ // will short-circuit the trailing recipe before any wipe happens.
107
+ function resetEditorStateScript({ E }) {
108
+ return [
109
+ 'cd /workspace',
110
+ `command -v ${E} >/dev/null`,
111
+ // Tolerate a missing broker: `pty-broker stop` exits non-zero when none is
112
+ // running; the subshell + `|| true` keeps it non-fatal without swallowing
113
+ // the binary-existence gate above.
114
+ 'echo ">> [1/4] stopping agent session + clearing workflow state"',
115
+ `(${E} editor pty-broker stop || true)`,
116
+ // Drop a stale LOCAL credential.helper (fragility #29) so `git fetch` falls
117
+ // back to the working system helper. No-op when none is set.
118
+ '(git config --local --unset-all credential.helper || true)',
119
+ // Advance the VM's own branch to origin (skip on a greenfield local-only repo
120
+ // with no remote). fetch + `reset --hard origin/<branch>`, NOT `pull --ff-only`
121
+ // (which dies "Cannot fast-forward to multiple branches" on some VM states).
122
+ 'if git remote | grep -qx origin; then ' +
123
+ 'BR=$(git branch --show-current); ' +
124
+ 'echo ">> [2/4] advancing source on $BR to origin tip"; ' +
125
+ 'git reset --hard && git fetch origin "$BR" && git reset --hard "origin/$BR"; ' +
126
+ 'else echo ">> [2/4] no origin remote (greenfield project) — skipping source advance"; fi',
127
+ // Deep-clean .codeyam: `git reset --hard` + session-reset leave git-IGNORED /
128
+ // untracked state (workflow + design-step state, server-state, logs, caches).
129
+ // Preserve editor.local.json (previewOrigin + agent API key — a blind wipe
130
+ // breaks Live Preview + auth) and server-state.json (runtime pid + control
131
+ // port the restart's verify reads), wipe .codeyam, restore it from the branch
132
+ // (tracked, e.g. self-host) or re-init (greenfield), then put both files back.
133
+ 'echo ">> [3/4] deep-cleaning .codeyam (clears ignored state; keeps editor.local.json + server-state.json)"',
134
+ ...codeyamDeepCleanSteps(E),
135
+ `${E} editor session-reset`,
136
+ // No rebuild here (that's Rebuild binary). Restart the server in place to
137
+ // respawn the broker we stopped: restart-server SIGUSR1s pid 1 and the
138
+ // docker-exec returns cleanly, so `|| true` keeps a broker-down VM green.
139
+ 'echo ">> [4/4] restarting server to respawn the broker (no rebuild)"',
140
+ `(${E} editor restart-server --force-in-container || true)`,
141
+ ].join(' && ');
142
+ }
143
+
144
+ module.exports = { codeyamDeepCleanSteps, resetClientScript, resetEditorStateScript };