@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.
Files changed (66) hide show
  1. package/npm/cloud-branch-switch.js +1 -1
  2. package/npm/cloud.js +1415 -73
  3. package/npm/container.js +3 -0
  4. package/npm/disk-pressure.js +78 -0
  5. package/npm/editor-dev.js +96 -15
  6. package/npm/editor.js +85 -37
  7. package/npm/fleet-auth-health.js +75 -0
  8. package/npm/fleet-auth.js +6 -1
  9. package/npm/fleet-build-data.js +51 -2
  10. package/npm/fleet-cleanup.js +118 -7
  11. package/npm/fleet-destroy-safety.js +55 -0
  12. package/npm/fleet-effect-status.js +33 -32
  13. package/npm/fleet-host.js +74 -0
  14. package/npm/fleet-job-badge.js +1 -1
  15. package/npm/fleet-launchd.js +171 -0
  16. package/npm/fleet-live-status.js +123 -0
  17. package/npm/fleet-plans.js +135 -0
  18. package/npm/fleet-prewarm.js +165 -0
  19. package/npm/fleet-public-url.js +160 -0
  20. package/npm/fleet-queue.js +38 -1
  21. package/npm/fleet-reconcile.js +43 -1
  22. package/npm/fleet-reset.js +298 -13
  23. package/npm/fleet-roster.js +6 -8
  24. package/npm/fleet-self-update.js +142 -0
  25. package/npm/fleet-setup-steps.js +105 -0
  26. package/npm/fleet-sleep-status.js +33 -0
  27. package/npm/fleet-strand.js +150 -0
  28. package/npm/fleet-stuck-status.js +111 -0
  29. package/npm/fleet-vm-access.js +234 -0
  30. package/npm/fleet-vm-readiness.js +104 -0
  31. package/npm/fleet-vm-slugs.js +116 -0
  32. package/npm/google-ingress.js +211 -0
  33. package/npm/ingress-invoke.js +292 -0
  34. package/npm/launch-config.js +204 -3
  35. package/npm/scenario-check.js +603 -32
  36. package/npm/scenario-handlers.js +78 -12
  37. package/npm/scenario-metrics.js +116 -1
  38. package/npm/scenario-mocks.js +20 -0
  39. package/npm/scenario-playwright.js +366 -29
  40. package/npm/script-drift.js +81 -0
  41. package/npm/utils.js +329 -50
  42. package/npm/vm-urls.js +69 -8
  43. package/package.json +5 -5
  44. package/ui/dist/assets/ContainerInspector-D7VZkkaj.js +2 -0
  45. package/ui/dist/assets/EditorShell-DHhIaotd.css +1 -0
  46. package/ui/dist/assets/EditorShell-r-796N_W.js +191 -0
  47. package/ui/dist/assets/{Markdown-BHBqz-Mj.js → Markdown-BoLHHFXH.js} +1 -1
  48. package/ui/dist/assets/ProjectLauncherScreen-Ck-XWkQN.js +1 -0
  49. package/ui/dist/assets/ScenarioDataPanel-B9iDSUpA.js +5 -0
  50. package/ui/dist/assets/ScenarioDataPanelFullPage-CBLTGC4-.js +1 -0
  51. package/ui/dist/assets/ScenarioRenderer-BhLfT-nX.js +21 -0
  52. package/ui/dist/assets/StepsSimulator-BFldWyoX.js +1 -0
  53. package/ui/dist/assets/index-CIXZkwfB.css +1 -0
  54. package/ui/dist/assets/{index-BuK0lizt.js → index-QsOiOG9x.js} +9 -9
  55. package/ui/dist/assets/useEvents-DHkLJZR4.js +1 -0
  56. package/ui/dist/codeyam-mark.svg +4 -0
  57. package/ui/dist/codeyam-wordmark.svg +5 -0
  58. package/ui/dist/index.html +2 -2
  59. package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +0 -2
  60. package/ui/dist/assets/EditorShell-tMMdarZD.js +0 -187
  61. package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +0 -5
  62. package/ui/dist/assets/ScenarioDataPanelFullPage-5G6KuZgw.js +0 -1
  63. package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +0 -1
  64. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
  65. package/ui/dist/assets/index-BGqairFV.css +0 -1
  66. package/ui/dist/assets/useEvents-DggWrtHe.js +0 -1
@@ -33,6 +33,32 @@ function computeUnrostered(running, shown) {
33
33
  .sort((a, b) => a - b);
34
34
  }
35
35
 
36
+ // Streak bookkeeping for the adopt-direction stranded-instance reconcile — the
37
+ // mirror of pollVanishedInstances' `unreachableStreak` gate, in the opposite
38
+ // (adopt) direction. Given the currently-stranded VM numbers (running in GCP
39
+ // but absent from roster / provisioning / orphan), the prior streak map, and
40
+ // the threshold, return the NEXT streak map (incremented for each still-stranded
41
+ // number; entries for no-longer-stranded numbers dropped so a transient blip
42
+ // never leaves a stale count) plus `ready` — the numbers whose streak has
43
+ // reached the threshold and may now be auto-finished. A single stale
44
+ // `runningFleetVms` snapshot therefore can't trigger a recovery: the instance
45
+ // must read stranded for `threshold` consecutive polls first. Pure, so the gate
46
+ // is unit-tested without gcloud or a running server.
47
+ function strandedAdoptDecision({ stranded, streaks, threshold } = {}) {
48
+ const prev = streaks || {};
49
+ const t = Number(threshold) > 0 ? Number(threshold) : 1;
50
+ const present = [...new Set((stranded || []).filter((n) => Number.isInteger(n) && n > 0))];
51
+ const next = {};
52
+ const ready = [];
53
+ for (const n of present) {
54
+ const count = (Number(prev[n]) || 0) + 1;
55
+ next[n] = count;
56
+ if (count >= t) ready.push(n);
57
+ }
58
+ ready.sort((a, b) => a - b);
59
+ return { streaks: next, ready };
60
+ }
61
+
36
62
  // Map a single degraded reason to the live health signal that resolves it.
37
63
  // Returns true only when that signal now passes; a reason with no mapped signal
38
64
  // returns false (retained — a genuine config problem, never a false-green).
@@ -90,4 +116,20 @@ function reconcileTerminalAddBadge({ job, health } = {}) {
90
116
  return null;
91
117
  }
92
118
 
93
- module.exports = { parseRunningFleetVms, computeUnrostered, reconcileTerminalAddBadge };
119
+ // Self-heal for the slug sidecar: given the slug map (`{ "<N>": "<slug>" }`) and
120
+ // the roster (the live VM numbers), return the sorted, de-duped VM numbers whose
121
+ // slug entry should be released because the VM is no longer rostered. A
122
+ // destroyed VM is removed from the roster, so any slug still keyed to a
123
+ // non-rostered number is stranded (e.g. a destroy path that failed to call
124
+ // `removeSlug`) and would otherwise block re-using that name forever. Pure: the
125
+ // caller reads the sidecar + roster (I/O at the edge) and applies `removeSlug`
126
+ // to each returned number. Tolerates a null/absent slug map and an empty roster.
127
+ function pruneOrphanSlugs({ slugs, roster } = {}) {
128
+ const rostered = new Set((roster || []).filter(Number.isInteger));
129
+ const orphans = Object.keys(slugs || {})
130
+ .map((k) => Number(k))
131
+ .filter((n) => Number.isInteger(n) && n > 0 && !rostered.has(n));
132
+ return [...new Set(orphans)].sort((a, b) => a - b);
133
+ }
134
+
135
+ module.exports = { parseRunningFleetVms, computeUnrostered, strandedAdoptDecision, reconcileTerminalAddBadge, pruneOrphanSlugs };
@@ -123,11 +123,15 @@ function codeyamBrokerEnsureStep(E) {
123
123
  }
124
124
 
125
125
  // Build the in-container bash for "Reset client" — hard-clean the CLIENT
126
- // project (/workspace) on ANY VM shape. The decisions encoded here:
127
- // - Reset to HEAD, NOT origin: `git reset --hard` (bare) discards uncommitted
128
- // churn but PRESERVES committed work. Advancing to origin tip is the
129
- // separate "Update client" action — this must never `git fetch`/reset to
130
- // `origin/<branch>`.
126
+ // project (/workspace) on ANY VM shape, advancing to origin tip. The decisions
127
+ // encoded here:
128
+ // - Advance to ORIGIN tip: fetch + `reset --hard origin/<branch>` (the same
129
+ // shape resetEditorStateScript uses), so the working tree ends clean AT the
130
+ // origin tip. This discards uncommitted churn AND unpushed commits — more
131
+ // destructive than the old reset-to-HEAD; the card's confirm dialog warns
132
+ // about the unpushed-commit loss. A greenfield local-only repo (no origin)
133
+ // falls back to a bare `git reset --hard` to HEAD. The non-destructive
134
+ // ff-to-tip-that-keeps-the-session path is the separate "Update client".
131
135
  // - `git reset --hard` leaves untracked files, so node_modules + a per-VM
132
136
  // .env survive (no forced re-install / lost secrets).
133
137
  // - Stop the broker FIRST so abandoned agent WIP can't be re-written to disk
@@ -145,18 +149,34 @@ function resetClientScript({ E, gitReseedBody }) {
145
149
  'echo ">> [1/5] stopping agent session"',
146
150
  `(${E} editor pty-broker stop || true)`,
147
151
  '(git config --local --unset-all credential.helper || true)',
148
- 'echo ">> [2/5] discarding uncommitted changes (git reset --hard to HEAD; keeps committed work + untracked files)"',
149
- 'git reset --hard',
152
+ 'if git remote | grep -qx origin; then ' +
153
+ 'BR=$(git branch --show-current); ' +
154
+ 'echo ">> [2/5] advancing client to origin tip (git fetch + reset --hard origin/$BR; discards uncommitted AND unpushed; keeps untracked files)"; ' +
155
+ 'git reset --hard && git fetch origin "$BR" && git reset --hard "origin/$BR"; ' +
156
+ 'else echo ">> [2/5] no origin remote (greenfield) — git reset --hard to HEAD"; git reset --hard; fi',
150
157
  'echo ">> [3/5] deep-cleaning .codeyam (clears ignored runtime state; keeps editor.local.json + server-state.json)"',
151
158
  ...codeyamDeepCleanSteps(E),
152
159
  `${E} editor session-reset`,
153
- 'echo ">> [4/5] restarting server to respawn the broker (no rebuild)"',
160
+ // Self-host: client === editor, so a client reset advances /workspace (the
161
+ // editor source) and MUST rebuild before restart or the binary↔source
162
+ // mismatch loops. No-op off self-host (the client /workspace is the client
163
+ // app, not the editor source). See selfHostRebuildStep.
164
+ selfHostRebuildStep(E),
165
+ 'echo ">> [4/5] restarting server to respawn the broker"',
154
166
  `(${E} editor restart-server --force-in-container || true)`,
155
167
  // Belt-and-suspenders: ensure a broker is live even on an older binary
156
168
  // whose restart-server doesn't yet respawn it. Idempotent / status-gated.
157
169
  codeyamBrokerEnsureStep(E),
158
170
  'echo ">> [5/5] re-seeding git credential cache"',
159
171
  `( ( ${gitReseedBody} ) || echo "WARN git cache re-seed failed" )`,
172
+ // Terminal contract guard: the reset PROMISES idle, so verify it and end any
173
+ // agent that slipped back after the restart/broker-ensure. No-op when the
174
+ // reset already reached idle. See verifyIdleOrEndSessionStep.
175
+ verifyIdleOrEndSessionStep({ E }),
176
+ // Verify the editor came back SERVING THE PROJECT on :14199 (not stranded in
177
+ // launcher mode); on a self-host strand emit CY_NEEDS_RECONFIGURE so the
178
+ // dashboard auto-falls-back to reconfigure.
179
+ verifyServingProjectOrFlagReconfigureStep(),
160
180
  ].join(' && ');
161
181
  }
162
182
 
@@ -174,7 +194,12 @@ function resetClientScript({ E, gitReseedBody }) {
174
194
  // `git reset --hard` / `rm -rf .codeyam`. The composite action relies on this
175
195
  // same gate as its safety contract — a failed rebuild that leaves no binary
176
196
  // will short-circuit the trailing recipe before any wipe happens.
177
- function resetEditorStateScript({ E }) {
197
+ // `rebuildOnSelfHost` (default true): rebuild the binary before the final
198
+ // restart when this is a self-host VM (see selfHostRebuildStep). The composite
199
+ // update-reset-everything passes `false` because its leading
200
+ // rebuildBinaryHostScript already rebuilt — a second cargo build would be a
201
+ // ~5-8 min no-op.
202
+ function resetEditorStateScript({ E, rebuildOnSelfHost = true } = {}) {
178
203
  return [
179
204
  'cd /workspace',
180
205
  `command -v ${E} >/dev/null`,
@@ -203,20 +228,280 @@ function resetEditorStateScript({ E }) {
203
228
  'echo ">> [3/4] deep-cleaning .codeyam (clears ignored state; keeps editor.local.json + server-state.json)"',
204
229
  ...codeyamDeepCleanSteps(E),
205
230
  `${E} editor session-reset`,
206
- // No rebuild here (that's Rebuild binary). Restart the server in place to
207
- // respawn the broker we stopped: restart-server SIGUSR1s pid 1 and the
208
- // docker-exec returns cleanly, so `|| true` keeps a broker-down VM green.
209
- 'echo ">> [4/4] restarting server to respawn the broker (no rebuild)"',
231
+ // Self-host VMs build the binary from /workspace, so a reset that advanced
232
+ // source MUST rebuild before restarting or the binary↔source mismatch sends
233
+ // the editor into a stale-binary reexec loop. No-op off self-host (the
234
+ // binary is shipped). Skipped when the composite already rebuilt.
235
+ ...(rebuildOnSelfHost ? [selfHostRebuildStep(E)] : []),
236
+ // Restart the server in place to respawn the broker we stopped (now on a
237
+ // binary that matches the advanced source on self-host): restart-server
238
+ // SIGUSR1s pid 1 and the docker-exec returns cleanly, so `|| true` keeps a
239
+ // broker-down VM green.
240
+ 'echo ">> [4/4] restarting server to respawn the broker"',
210
241
  `(${E} editor restart-server --force-in-container || true)`,
211
242
  // Belt-and-suspenders: ensure a broker is live even on an older binary
212
243
  // whose restart-server doesn't yet respawn it. Idempotent / status-gated.
213
244
  codeyamBrokerEnsureStep(E),
245
+ // Terminal contract guard: the reset PROMISES idle, so verify it and end any
246
+ // agent that slipped back after the restart/broker-ensure. No-op when the
247
+ // reset already reached idle. See verifyIdleOrEndSessionStep.
248
+ verifyIdleOrEndSessionStep({ E }),
249
+ // Verify the editor came back SERVING THE PROJECT on :14199 (not stranded in
250
+ // launcher mode on an ephemeral port); on a self-host strand emit
251
+ // CY_NEEDS_RECONFIGURE so the dashboard auto-falls-back to reconfigure.
252
+ verifyServingProjectOrFlagReconfigureStep(),
214
253
  ].join(' && ');
215
254
  }
216
255
 
256
+ // Build the in-container bash for "End session" — the MINIMAL "stop the agent"
257
+ // primitive. Unlike the reset family this does NOT advance source, deep-clean
258
+ // .codeyam, or rebuild: it kills the live agent process and clears the
259
+ // session-specific state files, taking the VM from a live `waiting`/`building`
260
+ // session back to a true `idle` in seconds.
261
+ //
262
+ // The recipe takes the VM to idle (`hasSession:false`) while leaving a BARE
263
+ // broker daemon ready for the next run:
264
+ // - `pty-broker stop` SIGTERMs the broker daemon, which kills the live agent.
265
+ // Fail-soft (`|| true`): a VM with no broker running legitimately exits
266
+ // non-zero (crates/codeyam-editor/src/commands/editor/pty_broker.rs), and a
267
+ // no-op end must not abort the chain.
268
+ // - `session-reset` deletes the session-specific state files (no resume
269
+ // target) so the still-running editor server's view drops to no-session.
270
+ // - codeyamBrokerEnsureStep respawns a BARE daemon (no agent attached) — only
271
+ // if none is reachable. Order matters: stop → reset → ensure, so the
272
+ // ensure can never re-bind stale session state.
273
+ //
274
+ // Why ensure a bare daemon instead of leaving the broker down: End session
275
+ // means "no agent session," NOT "no broker at all." The Build-tab terminal
276
+ // WebSocket handler does NOT respawn the broker on connect — if the daemon is
277
+ // down, `attach_or_spawn` fails with "PTY broker unreachable"
278
+ // (crates/control-api/src/terminal.rs). So leaving the broker down means the
279
+ // next Run / Build-tab open errors until the watchdog catches up. A bare daemon
280
+ // (broker alive, zero sessions) still reports `hasSession:false` → status
281
+ // `idle`, and lets the next `attach_or_spawn` attach instantly and spawn a
282
+ // fresh agent. The builder unit test asserts the chain ends with the daemon
283
+ // ensure and contains NO `restart-server`.
284
+ //
285
+ // CRITICAL: there is NO `restart-server` here. `restart-server` deliberately
286
+ // reattaches the surviving/respawned broker session and brings the agent back
287
+ // (crates/codeyam-editor/src/commands/editor/restart_server.rs), which would
288
+ // undo the end. codeyamBrokerEnsureStep, by contrast, never attaches an agent —
289
+ // it only spawns a bare daemon.
290
+ function endSessionScript({ E }) {
291
+ return [
292
+ 'cd /workspace',
293
+ // Binary-existence gate FIRST, mirroring the reset builders.
294
+ `command -v ${E} >/dev/null`,
295
+ `(${E} editor pty-broker stop || true)`,
296
+ `${E} editor session-reset`,
297
+ // Respawn a BARE daemon (no agent) so the next Run / Build-tab open attaches
298
+ // instantly. Status-gated + idempotent, and NOT restart-server (no resume).
299
+ codeyamBrokerEnsureStep(E),
300
+ ].join(' && ');
301
+ }
302
+
303
+ // Build the TERMINAL "verify idle, end the agent if one slipped back" step,
304
+ // appended as the LAST step of every reset recipe. This is the contract fix for
305
+ // `vm: Resets Must Land at Idle`: the reset actions ("Reset client", "Reset
306
+ // editor", "Reset all + rebuild") all promise to drop the agent and return the
307
+ // VM to a fresh-provision state, but in practice a live Claude agent came back
308
+ // resumed at the Plan step after the reset's trailing restart-server +
309
+ // codeyamBrokerEnsureStep — so the dashboard card read `waiting` instead of
310
+ // `idle` (observed live on VM-4).
311
+ //
312
+ // Diagnosis note (the fix is MECHANISM-AGNOSTIC on purpose): the reset already
313
+ // STOPS the broker before the wipe (codeyamDeepCleanSteps) and runs
314
+ // session-reset, so the agent is killed mid-reset; it reappears only because of
315
+ // the trailing restart-server + broker-ensure — the same two steps
316
+ // endSessionScript deliberately OMITS to land at idle (see its idle-end
317
+ // invariant test). The EXACT re-attach path — restart-server's in-place SIGUSR1
318
+ // reexec resuming a session, the broker-ensure respawn the server then attaches
319
+ // an agent to, or the editor server auto-resuming a workflow on startup — was
320
+ // NOT reproduced on a scratch cloud VM in the authoring environment, so rather
321
+ // than guess at one we make the reset TERMINALLY VERIFY idle regardless of which
322
+ // path fires: read /api/session-info from the now-running server and, if it
323
+ // still reports an active session (`hasSession: true`) OR a live build-slot
324
+ // broker agent (`agentBrokerLive: true`), end the session with the proven
325
+ // endSessionScript primitive (`pty-broker stop` + `session-reset`, with NO
326
+ // further restart). The EITHER condition exactly mirrors the editor UI's
327
+ // Build-dot clear condition in useBuildSessionLive (the dot clears only when
328
+ // `!hasSession && !agentBrokerLive`): a reset that left a live build-slot agent
329
+ // in the broker — the `fresh_branch_surfaces_agent_broker_live_true` server
330
+ // shape, `hasSession:false` + `agentBrokerLive:true` — previously slipped past a
331
+ // `hasSession`-only check, reading "idle" on the dashboard while the editor UI
332
+ // Build dot stayed lit. Gating on either signal closes that gap (and
333
+ // `pty-broker stop` is what drops `agentBrokerLive` to false). Robust even if a
334
+ // future change reintroduces a resume path, and a no-op on the happy path where
335
+ // the reset already reached idle (the bare broker daemon from
336
+ // codeyamBrokerEnsureStep stays up — which reads idle, both signals false).
337
+ //
338
+ // Stack assumption: in-container the editor server's control port is the
339
+ // preserved server-state.json `controlPort` (rewritten by restart-server),
340
+ // falling back to the standard 14199 — the same port the rebuild verify curls
341
+ // (scripts/operator/fleet-dashboard/server.js). server-state.json survives the
342
+ // deep-clean wipe (codeyamDeepCleanSteps cp's it aside and restores it).
343
+ //
344
+ // SETTLE-WINDOW POLL (vm: Resets Stay at Idle After restart-server Reattach):
345
+ // a single point-in-time probe loses a race. The reset family ends with
346
+ // `restart-server --force-in-container`, which is DESIGNED to reattach the
347
+ // surviving broker session and bring the agent back asynchronously
348
+ // (crates/codeyam-editor/src/commands/editor/restart_server.rs:29-33) — a beat
349
+ // AFTER this terminal guard's first curl already saw `hasSession:false` and
350
+ // declared idle. Observed live on VM-3/VM-6 (2026-06-22): the reset-client job
351
+ // logged `>> reset reached idle` then ~90s later `/data` reported
352
+ // `status=building, hasSession=true`. The one-shot check won the probe but lost
353
+ // the race. endSessionScript lands at idle reliably ONLY because it omits
354
+ // restart-server; the reset NEEDS restart-server to respawn the broker / advance
355
+ // the binary, so it must DEFEAT the reattach rather than avoid it.
356
+ //
357
+ // The fix keeps every property of the single-probe guard above (mechanism-
358
+ // agnostic `hasSession || agentBrokerLive` grep, the reused endSessionScript
359
+ // primitive with NO restart-server, the `( …; true )` non-fatal wrapper, the
360
+ // server-state.json controlPort resolution) and turns "checked once, too early"
361
+ // into "checked until stable": a bounded loop probes /api/session-info
362
+ // PROBE_COUNT times (~1s apart), and on ANY probe where a session or live broker
363
+ // agent reappears it ends it with endSessionScript (`pty-broker stop` +
364
+ // `session-reset`, NEVER another restart) and keeps probing — so a late reattach,
365
+ // or even a second one within the window, is each caught. The window outlasts
366
+ // restart-server's async reattach (a few seconds) without stretching the reset.
367
+ // On the happy path (already idle and stays idle) every probe reads idle, zero
368
+ // endSession calls fire, and it ends with the same `reset reached idle` line as
369
+ // before — a no-op, same as the old single probe.
370
+ //
371
+ // Wrapped in `( …; true )` so a curl/parse hiccup mid-window can never abort the
372
+ // reset's &&-chain, exactly like codeyamBrokerEnsureStep.
373
+ function verifyIdleOrEndSessionStep({ E, controlPort = 14199 }) {
374
+ const end = endSessionScript({ E });
375
+ // Settle-window budget. Kept as named locals so the builder test can assert the
376
+ // loop is bounded and a tuning change is a one-line edit. PROBE_COUNT probes
377
+ // PROBE_SLEEP_SECS apart ⇒ ~7s window — long enough to outlast restart-server's
378
+ // async reattach, short enough not to stretch the reset job.
379
+ const PROBE_COUNT = 8;
380
+ const PROBE_SLEEP_SECS = 1;
381
+ const probe = `si=$(curl -sf "http://127.0.0.1:$PORT/api/session-info" 2>/dev/null || true)`;
382
+ const sessionLiveGate = `printf '%s' "$si" | grep -qE '"(hasSession|agentBrokerLive)"[[:space:]]*:[[:space:]]*true'`;
383
+ const reattachMsg =
384
+ '">> reset left a session or live broker agent attached — ending the session to land at idle"';
385
+ return (
386
+ `( PORT=$(grep -oE '"controlPort"[^0-9]*[0-9]+' .codeyam/server-state.json 2>/dev/null | grep -oE '[0-9]+$' | head -1); ` +
387
+ `PORT=\${PORT:-${controlPort}}; ` +
388
+ // Settle window: probe up to PROBE_COUNT times; end any session/agent that
389
+ // reattaches on each probe and keep polling, so a late (or double) reattach
390
+ // after restart-server is caught instead of slipping past a single probe.
391
+ `i=0; while [ "$i" -lt ${PROBE_COUNT} ]; do ` +
392
+ `${probe}; ` +
393
+ `if ${sessionLiveGate}; then echo ${reattachMsg}; ${end}; fi; ` +
394
+ `i=$((i+1)); [ "$i" -lt ${PROBE_COUNT} ] && sleep ${PROBE_SLEEP_SECS}; ` +
395
+ `done; ` +
396
+ // Confirming trailing probe — the window has now outlasted the async
397
+ // reattach, so this read is the authoritative idle verdict. End once more if
398
+ // a session somehow survived the final loop iteration; otherwise declare idle.
399
+ `${probe}; ` +
400
+ `if ${sessionLiveGate}; then echo ${reattachMsg}; ${end}; ` +
401
+ `else echo ">> reset reached idle (no session, no live broker agent)"; fi; true )`
402
+ );
403
+ }
404
+
405
+ // REBUILD-BEFORE-RESTART on a self-host VM. The reset family advances source to
406
+ // origin tip; on a self-host VM (the client project IS codeyam-editor, so
407
+ // /workspace/Cargo.toml exists) the binary is BUILT from /workspace, so a reset
408
+ // that advances source without rebuilding deterministically produces a
409
+ // binary↔source mismatch — and the editor, deciding it's stale, enters a
410
+ // reexec/rebuild loop. So before the final restart we rebuild the binary in
411
+ // place (`rebuild-self`, the same mechanism rebuildBinaryHostScript uses). The
412
+ // self-host axis is detected at RUNTIME with the same `/workspace/Cargo.toml`
413
+ // probe rebuildBinaryHostScript uses, so the NON-self-host path — where the
414
+ // editor binary is shipped, not built from the client repo — keeps its fast
415
+ // "(no rebuild)" restart untouched.
416
+ //
417
+ // Stack assumption: only a self-host VM has /workspace/Cargo.toml AND builds its
418
+ // own binary; a normal client project's /workspace is the client's app, never
419
+ // the editor source, so the probe is false and this is a silent no-op there.
420
+ //
421
+ // Fail-soft: a `rebuild-self` failure `|| echo WARN`s rather than aborting the
422
+ // reset's &&-chain — restarting on the prior (un-advanced-binary) build is
423
+ // recoverable (the restart-server full-restart fallback makes the stale-binary
424
+ // loop non-fatal), and the trailing serving-probe still flags reconfigure if the
425
+ // editor doesn't come back. The whole step is wrapped so it can never break the
426
+ // chain.
427
+ function selfHostRebuildStep(E) {
428
+ return (
429
+ '( if test -f /workspace/Cargo.toml; then ' +
430
+ 'echo ">> self-host VM — rebuilding binary before restart so it matches the advanced source (cargo ~5-8 min)"; ' +
431
+ `${E} editor rebuild-self --force-in-container || echo "WARN self-host rebuild-self failed — restart may hit the stale-binary reexec path"; ` +
432
+ 'fi; true )'
433
+ );
434
+ }
435
+
436
+ // POST-RESET SERVING PROBE — verify the editor came back SERVING THE PROJECT on
437
+ // :14199, and on a self-host VM flag an auto-reconfigure if it didn't. The
438
+ // Project Launcher binds an EPHEMERAL port (never :14199 — see
439
+ // crates/codeyam-editor/src/commands/launcher/server.rs `TcpListener::bind(…, 0)`),
440
+ // so a launcher-mode strand — or a server that never came back — shows up
441
+ // identically: :14199 stops answering /api/health. (The serve-on-:14199 fix in
442
+ // start.rs::decide_launch prevents the strand at the source; this is the
443
+ // belt-and-suspenders that turns a residual strand into an automatic recovery
444
+ // instead of a hand-run reconfigure.)
445
+ //
446
+ // On a self-host VM that isn't serving :14199, emit the `CY_NEEDS_RECONFIGURE`
447
+ // marker the dashboard's job-completion handler reacts to by auto-running
448
+ // reconfigure (the proven container-recreate recovery). Off self-host we only
449
+ // WARN: reconfigure's container recreate is the wrong hammer for a shipped-binary
450
+ // client VM, so the operator decides.
451
+ //
452
+ // Reuses the server-state.json controlPort resolution (falling back to 14199)
453
+ // exactly like verifyIdleOrEndSessionStep, and is wrapped in `( …; true )` so a
454
+ // curl hiccup can never abort the reset's &&-chain.
455
+ function verifyServingProjectOrFlagReconfigureStep({ controlPort = 14199 } = {}) {
456
+ // ~30s window — long enough for restart-server's async respawn to bind the
457
+ // port, short enough not to stretch the reset job.
458
+ const PROBE_COUNT = 15;
459
+ const PROBE_SLEEP_SECS = 2;
460
+ // VERIFY-AFTER-REBUILD (VM-4 2026-06-25): a rebuild that produces a binary and
461
+ // answers /api/health on :14199 is NOT proof the fix took — the launcher, when
462
+ // it lands from the boot decision, binds the CONTROL port (so `npm run editor`
463
+ // finds it) and ALSO serves /api/health, so /api/health alone can't tell a
464
+ // serving editor from a launcher strand sitting ON :14199. /api/projects is
465
+ // launcher-ONLY (the editor serves /api/session-info, never /api/projects) and
466
+ // returns JSON, so a launcher signature ON the control port means the strand
467
+ // SURVIVED the rebuild — treat it as a FAILURE (emit the reconfigure marker),
468
+ // never as success. This makes "serves the PROJECT on :14199" the hard
469
+ // post-condition, not merely "something answers /api/health".
470
+ const launcherOnPort =
471
+ 'body=$(curl -sf --max-time 2 "http://127.0.0.1:$PORT/api/projects" 2>/dev/null); ' +
472
+ 'case "$body" in "["*|"{"*) true;; *) false;; esac';
473
+ return (
474
+ `( PORT=$(grep -oE '"controlPort"[^0-9]*[0-9]+' .codeyam/server-state.json 2>/dev/null | grep -oE '[0-9]+$' | head -1); ` +
475
+ `PORT=\${PORT:-${controlPort}}; ` +
476
+ `serving=0; launchered=0; i=0; while [ "$i" -lt ${PROBE_COUNT} ]; do ` +
477
+ 'if curl -sf "http://127.0.0.1:$PORT/api/health" >/dev/null 2>&1; then ' +
478
+ `if ${launcherOnPort}; then launchered=1; else serving=1; break; fi; fi; ` +
479
+ `i=$((i+1)); sleep ${PROBE_SLEEP_SECS}; done; ` +
480
+ 'if [ "$serving" = "1" ]; then echo ">> editor serving project on :$PORT"; ' +
481
+ 'elif test -f /workspace/Cargo.toml; then ' +
482
+ 'echo "CY_NEEDS_RECONFIGURE self-host editor not serving project on :$PORT after reset ($([ "$launchered" = "1" ] && echo "launcher-mode strand ON the control port — rebuild did not adopt the guard" || echo "launcher-mode strand or server down") ) — falling back to reconfigure"; ' +
483
+ 'else echo "WARN editor not serving project on :$PORT after reset — check the VM"; fi; true )'
484
+ );
485
+ }
486
+
487
+ // Pure predicate the dashboard's job-completion handler uses to decide whether a
488
+ // finished reset must auto-fall-back to reconfigure: true iff the job log
489
+ // carries the `CY_NEEDS_RECONFIGURE` marker emitted by
490
+ // verifyServingProjectOrFlagReconfigureStep (self-host, didn't come back serving
491
+ // :14199). Kept here as a pure function of the log string so the decision is
492
+ // unit-tested without the gcloud/docker dispatch.
493
+ function needsReconfigureFallback(log) {
494
+ return typeof log === 'string' && log.includes('CY_NEEDS_RECONFIGURE');
495
+ }
496
+
217
497
  module.exports = {
218
498
  codeyamDeepCleanSteps,
219
499
  codeyamBrokerEnsureStep,
500
+ selfHostRebuildStep,
501
+ verifyServingProjectOrFlagReconfigureStep,
502
+ needsReconfigureFallback,
220
503
  resetClientScript,
221
504
  resetEditorStateScript,
505
+ endSessionScript,
506
+ verifyIdleOrEndSessionStep,
222
507
  };
@@ -31,16 +31,14 @@ function normalizeRoster(arr) {
31
31
  return [...new Set((arr || []).filter(Number.isInteger))].sort((a, b) => a - b);
32
32
  }
33
33
 
34
- // Read the roster, returning a normalized integer array. A missing or malformed
35
- // file is treated as the empty roster `[]` (the documented contract the previous
36
- // `readRoster`/`loadVmsState` duplicates shared) so a truncated write or an
37
- // absent file never throws into a consumer's poll loop.
34
+ // Read the roster, returning a normalized integer array, or null if the file is
35
+ // missing or malformed.
38
36
  function readRoster() {
39
37
  try {
40
38
  const data = JSON.parse(fs.readFileSync(VMS_STATE_FILE, 'utf8'));
41
39
  if (Array.isArray(data)) return normalizeRoster(data);
42
- } catch { /* missing or malformed — treated as empty */ }
43
- return [];
40
+ } catch { /* missing or malformed */ }
41
+ return null;
44
42
  }
45
43
 
46
44
  // Persist `arr` as the roster, atomically: write a temp file then `rename` it
@@ -62,13 +60,13 @@ function writeRoster(arr) {
62
60
  // onSuccess rosters again — is a harmless no-op, which is exactly what closes
63
61
  // the race where one writer's roster write could fail.
64
62
  function addToRoster(n) {
65
- return writeRoster([...readRoster(), n]);
63
+ return writeRoster([...(readRoster() || []), n]);
66
64
  }
67
65
 
68
66
  // Remove `n` from the roster, returning the new roster. Removing an absent
69
67
  // entry is a no-op.
70
68
  function removeFromRoster(n) {
71
- return writeRoster(readRoster().filter((x) => x !== n));
69
+ return writeRoster((readRoster() || []).filter((x) => x !== n));
72
70
  }
73
71
 
74
72
  module.exports = {
@@ -0,0 +1,142 @@
1
+ 'use strict';
2
+ // Pure decision + bash-script composition for the dashboard's SELF-update action
3
+ // (scripts/operator/fleet-dashboard/server.js requires this). Mirrors the
4
+ // fleet-branch.js / fleet-reset.js pattern: string + decision logic only, no I/O
5
+ // (no git exec, no env detection, no `exec`), so the self-update *recipe* — what
6
+ // the detached bash actually does to pull the dashboard's own checkout to tip and
7
+ // which supervisor it restarts through — stays unit-testable in isolation
8
+ // (npm/fleet-self-update.test.js). server.js owns the I/O: current-branch read,
9
+ // `id -u`, FLEET_ON_GCE detection, and the detached `exec`.
10
+ //
11
+ // The dashboard's Node half is plain source (it `require`s server.js +
12
+ // npm/fleet-*.js from REPO_ROOT at boot, no compile step), so refreshing *it* is
13
+ // just "pull new source + restart the node process". Both supervisors auto-respawn
14
+ // on exit (systemd Restart=always, launchd KeepAlive), so the restart is a clean
15
+ // hand-off. But the dashboard also depends on the Rust `codeyam-editor` binary at
16
+ // runtime — `pollPlans` shells out to `codeyam-editor editor plans-snapshot` for the
17
+ // Queued-Plans origin view — so a self-update that advances editor source past a
18
+ // changed/new subcommand must rebuild the binary too, or the JS moves while a stale
19
+ // binary keeps erroring. buildSelfUpdateScript therefore rebuilds the binary, but
20
+ // only when editor source actually changed across the update and fail-soft so a
21
+ // build problem never blocks the restart.
22
+
23
+ const { cleanBranch } = require('./fleet-branch');
24
+
25
+ // Single-quote a value for safe bash interpolation — identical to server.js's
26
+ // `shq`, duplicated here so this module stays a pure, dependency-light leaf (it
27
+ // must not require the untestable server shell).
28
+ function shq(s) {
29
+ return `'${String(s).replace(/'/g, `'\\''`)}'`;
30
+ }
31
+
32
+ // A branch name we're willing to hand to `git checkout` / `git reset --hard`.
33
+ // git's own ref rules are broader, but the dashboard only ever targets ordinary
34
+ // fleet branches, so we accept a conservative charset (letters, digits, and the
35
+ // `._/-` that real branch names use) and reject everything else. This is the
36
+ // belt to shq's suspenders: even though every interpolated value is quoted, an
37
+ // up-front reject turns an injection attempt / typo into a clear `{ok:false}`
38
+ // instead of a script that runs and fails deep in the detached child.
39
+ function isSafeBranch(b) {
40
+ return /^[A-Za-z0-9._/-]+$/.test(b) && !b.includes('..');
41
+ }
42
+
43
+ // Decide whether a self-update request is actionable and on which branch.
44
+ // - `alreadyRunning` (a self-update already in flight) is refused first, so the
45
+ // in-flight guard is part of the tested decision rather than action branching.
46
+ // - A blank requested branch falls back to the dashboard's current branch
47
+ // (the modal prefills the current branch, but an operator can clear it).
48
+ // - An unsafe / empty resulting branch is rejected with a clear `msg`.
49
+ // Pure; no I/O. Returns `{ ok, branch, msg }`. Keeping the guard + resolution +
50
+ // validation here lets server.js's actionUpdateDashboard stay a thin I/O shell.
51
+ function decideSelfUpdate({ requestedBranch, currentBranch, alreadyRunning } = {}) {
52
+ if (alreadyRunning) {
53
+ return { ok: false, msg: 'dashboard update already running' };
54
+ }
55
+ const branch = cleanBranch(requestedBranch) || cleanBranch(currentBranch);
56
+ if (!branch) {
57
+ return { ok: false, msg: 'self-update: no branch given and current branch is unknown' };
58
+ }
59
+ if (!isSafeBranch(branch)) {
60
+ return { ok: false, msg: `self-update: invalid branch name '${branch}'` };
61
+ }
62
+ return { ok: true, branch, msg: `updating dashboard to ${branch}` };
63
+ }
64
+
65
+ // The supervisor-appropriate restart command. The dashboard restarting itself
66
+ // kills the very process serving the request, so this runs inside the detached
67
+ // child (see buildSelfUpdateScript). Selection:
68
+ // - GCE operator host (systemd) → `sudo systemctl restart codeyam-dashboard`
69
+ // (needs a passwordless sudoers drop-in; see the plan's provisioning note).
70
+ // - laptop (launchd) → `launchctl kickstart -k gui/<uid>/<label>`
71
+ // - bare-nohup fallback → `<repoRoot>/scripts/operator/fleet-dashboard/start.sh`
72
+ // The non-GCE path tries launchd first and falls back to start.sh in one `||`
73
+ // chain, so a laptop with no launchd agent (a bare `nohup node server.js`) still
74
+ // restarts. start.sh is idempotent (kills the port, re-execs node).
75
+ function restartCommand({ onGce, launchdUid, label, repoRoot }) {
76
+ if (onGce) return 'sudo systemctl restart codeyam-dashboard';
77
+ const target = shq(`gui/${launchdUid}/${label}`);
78
+ const startSh = shq(`${repoRoot}/scripts/operator/fleet-dashboard/start.sh`);
79
+ return `launchctl kickstart -k ${target} || ${startSh}`;
80
+ }
81
+
82
+ // Compose the bash the detached child runs: pull the dashboard's own checkout to
83
+ // the tip of `branch`, conditionally rebuild the editor binary, then restart
84
+ // through the environment-appropriate supervisor. Hard-reset semantics
85
+ // (operator's choice): always land exactly on `origin/<branch>`, discarding any
86
+ // uncommitted/divergent state on the dashboard checkout. Bails loudly if the
87
+ // branch doesn't exist on origin (nothing is reset/restarted).
88
+ //
89
+ // Rebuild gating (between the reset and the restart): record the pre-reset HEAD,
90
+ // compare it to the post-reset HEAD restricted to the editor source paths, and
91
+ // rebuild only when something under `crates`/`Cargo.toml`/`Cargo.lock` actually
92
+ // moved — a JS-only update must not pay a multi-minute Rust compile. The rebuild
93
+ // is additionally gated on `codeyam-editor` being on PATH so a host without the
94
+ // toolchain skips cleanly, and is fail-soft (`|| echo …`) so a build failure can't
95
+ // abort the `set -e` script before the restart: a degraded-but-running dashboard
96
+ // beats a wedged one. The restart stays last and unconditional.
97
+ //
98
+ // Every interpolated value is shq-quoted. Pure; no I/O.
99
+ function buildSelfUpdateScript({ repoRoot, branch, onGce, launchdUid, label } = {}) {
100
+ const R = shq(repoRoot);
101
+ const B = shq(branch);
102
+ const originRef = shq(`origin/${branch}`);
103
+ const restart = restartCommand({ onGce, launchdUid, label, repoRoot });
104
+ return [
105
+ 'set -e',
106
+ `echo ">> self-update: fetching origin in ${repoRoot}"`,
107
+ `git -C ${R} fetch origin`,
108
+ // Verify origin/<branch> exists BEFORE touching the working tree, so a typo'd
109
+ // / unknown branch bails with nothing reset or restarted.
110
+ `git -C ${R} rev-parse --verify ${originRef} >/dev/null 2>&1 || ` +
111
+ `{ echo "self-update: origin/${branch} not found" >&2; exit 1; }`,
112
+ // Record the editor-source revision BEFORE the reset, so we can tell after
113
+ // whether the editor binary needs rebuilding. Read-only; touches nothing.
114
+ `OLD=$(git -C ${R} rev-parse HEAD)`,
115
+ `echo ">> self-update: checking out ${branch}"`,
116
+ // DWIM creates a local tracking branch when one doesn't exist yet; a no-op
117
+ // when already on it.
118
+ `git -C ${R} checkout ${B}`,
119
+ `echo ">> self-update: hard-reset to origin/${branch} (discards local changes)"`,
120
+ `git -C ${R} reset --hard ${originRef}`,
121
+ `NEW=$(git -C ${R} rev-parse HEAD)`,
122
+ // Rebuild the editor binary, but only when (a) editor source actually moved
123
+ // and (b) the binary is on PATH. The `git diff --quiet … -- crates …` exits 0
124
+ // when nothing under the editor source paths changed → skip (the fast JS-only
125
+ // path). `command -v` skips a toolchain-less host. The rebuild itself is
126
+ // fail-soft so a build failure still falls through to the restart. Each guard
127
+ // sits in an `if`/`elif` condition, where a non-zero exit is allowed under
128
+ // `set -e`.
129
+ `if git -C ${R} diff --quiet "$OLD" "$NEW" -- crates Cargo.toml Cargo.lock; then ` +
130
+ `echo ">> self-update: skipping rebuild (no editor source change)"; ` +
131
+ `elif ! command -v codeyam-editor >/dev/null 2>&1; then ` +
132
+ `echo ">> self-update: skipping rebuild (codeyam-editor not on PATH)"; ` +
133
+ `else echo ">> self-update: editor source changed — rebuilding binary"; ` +
134
+ `codeyam-editor editor rebuild-self || ` +
135
+ `echo ">> self-update: rebuild-self FAILED — continuing to restart with the existing binary" >&2; ` +
136
+ `fi`,
137
+ `echo ">> self-update: restarting dashboard"`,
138
+ restart,
139
+ ].join('\n');
140
+ }
141
+
142
+ module.exports = { shq, isSafeBranch, decideSelfUpdate, restartCommand, buildSelfUpdateScript };