@codeyam-editor/codeyam-editor 0.1.1 → 0.1.3

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 (53) hide show
  1. package/npm/cli.js +5 -2
  2. package/npm/cloud-branch-switch.js +442 -0
  3. package/npm/cloud.js +1929 -0
  4. package/npm/container.js +22 -3
  5. package/npm/editor-dev.js +368 -37
  6. package/npm/editor.js +133 -52
  7. package/npm/empty-project-dir.js +33 -0
  8. package/npm/fleet-auth.js +111 -0
  9. package/npm/fleet-branch.js +49 -0
  10. package/npm/fleet-broker-death.js +44 -0
  11. package/npm/fleet-broker-debounce.js +63 -0
  12. package/npm/fleet-broker-status.js +45 -0
  13. package/npm/fleet-cleanup.js +297 -0
  14. package/npm/fleet-effect-status.js +179 -0
  15. package/npm/fleet-env.js +125 -0
  16. package/npm/fleet-grouping.js +56 -0
  17. package/npm/fleet-job-badge.js +39 -0
  18. package/npm/fleet-memory.js +157 -0
  19. package/npm/fleet-pool-policy.js +78 -0
  20. package/npm/fleet-pool.js +401 -0
  21. package/npm/fleet-queue.js +40 -0
  22. package/npm/fleet-reconcile.js +36 -0
  23. package/npm/fleet-reset.js +222 -0
  24. package/npm/fleet-roster.js +81 -0
  25. package/npm/launch-config.js +622 -0
  26. package/npm/preflight-guidance.js +77 -0
  27. package/npm/scenario-check.js +323 -14
  28. package/npm/scenario-handlers.js +16 -5
  29. package/npm/scenario-interactivity.js +245 -0
  30. package/npm/scenario-metrics.js +16 -2
  31. package/npm/scenario-mocks.js +62 -25
  32. package/npm/scenario-playwright.js +350 -8
  33. package/npm/utils.js +562 -22
  34. package/npm/vm-urls.js +117 -0
  35. package/package.json +8 -7
  36. package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +2 -0
  37. package/ui/dist/assets/EditorShell-CGsBSA6u.js +186 -0
  38. package/ui/dist/assets/Markdown-BHBqz-Mj.js +1 -0
  39. package/ui/dist/assets/ScenarioDataPanel-DYP7pi_n.css +32 -0
  40. package/ui/dist/assets/ScenarioDataPanel-nuLXJzLf.js +5 -0
  41. package/ui/dist/assets/ScenarioDataPanelFullPage-Bnghtj8a.js +1 -0
  42. package/ui/dist/assets/ScenarioRenderer-B_Za9jL8.js +1 -0
  43. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +1 -0
  44. package/ui/dist/assets/index-Bih6HJ_u.css +1 -0
  45. package/ui/dist/assets/index-KDFAgt2e.js +27 -0
  46. package/ui/dist/assets/markdown-v0F0UEt3.js +37 -0
  47. package/ui/dist/assets/react-CSS0HapR.js +28 -0
  48. package/ui/dist/assets/useEvents-DggWrtHe.js +1 -0
  49. package/ui/dist/assets/xterm--24IGk-x.js +9 -0
  50. package/ui/dist/favicon.ico +0 -0
  51. package/ui/dist/favicon.png +0 -0
  52. package/ui/dist/index.html +16 -0
  53. package/npm/postinstall.js +0 -132
@@ -0,0 +1,622 @@
1
+ "use strict";
2
+ // Pure helpers for the fleet-dashboard's parametrized launch + cached history.
3
+ //
4
+ // Dependency-free and unit-tested (npm/launch-config.test.js) so the dashboard
5
+ // server (scripts/operator/fleet-dashboard/server.js) can require them without
6
+ // pulling logic into its poll/HTTP path. A "launch config" is the operator's
7
+ // choice of WHICH client project (repo/branch) + WHICH editor (variant/branch)
8
+ // runs on a VM; the history is the cache that makes the last choice the default
9
+ // and past launches one click to re-run.
10
+
11
+ const { CANONICAL_EDITOR_REPO, EDITOR_VARIANTS, repoBasename } = require("./cloud");
12
+
13
+ const EMPTY_CONFIG = Object.freeze({
14
+ clientRepo: "",
15
+ clientBranch: "",
16
+ // Greenfield: when set (and clientRepo empty), launch against a NEW empty
17
+ // folder of this name instead of cloning a repo.
18
+ newProjectName: "",
19
+ editorVariant: "dev",
20
+ editorBranch: "",
21
+ });
22
+
23
+ /** Coerce a raw form/query object into the canonical launch-config shape: trim
24
+ * strings, default the variant to a known value. Never throws — validation is
25
+ * a separate, explicit step. */
26
+ function normalizeLaunchConfig(input = {}) {
27
+ const str = (v) => (typeof v === "string" ? v.trim() : "");
28
+ const editorVariant = EDITOR_VARIANTS.includes(input.editorVariant) ? input.editorVariant : "dev";
29
+ return {
30
+ clientRepo: str(input.clientRepo),
31
+ clientBranch: str(input.clientBranch),
32
+ newProjectName: str(input.newProjectName),
33
+ editorVariant,
34
+ editorBranch: str(input.editorBranch),
35
+ };
36
+ }
37
+
38
+ /** Validate a normalized config. Returns { ok, msg } so the HTTP layer can
39
+ * surface the reason without a try/catch. */
40
+ function validateLaunchConfig(cfg) {
41
+ const hasRepo = !!(cfg && cfg.clientRepo);
42
+ const hasNew = !!(cfg && cfg.newProjectName);
43
+ if (hasRepo && hasNew) {
44
+ return { ok: false, msg: "specify either a client repo or a new-project name, not both" };
45
+ }
46
+ if (!hasRepo && !hasNew) {
47
+ return { ok: false, msg: "a client repo or a new-project name is required" };
48
+ }
49
+ if (hasRepo && !/^(https?:\/\/|git@)/.test(cfg.clientRepo)) {
50
+ return { ok: false, msg: "client repo must be an https:// or git@ URL" };
51
+ }
52
+ if (hasNew && !/^[A-Za-z0-9._-]+$/.test(cfg.newProjectName)) {
53
+ return { ok: false, msg: "new-project name may contain only letters, numbers, dot, dash, underscore" };
54
+ }
55
+ if (!EDITOR_VARIANTS.includes(cfg.editorVariant)) {
56
+ return { ok: false, msg: `unknown editor variant: ${cfg.editorVariant}` };
57
+ }
58
+ return { ok: true };
59
+ }
60
+
61
+ /** Gate mirror of cloud.js::assertVariantProvisionable — which editor variants
62
+ * can be provisioned onto a cloud VM. All three variants now provision: `dev`
63
+ * builds the editor image from source, while `staging`/`production` run the
64
+ * published npm package (gcp-bootstrap pulls/builds Dockerfile.npm keyed off
65
+ * the variant). An unknown variant is excluded so a typo never reaches a
66
+ * launch. Pure; no I/O. */
67
+ function isVariantProvisionable(variant) {
68
+ return EDITOR_VARIANTS.includes(variant);
69
+ }
70
+
71
+ /** Gate for the in-place BINARY-REBUILD operations (the dashboard's "Rebuild
72
+ * binary" / "Update & Reset Everything" actions, and reconfigure's trailing
73
+ * binary refresh). Historically distinct from `isVariantProvisionable` because a
74
+ * packaged variant's rebuild is an npm-reinstall (rebuildStrategy ===
75
+ * "npm-reinstall") and that host-script path was not wired — so this stayed
76
+ * dev-only and packaged rebuild buttons failed loud. With the npm-reinstall
77
+ * branch now wired into `rebuildBinaryHostScript` (it rebuilds the Dockerfile.npm
78
+ * image at the variant's dist-tag instead of source-building Dockerfile), every
79
+ * provisionable variant is also rebuildable, so this now mirrors
80
+ * `isVariantProvisionable` — only an unknown variant is rejected. Pure; no I/O. */
81
+ function isVariantRebuildable(variant) {
82
+ return isVariantProvisionable(variant);
83
+ }
84
+
85
+ /** Map an editor variant to the npm dist-tag its package was published under.
86
+ * CI publishes `codeyam.com` → "latest" (production) and `staging.codeyam.com`
87
+ * → "staging" (see .github/workflows/cicd.yml "Set release variant"). `dev` has
88
+ * no published package (it builds the image from source), so return null and let
89
+ * callers reject it. This is the single source of truth for the variant→dist-tag
90
+ * contract — the Dockerfile.npm build-arg, publish-editor-image.sh's `--from-npm`
91
+ * mode, and the future reconfigure path all derive from it so they can't drift.
92
+ * Pure; no I/O. */
93
+ function variantNpmDistTag(variant) {
94
+ if (variant === "production") return "latest";
95
+ if (variant === "staging") return "staging";
96
+ return null; // dev / unknown — no packaged dist-tag
97
+ }
98
+
99
+ /** Map a packaged editor variant to the GHCR image-tag suffix the packaged
100
+ * image is pushed under (`<registry>/codeyam-editor-npm:<suffix>`). Lives beside
101
+ * `variantNpmDistTag` so the registry naming convention and the dist-tag mapping
102
+ * stay one reviewable unit; `scripts/publish-editor-image.sh --from-npm` asserts
103
+ * the literal string this returns. `dev` has no packaged image (built from
104
+ * source), so return null. Pure; no I/O. */
105
+ function variantImageTag(variant) {
106
+ if (variant === "production") return "production-latest";
107
+ if (variant === "staging") return "staging-latest";
108
+ return null; // dev / unknown — no packaged image
109
+ }
110
+
111
+ /** Build the `cloud:up` flag array for a launch config + instance name. The
112
+ * editor source is the canonical codeyam-editor repo (dev variant builds the
113
+ * image from editorBranch); the client repo/branch is what lands in
114
+ * /workspace. Decoupled exactly as cloud.js consumes them. */
115
+ function cloudUpArgs(cfg, instanceName, agent = "claude") {
116
+ const args = ["--instance-name", instanceName];
117
+ // Greenfield (new folder) vs clone an existing repo.
118
+ if (cfg.newProjectName) args.push("--new-project", cfg.newProjectName);
119
+ else args.push("--repo", cfg.clientRepo);
120
+ args.push("--agent", agent, "--editor-repo", CANONICAL_EDITOR_REPO, "--editor-variant", cfg.editorVariant);
121
+ if (!cfg.newProjectName && cfg.clientBranch) args.push("--branch", cfg.clientBranch);
122
+ if (cfg.editorBranch) args.push("--editor-branch", cfg.editorBranch);
123
+ // The dashboard opens its own per-VM tunnel, so cloud:up must NOT open one:
124
+ // it lets cloud:up exit cleanly and avoids parallel batches colliding on the
125
+ // fixed local ports 14199/5173 (the cause of multi-add "false failures").
126
+ args.push("--no-tunnel");
127
+ return args;
128
+ }
129
+
130
+ /** Classify one container-up wait tick into a terminal/continue decision from
131
+ * what's been observed so far. This is the race-semantics seam fragility #33
132
+ * got wrong (`scripts/operator/fleet-dashboard/server.js::addVm`): the wait
133
+ * must poll container status on its own cadence and treat ANY single `Up` as
134
+ * terminal success.
135
+ *
136
+ * Rules:
137
+ * - everSeenUp ⇒ "up" any single `Up` is terminal —
138
+ * transient probe blips before it
139
+ * are irrelevant.
140
+ * - instanceGone ⇒ "failed" fast-bail: the VM is gone, no
141
+ * point waiting out the deadline.
142
+ * - deadlineSec>0 & elapsed>=it ⇒ "failed" genuine timeout, no `Up` ever seen
143
+ * (a non-positive deadline means no
144
+ * deadline set yet, so never fail).
145
+ * - otherwise ⇒ "waiting"
146
+ *
147
+ * Deliberately NOT an input: whether `cloud:up` is still alive (its `UP_PID`).
148
+ * Under `--no-tunnel` cloud:up exits cleanly the instant the container is up
149
+ * (see cloudUpArgs), so its death is the NORMAL success path and must never
150
+ * decide failure. The bash loop mirrors this — `kill -0 $UP_PID` is logged as
151
+ * informational only. Callers may pass `upPidAlive` for readable test intent;
152
+ * it is ignored by design. */
153
+ function classifyContainerWait({ everSeenUp = false, instanceGone = false, elapsedSec = 0, deadlineSec = 0 } = {}) {
154
+ if (everSeenUp) return "up";
155
+ if (instanceGone) return "failed";
156
+ if (deadlineSec > 0 && elapsedSec >= deadlineSec) return "failed";
157
+ return "waiting";
158
+ }
159
+
160
+ /** Decide whether a newly-reserved Add slot should start immediately or be
161
+ * queued, given the current in-flight count + the configured cap. Pure: pulls
162
+ * no globals, has no side effects.
163
+ *
164
+ * Returns "start" when there is headroom under maxConcurrency; "queue"
165
+ * otherwise. A non-positive or non-numeric maxConcurrency falls back to
166
+ * unbounded ("start" always) — the historical behavior before throttling
167
+ * existed, so a misconfigured env var degrades to "no throttle" instead of
168
+ * silently freezing all adds.
169
+ *
170
+ * The caller is responsible for the side effects: pushing to a pending
171
+ * queue + posting a `queued` job entry on "queue", or starting addVm on
172
+ * "start". Decoupling the decision from the I/O makes it unit-testable
173
+ * against arbitrary (cap, active) tuples — the test signal the OOM bug
174
+ * needed and the queue never had. */
175
+ function decideAddSlot({ maxConcurrency, activeCount = 0 } = {}) {
176
+ const cap = Number(maxConcurrency);
177
+ if (!Number.isFinite(cap) || cap <= 0) return "start";
178
+ return Number(activeCount) < cap ? "start" : "queue";
179
+ }
180
+
181
+ /** Closed set of "heavy" action names — those that invoke `npm run cloud:up`
182
+ * (over gcloud-ssh) and so contend for the shared spawn budget that prevents
183
+ * the dashboard's launchd-managed node process from OOMing under burst. The
184
+ * set is the single source of truth shared between server.js's throttle gate
185
+ * (tryStartOrQueue) and the per-action queued-status mapping below; adding a
186
+ * new heavy action means editing both this set and queuedStatusForAction.
187
+ * Other actions (set-env, run-setup, reset-*, update-*, destroy) are a single
188
+ * ssh round-trip with no long-running child and bypass the throttle entirely
189
+ * — they can never OOM the dashboard on burst. */
190
+ const HEAVY_ACTIONS = Object.freeze(["add", "rebuild", "reconfigure", "reset-all", "fleet-ready"]);
191
+
192
+ /** Map a heavy action to its per-action "queued" UI status string. Used by
193
+ * server.js's deriveStatus to render the throttled-but-not-yet-started card
194
+ * with a status that matches the eventual running status family
195
+ * (add → add-queued / adding, rebuild → rebuild-queued / rebuilding,
196
+ * reconfigure → reconfigure-queued / reconfiguring). Returns null for any
197
+ * action not in HEAVY_ACTIONS (so the caller can fall through to the
198
+ * generic running-state branch). Pure; no I/O. */
199
+ function queuedStatusForAction(action) {
200
+ switch (action) {
201
+ case "add": return "add-queued";
202
+ case "rebuild": return "rebuild-queued";
203
+ case "reconfigure": return "reconfigure-queued";
204
+ case "reset-all": return "reset-all-queued";
205
+ // fleet-ready runs cloud:up/redeploy children, and deriveStatus already
206
+ // renders a *running* fleet-ready job as "rebuilding"; its queued card
207
+ // mirrors that same family rather than introducing an unstyled
208
+ // fleet-ready-queued status the dashboard CSS has no class for.
209
+ case "fleet-ready": return "rebuild-queued";
210
+ default: return null;
211
+ }
212
+ }
213
+
214
+ /** Push a launch entry onto the newest-first history, capped to `cap`. No
215
+ * cross-VM dedup at write time — the full log is what powers per-VM config
216
+ * lookups (configForVm); the "recent launches" picker de-dups at read time
217
+ * (distinctConfigs). Returns a new array; does not mutate the input. */
218
+ function pushHistory(history, entry, cap = 30) {
219
+ const arr = Array.isArray(history) ? history.slice() : [];
220
+ arr.unshift(entry);
221
+ return arr.slice(0, Math.max(1, cap));
222
+ }
223
+
224
+ /** Distinct configs, newest-first, for the "recent launches" picker (so the
225
+ * same config launched on several VMs shows once — the most-recent
226
+ * occurrence). */
227
+ function distinctConfigs(history, limit = 12) {
228
+ const seen = new Set();
229
+ const out = [];
230
+ for (const e of Array.isArray(history) ? history : []) {
231
+ const k = JSON.stringify(e && e.config);
232
+ if (seen.has(k)) continue;
233
+ seen.add(k);
234
+ out.push(e);
235
+ if (out.length >= limit) break;
236
+ }
237
+ return out;
238
+ }
239
+
240
+ /** The default config to pre-fill the launch form: the most recent launch's
241
+ * config, or an empty config when there's no history. */
242
+ function defaultConfig(history) {
243
+ const h = Array.isArray(history) && history.length ? history[0] : null;
244
+ return h && h.config ? { ...EMPTY_CONFIG, ...h.config } : { ...EMPTY_CONFIG };
245
+ }
246
+
247
+ /** The most recent config used on a specific VM number (for the per-card
248
+ * Reconfigure form + the card's project/variant display). null when unknown
249
+ * (e.g. a VM launched before this feature existed). */
250
+ function configForVm(history, n) {
251
+ const h = (Array.isArray(history) ? history : []).find((e) => e && e.vm === n);
252
+ return h && h.config ? { ...EMPTY_CONFIG, ...h.config } : null;
253
+ }
254
+
255
+ /** Resolve the card's display identity, preferring LIVE truth (the editor's
256
+ * `/api/session-info` `clientProject` + `editorIdentity`) over STALE launch
257
+ * history. This is the fix for fragility #33's lying label: a CLI branch
258
+ * switch (`git checkout -B … && rebuild-self`) never updates launch history,
259
+ * so a history-sourced label keeps showing the old branch — but the editor's
260
+ * live self-report tracks the actual branch.
261
+ *
262
+ * `live` is the polled VM object ({ clientProject?, editorIdentity? }); an
263
+ * older VM whose editor predates this feature reports neither, so the result
264
+ * falls back to `historyCfg` (a launch-config shape) field-by-field and
265
+ * `source` flags the card as showing inferred-not-live data.
266
+ *
267
+ * Returns { project, clientBranch, editorLabel, source }:
268
+ * - project bare project name (no @branch) — live name wins, else the
269
+ * history repo basename / new-project name.
270
+ * - clientBranch live client branch wins, else historyCfg.clientBranch; null
271
+ * when neither (e.g. a non-git client project).
272
+ * - editorLabel from editorIdentity — the branch when kind === "branch",
273
+ * "npm v<version>" when kind === "npm"; else the history
274
+ * variant(@branch) fallback.
275
+ * - source "live" when editorIdentity drove editorLabel, else
276
+ * "history" — the affordance the card uses to mark inferred
277
+ * data. Pure; no I/O. */
278
+ function resolveCardIdentity(live, historyCfg) {
279
+ const cfg = historyCfg || {};
280
+ const lcp = live && live.clientProject;
281
+ const lei = live && live.editorIdentity;
282
+
283
+ const historyName = cfg.clientRepo ? repoBasename(cfg.clientRepo) : (cfg.newProjectName || "");
284
+ const project = (lcp && lcp.name) || historyName || null;
285
+
286
+ const clientBranch = (lcp && lcp.branch) || cfg.clientBranch || null;
287
+
288
+ let editorLabel;
289
+ let source;
290
+ if (lei && lei.kind === "branch" && lei.branch) {
291
+ editorLabel = lei.branch;
292
+ source = "live";
293
+ } else if (lei && lei.kind === "npm") {
294
+ editorLabel = "npm v" + (lei.version || "?");
295
+ source = "live";
296
+ } else {
297
+ const variant = cfg.editorVariant || "dev";
298
+ editorLabel = variant + (cfg.editorBranch ? "@" + cfg.editorBranch : "");
299
+ source = "history";
300
+ }
301
+
302
+ return { project, clientBranch, editorLabel, source };
303
+ }
304
+
305
+ /** Sticky-merge a VM poll's `clientProject` against the last-known-good value
306
+ * so a transient null poll (timeout / partial probe) does not clobber a
307
+ * populated identity. The live `clientProject.branch` is the fleet
308
+ * classifier's first-choice branch source (`plan_actions` in
309
+ * fleet-roll-to-branch.sh); dropping it to null on a missed cycle would
310
+ * regress classification to the stale launch-history branch. Mirrors how the
311
+ * other live fields tolerate a missed poll: a populated `prev` survives a
312
+ * null/absent fresh poll, and a populated fresh poll always wins. Pure; no I/O.
313
+ *
314
+ * @param prev the previous `state.vms[n].clientProject` (null/undefined ok)
315
+ * @param info the fresh poll result object (null when the probe failed)
316
+ * @returns the clientProject to store: fresh value when present, else the
317
+ * last-known-good `prev`, else null. */
318
+ function stickyClientProject(prev, info) {
319
+ const fresh = info && info.clientProject;
320
+ if (fresh) return fresh;
321
+ return prev || null;
322
+ }
323
+
324
+ /** Classify a VM's project KIND as `"self-host"` vs `"guest"` from the live
325
+ * identity reported in `/api/session-info` (`clientProject.repo`), with
326
+ * launch-history `clientRepo` as a fallback when live is absent. A guest is
327
+ * any VM whose repo basename differs from `CANONICAL_EDITOR_REPO`'s
328
+ * (`codeyam-editor`) — Margo today, future guests tomorrow.
329
+ *
330
+ * The dashboard surfaces the result with a small `🛡 guest` chip so the
331
+ * "leave-the-margo-one-alone"-style caveats become a visible guardrail at
332
+ * glance instead of a memo the operator has to remember. The kind is a
333
+ * *type*, not an alert — the status badge remains the primary signal.
334
+ *
335
+ * Defaults: both signals missing → `"self-host"` (the dominant fleet shape;
336
+ * a falsely visible "guest" badge on a real self-host VM would be more
337
+ * confusing than a missing one). Empty-string repos collapse to the same
338
+ * unknown-→-self-host default. Reuses `CANONICAL_EDITOR_REPO` so the
339
+ * self-host identity stays a single source of truth shared with
340
+ * `cloudUpArgs`. Pure; no I/O. */
341
+ function classifyProjectKind(live, historyCfg) {
342
+ const cfg = historyCfg || {};
343
+ const lcp = live && live.clientProject;
344
+ const liveRepo = lcp && typeof lcp.repo === "string" ? lcp.repo : "";
345
+ const repoUrl = liveRepo || (typeof cfg.clientRepo === "string" ? cfg.clientRepo : "");
346
+ if (!repoUrl) return "self-host";
347
+ const canonical = repoBasename(CANONICAL_EDITOR_REPO);
348
+ return repoBasename(repoUrl) === canonical ? "self-host" : "guest";
349
+ }
350
+
351
+ /** Resolve where a VM's codeyam-editor SOURCE lives, given its shape. This is
352
+ * the load-bearing per-VM-shape branch for the dashboard's "update editor
353
+ * source" + "rebuild binary" actions: the editor source — and how you rebuild
354
+ * from it — differs between a self-host VM (the client project IS
355
+ * codeyam-editor) and a non-self-host one (editing some other project).
356
+ *
357
+ * - Self-host: the editor source IS the agent's workspace, so it's `/workspace`
358
+ * INSIDE the container; the rebuild runs there in place (`rebuild-self`).
359
+ * - Non-self-host: the editor binary comes from the `codeyam-editor:local`
360
+ * image, built on the VM HOST. `scripts/gcp-bootstrap.sh` clones the editor
361
+ * repo to `$HOME/codeyam-editor` and builds the image from there, so that is
362
+ * the authoritative image source. A self-host VM ALSO carries a creds-bearing
363
+ * workspace clone at `$HOME/projects/codeyam-editor` (docs/cloud-editor-stability.md
364
+ * "VM-side paths"); the image-rebuild flow prefers it WHEN PRESENT (it can
365
+ * `git pull` private repos), else falls back to the canonical image clone.
366
+ * The caller checks `path` then `fallbackPath` on disk at runtime.
367
+ *
368
+ * Stack assumption: this encodes the cloud-VM editor-source layout (Docker
369
+ * image + ~/projects mount). A non-Docker editor host would need its own
370
+ * resolver branch. Pure; no I/O. */
371
+ function resolveEditorSource({ selfHost = false, home = "$HOME" } = {}) {
372
+ if (selfHost) {
373
+ return { selfHost: true, location: "container", path: "/workspace", fallbackPath: null };
374
+ }
375
+ return {
376
+ selfHost: false,
377
+ location: "host",
378
+ path: `${home}/projects/codeyam-editor`,
379
+ fallbackPath: `${home}/codeyam-editor`,
380
+ };
381
+ }
382
+
383
+ /** Emit the shell snippet that resolves the host editor-source clone ON THE
384
+ * REMOTE VM, assigning the chosen path to `$ESRC`. Prefer the credentialed
385
+ * projects clone (`src.path`); fall back to the image clone (`src.fallbackPath`)
386
+ * only when the primary has no `.git`.
387
+ *
388
+ * CRITICAL — the paths must EXPAND on the VM, not on the laptop. `resolveEditorSource`
389
+ * returns `$HOME`-relative CONSTANTS (the dashboard doesn't know the VM's home dir),
390
+ * so they go into DOUBLE quotes here, never `shq()`/single-quotes. Single-quoting a
391
+ * `$HOME`-bearing path defeats remote expansion: `cd "$ESRC"` then fails with
392
+ * `cd: $HOME/...: No such file or directory` on every non-self-host VM (the improve34
393
+ * bug). These paths are fixed constants, not user input — no injection surface, so
394
+ * `shq()` (which exists to neutralize user input) is the wrong tool. Pure; no I/O.
395
+ *
396
+ * The primary clone is kept only when it is actually USABLE as an editor source,
397
+ * not merely when a `.git` directory exists. A half-cloned / corrupt tree has a
398
+ * `.git` dir yet can't build — `git rev-parse` fails and there's no Dockerfile, so
399
+ * `docker build "$ESRC"` dies on `open Dockerfile: no such file or directory` (the
400
+ * improve39 VM-9 bug). The gate therefore requires a valid work tree AND a
401
+ * Dockerfile; otherwise it falls back to the canonical image clone
402
+ * `$HOME/codeyam-editor` (the authoritative source provisioning built the image
403
+ * from). This preserves the prefer-credentialed-clone behavior, but only when the
404
+ * credentialed clone actually works. */
405
+ function editorSourceShellResolve(src) {
406
+ const primary = `ESRC="${src.path}"`;
407
+ if (!src.fallbackPath) return primary;
408
+ return `${primary}; { git -C "$ESRC" rev-parse --is-inside-work-tree >/dev/null 2>&1 && [ -f "$ESRC/Dockerfile" ]; } || ESRC="${src.fallbackPath}"`;
409
+ }
410
+
411
+ /** Choose HOW to rebuild the editor binary on a VM, from its shape + the editor
412
+ * variant. The three mechanisms map 1:1 to the EDITOR_VARIANTS contract:
413
+ * - "rebuild-self" — self-host `dev`: compile in place inside the container
414
+ * (`rebuild-self --force-in-container`); /workspace is the
415
+ * editor source.
416
+ * - "image-rebuild" — non-self-host `dev`: the binary ships in the
417
+ * `codeyam-editor:local` image, so it needs a host-side
418
+ * `docker build` + container recreate.
419
+ * - "npm-reinstall" — a packaged variant (`staging`/`production`): the binary
420
+ * is the published editor npm package, so a rebuild is a
421
+ * reinstall, not a compile.
422
+ * Pure; no I/O. `rebuildBinaryHostScript` consumes this to pick its build line
423
+ * (the npm-reinstall branch rebuilds the Dockerfile.npm image at the variant's
424
+ * dist-tag); the self-host axis is detected at runtime
425
+ * on the VM (the `[ -f /workspace/Cargo.toml ]` signal is authoritative even
426
+ * for VMs the dashboard has no launch-history record for). */
427
+ function rebuildStrategy({ selfHost = false, editorVariant = "dev" } = {}) {
428
+ if (editorVariant !== "dev") return "npm-reinstall";
429
+ return selfHost ? "rebuild-self" : "image-rebuild";
430
+ }
431
+
432
+ /** Decide whether a new per-VM job may start, given the VM's currently-tracked
433
+ * job. `state.jobs[n]` is a SINGLE slot per VM, so a second `startJob` would
434
+ * OVERWRITE an in-flight job's tracker — the bug where `reapplyVmEnv`'s
435
+ * follow-on `set-env` clobbered a still-running Rebuild's card, so the operator
436
+ * saw a `set-env` completion instead of the rebuild they asked for, with no
437
+ * error surfaced. Any job already `running` blocks a new one:
438
+ * - `idle` — no in-flight job (or the slot's job already finished);
439
+ * the caller starts normally.
440
+ * - `same-action` — a job with the SAME action is already running; reject
441
+ * (the pre-existing duplicate-click guard).
442
+ * - `different-action`— a DIFFERENT action is running; reject rather than
443
+ * clobber its tracker. Operators sequence the two jobs
444
+ * manually (the rare legitimate case).
445
+ * Returns `{ decision: 'start' }` or
446
+ * `{ decision: 'reject', reason, runningAction }` so the caller builds the
447
+ * user-facing message (the two reject reasons word it differently). Pure; no
448
+ * I/O — `existing` is the job record, `requested` the action about to start. */
449
+ function decideStartJob({ existing, requested } = {}) {
450
+ if (!existing || existing.state !== "running") return { decision: "start" };
451
+ const runningAction = existing.action;
452
+ if (runningAction === requested) {
453
+ return { decision: "reject", reason: "same-action", runningAction };
454
+ }
455
+ return { decision: "reject", reason: "different-action", runningAction };
456
+ }
457
+
458
+ /** Decide where a freshly-added VM's credentials come from. The dashboard's
459
+ * default is a container→container copy from a live donor VM, but a full
460
+ * teardown empties the roster and leaves no donor — so Add must fall back to
461
+ * operator-configured sources. Precedence, highest first:
462
+ * - "donor" — a reachable donor exists; copy claude + git from it (the
463
+ * unchanged default). `donor` = the chosen VM number.
464
+ * - "host-stash" — no donor, but CLAUDE_CREDS_SRC points at a known-good
465
+ * .credentials.json to push into the new container.
466
+ * - "token" — no donor and no stash, but GITHUB_TOKEN is set (cloud:up
467
+ * seeds git creds at boot). claude auth is NOT seeded.
468
+ * - "none" — nothing available; wire + roster only, claude + git both
469
+ * unseeded.
470
+ * Modes "token" and "none" carry no claude auth, so the caller marks the VM
471
+ * add-degraded with a `claude /login` remediation. `reachableDonors` must be
472
+ * pre-filtered to VMs that actually answered (a destroyed-but-rostered orphan
473
+ * is not a donor — fragility #24). Pure; no I/O. */
474
+ function resolveCredsSource({ reachableDonors = [], claudeCredsSrc = "", githubToken = "" } = {}) {
475
+ const donor = Array.isArray(reachableDonors) && reachableDonors.length ? reachableDonors[0] : null;
476
+ if (donor != null) return { mode: "donor", donor };
477
+ if (claudeCredsSrc) return { mode: "host-stash", donor: null };
478
+ if (githubToken) return { mode: "token", donor: null };
479
+ return { mode: "none", donor: null };
480
+ }
481
+
482
+ // Single-quote a string for safe inclusion in a remote shell command. Mirrors
483
+ // the server.js helper so callers don't have to thread one through.
484
+ function _shq(s) { return `'${String(s).replace(/'/g, `'\\''`)}'`; }
485
+
486
+ /** Build the shell steps that seed a freshly-added VM's claude + git credentials,
487
+ * for the resolved `mode` (see `resolveCredsSource`). Returns an array of shell
488
+ * strings the caller newline-joins into the larger provisioning script — each
489
+ * step is independent; a `CY_DEGRADED <message>` from one does not block the
490
+ * next, and the dashboard's job-attribution path lifts those markers onto the
491
+ * card as `degradedReasons`.
492
+ *
493
+ * Donor mode (the default) copies, container→container from the donor VM:
494
+ * 1. host-file git credentials → `$HOME/.codeyam-cloud-git-credentials`
495
+ * 2. claude `.credentials.json` → `/root/.claude/.credentials.json`
496
+ * 3. claude `.claude.json` → `/root/.claude.json`
497
+ * Step 3 (onboarding state — `hasCompletedOnboarding` + sibling keys) is what
498
+ * keeps the first interactive `claude` TUI from popping the welcome "Select
499
+ * login method" menu; without it the non-interactive `claude --print` probe
500
+ * still passes (so `/api/config` reports `authenticated`), but the Build tab
501
+ * PTY stalls at the picker. Failure of any single step emits its own
502
+ * `CY_DEGRADED` marker — the credentials copy is the load-bearing one; the
503
+ * onboarding-state copy is recoverable via `claude /login`.
504
+ *
505
+ * host-stash / token / none modes don't have a donor `.claude.json` to copy —
506
+ * for now those paths still land degraded for the same reason; a separate plan
507
+ * can extend host-stash with a sibling onboarding-stash env knob.
508
+ *
509
+ * Pure; no I/O. */
510
+ function buildCredsSteps({ mode, refInst = "", inst, container, gcloud, zone, n, claudeCredsSrc = "" } = {}) {
511
+ const claudeSink = `docker exec -i ${container} bash -c "umask 077; base64 -d > /root/.claude/.credentials.json && chmod 600 /root/.claude/.credentials.json"`;
512
+ const claudeJsonSink = `docker exec -i ${container} bash -c "umask 077; base64 -d > /root/.claude.json && chmod 600 /root/.claude.json"`;
513
+ const loginHint = `run: docker exec -it ${container} claude /login on ${inst}`;
514
+ if (mode === "donor") {
515
+ return [
516
+ `echo "copying git creds from ${refInst}..."`,
517
+ `${gcloud} compute ssh ${refInst} --tunnel-through-iap --zone=${zone} --command='cat $HOME/.codeyam-cloud-git-credentials' 2>/dev/null | ${gcloud} compute ssh ${inst} --tunnel-through-iap --zone=${zone} --command='umask 077; cat > $HOME/.codeyam-cloud-git-credentials' 2>/dev/null || echo "CY_DEGRADED git creds copy failed on VM-${n} — re-copy creds from a live donor (the agent can't git pull/push until then)"`,
518
+ `echo "copying claude auth from ${refInst} (container->container)..."`,
519
+ `${gcloud} compute ssh ${refInst} --tunnel-through-iap --zone=${zone} --command='docker exec ${container} base64 /root/.claude/.credentials.json' 2>/dev/null | ${gcloud} compute ssh ${inst} --tunnel-through-iap --zone=${zone} --command=${_shq(claudeSink)} 2>/dev/null && echo "claude auth copied" || echo "CY_DEGRADED claude auth copy failed on VM-${n} — ${loginHint}"`,
520
+ `echo "copying claude.json (onboarding state) from ${refInst} (container->container)..."`,
521
+ `${gcloud} compute ssh ${refInst} --tunnel-through-iap --zone=${zone} --command='docker exec ${container} base64 /root/.claude.json' 2>/dev/null | ${gcloud} compute ssh ${inst} --tunnel-through-iap --zone=${zone} --command=${_shq(claudeJsonSink)} 2>/dev/null && echo "claude.json copied" || echo "CY_DEGRADED claude.json copy failed on VM-${n} — first interactive claude launch may show 'Select login method' menu; ${loginHint} once to complete onboarding"`,
522
+ ];
523
+ }
524
+ if (mode === "host-stash") {
525
+ return [
526
+ `echo "no reachable donor — seeding claude auth from host stash..."`,
527
+ `base64 < ${_shq(claudeCredsSrc)} | ${gcloud} compute ssh ${inst} --tunnel-through-iap --zone=${zone} --command=${_shq(claudeSink)} 2>/dev/null && echo "claude auth seeded from host stash" || echo "WARN claude stash seed failed — ${loginHint}"`,
528
+ ];
529
+ }
530
+ if (mode === "token") {
531
+ return [
532
+ `echo "no reachable donor and no CLAUDE_CREDS_SRC — git creds seeded by cloud:up; claude auth NOT seeded. ${loginHint}"`,
533
+ ];
534
+ }
535
+ return [
536
+ `echo "no reachable donor and no configured creds source — wiring + rostering only; claude AND git auth NOT seeded. ${loginHint}, and set CLAUDE_CREDS_SRC/GITHUB_TOKEN in the dashboard env"`,
537
+ ];
538
+ }
539
+
540
+ /** Build the two host-side shell snippets that PRESERVE the container's
541
+ * /root/.claude.json (claude onboarding state) across a non-self-host Rebuild's
542
+ * container recreate. The whole /root/.claude DIRECTORY is bind-mounted from the
543
+ * host ($HOME/.codeyam-cloud-claude → /root/.claude, see docker-compose.cloud.yml),
544
+ * so /root/.claude/.credentials.json survives `docker compose down && up`. But
545
+ * /root/.claude.json is a SIBLING in the container's EPHEMERAL fs — NOT in the
546
+ * bind mount — so a recreate resets it to the image baseline, dropping the
547
+ * operator's `hasCompletedOnboarding`. The first interactive `claude` TUI then
548
+ * pops the "Select login method" welcome menu and the Build tab is blocked,
549
+ * even though the non-interactive `claude --print` auth probe still passes
550
+ * (improve36). The good copy is still in the LIVE container right up until
551
+ * `docker compose down`, so we capture it to a host temp file, then restore it
552
+ * into the freshly-recreated container — no donor required.
553
+ *
554
+ * Returns { capture, restore }, each an array of shell lines for the on-host
555
+ * rebuild script (run via gcloud-ssh, direct `docker exec` — same execution
556
+ * context as gitReseedHostStep). `capture` runs BEFORE `docker compose down`;
557
+ * `restore` runs AFTER `up`, BEFORE the editor verify probe. The lines share a
558
+ * `bakVar` shell variable holding the temp-file path, so they MUST be spread
559
+ * into the SAME bash invocation. A failed restore emits a `CY_DEGRADED` marker
560
+ * (the dashboard lifts it onto the card as a degradedReason) with a
561
+ * `claude /login` remediation — the rebuild itself still succeeded, so the job
562
+ * lands `done`, not `error`. Pure; no I/O. */
563
+ function buildClaudeJsonPreserveSteps({ container, n = "", bakVar = "CY_CLAUDE_JSON_BAK" } = {}) {
564
+ const loginHint = `run: docker exec -it ${container} claude /login`;
565
+ const capture = [
566
+ `${bakVar}=$(mktemp)`,
567
+ `if docker exec ${container} cat /root/.claude.json > "$${bakVar}" 2>/dev/null && [ -s "$${bakVar}" ]; then echo "captured /root/.claude.json ($(wc -c < "$${bakVar}") bytes) to preserve onboarding state across recreate"; else echo "no /root/.claude.json to preserve (new container will use the image baseline)"; : > "$${bakVar}"; fi`,
568
+ ];
569
+ const restore = [
570
+ `if [ -s "$${bakVar}" ]; then docker exec -i ${container} bash -c 'umask 077; cat > /root/.claude.json && chmod 600 /root/.claude.json' < "$${bakVar}" && echo "re-seeded /root/.claude.json (onboarding state preserved across recreate)" || echo "CY_DEGRADED claude.json re-seed failed on VM-${n} after Rebuild — first interactive claude launch may show 'Select login method'; ${loginHint} once to complete onboarding"; else echo "no preserved /root/.claude.json; ${loginHint} if the Build tab shows the welcome menu"; fi`,
571
+ `rm -f "$${bakVar}"`,
572
+ ];
573
+ return { capture, restore };
574
+ }
575
+
576
+ /** Build the shell command that base64-pipes a VM's persisted env over ssh +
577
+ * docker-exec stdin into the container's /workspace/.env (umask 077, chmod
578
+ * 600), chowns it to the workspace owner so the bind-mounted host file is
579
+ * readable by `docker compose --force-recreate` (Rebuild / Reconfigure /
580
+ * Add — host-side, as the unprivileged SSH user, not root), and restarts
581
+ * the dev server. The docker exec runs as root inside the container, so
582
+ * without the chown the file lands `root:root 0600` on the host bind-mount
583
+ * and the next compose recreate fails `open .env: permission denied`
584
+ * (fragility — improve36). `chown --reference=/workspace` inherits whatever
585
+ * uid the image's entrypoint assigned to the workspace (currently 1003 =
586
+ * the host SSH user), so the fix is robust against future UID changes.
587
+ * Values stay out of argv/logs: they ride as base64 over the ssh + docker
588
+ * exec stdin, never as CLI arguments. Pure; no I/O. */
589
+ function buildEnvApplyCommand({ gcloud, instance, zone, envFile, container, editorBin } = {}) {
590
+ const sink = `docker exec -i ${container} bash -lc "umask 077; base64 -d > /workspace/.env && chmod 600 /workspace/.env && chown --reference=/workspace /workspace/.env && cd /workspace && ${editorBin} editor restart-dev-server"`;
591
+ return `base64 < ${_shq(envFile)} | ${gcloud} compute ssh ${instance} --tunnel-through-iap --zone=${zone} --command=${_shq(sink)}`;
592
+ }
593
+
594
+ module.exports = {
595
+ EMPTY_CONFIG,
596
+ normalizeLaunchConfig,
597
+ validateLaunchConfig,
598
+ isVariantProvisionable,
599
+ isVariantRebuildable,
600
+ variantNpmDistTag,
601
+ variantImageTag,
602
+ cloudUpArgs,
603
+ classifyContainerWait,
604
+ decideAddSlot,
605
+ HEAVY_ACTIONS,
606
+ queuedStatusForAction,
607
+ pushHistory,
608
+ distinctConfigs,
609
+ defaultConfig,
610
+ configForVm,
611
+ resolveCardIdentity,
612
+ stickyClientProject,
613
+ classifyProjectKind,
614
+ resolveCredsSource,
615
+ buildCredsSteps,
616
+ buildClaudeJsonPreserveSteps,
617
+ buildEnvApplyCommand,
618
+ resolveEditorSource,
619
+ editorSourceShellResolve,
620
+ rebuildStrategy,
621
+ decideStartJob,
622
+ };