@codeyam-editor/codeyam-editor 0.1.2 → 0.1.4

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 (37) hide show
  1. package/npm/cloud.js +779 -116
  2. package/npm/container.js +11 -0
  3. package/npm/disk-pressure.js +89 -0
  4. package/npm/editor-dev.js +144 -52
  5. package/npm/editor.js +47 -17
  6. package/npm/fleet-branch.js +49 -0
  7. package/npm/fleet-broker-death.js +44 -0
  8. package/npm/fleet-broker-debounce.js +63 -0
  9. package/npm/fleet-broker-status.js +45 -0
  10. package/npm/fleet-build-data.js +81 -0
  11. package/npm/fleet-cleanup.js +216 -16
  12. package/npm/fleet-effect-status.js +179 -0
  13. package/npm/fleet-grouping.js +56 -0
  14. package/npm/fleet-job-badge.js +83 -0
  15. package/npm/fleet-memory.js +35 -5
  16. package/npm/fleet-plans.js +96 -0
  17. package/npm/fleet-reconcile.js +58 -1
  18. package/npm/fleet-reset.js +94 -16
  19. package/npm/launch-config.js +197 -14
  20. package/npm/preflight-guidance.js +9 -0
  21. package/npm/scenario-check.js +106 -1
  22. package/npm/scenario-handlers.js +16 -0
  23. package/npm/scenario-interactivity.js +53 -7
  24. package/npm/scenario-playwright.js +184 -5
  25. package/npm/utils.js +238 -53
  26. package/package.json +5 -5
  27. package/ui/dist/assets/EditorShell-tMMdarZD.js +187 -0
  28. package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +5 -0
  29. package/ui/dist/assets/{ScenarioDataPanelFullPage-C0YHQB-T.js → ScenarioDataPanelFullPage-5G6KuZgw.js} +1 -1
  30. package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +1 -0
  31. package/ui/dist/assets/index-BGqairFV.css +1 -0
  32. package/ui/dist/assets/{index-BUYNFqKK.js → index-BuK0lizt.js} +9 -9
  33. package/ui/dist/index.html +2 -2
  34. package/ui/dist/assets/EditorShell-DWaeBxgj.js +0 -186
  35. package/ui/dist/assets/ScenarioDataPanel-zJSXtXL4.js +0 -5
  36. package/ui/dist/assets/ScenarioRenderer-CgDxuigm.js +0 -1
  37. package/ui/dist/assets/index-DVr5rY2d.css +0 -1
@@ -0,0 +1,56 @@
1
+ // Group an already-sorted/filtered VM list by client project for the Fleet
2
+ // Dashboard's grouped layout.
3
+ //
4
+ // Returns an ORDERED array of { project, vms } groups: real project groups
5
+ // first, sorted case-insensitively by name (locale compare); the
6
+ // unknown/starting-up bucket (project === null) pinned LAST. Within each group
7
+ // the incoming order of `vms` is preserved untouched, so the caller's active
8
+ // Sort mode continues to drive within-group order — grouping never re-sorts the
9
+ // cards, it only partitions them.
10
+ //
11
+ // `projectKey(v)` resolves the group key for a VM and defaults to the same
12
+ // identity `card()` renders in the dashboard:
13
+ // (v.cardIdentity && v.cardIdentity.project) || v.project || null
14
+ // It is injectable so unit tests can drive grouping without constructing full
15
+ // card payloads. A null/empty key routes the VM into the trailing unknown
16
+ // bucket so VMs mid-provision (blank identity) never silently disappear.
17
+ //
18
+ // Pure; no I/O.
19
+
20
+ function defaultProjectKey(v) {
21
+ return (v && v.cardIdentity && v.cardIdentity.project) || (v && v.project) || null;
22
+ }
23
+
24
+ function groupVmsByProject(vms, projectKey = defaultProjectKey) {
25
+ // First pass: stable bucketing. `Map` preserves insertion order, so the
26
+ // first VM of each project fixes that group's relative position before we
27
+ // re-order — but we re-sort named groups below regardless, so insertion
28
+ // order only matters for tie-stability within equal-name groups (none here).
29
+ const named = new Map();
30
+ let unknown = null;
31
+
32
+ for (const v of vms) {
33
+ const raw = projectKey(v);
34
+ const key = raw == null || raw === "" ? null : raw;
35
+ if (key === null) {
36
+ if (!unknown) unknown = { project: null, vms: [] };
37
+ unknown.vms.push(v);
38
+ continue;
39
+ }
40
+ let group = named.get(key);
41
+ if (!group) {
42
+ group = { project: key, vms: [] };
43
+ named.set(key, group);
44
+ }
45
+ group.vms.push(v);
46
+ }
47
+
48
+ const groups = Array.from(named.values());
49
+ groups.sort((a, b) =>
50
+ a.project.localeCompare(b.project, undefined, { sensitivity: "base" })
51
+ );
52
+ if (unknown) groups.push(unknown);
53
+ return groups;
54
+ }
55
+
56
+ module.exports = { groupVmsByProject, defaultProjectKey };
@@ -0,0 +1,83 @@
1
+ 'use strict';
2
+
3
+ // npm/fleet-job-badge.js — pure verdict for the fleet dashboard's failed-job
4
+ // badge reconciliation (improve39). A VM's last terminal-error heavy job
5
+ // (add/error, reset-all error, reset-editor error, …) used to stick on the
6
+ // dashboard card indefinitely — even after the VM was recovered and is healthy
7
+ // again — because the card renders the last job's error with no check against
8
+ // the VM's live state, so a recovered VM keeps screaming a red error and
9
+ // misleads the operator. This decides whether such a badge should be RECONCILED
10
+ // (presented as "recovered" rather than a live error) because the VM is
11
+ // demonstrably fine now. The live signals are gathered in server.js (the
12
+ // untestable server shell); the verdict lives here so the cases are unit-tested
13
+ // without a live VM.
14
+
15
+ // Should the VM's last terminal-error job badge be reconciled against live
16
+ // health — i.e. presented as "recovered" rather than a live red error?
17
+ //
18
+ // job the carried job record ({ state, action, ... }) or null
19
+ // reachable the VM's editor answered this poll (true = healthy / responding)
20
+ // drifted a project-dir-drift signal is present (reachable-but-unwired /
21
+ // half-provisioned add — genuinely needs-recovery, owned by the
22
+ // companion reconcile-inflight-jobs plan; never falsely cleared here)
23
+ // offBranch the VM's live client branch differs from its target branch
24
+ // degraded the job completed but a wiring sub-step is flagged degraded
25
+ //
26
+ // Returns true only when the job is a terminal error AND the VM is demonstrably
27
+ // fine (reachable, not drifted, on-branch, not degraded). An unreachable /
28
+ // drifted / off-branch VM keeps its error surfaced — a real problem must stay
29
+ // visible.
30
+ function shouldReconcileFailedBadge({ job, reachable, drifted, offBranch, degraded } = {}) {
31
+ if (!job || job.state !== 'error') return false;
32
+ if (!reachable) return false;
33
+ if (drifted) return false;
34
+ if (offBranch) return false;
35
+ if (degraded) return false;
36
+ return true;
37
+ }
38
+
39
+ // Given a reconcile verdict (from shouldReconcileFailedBadge) and whether the VM
40
+ // is now idle, decide whether the recovered badge should be FULLY CLEARED — the
41
+ // job entry dropped from state.jobs so the card goes clean — rather than merely
42
+ // muted as a "recovered (was: …)" note (improve41).
43
+ //
44
+ // badgeReconciled the shouldReconcileFailedBadge verdict for this VM
45
+ // idle the VM answered this poll, is not running a build session,
46
+ // and its broker is not actively serving an agent
47
+ //
48
+ // A recovered VM that is also idle has demonstrably finished recovering, so the
49
+ // stale error is dropped entirely. A recovered-but-not-yet-idle VM (mid-build,
50
+ // or a poll hasn't confirmed idle) keeps the muted note so the prior log stays
51
+ // visible during that transient window. Never clears a badge that wasn't first
52
+ // judged recovered — an unreachable / drifted / off-branch error stays surfaced.
53
+ function shouldClearReconciledBadge({ badgeReconciled, idle } = {}) {
54
+ return !!badgeReconciled && !!idle;
55
+ }
56
+
57
+ // Pure guard for the operator "Clear badge" action (improve41). Decides whether
58
+ // VM-n's persisted job entry may be dropped after an out-of-band recovery,
59
+ // independent of the server's state / fs I/O so the cases are unit-tested
60
+ // without a live dashboard. The shell (server.js) supplies the live signals and
61
+ // turns the verdict into the {ok,msg} response + the actual delete + snapshot.
62
+ //
63
+ // rostered the VM number is in the live roster
64
+ // job the carried job record ({ state, ... }) or null/undefined
65
+ //
66
+ // Only a TERMINAL job (error/done) on a rostered VM may be cleared, OR a terminal
67
+ // failed add (add/error) on a non-rostered VM (which deletes the card + add-N sidecar):
68
+ // any other job state / non-rostered combination is not clearable.
69
+ // Returns { ok, reason, cleanup } — reason is a stable code the shell maps to an
70
+ // operator message, and cleanup is the cleanup action to perform ('add-failed' vs 'badge-only').
71
+ function canClearBadge({ rostered, job } = {}) {
72
+ if (!rostered) {
73
+ if (job && job.action === 'add' && job.state === 'error') {
74
+ return { ok: true, reason: null, cleanup: 'add-failed' };
75
+ }
76
+ return { ok: false, reason: 'not-rostered' };
77
+ }
78
+ if (!job) return { ok: false, reason: 'no-badge' };
79
+ if (job.state !== 'error' && job.state !== 'done') return { ok: false, reason: 'job-active' };
80
+ return { ok: true, reason: null, cleanup: 'badge-only' };
81
+ }
82
+
83
+ module.exports = { shouldReconcileFailedBadge, shouldClearReconciledBadge, canClearBadge };
@@ -74,17 +74,30 @@ function decodeStateSnapshot(buf) {
74
74
  * Returns null when the snapshot is missing, malformed, or older than
75
75
  * maxAgeMs (stale snapshots are worse than no snapshot — pollVMs will
76
76
  * repopulate from real data). Otherwise returns { jobs, vms, orphans }
77
- * with two safety transforms applied to jobs:
77
+ * with these safety transforms applied to jobs:
78
78
  *
79
- * • `running` → `error` with a log marker, because the child process
80
- * died with the dashboard; we can't possibly have a live job tracker
81
- * after a process restart.
79
+ * • `running` WITH a pending-job sidecar → `recovering` with a deferral
80
+ * marker, NOT `error`. A heavy job that stashed a recoverable sidecar
81
+ * (its cfg) must defer its final state to the startup reap, which
82
+ * actually checks gcloud — the blanket `error` was the improve39 VM-7
83
+ * bug: a mid-add restart stranded an instance that was really up as a
84
+ * dead `add-failed`. `recovering` is an honest "the reap will resolve
85
+ * this" surface; the reap then supersedes it (reconcile/half-provisioned
86
+ * /cleanup) once it has the gcloud verdict.
87
+ * • `running` WITHOUT a sidecar → `error` with a log marker, because the
88
+ * child process died with the dashboard and there's nothing to recover
89
+ * from — un-recoverable history, the old behavior.
82
90
  * • `queued` → dropped, because the in-memory pendingJobQueue array
83
91
  * is gone, so promoteFromJobQueue can never start them. The operator
84
92
  * will re-click (the queued click had done no work yet).
85
93
  *
94
+ * `sidecarKeys` is the set of `${action}-${n}` strings the caller read from
95
+ * the pending-jobs dir (server.js passes it; tests pass a plain array/Set).
96
+ * Omitted → no job has a sidecar → every running job downgrades to `error`
97
+ * exactly as before, so existing callers/tests are unaffected.
98
+ *
86
99
  * Pure; no I/O. */
87
- function restoreFromSnapshot({ snapshot, maxAgeMs, nowIso } = {}) {
100
+ function restoreFromSnapshot({ snapshot, maxAgeMs, nowIso, sidecarKeys } = {}) {
88
101
  if (!snapshot || typeof snapshot !== 'object') return null;
89
102
  if (typeof snapshot.timestamp !== 'string') return null;
90
103
  const ts = Date.parse(snapshot.timestamp);
@@ -95,16 +108,33 @@ function restoreFromSnapshot({ snapshot, maxAgeMs, nowIso } = {}) {
95
108
  if (!Number.isFinite(cap) || cap <= 0) return null;
96
109
  if (ageMs > cap) return null;
97
110
 
111
+ const sidecars = sidecarKeys instanceof Set ? sidecarKeys : new Set(sidecarKeys || []);
98
112
  const srcJobs = (snapshot.jobs && typeof snapshot.jobs === 'object') ? snapshot.jobs : {};
99
113
  const jobs = {};
100
114
  for (const [k, j] of Object.entries(srcJobs)) {
101
115
  if (!j || typeof j !== 'object') continue;
102
116
  if (j.state === 'queued') continue;
103
117
  if (j.state === 'running') {
118
+ if (sidecars.has(`${j.action}-${k}`)) {
119
+ jobs[k] = {
120
+ ...j,
121
+ state: 'recovering',
122
+ log: `${j.log ? j.log + '\n' : ''}(snapshot-restored — deferring to startup reap)`,
123
+ };
124
+ continue;
125
+ }
104
126
  jobs[k] = {
105
127
  ...j,
106
128
  state: 'error',
107
129
  finishedAt: nowIso,
130
+ // improve40: mark this as a restart-orphan, NOT a genuine in-process
131
+ // failure. The job was `running` when the restart downgraded it and has
132
+ // NO sidecar to reconcile from — so the boot path must probe the VM's
133
+ // REAL provision state (orphanedAddsToReconcile → gcloud reap) rather
134
+ // than leave a dead `add-failed` badge on a VM that may have actually
135
+ // provisioned (the VM-7 hole). An add that failed IN-PROCESS keeps its
136
+ // own terminal `error` with no flag, so it is never re-probed/cleared.
137
+ restartOrphaned: true,
108
138
  log: `${j.log ? j.log + '\n' : ''}(snapshot-restored — dashboard restarted before this finished)`,
109
139
  };
110
140
  continue;
@@ -0,0 +1,96 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Indexes the currently-reachable VMs by their live sessionId.
5
+ * @param {Array|Object} vms The merged VM states on the dashboard.
6
+ * @returns {Object} A map of sessionId -> VM number (n).
7
+ */
8
+ function indexVmsBySessionId(vms) {
9
+ const map = {};
10
+ if (!vms) return map;
11
+ const list = Array.isArray(vms) ? vms : Object.values(vms);
12
+ for (const vm of list) {
13
+ if (vm && vm.sessionId && vm.n !== undefined) {
14
+ map[vm.sessionId] = vm.n;
15
+ }
16
+ }
17
+ return map;
18
+ }
19
+
20
+ /**
21
+ * Computes which dependencies are unsatisfied (i.e. not in the completedSlugs set).
22
+ * @param {string[]} dependsOn Prerequisite slugs.
23
+ * @param {Set|string[]} completedSlugs Set or array of completed plan slugs.
24
+ * @returns {string[]} The unsatisfied dependency slugs.
25
+ */
26
+ function unsatisfiedDeps(dependsOn, completedSlugs) {
27
+ const deps = Array.isArray(dependsOn) ? dependsOn : [];
28
+ const completedSet = completedSlugs instanceof Set ? completedSlugs : new Set(completedSlugs || []);
29
+ return deps.filter(dep => !completedSet.has(dep));
30
+ }
31
+
32
+ /**
33
+ * Resolves claims and dependency states for each queued plan.
34
+ * @param {Object} params
35
+ * @param {Object[]} params.plans Array of PlanSummary objects from /api/plans.
36
+ * @param {Object[]} params.claims Array of PlanClaim objects from /api/plan-claims.
37
+ * @param {Set|string[]} params.completedSlugs Completed plan slugs.
38
+ * @param {Object} params.vmsBySessionId Map of sessionId -> VM number.
39
+ * @returns {Object[]} Annotated plan rows.
40
+ */
41
+ function resolvePlanRows({ plans, claims, completedSlugs, vmsBySessionId }) {
42
+ const list = Array.isArray(plans) ? plans : [];
43
+ const claimsList = Array.isArray(claims) ? claims : [];
44
+
45
+ return list.map(plan => {
46
+ // 1. Extract plan fields
47
+ const slug = plan.slug;
48
+ const title = plan.title;
49
+ const mode = plan.mode || null;
50
+ const order = plan.order != null ? plan.order : null;
51
+ const prefix = plan.prefix || null;
52
+
53
+ // 2. Resolve dependency-blocked state
54
+ const dependsOn = Array.isArray(plan.depends_on) ? plan.depends_on : (plan.dependsOn ? plan.dependsOn : []);
55
+ const blockedBy = unsatisfiedDeps(dependsOn, completedSlugs);
56
+
57
+ // 3. Resolve claim state
58
+ const claim = claimsList.find(c => c.slug === slug);
59
+ let claimInfo = null;
60
+ if (claim) {
61
+ const claimedBy = claim.claimedBy || claim.claimed_by || {};
62
+ const sessionId = claimedBy.sessionId || claimedBy.session_id || null;
63
+ const vm = (sessionId && vmsBySessionId && vmsBySessionId[sessionId] !== undefined) ? vmsBySessionId[sessionId] : null;
64
+
65
+ claimInfo = {
66
+ vm,
67
+ machine: claimedBy.machine || null,
68
+ agent: claimedBy.agentProvider || claimedBy.agent_provider || null,
69
+ sessionAlive: claim.sessionAlive !== undefined ? claim.sessionAlive : (claim.session_alive !== undefined ? claim.session_alive : null),
70
+ source: claim.source || null,
71
+ currentStep: claimedBy.currentStep || claimedBy.current_step || null,
72
+ };
73
+ }
74
+
75
+ return {
76
+ slug,
77
+ title,
78
+ mode,
79
+ order,
80
+ prefix,
81
+ blockedBy,
82
+ claim: claimInfo
83
+ };
84
+ });
85
+ }
86
+
87
+ const REQUIRED_PLAN_ROW_FIELDS = [
88
+ 'slug', 'title', 'mode', 'order', 'prefix', 'blockedBy', 'claim'
89
+ ];
90
+
91
+ module.exports = {
92
+ indexVmsBySessionId,
93
+ unsatisfiedDeps,
94
+ resolvePlanRows,
95
+ REQUIRED_PLAN_ROW_FIELDS
96
+ };
@@ -33,4 +33,61 @@ function computeUnrostered(running, shown) {
33
33
  .sort((a, b) => a - b);
34
34
  }
35
35
 
36
- module.exports = { parseRunningFleetVms, computeUnrostered };
36
+ // Map a single degraded reason to the live health signal that resolves it.
37
+ // Returns true only when that signal now passes; a reason with no mapped signal
38
+ // returns false (retained — a genuine config problem, never a false-green).
39
+ // Start with the two observed classes (improve41); extend the table as new
40
+ // degraded reasons gain a corresponding live signal.
41
+ function reasonClearedByHealth(reason, { reachableServing, authed }) {
42
+ const r = String(reason || '');
43
+ if (/claude auth|claude\.json|login/i.test(r)) return authed;
44
+ if (/container|never came up|provisioning failed/i.test(r)) return reachableServing;
45
+ return false;
46
+ }
47
+
48
+ // Given a terminal add-job badge and the VM's live health, return the corrected
49
+ // badge — or null when nothing changes. Conservative by construction: clears a
50
+ // degraded reason only when its live health signal now passes; flips a bare
51
+ // `error` add to `done` when the VM is reachable + serving; leaves any reason
52
+ // with no health signal intact (no false-green). The poll loop supplies the live
53
+ // facts (I/O at the edge) and applies the result; the verdict itself is pure.
54
+ // job: { state: 'done'|'error', degraded, degradedReasons }
55
+ // health: { reachable, editorHttpOk, providerAuthState }
56
+ // → null | { state, degraded, degradedReasons, transition }
57
+ function reconcileTerminalAddBadge({ job, health } = {}) {
58
+ if (!job || !health) return null;
59
+ const reachableServing = !!(health.reachable && health.editorHttpOk);
60
+ const authed = health.providerAuthState === 'authenticated';
61
+
62
+ // Bare `error` add (no degraded reasons): flip to `done` once the VM is
63
+ // reachable + serving — the VM3 case (a manual `cloud:up` recovered the editor
64
+ // outside the dashboard's view).
65
+ if (job.state === 'error') {
66
+ const reasons = job.degradedReasons || [];
67
+ if (reasons.length === 0 && reachableServing) {
68
+ return { state: 'done', degraded: false, degradedReasons: [], transition: 'error→done (editor 200)' };
69
+ }
70
+ return null;
71
+ }
72
+
73
+ // Terminal `done` that is degraded: clear each reason whose live signal now
74
+ // passes; retain the rest. Drop `degraded` only when no reasons remain.
75
+ if (job.state === 'done' && job.degraded) {
76
+ const reasons = job.degradedReasons || [];
77
+ const remaining = reasons.filter((reason) => !reasonClearedByHealth(reason, { reachableServing, authed }));
78
+ if (remaining.length === reasons.length) return null; // nothing cleared → no-op
79
+ const cleared = reasons.length - remaining.length;
80
+ return {
81
+ state: 'done',
82
+ degraded: remaining.length > 0,
83
+ degradedReasons: remaining,
84
+ transition: remaining.length > 0
85
+ ? `degraded: cleared ${cleared} reason(s), ${remaining.length} remain`
86
+ : 'degraded→clear (health restored)',
87
+ };
88
+ }
89
+
90
+ return null;
91
+ }
92
+
93
+ module.exports = { parseRunningFleetVms, computeUnrostered, reconcileTerminalAddBadge };
@@ -31,30 +31,97 @@
31
31
  // Best-effort: a no-remote / queue-disabled project legitimately exits
32
32
  // non-zero, and we should not block the reset.
33
33
  //
34
- // Between the cp-asides and the `rm -rf` we cooperatively pause the editor
35
- // server (`stop-server --hold-for-restart`). Without that, the server keeps
36
- // writing to `.codeyam/logs/editor-server.log` and `.codeyam/run/` between
37
- // when `rm -rf` enumerates the dir and when it tries to `rmdir` the empty
38
- // directory — it surfaces as `rm: cannot remove '.codeyam': Directory not
39
- // empty`, which the dashboard reports as `error`. SIGUSR2 closes listeners +
40
- // stops the dev-server and parks the PID; the parent script's trailing
41
- // `restart-server --force-in-container` then resurrects via the existing
42
- // SIGUSR1 in-place reexec. Fail-soft `|| true` so a server that's already
43
- // dead (e.g. the reset is rerun after a partial failure) doesn't block the
44
- // chain.
34
+ // Before the wipe we QUIESCE ALL WRITERS: park the editor server
35
+ // (`stop-server --hold-for-restart`) AND stop the PTY broker
36
+ // (`pty-broker stop`). Parking only the editor server is insufficient — the
37
+ // broker is a SEPARATE process (and hooks / agent subprocesses can fire), so
38
+ // it keeps re-creating `.codeyam/logs/` and `.codeyam/run/` between when the
39
+ // wipe enumerates the dir and when it removes it. That race surfaced live
40
+ // (VM-1, 2026-06-01) as `rm: cannot remove '.codeyam': Directory not empty`,
41
+ // aborting the whole reset. Both quiesce calls are fail-soft `|| true` so a
42
+ // server/broker that's already dead (e.g. the reset is rerun after a partial
43
+ // failure) doesn't block the chain.
44
+ //
45
+ // The wipe itself is RETRY-RESILIENT instead of a single `rm -rf .codeyam`
46
+ // that aborts the &&-chain on one transient re-creation: a bounded loop of
47
+ // `find .codeyam -mindepth 1 -delete` empties the contents and tolerates a
48
+ // brief re-create, then proceeds even if `.codeyam` is still non-empty after
49
+ // best-effort (a soft retry, never a hard failure — `git checkout`/`init`
50
+ // repopulates over whatever remains). Emptying contents (not removing the dir)
51
+ // also sidesteps the `rmdir` race entirely.
52
+ //
53
+ // The destructive region (park → broker-stop → wipe → restore) is made
54
+ // FAILURE-SAFE with an EXIT trap installed before the park and disarmed after
55
+ // a successful restore. If ANY step in that region aborts the chain, the trap
56
+ // still restores tracked `.codeyam` (or re-inits), copies the two preserved
57
+ // infra files back, and runs `restart-server --force-in-container` before
58
+ // exiting non-zero — so a failed deep-clean self-recovers to a working
59
+ // (un-reset, but UP) VM instead of leaving it parked-and-down with a
60
+ // half-wiped `.codeyam`, and still reports the failure. The happy path is
61
+ // unchanged: the restore steps run, the trap is disarmed, and the caller's
62
+ // own trailing `restart-server` resurrects the parked server.
45
63
  function codeyamDeepCleanSteps(E) {
64
+ // Restore primitives shared by the happy path and the trap recovery body.
65
+ const restoreCodeyam = `git checkout HEAD -- .codeyam 2>/dev/null || ${E} init`;
66
+ const restoreElj =
67
+ 'test -f /tmp/cy-elj.bak && mkdir -p .codeyam && cp /tmp/cy-elj.bak .codeyam/editor.local.json';
68
+ const restoreSs =
69
+ 'test -f /tmp/cy-ss.bak && mkdir -p .codeyam && cp /tmp/cy-ss.bak .codeyam/server-state.json';
70
+ // Recovery body: restore .codeyam + the two infra backups, then resurrect the
71
+ // parked server. Runs ONLY on the failure path (the happy path's own restore
72
+ // steps remove the backups; recovery here intentionally leaves them in place
73
+ // because on a mid-region abort those steps never ran).
74
+ const recover =
75
+ `${restoreCodeyam}; ( ${restoreElj}; true ); ( ${restoreSs}; true ); ` +
76
+ `${E} editor restart-server --force-in-container || true`;
46
77
  return [
47
78
  `( ${E} editor branch-queue release-mine --yes 2>/dev/null; true )`,
48
79
  '( cp .codeyam/editor.local.json /tmp/cy-elj.bak 2>/dev/null; true )',
49
80
  '( cp .codeyam/server-state.json /tmp/cy-ss.bak 2>/dev/null; true )',
81
+ // A brace group (NOT a subshell) so the function + trap persist into the
82
+ // current shell where the rest of the &&-chain runs.
83
+ `{ __cy_recover() { ${recover}; }; trap '__cy_recover; exit 1' EXIT; }`,
50
84
  `(${E} editor stop-server --hold-for-restart --force-in-container || true)`,
51
- 'rm -rf .codeyam',
52
- `( git checkout HEAD -- .codeyam 2>/dev/null || ${E} init )`,
53
- '( test -f /tmp/cy-elj.bak && mkdir -p .codeyam && cp /tmp/cy-elj.bak .codeyam/editor.local.json && rm -f /tmp/cy-elj.bak; true )',
54
- '( test -f /tmp/cy-ss.bak && mkdir -p .codeyam && cp /tmp/cy-ss.bak .codeyam/server-state.json && rm -f /tmp/cy-ss.bak; true )',
85
+ `(${E} editor pty-broker stop || true)`,
86
+ '( for i in 1 2 3 4 5; do find .codeyam -mindepth 1 -delete 2>/dev/null; [ -z "$(ls -A .codeyam 2>/dev/null)" ] && break; sleep 0.2; done; true )',
87
+ `( ${restoreCodeyam} )`,
88
+ `( ${restoreElj} && rm -f /tmp/cy-elj.bak; true )`,
89
+ `( ${restoreSs} && rm -f /tmp/cy-ss.bak; true )`,
90
+ // Restore succeeded — disarm the failure-safe trap so normal completion
91
+ // (and the caller's trailing session-reset + restart) doesn't re-trigger it.
92
+ 'trap - EXIT',
55
93
  ];
56
94
  }
57
95
 
96
+ // Belt-and-suspenders broker-ensure, appended after the trailing
97
+ // `restart-server` in every reset recipe. The PRIMARY fix lives in the
98
+ // editor server's `ensure_broker_running`: it now `rm -f`s the stale
99
+ // socket and retries the spawn, so the reexec'd `start` (which
100
+ // restart-server triggers) reliably respawns the broker with no
101
+ // down-window. This step is the safety net for an OLDER in-container
102
+ // binary whose `restart-server` predates that fix — it mirrors the
103
+ // vm-watchdog's recovery so a reset still ends broker-up regardless of
104
+ // which binary is installed.
105
+ //
106
+ // Shape: status-gated and idempotent. `pty-broker status` exits non-zero
107
+ // only when no broker is reachable; ONLY then does the `||` branch fire
108
+ // (`rm -f` the stale socket — mirroring the watchdog — then `nohup` a
109
+ // detached daemon). When a broker is already alive the status succeeds
110
+ // and the respawn branch never runs, so a healthy broker is NEVER killed.
111
+ // Wrapped in `( …; true )` so it can never break the reset's &&-chain
112
+ // (a down broker that fails to respawn must not abort the rest of the
113
+ // reset). The socket glob + `pty-broker daemon` invocation match the
114
+ // watchdog (`scripts/operator/vm-watchdog.sh`) and the per-project
115
+ // `$TMPDIR`/`/tmp` socket layout (`socket_path_for`).
116
+ function codeyamBrokerEnsureStep(E) {
117
+ return (
118
+ `( ${E} editor pty-broker status >/dev/null 2>&1 || ` +
119
+ `( rm -f /tmp/codeyam-pty-broker-*.sock; ` +
120
+ `nohup ${E} editor pty-broker daemon </dev/null >/tmp/broker-spawn.log 2>&1 & disown ); ` +
121
+ 'true )'
122
+ );
123
+ }
124
+
58
125
  // Build the in-container bash for "Reset client" — hard-clean the CLIENT
59
126
  // project (/workspace) on ANY VM shape. The decisions encoded here:
60
127
  // - Reset to HEAD, NOT origin: `git reset --hard` (bare) discards uncommitted
@@ -85,6 +152,9 @@ function resetClientScript({ E, gitReseedBody }) {
85
152
  `${E} editor session-reset`,
86
153
  'echo ">> [4/5] restarting server to respawn the broker (no rebuild)"',
87
154
  `(${E} editor restart-server --force-in-container || true)`,
155
+ // Belt-and-suspenders: ensure a broker is live even on an older binary
156
+ // whose restart-server doesn't yet respawn it. Idempotent / status-gated.
157
+ codeyamBrokerEnsureStep(E),
88
158
  'echo ">> [5/5] re-seeding git credential cache"',
89
159
  `( ( ${gitReseedBody} ) || echo "WARN git cache re-seed failed" )`,
90
160
  ].join(' && ');
@@ -138,7 +208,15 @@ function resetEditorStateScript({ E }) {
138
208
  // docker-exec returns cleanly, so `|| true` keeps a broker-down VM green.
139
209
  'echo ">> [4/4] restarting server to respawn the broker (no rebuild)"',
140
210
  `(${E} editor restart-server --force-in-container || true)`,
211
+ // Belt-and-suspenders: ensure a broker is live even on an older binary
212
+ // whose restart-server doesn't yet respawn it. Idempotent / status-gated.
213
+ codeyamBrokerEnsureStep(E),
141
214
  ].join(' && ');
142
215
  }
143
216
 
144
- module.exports = { codeyamDeepCleanSteps, resetClientScript, resetEditorStateScript };
217
+ module.exports = {
218
+ codeyamDeepCleanSteps,
219
+ codeyamBrokerEnsureStep,
220
+ resetClientScript,
221
+ resetEditorStateScript,
222
+ };