@codeyam-editor/codeyam-editor 0.1.4 → 0.1.6

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 (97) hide show
  1. package/npm/cli.js +5 -1
  2. package/npm/cloud-branch-switch.js +1 -1
  3. package/npm/cloud.js +3166 -150
  4. package/npm/codesign-plan.js +68 -0
  5. package/npm/container.js +3 -0
  6. package/npm/disk-pressure.js +78 -0
  7. package/npm/editor-dev.js +96 -15
  8. package/npm/editor.js +85 -37
  9. package/npm/empty-project-dir.js +92 -7
  10. package/npm/fleet-add-failure.js +252 -0
  11. package/npm/fleet-asset-version.js +37 -0
  12. package/npm/fleet-auth-health.js +213 -0
  13. package/npm/fleet-auth.js +6 -1
  14. package/npm/fleet-build-data.js +109 -5
  15. package/npm/fleet-cleanup.js +232 -16
  16. package/npm/fleet-destroy-safety.js +272 -0
  17. package/npm/fleet-early-roster.js +87 -0
  18. package/npm/fleet-effect-poll.js +110 -0
  19. package/npm/fleet-effect-status.js +33 -32
  20. package/npm/fleet-env.js +86 -0
  21. package/npm/fleet-ghcr-secret.js +129 -0
  22. package/npm/fleet-git-maintenance.js +142 -0
  23. package/npm/fleet-host-op-limiter.js +68 -0
  24. package/npm/fleet-host.js +74 -0
  25. package/npm/fleet-iap-token.js +165 -0
  26. package/npm/fleet-job-badge.js +1 -1
  27. package/npm/fleet-launchd.js +171 -0
  28. package/npm/fleet-live-status.js +155 -0
  29. package/npm/fleet-memory.js +245 -9
  30. package/npm/fleet-plans.js +233 -4
  31. package/npm/fleet-pool.js +75 -16
  32. package/npm/fleet-prewarm.js +241 -0
  33. package/npm/fleet-project-drift.js +58 -0
  34. package/npm/fleet-public-url.js +394 -0
  35. package/npm/fleet-queue.js +95 -1
  36. package/npm/fleet-reachability-debounce.js +69 -0
  37. package/npm/fleet-reachability.js +80 -0
  38. package/npm/fleet-reconcile.js +43 -1
  39. package/npm/fleet-reset.js +330 -13
  40. package/npm/fleet-roster.js +6 -8
  41. package/npm/fleet-running-plan.js +85 -0
  42. package/npm/fleet-self-update.js +142 -0
  43. package/npm/fleet-session-token-cache.js +65 -0
  44. package/npm/fleet-setup-steps.js +112 -0
  45. package/npm/fleet-sleep-status.js +33 -0
  46. package/npm/fleet-status-refs.js +96 -0
  47. package/npm/fleet-strand.js +181 -0
  48. package/npm/fleet-stuck-status.js +117 -0
  49. package/npm/fleet-vm-access.js +404 -0
  50. package/npm/fleet-vm-probe-target.js +282 -0
  51. package/npm/fleet-vm-readiness.js +237 -0
  52. package/npm/fleet-vm-slugs.js +116 -0
  53. package/npm/google-ingress.js +226 -0
  54. package/npm/ingress-invoke.js +770 -0
  55. package/npm/launch-config.js +386 -9
  56. package/npm/scenario-check.js +880 -37
  57. package/npm/scenario-handlers.js +154 -30
  58. package/npm/scenario-interactivity.js +145 -6
  59. package/npm/scenario-issues.js +13 -1
  60. package/npm/scenario-metrics.js +116 -1
  61. package/npm/scenario-mocks.js +144 -21
  62. package/npm/scenario-playwright.js +649 -38
  63. package/npm/script-drift.js +152 -0
  64. package/npm/utils.js +365 -52
  65. package/npm/vm-urls.js +69 -8
  66. package/package.json +5 -5
  67. package/ui/dist/assets/ContainerInspector-DgM0pTAx.js +2 -0
  68. package/ui/dist/assets/EditorShell-B7Q06uJ_.js +252 -0
  69. package/ui/dist/assets/EditorShell-DP5UY20G.css +1 -0
  70. package/ui/dist/assets/{Markdown-BHBqz-Mj.js → Markdown-Chgdf3PW.js} +1 -1
  71. package/ui/dist/assets/ProjectLauncherScreen-BRrEr8Ng.js +1 -0
  72. package/ui/dist/assets/ScenarioDataPanel-BeQfa28g.js +9 -0
  73. package/ui/dist/assets/{ScenarioDataPanel-DYP7pi_n.css → ScenarioDataPanel-C1LukYI6.css} +1 -1
  74. package/ui/dist/assets/ScenarioDataPanelFullPage-BLqRCxQX.js +1 -0
  75. package/ui/dist/assets/ScenarioRenderer-CIAxfHPh.js +21 -0
  76. package/ui/dist/assets/StepsSimulator-CymGgfbu.js +1 -0
  77. package/ui/dist/assets/index-BygCiJSb.css +1 -0
  78. package/ui/dist/assets/{index-BuK0lizt.js → index-DCHqRrwO.js} +9 -9
  79. package/ui/dist/assets/{markdown-v0F0UEt3.js → markdown-C9tbsVHX.js} +1 -1
  80. package/ui/dist/assets/react-nrLBr15I.js +28 -0
  81. package/ui/dist/assets/useEvents-S3Qk1Y_d.js +1 -0
  82. package/ui/dist/assets/useServerIdentity-B7rI0u3O.js +1 -0
  83. package/ui/dist/codeyam-mark.svg +4 -0
  84. package/ui/dist/codeyam-wordmark.svg +5 -0
  85. package/ui/dist/index.html +4 -4
  86. package/ui/dist/open-source/codeyam-counter.png +0 -0
  87. package/ui/dist/open-source/el-carot.png +0 -0
  88. package/ui/dist/open-source/tabcommand.png +0 -0
  89. package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +0 -2
  90. package/ui/dist/assets/EditorShell-tMMdarZD.js +0 -187
  91. package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +0 -5
  92. package/ui/dist/assets/ScenarioDataPanelFullPage-5G6KuZgw.js +0 -1
  93. package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +0 -1
  94. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
  95. package/ui/dist/assets/index-BGqairFV.css +0 -1
  96. package/ui/dist/assets/react-CSS0HapR.js +0 -28
  97. 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,312 @@ 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
+
497
+ // The reset-family actions whose in-container recipe REBUILDS the codeyam-editor
498
+ // binary before restart when the target is a self-host VM. Both resetClientScript
499
+ // and resetEditorStateScript (default rebuildOnSelfHost=true) run selfHostRebuildStep,
500
+ // so on a self-host VM either one triggers a full cargo compile (~10-14 min). The
501
+ // composite reset-all budgets that rebuild separately; update-client (a non-destructive
502
+ // ff-merge with NO rebuild/restart) is deliberately absent.
503
+ const REBUILDS_ON_SELF_HOST = new Set(['reset-client', 'reset-editor']);
504
+
505
+ // Default per-action SHORT budgets (ms) — the historical non-self-host timeout for
506
+ // each reset action, kept so a genuine client-VM hang still surfaces promptly.
507
+ const RESET_SHORT_TIMEOUT_MS = { 'reset-client': 300000, 'reset-editor': 600000 };
508
+ // Default self-host REBUILD budget (ms): the same 27.5-min allowance reset-all
509
+ // already uses for a self-host binary rebuild. Overridable via the rebuildMs arg
510
+ // (server.js resolves it from RESET_REBUILD_TIMEOUT_MS).
511
+ const RESET_REBUILD_TIMEOUT_MS = 1650000;
512
+
513
+ // Select the startJob timeout (ms) for a reset-family action, accounting for the
514
+ // self-host rebuild. On a self-host VM a rebuild-bearing reset (see
515
+ // REBUILDS_ON_SELF_HOST) advances the editor's OWN source and MUST rebuild the
516
+ // binary before restart, a ~10-14 min cargo compile the old flat 5-min budget
517
+ // killed mid-compile — surfacing a false `error` badge on a reset that actually
518
+ // succeeded. A non-self-host client reset genuinely does not rebuild and keeps its
519
+ // short budget. Pure: budgets default to the constants above but are injectable for
520
+ // env-tunability + tests. Unknown/non-rebuild action or non-self-host → short budget.
521
+ function resetJobTimeoutMs({ action, selfHost, shortMs, rebuildMs } = {}) {
522
+ const short = Number.isFinite(shortMs) ? shortMs : RESET_SHORT_TIMEOUT_MS[action];
523
+ const rebuild = Number.isFinite(rebuildMs) ? rebuildMs : RESET_REBUILD_TIMEOUT_MS;
524
+ return selfHost && REBUILDS_ON_SELF_HOST.has(action) ? rebuild : short;
525
+ }
526
+
217
527
  module.exports = {
218
528
  codeyamDeepCleanSteps,
219
529
  codeyamBrokerEnsureStep,
530
+ selfHostRebuildStep,
531
+ verifyServingProjectOrFlagReconfigureStep,
532
+ needsReconfigureFallback,
220
533
  resetClientScript,
221
534
  resetEditorStateScript,
535
+ endSessionScript,
536
+ verifyIdleOrEndSessionStep,
537
+ resetJobTimeoutMs,
538
+ REBUILDS_ON_SELF_HOST,
222
539
  };
@@ -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,85 @@
1
+ 'use strict';
2
+
3
+ // Resolve the running-plan fields a fleet card shows — feature, step, mode,
4
+ // selectedPlan, holderSessionId — from the two independent per-VM probes plus
5
+ // the last-known-good state.
6
+ //
7
+ // Why this exists: the card used to hang these fields off `/api/session-info`
8
+ // alone (spread as `...info` in server.js). But a VM whose editor server
9
+ // restarted while the PTY broker kept the agent alive reports
10
+ // `hasSession:false` / `sessionId:null` and session-info then OMITS
11
+ // feature/step/mode/selectedPlan — so an actively-building VM showed a BLANK
12
+ // card. `GET /api/step` reads `editor-step.json` directly, independent of
13
+ // session registration, so it stays populated across that restart. This helper
14
+ // makes `/api/step` the preferred source and falls back to session-info.
15
+ //
16
+ // Stickiness — the subtle part. The last-known `prev` is carried ONLY to bridge
17
+ // a genuine probe failure (the editor answered NOTHING this poll), so a single
18
+ // timed-out cycle can't blank a plan the card is already showing. It is NOT a
19
+ // blanket fallback: when the editor DID answer, the fresh read is authoritative
20
+ // for the plan the VM is executing NOW. Carrying prev unconditionally pinned a
21
+ // COMPLETED plan's feature onto the next one — feature-complete deletes
22
+ // editor-step.json (reset_step_state), and a plan's `feature` is null until
23
+ // step 4, so a stale prev would win over the correct null and the card kept
24
+ // showing plan A while the VM built plan B. So prev is carried only within the
25
+ // SAME plan (matching selectedPlan); a plan change (or going idle) drops it.
26
+ //
27
+ // Precedence per field, when the editor answered: `/api/step` (session-
28
+ // independent) > `/api/session-info` > same-plan `prev`. On a full probe
29
+ // failure: `prev` verbatim. Pure; no I/O — unit-tested in
30
+ // fleet-running-plan.test.js.
31
+ //
32
+ // @param {Object} params
33
+ // @param {?Object} params.step Parsed `/api/step` body (EditorStepState) or
34
+ // null when the VM has no `editor-step.json` (idle) / the probe failed.
35
+ // @param {?Object} params.info Parsed `/api/session-info` body (carries
36
+ // `reachable`), or null on a failed/timed-out probe.
37
+ // @param {?Object} params.prev The previous `state.vms[n]` (carries the
38
+ // last-known feature/step/mode/selectedPlan/holderSessionId). null/undefined
39
+ // on the first poll.
40
+ // @returns {{feature: ?string, step: ?number, mode: ?string,
41
+ // selectedPlan: ?string, holderSessionId: ?string}}
42
+ function resolveRunningPlan({ step, info, prev } = {}) {
43
+ const s = step || {};
44
+ const i = info || {};
45
+ const p = prev || {};
46
+
47
+ // Did the editor answer at all this poll? `/api/step` returning a body proves
48
+ // it; so does a reachable `/api/session-info`. If NEITHER answered, this is a
49
+ // transient outage — hold the last-known-good plan verbatim rather than blank
50
+ // the card for one cycle.
51
+ const editorAnswered = step != null || !!i.reachable;
52
+ if (!editorAnswered) {
53
+ return {
54
+ feature: p.feature || null,
55
+ step: p.step != null ? p.step : null,
56
+ mode: p.mode || null,
57
+ selectedPlan: p.selectedPlan || null,
58
+ holderSessionId: p.holderSessionId || null,
59
+ };
60
+ }
61
+
62
+ // Editor answered: the fresh read is authoritative for the current plan.
63
+ const freshSelectedPlan = s.selectedPlan || i.selectedPlan || null;
64
+ // Carry prev ONLY within the same plan — a blip-fallback for a momentarily
65
+ // missing field (e.g. /api/step timed out but session-info answered). Across
66
+ // a plan change or an idle poll, prev is stale and MUST be dropped.
67
+ const samePlan = freshSelectedPlan != null && freshSelectedPlan === (p.selectedPlan || null);
68
+ const carry = samePlan ? p : {};
69
+
70
+ const stepNum =
71
+ s.step != null ? s.step : i.step != null ? i.step : carry.step != null ? carry.step : null;
72
+
73
+ return {
74
+ feature: s.feature || i.feature || carry.feature || null,
75
+ step: stepNum,
76
+ mode: s.mode || i.mode || carry.mode || null,
77
+ selectedPlan: freshSelectedPlan || carry.selectedPlan || null,
78
+ // session-info never carries holderSessionId — it comes from /api/step
79
+ // (editor-step.json) or the same-plan carry. This is what lets a claim map
80
+ // to the executing VM after an editor restart drops the live sessionId.
81
+ holderSessionId: s.holderSessionId || carry.holderSessionId || null,
82
+ };
83
+ }
84
+
85
+ module.exports = { resolveRunningPlan };