@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.
- package/npm/cli.js +5 -1
- package/npm/cloud-branch-switch.js +1 -1
- package/npm/cloud.js +3166 -150
- package/npm/codesign-plan.js +68 -0
- package/npm/container.js +3 -0
- package/npm/disk-pressure.js +78 -0
- package/npm/editor-dev.js +96 -15
- package/npm/editor.js +85 -37
- package/npm/empty-project-dir.js +92 -7
- package/npm/fleet-add-failure.js +252 -0
- package/npm/fleet-asset-version.js +37 -0
- package/npm/fleet-auth-health.js +213 -0
- package/npm/fleet-auth.js +6 -1
- package/npm/fleet-build-data.js +109 -5
- package/npm/fleet-cleanup.js +232 -16
- package/npm/fleet-destroy-safety.js +272 -0
- package/npm/fleet-early-roster.js +87 -0
- package/npm/fleet-effect-poll.js +110 -0
- package/npm/fleet-effect-status.js +33 -32
- package/npm/fleet-env.js +86 -0
- package/npm/fleet-ghcr-secret.js +129 -0
- package/npm/fleet-git-maintenance.js +142 -0
- package/npm/fleet-host-op-limiter.js +68 -0
- package/npm/fleet-host.js +74 -0
- package/npm/fleet-iap-token.js +165 -0
- package/npm/fleet-job-badge.js +1 -1
- package/npm/fleet-launchd.js +171 -0
- package/npm/fleet-live-status.js +155 -0
- package/npm/fleet-memory.js +245 -9
- package/npm/fleet-plans.js +233 -4
- package/npm/fleet-pool.js +75 -16
- package/npm/fleet-prewarm.js +241 -0
- package/npm/fleet-project-drift.js +58 -0
- package/npm/fleet-public-url.js +394 -0
- package/npm/fleet-queue.js +95 -1
- package/npm/fleet-reachability-debounce.js +69 -0
- package/npm/fleet-reachability.js +80 -0
- package/npm/fleet-reconcile.js +43 -1
- package/npm/fleet-reset.js +330 -13
- package/npm/fleet-roster.js +6 -8
- package/npm/fleet-running-plan.js +85 -0
- package/npm/fleet-self-update.js +142 -0
- package/npm/fleet-session-token-cache.js +65 -0
- package/npm/fleet-setup-steps.js +112 -0
- package/npm/fleet-sleep-status.js +33 -0
- package/npm/fleet-status-refs.js +96 -0
- package/npm/fleet-strand.js +181 -0
- package/npm/fleet-stuck-status.js +117 -0
- package/npm/fleet-vm-access.js +404 -0
- package/npm/fleet-vm-probe-target.js +282 -0
- package/npm/fleet-vm-readiness.js +237 -0
- package/npm/fleet-vm-slugs.js +116 -0
- package/npm/google-ingress.js +226 -0
- package/npm/ingress-invoke.js +770 -0
- package/npm/launch-config.js +386 -9
- package/npm/scenario-check.js +880 -37
- package/npm/scenario-handlers.js +154 -30
- package/npm/scenario-interactivity.js +145 -6
- package/npm/scenario-issues.js +13 -1
- package/npm/scenario-metrics.js +116 -1
- package/npm/scenario-mocks.js +144 -21
- package/npm/scenario-playwright.js +649 -38
- package/npm/script-drift.js +152 -0
- package/npm/utils.js +365 -52
- package/npm/vm-urls.js +69 -8
- package/package.json +5 -5
- package/ui/dist/assets/ContainerInspector-DgM0pTAx.js +2 -0
- package/ui/dist/assets/EditorShell-B7Q06uJ_.js +252 -0
- package/ui/dist/assets/EditorShell-DP5UY20G.css +1 -0
- package/ui/dist/assets/{Markdown-BHBqz-Mj.js → Markdown-Chgdf3PW.js} +1 -1
- package/ui/dist/assets/ProjectLauncherScreen-BRrEr8Ng.js +1 -0
- package/ui/dist/assets/ScenarioDataPanel-BeQfa28g.js +9 -0
- package/ui/dist/assets/{ScenarioDataPanel-DYP7pi_n.css → ScenarioDataPanel-C1LukYI6.css} +1 -1
- package/ui/dist/assets/ScenarioDataPanelFullPage-BLqRCxQX.js +1 -0
- package/ui/dist/assets/ScenarioRenderer-CIAxfHPh.js +21 -0
- package/ui/dist/assets/StepsSimulator-CymGgfbu.js +1 -0
- package/ui/dist/assets/index-BygCiJSb.css +1 -0
- package/ui/dist/assets/{index-BuK0lizt.js → index-DCHqRrwO.js} +9 -9
- package/ui/dist/assets/{markdown-v0F0UEt3.js → markdown-C9tbsVHX.js} +1 -1
- package/ui/dist/assets/react-nrLBr15I.js +28 -0
- package/ui/dist/assets/useEvents-S3Qk1Y_d.js +1 -0
- package/ui/dist/assets/useServerIdentity-B7rI0u3O.js +1 -0
- package/ui/dist/codeyam-mark.svg +4 -0
- package/ui/dist/codeyam-wordmark.svg +5 -0
- package/ui/dist/index.html +4 -4
- package/ui/dist/open-source/codeyam-counter.png +0 -0
- package/ui/dist/open-source/el-carot.png +0 -0
- package/ui/dist/open-source/tabcommand.png +0 -0
- package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +0 -2
- package/ui/dist/assets/EditorShell-tMMdarZD.js +0 -187
- package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +0 -5
- package/ui/dist/assets/ScenarioDataPanelFullPage-5G6KuZgw.js +0 -1
- package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +0 -1
- package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
- package/ui/dist/assets/index-BGqairFV.css +0 -1
- package/ui/dist/assets/react-CSS0HapR.js +0 -28
- package/ui/dist/assets/useEvents-DggWrtHe.js +0 -1
package/npm/fleet-reconcile.js
CHANGED
|
@@ -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
|
-
|
|
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 };
|
package/npm/fleet-reset.js
CHANGED
|
@@ -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
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
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
|
-
'
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
|
|
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
|
};
|
package/npm/fleet-roster.js
CHANGED
|
@@ -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
|
|
35
|
-
//
|
|
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
|
|
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 };
|