@codeyam-editor/codeyam-editor 0.1.5 → 0.1.7

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 (80) hide show
  1. package/npm/cli.js +5 -1
  2. package/npm/cloud.js +2079 -242
  3. package/npm/codesign-plan.js +68 -0
  4. package/npm/compose-conflict.js +59 -0
  5. package/npm/empty-project-dir.js +92 -7
  6. package/npm/fleet-add-failure.js +361 -0
  7. package/npm/fleet-asset-version.js +37 -0
  8. package/npm/fleet-auth-health.js +143 -5
  9. package/npm/fleet-build-data.js +60 -5
  10. package/npm/fleet-cleanup.js +243 -9
  11. package/npm/fleet-destroy-safety.js +230 -13
  12. package/npm/fleet-early-roster.js +87 -0
  13. package/npm/fleet-effect-poll.js +110 -0
  14. package/npm/fleet-env.js +86 -0
  15. package/npm/fleet-ghcr-secret.js +129 -0
  16. package/npm/fleet-git-maintenance.js +142 -0
  17. package/npm/fleet-host-op-limiter.js +68 -0
  18. package/npm/fleet-iap-token.js +165 -0
  19. package/npm/fleet-job-badge.js +28 -12
  20. package/npm/fleet-live-status.js +40 -8
  21. package/npm/fleet-memory.js +289 -9
  22. package/npm/fleet-plans.js +98 -4
  23. package/npm/fleet-pool.js +75 -16
  24. package/npm/fleet-prewarm.js +87 -11
  25. package/npm/fleet-probe-transport.js +155 -0
  26. package/npm/fleet-project-drift.js +58 -0
  27. package/npm/fleet-public-url.js +234 -0
  28. package/npm/fleet-queue.js +58 -1
  29. package/npm/fleet-reachability-debounce.js +69 -0
  30. package/npm/fleet-reachability.js +80 -0
  31. package/npm/fleet-reset.js +32 -0
  32. package/npm/fleet-revision.js +104 -0
  33. package/npm/fleet-running-plan.js +115 -0
  34. package/npm/fleet-session-token-cache.js +65 -0
  35. package/npm/fleet-setup-steps.js +19 -12
  36. package/npm/fleet-status-refs.js +96 -0
  37. package/npm/fleet-strand.js +39 -8
  38. package/npm/fleet-stuck-status.js +13 -7
  39. package/npm/fleet-vm-access.js +170 -0
  40. package/npm/fleet-vm-probe-target.js +282 -0
  41. package/npm/fleet-vm-readiness.js +136 -3
  42. package/npm/google-ingress.js +15 -0
  43. package/npm/ingress-invoke.js +478 -0
  44. package/npm/launch-config.js +302 -10
  45. package/npm/scenario-check.js +318 -12
  46. package/npm/scenario-handlers.js +85 -27
  47. package/npm/scenario-interactivity.js +145 -6
  48. package/npm/scenario-issues.js +13 -1
  49. package/npm/scenario-mocks.js +129 -26
  50. package/npm/scenario-playwright.js +330 -22
  51. package/npm/script-drift.js +72 -1
  52. package/npm/utils.js +37 -3
  53. package/package.json +5 -5
  54. package/ui/dist/assets/{ContainerInspector-D7VZkkaj.js → ContainerInspector-Cu1tSN-o.js} +1 -1
  55. package/ui/dist/assets/EditorShell--V2ExYQ6.js +260 -0
  56. package/ui/dist/assets/EditorShell-DP5UY20G.css +1 -0
  57. package/ui/dist/assets/{Markdown-BoLHHFXH.js → Markdown-Chgdf3PW.js} +1 -1
  58. package/ui/dist/assets/ProjectLauncherScreen-Dmm6Z0mv.js +1 -0
  59. package/ui/dist/assets/{ScenarioDataPanel-DYP7pi_n.css → ScenarioDataPanel-C1LukYI6.css} +1 -1
  60. package/ui/dist/assets/ScenarioDataPanel-DjprZq2L.js +9 -0
  61. package/ui/dist/assets/{ScenarioDataPanelFullPage-CBLTGC4-.js → ScenarioDataPanelFullPage-BqMxWVKW.js} +1 -1
  62. package/ui/dist/assets/ScenarioRenderer-DqLVVMYS.js +21 -0
  63. package/ui/dist/assets/{StepsSimulator-BFldWyoX.js → StepsSimulator-CymGgfbu.js} +1 -1
  64. package/ui/dist/assets/index-CjxWhkSy.css +1 -0
  65. package/ui/dist/assets/{index-QsOiOG9x.js → index-VuyqBbGJ.js} +4 -4
  66. package/ui/dist/assets/{markdown-v0F0UEt3.js → markdown-C9tbsVHX.js} +1 -1
  67. package/ui/dist/assets/react-nrLBr15I.js +28 -0
  68. package/ui/dist/assets/{useEvents-DHkLJZR4.js → useEvents-S3Qk1Y_d.js} +1 -1
  69. package/ui/dist/assets/useServerIdentity-BdXy8TQv.js +1 -0
  70. package/ui/dist/index.html +4 -4
  71. package/ui/dist/open-source/codeyam-counter.png +0 -0
  72. package/ui/dist/open-source/el-carot.png +0 -0
  73. package/ui/dist/open-source/tabcommand.png +0 -0
  74. package/ui/dist/assets/EditorShell-DHhIaotd.css +0 -1
  75. package/ui/dist/assets/EditorShell-r-796N_W.js +0 -191
  76. package/ui/dist/assets/ProjectLauncherScreen-Ck-XWkQN.js +0 -1
  77. package/ui/dist/assets/ScenarioDataPanel-B9iDSUpA.js +0 -5
  78. package/ui/dist/assets/ScenarioRenderer-BhLfT-nX.js +0 -21
  79. package/ui/dist/assets/index-CIXZkwfB.css +0 -1
  80. package/ui/dist/assets/react-CSS0HapR.js +0 -28
@@ -11,27 +11,161 @@
11
11
  //
12
12
  // Pure logic only — no I/O, no env reads, no clock, no global state.
13
13
 
14
+ // Concurrent-gcloud count above which a pile-up is itself evidence that the
15
+ // operator plane is resource-throttled rather than credential-broken. Measured
16
+ // 2026-07-29: 16 concurrent gcloud processes at load 8.49 with the cgroup near
17
+ // MemoryHigh, against a steady state of 0-2. Set well above steady state so a
18
+ // couple of ordinary in-flight polls never read as a pile-up.
19
+ const GCLOUD_PILEUP_THRESHOLD = 8;
20
+
21
+ // Fraction of the cgroup's memory.high at which memory counts as throttling
22
+ // pressure. MUST stay below the 0.874 that actually occurred on 2026-07-29, or
23
+ // the incident goes undetected and this whole change is inert — there is a
24
+ // regression test pinning exactly that.
25
+ //
26
+ // Deliberately NOT fleet-memory.decideMemoryDrain, which looks superficially like
27
+ // the same decision and is the wrong signal twice over:
28
+ // 1. WRONG QUANTITY. It reads process.memoryUsage().rss — the node process
29
+ // alone. memory.current is the whole CGROUP, including the 16 gcloud
30
+ // subprocesses. Under a fork storm the pressure lives precisely in what RSS
31
+ // excludes, so RSS systematically under-reads this condition.
32
+ // 2. WRONG THRESHOLD. Its soft cap is 1500 MB = 97.7% of the observed
33
+ // memory.high, but the incident sat at 87.4% — it returns false at the
34
+ // measured value.
35
+ // The two coexist because they answer different questions: decideMemoryDrain
36
+ // asks "should I admit another heavy job?", this asks "is the plane too starved
37
+ // to trust its own probes?".
38
+ const MEMORY_PRESSURE_RATIO = 0.85;
39
+
40
+ // The recovery for a resource-throttled operator plane. Named once here so the
41
+ // refusal message, the log line, and the tests can never drift apart.
42
+ const THROTTLE_NEXT_ACTION = 'systemctl restart codeyam-dashboard';
43
+
44
+ // Classify whether the operator plane is RESOURCE-THROTTLED (starved) rather
45
+ // than credential-broken, from the cheap local pressure signals server.js reads.
46
+ // Neither input spawns a subprocess — which matters, because spawning one is
47
+ // exactly what fails when this condition holds.
48
+ // memoryCurrent the cgroup's memory.current, in bytes
49
+ // memoryHigh the cgroup's memory.high, in bytes ('max' when the cgroup
50
+ // is unlimited, which yields no ratio and so no signal)
51
+ // gcloudProcessCount concurrent gcloud processes on the host
52
+ // Returns { throttled, signals }: `signals` names the evidence, so the refusal
53
+ // message can cite WHY it concluded throttling instead of asserting it. An absent
54
+ // or unreadable signal contributes nothing rather than defaulting to pressure —
55
+ // fabricating pressure would relabel a REAL auth break as a memory problem, which
56
+ // is the same class of misdiagnosis in the opposite direction.
57
+ function classifyOperatorThrottle({ memoryCurrent, memoryHigh, gcloudProcessCount } = {}) {
58
+ const signals = [];
59
+ const cur = Number(memoryCurrent);
60
+ const high = Number(memoryHigh);
61
+ if (Number.isFinite(cur) && Number.isFinite(high) && high > 0) {
62
+ const ratio = cur / high;
63
+ if (ratio >= MEMORY_PRESSURE_RATIO) {
64
+ signals.push(`${Math.round(ratio * 100)}% of the cgroup memory throttle`);
65
+ }
66
+ }
67
+ const procs = Number(gcloudProcessCount);
68
+ if (Number.isFinite(procs) && procs >= GCLOUD_PILEUP_THRESHOLD) {
69
+ signals.push(`${procs} concurrent gcloud processes`);
70
+ }
71
+ return { throttled: signals.length > 0, signals };
72
+ }
73
+
14
74
  // Classify whether a destructive removal (hard destroy or --shrink) must be
15
- // REFUSED because the operator's own side is faulted, from the two booleans
16
- // server.js computes. Returns { blocked, reason }: blocked is true when the
17
- // operator's gcloud auth is degraded OR a fleet-wide unreachable spike is in
18
- // progress — the signature of a laptop-side fault (which makes EVERY VM read
19
- // unreachable), NOT N genuinely-dead VMs. authDegraded takes precedence in the
20
- // reason because it is the upstream cause (a broken operator auth is what
21
- // produces the fleet-wide false-unreachable spike in the first place), so the
22
- // message points at the thing to fix.
23
- // authDegraded state.operatorAuthDegraded — operator gcloud auth is broken
24
- // fleetWide fleetWideUnreachableNow() — N+ VMs unreachable at once
25
- function classifyDestroyBlock({ authDegraded, fleetWide } = {}) {
75
+ // REFUSED because the operator's own side is faulted, from the facts server.js
76
+ // computes. Returns { blocked, kind, reason, nextAction }: blocked is true when
77
+ // the operator's plane is faulted (auth degraded) OR a fleet-wide unreachable
78
+ // spike is in progress — the signature of a host-side fault (which makes EVERY VM
79
+ // read unreachable), NOT N genuinely-dead VMs.
80
+ //
81
+ // The `kind` split exists because a throttled plane and a broken credential have
82
+ // OPPOSITE recoveries, and reporting the wrong one is the 2026-07-29 defect: the
83
+ // banner sent the operator to fix an SA binding that was perfectly healthy, or to
84
+ // reach for override-safety — punching through the one guard that exists because
85
+ // a false-unreachable cascade permanently deleted VM2/3/4 on 2026-06-26. The
86
+ // correct action, restarting the dashboard, was named nowhere.
87
+ //
88
+ // A throttled verdict requires BOTH halves of the evidence: the auth probe
89
+ // produced no answer (authProbeFailed — a failed invocation, not a failed
90
+ // credential) AND local pressure corroborates it. Empty probe output on its own
91
+ // stays inconclusive and keeps the auth reason, so a transient hiccup is never
92
+ // relabeled a memory problem; a genuine auth ERROR is never reclassified at all,
93
+ // no matter how loaded the host is.
94
+ //
95
+ // What this does NOT do is let anything through. Every degraded classification
96
+ // returns blocked: true — the refusal was correct, only its diagnosis was wrong.
97
+ // authDegraded state.operatorAuthDegraded — the operator plane is faulted
98
+ // authProbeFailed the auth verdict's kind was 'probe-failed' (empty output,
99
+ // no error message) rather than 'auth'
100
+ // memoryCurrent the cgroup's memory.current in bytes (see
101
+ // memoryHigh the cgroup's memory.high in bytes classifyOperatorThrottle)
102
+ // gcloudProcessCount concurrent gcloud processes on the host
103
+ // fleetWide fleetWideUnreachableNow() — N+ VMs unreachable at once
104
+ function classifyDestroyBlock({ authDegraded, authProbeFailed, memoryCurrent, memoryHigh, gcloudProcessCount, fleetWide } = {}) {
26
105
  if (authDegraded) {
27
- return { blocked: true, reason: 'operator gcloud auth degraded' };
106
+ const pressure = classifyOperatorThrottle({ memoryCurrent, memoryHigh, gcloudProcessCount });
107
+ if (authProbeFailed && pressure.throttled) {
108
+ return {
109
+ blocked: true,
110
+ kind: 'throttled',
111
+ reason: `operator plane resource-exhausted (${pressure.signals.join(', ')}) — the auth probe was starved, not denied`,
112
+ nextAction: THROTTLE_NEXT_ACTION,
113
+ };
114
+ }
115
+ return {
116
+ blocked: true,
117
+ kind: 'auth',
118
+ reason: 'operator gcloud auth degraded',
119
+ nextAction: 'resolve auth (SA binding) — scripts/operator/setup-service-account.sh',
120
+ };
28
121
  }
29
122
  if (fleetWide) {
30
- return { blocked: true, reason: 'fleet-wide unreachable spike in progress (operator-side fault signature)' };
123
+ return {
124
+ blocked: true,
125
+ kind: 'fleet-wide',
126
+ reason: 'fleet-wide unreachable spike in progress (operator-side fault signature)',
127
+ nextAction: 'wait out the spike',
128
+ };
31
129
  }
130
+ // The NOT-blocked shape stays exactly { blocked, reason } — `kind` and
131
+ // `nextAction` describe a fault, and there is no fault here to describe.
32
132
  return { blocked: false, reason: null };
33
133
  }
34
134
 
135
+ // Classify a non-Force destroy's unpushed-commit INTENT decision from the WARM
136
+ // ahead-count the dashboard reads off /api/session-info, mirroring the pure
137
+ // decision shape of classifyDestroyBlock. The three outcomes:
138
+ // ahead number > 0 → REFUSE: unpushed work exists (Force-recoverable)
139
+ // ahead number === 0 → PROCEED: verified clean
140
+ // ahead null / undefined → REFUSE: could NOT verify (editor unreachable /
141
+ // field absent) — a DISTINCT refusal, never conflated
142
+ // with "VM down", and Force-recoverable
143
+ // Returns { refuse, forceable, outcome, msg }: `outcome` is the
144
+ // logDestroyDecision attribution token (null when proceeding); `msg` is the
145
+ // operator-facing refusal text (null when proceeding). Pure — the caller owns the
146
+ // warm probe, the cached fallback, the logging, and the return; this only maps
147
+ // ahead → decision so it is unit-testable in isolation (the way the cold-SSH
148
+ // version never was).
149
+ function classifyUnpushedDestroy({ n, ahead } = {}) {
150
+ if (typeof ahead === 'number' && ahead > 0) {
151
+ return {
152
+ refuse: true,
153
+ forceable: true,
154
+ outcome: 'REFUSED:unpushed',
155
+ msg: `VM-${n} has ${ahead} unpushed commit(s) — not destroying. Use Force to override.`,
156
+ };
157
+ }
158
+ if (ahead === 0) {
159
+ return { refuse: false, forceable: false, outcome: null, msg: null };
160
+ }
161
+ return {
162
+ refuse: true,
163
+ forceable: true,
164
+ outcome: 'REFUSED:unverifiable',
165
+ msg: `VM-${n} — could not verify unpushed commits (editor unreachable). Use Force to destroy anyway.`,
166
+ };
167
+ }
168
+
35
169
  // Build the single attributable destroy-decision log line. Mirrors the
36
170
  // performAutoReset "confirmed … not fleet-wide" template so every destroy —
37
171
  // proceed OR refused, across every trigger source — is diagnosable from one
@@ -49,7 +183,90 @@ function formatDestroyDecision(info = {}) {
49
183
  );
50
184
  }
51
185
 
186
+ // Build the ENTRY line for a destroy request, emitted before any probe runs —
187
+ // the third sibling of formatDestroyDecision (the up-front DECISION) and
188
+ // formatDestroyOutcome (the terminal JOB fate). Without it a request that died
189
+ // mid-flight left NO record at all: on 2026-07-29 two destroy POSTs both ended in
190
+ // HTTP 000 and the log's last entry was an earlier refusal, so the operator could
191
+ // not tell whether a delete had started against their VM — the worst possible
192
+ // answer to "did you delete it?".
193
+ //
194
+ // It keeps the `destroy-decision:` prefix so ONE grep still finds a request's
195
+ // whole life, and carries only what is known at entry. It deliberately does NOT
196
+ // render authDegraded/fleetWide/hasSession: those are not measured yet, and
197
+ // printing `false` for an unmeasured verdict would read as "auth was fine" —
198
+ // exactly the false diagnosis this change exists to remove.
199
+ function formatDestroyAttempt(info = {}) {
200
+ const remote = info.remote ? `${info.remote.ip || '?'}/${info.remote.ua || '?'}` : 'n/a';
201
+ return (
202
+ `destroy-decision: VM-${info.n} attempt source=${info.source} force=${!!info.force} ` +
203
+ `override=${!!info.overrideSafety} remote=${remote}`
204
+ );
205
+ }
206
+
207
+ // Render the operator-facing REFUSAL for a blocked destructive removal, pairing
208
+ // the verdict's reason with its verdict-specific recovery. Extracted from
209
+ // server.js so the "a throttled refusal never sends the operator at auth"
210
+ // property is assertable at the MESSAGE level, not just on the reason field.
211
+ //
212
+ // The recovery renders as the greppable `Next valid action:` contract (CLAUDE.md
213
+ // CLI error conventions) rather than as prose. override-safety is mentioned LAST
214
+ // and never as the primary fix: it bypasses the guard that exists because a
215
+ // false-unreachable cascade permanently deleted VM2/3/4 on 2026-06-26, and
216
+ // putting it beside the real recovery is what made two operators reach for it
217
+ // within one session. `detail` lets the shrink path swap the wording while
218
+ // keeping an identical recovery contract, so the two destructive paths that share
219
+ // operatorFaultVerdict can never give conflicting advice for one fault.
220
+ function formatDestroyRefusal({ n, reason, nextAction, detail } = {}) {
221
+ const what = detail || `refusing to destroy VM-${n}`;
222
+ return (
223
+ `REFUSED: ${reason} — ${what}; a false-unreachable storm can delete healthy VMs.\n` +
224
+ `Next valid action: ${nextAction}\n` +
225
+ 'If this destructive action is genuinely intended right now, pass override-safety.'
226
+ );
227
+ }
228
+
229
+ // Render the refusal for a destroy whose DECISION exceeded its wall-clock budget.
230
+ // HTTP 000 is the worst possible answer to "did you delete my VM?" — it is
231
+ // indistinguishable from a delete that started and lost its response, and it is
232
+ // what two destroy POSTs returned on 2026-07-29 after 150s and 240s.
233
+ //
234
+ // The wording is deliberately "no verdict" and "no delete CONFIRMED dispatched",
235
+ // not "nothing happened": the in-flight decision may still resolve after the
236
+ // bound elapses, so claiming otherwise would be a second false certainty. It
237
+ // points at the `destroy-decision:` grep instead, which the entry-time attempt
238
+ // line guarantees is populated.
239
+ function formatDestroyBoundExceeded({ n, budgetMs } = {}) {
240
+ return (
241
+ `REFUSED: no destroy verdict for VM-${n} within ${Math.round(budgetMs / 1000)}s — ` +
242
+ 'the dashboard is not answering, which usually means it is resource-throttled.\n' +
243
+ `Next valid action: ${THROTTLE_NEXT_ACTION}\n` +
244
+ `No delete was confirmed dispatched; grep the log for \`destroy-decision: VM-${n}\` to see how far this request got.`
245
+ );
246
+ }
247
+
248
+ // Build the single TERMINAL-OUTCOME journal line for a completed destroy JOB —
249
+ // the sibling of formatDestroyDecision, which records the up-front DECISION. The
250
+ // decision line alone was a blind spot: diagnosing the VM-8 silent refusal
251
+ // surfaced only `destroy-decision … → proceed:delete`, nothing about how the job
252
+ // itself ended, so a job that refused/errored after the decision was invisible.
253
+ // With this line a completed destroy's fate is one `grep destroy-outcome` away.
254
+ // `outcome` is the caller-computed terminal string: `done`, `refused:<kind>`, or
255
+ // `error code=<n>`. Pure format only — the caller owns the classification.
256
+ function formatDestroyOutcome(info = {}) {
257
+ return `destroy-outcome: VM-${info.n} ${info.outcome}`;
258
+ }
259
+
52
260
  module.exports = {
261
+ GCLOUD_PILEUP_THRESHOLD,
262
+ MEMORY_PRESSURE_RATIO,
263
+ THROTTLE_NEXT_ACTION,
53
264
  classifyDestroyBlock,
265
+ classifyOperatorThrottle,
266
+ classifyUnpushedDestroy,
267
+ formatDestroyAttempt,
268
+ formatDestroyBoundExceeded,
54
269
  formatDestroyDecision,
270
+ formatDestroyOutcome,
271
+ formatDestroyRefusal,
55
272
  };
@@ -0,0 +1,87 @@
1
+ 'use strict';
2
+
3
+ // npm/fleet-early-roster.js — decides WHEN an in-flight `add` earns its roster
4
+ // entry.
5
+ //
6
+ // Background (vm--batch-add-must-roster-and-wire-each-vm): rostering used to
7
+ // happen in exactly one place on the live add path — `startJob`'s onSuccess
8
+ // callback — which only fires when the whole ~28-minute add script exits 0. A
9
+ // VM whose container came up but whose add later failed (wiring, reachability,
10
+ // a dashboard SIGKILL) was therefore never rostered, and an unrostered VM is
11
+ // invisible to `reconcile-ingress`, which skips it. That produced a deadlock:
12
+ //
13
+ // not rostered → reconcile skips → no backend service → LB 503 →
14
+ // not reachable → bootstrap recovery retries → exhausted → still not rostered
15
+ //
16
+ // Observed live 2026-07-20 provisioning VM-4..VM-11: the roster stayed `[1,2,3]`
17
+ // for a `count=8` add even though every container was `Up` and serving 200
18
+ // locally. The only recovery was to destroy and re-add one at a time.
19
+ //
20
+ // The fix is to roster EARLY — as soon as the instance exists and the container
21
+ // gate has passed — so a later-stage failure leaves a *rostered, reconcilable*
22
+ // VM that the periodic sweep can finish wiring, rather than an invisible one.
23
+ // The add script announces that moment by printing a marker line; `startJob`
24
+ // taps the child's stdout and hands the accumulated text here.
25
+ //
26
+ // This module is PURE (no fs, no network, no roster writes) so the "has this VM
27
+ // earned its roster entry yet" rule is unit-testable without a live VM. The
28
+ // actual `addToRoster` write stays in server.js.
29
+
30
+ // The marker the add script prints once its container gate passes. Chosen to
31
+ // match the existing `CY_DEGRADED` marker convention that `startJob` already
32
+ // scans for, so both live in the same namespace and are equally greppable in a
33
+ // job log.
34
+ const ROSTER_MARKER = 'CY_ROSTER';
35
+
36
+ // Build the marker line the add script emits. Kept here (rather than inlined in
37
+ // server.js) so the emitter and the parser can never drift apart — a test that
38
+ // round-trips `parseRosterMarkers(rosterMarkerLine(n))` pins them together.
39
+ function rosterMarkerLine(n) {
40
+ return `${ROSTER_MARKER} ${n}`;
41
+ }
42
+
43
+ // Extract every VM number announced by a roster marker in `text`.
44
+ //
45
+ // Scans the FULL accumulated buffer rather than a single chunk: child stdout
46
+ // arrives in arbitrary chunks that can split a line mid-marker, so a
47
+ // chunk-local parse would miss (or double-count) a marker straddling a
48
+ // boundary. Re-parsing the whole buffer is idempotent and cheap relative to the
49
+ // ~28-minute job it rides along with.
50
+ //
51
+ // Returns unique numbers in ascending order. Junk (`CY_ROSTER abc`,
52
+ // `CY_ROSTER 1.5`, a bare `CY_ROSTER`) is dropped — the roster is a set of
53
+ // VM-number integers, so a malformed marker must never poison it.
54
+ function parseRosterMarkers(text) {
55
+ if (typeof text !== 'string' || text === '') return [];
56
+ const out = new Set();
57
+ for (const line of text.split('\n')) {
58
+ const t = line.trim();
59
+ if (!t.startsWith(`${ROSTER_MARKER} `)) continue;
60
+ const rest = t.slice(ROSTER_MARKER.length + 1).trim();
61
+ // Reject anything that is not a plain positive integer. `Number()` alone
62
+ // would accept '1.5', '0x2', and ' 3 '; the regex gate keeps it strict.
63
+ if (!/^\d+$/.test(rest)) continue;
64
+ const n = Number(rest);
65
+ if (Number.isInteger(n) && n > 0) out.add(n);
66
+ }
67
+ return [...out].sort((a, b) => a - b);
68
+ }
69
+
70
+ // Should VM `n` be rostered right now, given the job output so far?
71
+ //
72
+ // `alreadyRostered` is the caller's own latch — server.js flips it after the
73
+ // first successful write so a marker sitting in the buffer for the rest of the
74
+ // job doesn't re-trigger a write on every subsequent chunk. (The roster write
75
+ // is itself idempotent, so this is an efficiency guard, not a correctness one.)
76
+ function shouldEarlyRoster({ text, vmNumber, alreadyRostered } = {}) {
77
+ if (alreadyRostered) return false;
78
+ if (!Number.isInteger(vmNumber) || vmNumber <= 0) return false;
79
+ return parseRosterMarkers(text).includes(vmNumber);
80
+ }
81
+
82
+ module.exports = {
83
+ ROSTER_MARKER,
84
+ rosterMarkerLine,
85
+ parseRosterMarkers,
86
+ shouldEarlyRoster,
87
+ };
@@ -0,0 +1,110 @@
1
+ 'use strict';
2
+ // Self-healing cycle guard for the Fleet Dashboard's slow effect poll
3
+ // (scripts/operator/fleet-dashboard/server.js requires this). No gcloud/SSH
4
+ // I/O — this module owns the control-flow guarantee ONLY; the server keeps its
5
+ // gather edges and worker pools.
6
+ //
7
+ // The bug this fixes: `pollEffects` guards against overlap with a
8
+ // `effectPollRunning` flag (two concurrent full cycles would double the gcloud
9
+ // load), releasing it in a `finally` that runs only AFTER
10
+ // `await Promise.all(...)` of the reachable + launcher-strand sweeps. On
11
+ // 2026-07-09 a single probe's callback never fired (an SSH/docker-exec against
12
+ // VM-8, whose GCP instance had been deleted mid-session), so that awaited
13
+ // promise never resolved, the `finally` never ran, and `effectPollRunning`
14
+ // stayed `true` forever — every subsequent 45s tick early-returned and fleet
15
+ // readiness (iapAccess, backendHealth, readiness, launcher-strand self-heal)
16
+ // froze fleet-wide until the dashboard was restarted.
17
+ //
18
+ // Two guarantees make that freeze impossible:
19
+ // makeGuardedCycle — the no-overlap guard is kept (it's correct), but its
20
+ // release is made unconditional: the returned promise ALWAYS settles, either
21
+ // when the tasks finish or when a whole-cycle watchdog fires, and the guard
22
+ // flag is cleared in a `finally` either way. A hung task can no longer strand
23
+ // the next tick.
24
+ // withTimeout — wraps a single probe promise so a never-firing callback
25
+ // resolves to a safe fallback verdict instead of hanging the sweep.
26
+
27
+ // Wrap a promise so it can never hang the caller: resolve to `fallback` if
28
+ // `promise` has not settled within `ms`, and also on rejection (a probe that
29
+ // throws must degrade gracefully, never reject the whole sweep). `ms <= 0`
30
+ // disables the timeout and just awaits the promise (rejections still fold to
31
+ // `fallback`), so a caller can opt a leg out without branching. Whichever of
32
+ // (settle, timeout) happens first wins; the other is a no-op.
33
+ function withTimeout(promise, ms, fallback) {
34
+ const settled = Promise.resolve(promise).catch(() => fallback);
35
+ if (!(ms > 0)) return settled;
36
+ return new Promise((resolve) => {
37
+ let done = false;
38
+ const finish = (val) => { if (!done) { done = true; resolve(val); } };
39
+ // NOT unref'd: this timer is load-bearing — it resolves the promise the
40
+ // caller awaits, so letting the event loop exit before it fires would leave
41
+ // the await hung. It is cleared the instant the real promise settles, so it
42
+ // holds the loop open only for the (short) timeout window.
43
+ const timer = setTimeout(() => finish(fallback), ms);
44
+ settled.then((val) => { clearTimeout(timer); finish(val); });
45
+ });
46
+ }
47
+
48
+ // Build a guarded cycle runner. `run(tasks)` executes an array of zero-arg async
49
+ // task factories (or bare promises) under a no-overlap guard:
50
+ // - while a cycle is in flight, a second `run(...)` returns `{ skipped: true }`
51
+ // immediately without launching the tasks — the no-overlap contract.
52
+ // - the returned promise ALWAYS settles: when the tasks finish, or — if
53
+ // `watchdogMs > 0` — when the whole-cycle watchdog fires first (returning
54
+ // `{ wedged: true }`). Either way the guard flag is cleared in a `finally`,
55
+ // so the NEXT tick can always run. A never-resolving task therefore cannot
56
+ // permanently wedge the loop (the 2026-07-09 freeze).
57
+ // `onWedge(elapsedMs)` (optional) is called when the watchdog fires, so the
58
+ // caller can log a wedge warning. A task that rejects is swallowed (one bad probe
59
+ // must not fail the whole cycle); the caller composes concurrency/ordering.
60
+ function makeGuardedCycle(opts) {
61
+ const o = opts || {};
62
+ const watchdogMs = o.watchdogMs > 0 ? o.watchdogMs : 0;
63
+ const onWedge = typeof o.onWedge === 'function' ? o.onWedge : null;
64
+ let running = false;
65
+ const run = async (tasks) => {
66
+ if (running) return { skipped: true };
67
+ running = true;
68
+ try {
69
+ const work = Promise.all((tasks || []).map((t) => (typeof t === 'function' ? t() : t)))
70
+ .then(() => false, () => false); // settle to `wedged=false`; swallow a rejecting probe
71
+ let wedged = false;
72
+ if (watchdogMs > 0) {
73
+ wedged = await new Promise((resolve) => {
74
+ // NOT unref'd: this watchdog resolves the promise the caller awaits, so
75
+ // it must keep the loop alive until it fires (or the work settles first
76
+ // and clears it). Unref'ing it would let a standalone await hang.
77
+ const timer = setTimeout(() => resolve(true), watchdogMs);
78
+ work.then((w) => { clearTimeout(timer); resolve(w); });
79
+ });
80
+ } else {
81
+ await work;
82
+ }
83
+ if (wedged && onWedge) { try { onWedge(watchdogMs); } catch (e) { /* logging must never re-wedge */ } }
84
+ return { skipped: false, wedged };
85
+ } finally {
86
+ // Unconditional release — the whole point. Whether the tasks finished or
87
+ // the watchdog fired, the guard is cleared so the next tick proceeds. Any
88
+ // still-pending task keeps running detached, but holds no guard.
89
+ running = false;
90
+ }
91
+ };
92
+ return { run };
93
+ }
94
+
95
+ // A roster entry whose Add errored out is an `add-failed` ghost: its GCP instance
96
+ // may be half-created or already reconciled away, so an SSH/docker-exec strand
97
+ // probe against it is the exact call that hung the 2026-07-09 sweep (VM-8). The
98
+ // strand selection skips it — a failed Add is owned by the reachable / vanish
99
+ // reconcilers, not the launcher-strand self-heal. `withTimeout` (above) is the
100
+ // general backstop for any hung probe; this removes the doomed probe at the
101
+ // source for the named trigger.
102
+ function isAddFailedGhost(job) {
103
+ return !!(job && job.state === 'error' && job.action === 'add');
104
+ }
105
+
106
+ module.exports = {
107
+ withTimeout,
108
+ makeGuardedCycle,
109
+ isAddFailedGhost,
110
+ };
package/npm/fleet-env.js CHANGED
@@ -99,6 +99,89 @@ function envToDict(text) {
99
99
  return dict;
100
100
  }
101
101
 
102
+ // Canonicalize a git repo URL for identity comparison. Strips any embedded
103
+ // credentials (`user@` / `user:pass@`), normalizes the two forms GitHub emits
104
+ // — `git@host:owner/repo` (scp-like) and `https://host/owner/repo` — to a
105
+ // single `host/owner/repo` shape, drops a trailing `.git` and trailing
106
+ // slashes, and lowercases the result. Returns '' for null/empty. This makes
107
+ // `https://github.com/sfpatchworks/back-office.git` equal to
108
+ // `git@github.com:sfpatchworks/back-office`, so the per-slot env tag matches
109
+ // the fleet repo regardless of which URL form each was recorded in.
110
+ function normalizeRepoUrl(url) {
111
+ let s = String(url == null ? '' : url).trim();
112
+ if (s === '') return '';
113
+ // scp-like `user@host:owner/repo` → `host/owner/repo` (only when there is no
114
+ // `://` scheme; `[^@/:]+@[^:/]+:` guarantees a single `host:` colon, not a
115
+ // scheme's `://`).
116
+ const scp = s.match(/^[^@/:]+@([^:/]+):(.+)$/);
117
+ if (scp) {
118
+ s = `${scp[1]}/${scp[2]}`;
119
+ } else {
120
+ s = s.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, ''); // strip scheme
121
+ s = s.replace(/^[^@/]+@/, ''); // strip credentials
122
+ }
123
+ s = s.replace(/\/+$/, '').replace(/\.git$/i, '');
124
+ return s.toLowerCase();
125
+ }
126
+
127
+ // Classify whether a slot's persisted env may be re-applied to the repo the
128
+ // slot now serves. Returns one of:
129
+ // 'apply' — metaRepo is non-empty and normalizes equal to currentRepo
130
+ // 'skip-mismatch' — metaRepo is non-empty but normalizes differently
131
+ // 'skip-untagged' — metaRepo is empty/absent (a legacy file with no meta
132
+ // sidecar; treated as unattributed and never replayed)
133
+ // This is the guard that stops a prior project's env from silently bleeding
134
+ // onto a new, unrelated project that reuses the same VM slot number.
135
+ function classifyEnvApplicability({ metaRepo, currentRepo } = {}) {
136
+ const tagged = normalizeRepoUrl(metaRepo);
137
+ if (tagged === '') return 'skip-untagged';
138
+ return tagged === normalizeRepoUrl(currentRepo) ? 'apply' : 'skip-mismatch';
139
+ }
140
+
141
+ // Prisma datasource providers we know how to scheme-check a `DATABASE_URL`
142
+ // against. Used to skip the generator block's `provider = "prisma-client-js"`
143
+ // and match only the datasource `provider`.
144
+ const DB_PROVIDERS = new Set([
145
+ 'sqlite', 'postgresql', 'postgres', 'mysql', 'sqlserver', 'mongodb', 'cockroachdb',
146
+ ]);
147
+
148
+ // Advisory: detect a Prisma datasource provider vs `DATABASE_URL` scheme
149
+ // mismatch. Returns a short, actionable warning string, or null. Only fires
150
+ // when `schemaText` declares a recognized datasource `provider` AND `envText`
151
+ // declares a `DATABASE_URL` whose scheme is incompatible with it — currently
152
+ // the two directions that actually broke a live project: provider `sqlite`
153
+ // with a `postgresql`/`postgres` URL, or provider `postgresql`/`postgres`
154
+ // with a `file:` (SQLite) URL. Returns null when there is no schema, no
155
+ // recognized datasource provider, no `DATABASE_URL`, or they agree — so
156
+ // non-Prisma / no-DB projects are a silent no-op. Pure: both texts are passed
157
+ // in by the I/O caller.
158
+ function detectDbProviderMismatch(envText, schemaText) {
159
+ const schema = String(schemaText == null ? '' : schemaText);
160
+ let provider = null;
161
+ const re = /provider\s*=\s*"([^"]+)"/g;
162
+ let m;
163
+ while ((m = re.exec(schema)) !== null) {
164
+ const p = m[1].toLowerCase();
165
+ if (DB_PROVIDERS.has(p)) { provider = p; break; }
166
+ }
167
+ if (!provider) return null;
168
+ const dict = envToDict(envText) || {};
169
+ const url = dict.DATABASE_URL;
170
+ if (!url) return null;
171
+ const scheme = (String(url).match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/) || [])[1];
172
+ if (!scheme) return null;
173
+ const s = scheme.toLowerCase();
174
+ const isPostgres = s === 'postgresql' || s === 'postgres';
175
+ const isSqlite = s === 'file';
176
+ if (provider === 'sqlite' && isPostgres) {
177
+ return `DATABASE_URL scheme "${scheme}" is a Postgres URL but the Prisma schema declares provider = "sqlite" — the app will fail to open its database. Re-enter env with a file: URL, or fix the schema provider.`;
178
+ }
179
+ if ((provider === 'postgresql' || provider === 'postgres') && isSqlite) {
180
+ return `DATABASE_URL scheme "file" is a SQLite URL but the Prisma schema declares provider = "${provider}" — the app expects a Postgres connection string. Re-enter env accordingly.`;
181
+ }
182
+ return null;
183
+ }
184
+
102
185
  // Wrap a parsed launch-form env dict in the cloud:up bootstrap-child-env
103
186
  // shape: spreads `envDict` so the keys land in the child's `process.env`, and
104
187
  // sets `CY_FORWARD_ENV_KEYS` to the comma-separated key list so
@@ -120,6 +203,9 @@ module.exports = {
120
203
  envHasSetupTrigger,
121
204
  envToDict,
122
205
  buildBootstrapChildEnv,
206
+ normalizeRepoUrl,
207
+ classifyEnvApplicability,
208
+ detectDbProviderMismatch,
123
209
  FORWARD_ENV_KEYS_ENV,
124
210
  KEY_RE,
125
211
  };