@codeyam-editor/codeyam-editor 0.1.3 → 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.
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+
3
+ // Assemble the per-VM wire object the dashboard's /data response carries to the
4
+ // browser. This is the single source of truth for a card's transport shape: the
5
+ // card UI (scripts/operator/fleet-dashboard/public/index.html) reads `v.<field>`
6
+ // off each entry, so every field the card renders MUST be emitted here.
7
+ //
8
+ // It was split out of server.js's buildData() so the shape is unit-testable.
9
+ // The regression that motivated the split: `currentProvider` (the VM's
10
+ // configured build agent) was merged onto the poll state AND read by the card to
11
+ // render the 🤖 agent badge, but it was silently missing from this envelope — so
12
+ // it lived in server memory and never reached the browser, and the badge never
13
+ // appeared. A hand-maintained object literal inside a 197KB monolith has no
14
+ // guard against that drift; a pure function does.
15
+ //
16
+ // `v` is the merged poll state (state.vms[n]). `ctx` carries the derived,
17
+ // cross-VM, and side-effecting values buildData computes per VM (status,
18
+ // debounced broker tri-state, reconciled job, queue position, launch cfg, …) —
19
+ // the things this pure function can't know on its own.
20
+ function buildVmEnvelope(v, ctx) {
21
+ return {
22
+ n: ctx.n,
23
+ url: v.url || null,
24
+ reachable: !!v.reachable,
25
+ hasSession: !!v.hasSession,
26
+ // Three-state: true = broker confirmed up, false = confirmed down, null =
27
+ // editor unreachable so broker state is unknown. Never conflate "down" with
28
+ // "unreachable" — that mismatch is what made the card show "broker down"
29
+ // while a direct pgrep proved the broker was up.
30
+ brokerLive: v.reachable ? !!v.agentBrokerLive : null,
31
+ // Broker tri-state: 'down' | 'idle' | 'agent-live' | 'unknown' (derived
32
+ // upstream, debounced). Richer than brokerLive's is-an-agent-live boolean.
33
+ brokerStatus: ctx.brokerStatus,
34
+ awaitingInput: !!v.awaitingUserInput,
35
+ feature: v.feature || null,
36
+ step: v.step != null ? v.step : null,
37
+ mode: v.mode || null,
38
+ label: v.label || null,
39
+ status: ctx.status,
40
+ orphan: ctx.orphan || null,
41
+ degradedReasons: ctx.degradedReasons || [],
42
+ isHead: !!ctx.isHead,
43
+ // True when this VM holds the queue head but is unreachable (operator
44
+ // decides manually whether to release it).
45
+ headHolderUnreachable: !!ctx.headHolderUnreachable,
46
+ queuePos: ctx.queuePos != null ? ctx.queuePos : null,
47
+ queueLen: ctx.queueLen,
48
+ job: ctx.job || null,
49
+ config: ctx.config || null,
50
+ project: ctx.project != null ? ctx.project : null,
51
+ variant: ctx.variant != null ? ctx.variant : null,
52
+ cardIdentity: ctx.cardIdentity,
53
+ brokerHistory: Array.isArray(v.brokerHistory) ? v.brokerHistory : [],
54
+ brokerDeath: ctx.brokerDeath,
55
+ // Auth state + reason + token expiry. Only surfaced when the editor is
56
+ // reachable (an unreachable VM's cached auth is stale and misleading).
57
+ providerAuth: v.reachable && v.providerAuth ? v.providerAuth : null,
58
+ effects: v.effects || null,
59
+ // The VM's live-configured build agent (lowercase provider), reported by
60
+ // /api/session-info and merged (sticky) onto the poll state. Drives the
61
+ // card's 🤖 agent badge. null for an older VM whose editor predates the
62
+ // field (the card omits the badge rather than rendering a blank chip).
63
+ currentProvider: v.currentProvider || null,
64
+ };
65
+ }
66
+
67
+ // The fields the card UI depends on being present in every /data VM entry. This
68
+ // is the contract `buildVmEnvelope` must satisfy: the regression that prompted
69
+ // this module was a field the card read but the envelope didn't emit, so the
70
+ // test asserts the envelope keys are a superset of this list. Adding a field the
71
+ // card renders means adding it here AND in buildVmEnvelope — the test fails loud
72
+ // if you do one without the other.
73
+ const REQUIRED_CARD_FIELDS = [
74
+ 'n', 'url', 'reachable', 'hasSession', 'brokerLive', 'brokerStatus',
75
+ 'awaitingInput', 'feature', 'step', 'mode', 'label', 'status', 'orphan',
76
+ 'degradedReasons', 'isHead', 'headHolderUnreachable', 'queuePos', 'queueLen',
77
+ 'job', 'config', 'project', 'variant', 'cardIdentity', 'brokerHistory',
78
+ 'brokerDeath', 'providerAuth', 'effects', 'currentProvider',
79
+ ];
80
+
81
+ module.exports = { buildVmEnvelope, REQUIRED_CARD_FIELDS };
@@ -206,6 +206,32 @@ function reattachVerdict({ markerPresent, exitCode, childAlive } = {}) {
206
206
  return 'reconcile';
207
207
  }
208
208
 
209
+ // improve45 — map a reattachVerdict tag to the dashboard's NEXT action, now that
210
+ // a clean exit marker is NECESSARY BUT NOT SUFFICIENT to roster. A detached
211
+ // `cloud:up` can exit 0 AFTER the image build but BEFORE `docker compose up`
212
+ // ever runs — an ssh broken-pipe or dashboard SIGKILL landing in that window,
213
+ // with the wrapping EXIT trap still recording 0 (the live VM2 signature: built
214
+ // image, repo cloned, ZERO editor containers). So 'done' no longer means "roster
215
+ // it"; it means "confirm the container is actually up first." Both the clean-exit
216
+ // ('done') and the no-usable-marker ('reconcile') cases therefore route through
217
+ // the SAME gcloud container + wiring probe (reconcileViaGcloud → fc.reapVerdict),
218
+ // which rosters only a genuinely-up + wired add and routes a half-provisioned one
219
+ // to recover-bootstrap. 'error' (real non-zero exit) and 'running' (child still
220
+ // alive) are unchanged. reattachVerdict stays the honest classifier of the
221
+ // child's exit SIGNAL; this helper expresses how that signal is CONSUMED now that
222
+ // exit-0 no longer implies rostering. Pure: server.js maps the returned action to
223
+ // its side-effecting step, so the matrix is exhaustively testable without a VM.
224
+ // verdict next step meaning
225
+ // 'done' 'probe' confirm the container before rostering
226
+ // 'reconcile' 'probe' no usable marker — let the gcloud probe decide
227
+ // 'error' 'surface-error' real failure — show add-failed with the code
228
+ // 'running' 'watch' child still alive — keep polling for the marker
229
+ function reattachNextStep(verdict) {
230
+ if (verdict === 'error') return 'surface-error';
231
+ if (verdict === 'running') return 'watch';
232
+ return 'probe'; // 'done' and 'reconcile' both confirm via the gcloud probe
233
+ }
234
+
209
235
  // Anti-loop budget for the `recover-bootstrap` path. Pure: given the count of
210
236
  // recoveries ALREADY persisted on the sidecar, return the next attempt number,
211
237
  // whether the reap should proceed (re-run cloud:up) or treat the VM as
@@ -294,4 +320,5 @@ module.exports = {
294
320
  readExitMarker,
295
321
  clearExitMarker,
296
322
  reattachVerdict,
323
+ reattachNextStep,
297
324
  };
@@ -36,4 +36,48 @@ function shouldReconcileFailedBadge({ job, reachable, drifted, offBranch, degrad
36
36
  return true;
37
37
  }
38
38
 
39
- module.exports = { shouldReconcileFailedBadge };
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 };
@@ -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 };
@@ -8,7 +8,14 @@
8
8
  // runs on a VM; the history is the cache that makes the last choice the default
9
9
  // and past launches one click to re-run.
10
10
 
11
- const { CANONICAL_EDITOR_REPO, EDITOR_VARIANTS, repoBasename } = require("./cloud");
11
+ const { CANONICAL_EDITOR_REPO, EDITOR_VARIANTS, repoBasename, AGENT_ENV_VARS } = require("./cloud");
12
+
13
+ /** The build agents a VM's editor terminal can run, as the keys of cloud.js's
14
+ * AGENT_ENV_VARS. Deriving the selectable set from that map keeps it a single
15
+ * source of truth with the agent→API-key-env-var mapping (cloud.js) and the
16
+ * gcp-bootstrap CLI installer, so adding an agent is still a one-line change in
17
+ * AGENT_ENV_VARS rather than a list to keep in sync here. */
18
+ const AGENTS = Object.freeze(Object.keys(AGENT_ENV_VARS));
12
19
 
13
20
  const EMPTY_CONFIG = Object.freeze({
14
21
  clientRepo: "",
@@ -18,6 +25,9 @@ const EMPTY_CONFIG = Object.freeze({
18
25
  newProjectName: "",
19
26
  editorVariant: "dev",
20
27
  editorBranch: "",
28
+ // Build agent provider the VM's editor terminal runs (claude | codex | gemini).
29
+ // Defaults to claude; cloudUpArgs forwards it as cloud:up's --agent flag.
30
+ agent: "claude",
21
31
  });
22
32
 
23
33
  /** Coerce a raw form/query object into the canonical launch-config shape: trim
@@ -26,12 +36,17 @@ const EMPTY_CONFIG = Object.freeze({
26
36
  function normalizeLaunchConfig(input = {}) {
27
37
  const str = (v) => (typeof v === "string" ? v.trim() : "");
28
38
  const editorVariant = EDITOR_VARIANTS.includes(input.editorVariant) ? input.editorVariant : "dev";
39
+ // Coerce an unknown/absent agent to the default rather than rejecting — the
40
+ // form's agent selector only ever submits a known value, so an out-of-set
41
+ // agent is a stale query param, not an error.
42
+ const agent = AGENTS.includes(input.agent) ? input.agent : "claude";
29
43
  return {
30
44
  clientRepo: str(input.clientRepo),
31
45
  clientBranch: str(input.clientBranch),
32
46
  newProjectName: str(input.newProjectName),
33
47
  editorVariant,
34
48
  editorBranch: str(input.editorBranch),
49
+ agent,
35
50
  };
36
51
  }
37
52
 
@@ -111,13 +126,25 @@ function variantImageTag(variant) {
111
126
  /** Build the `cloud:up` flag array for a launch config + instance name. The
112
127
  * editor source is the canonical codeyam-editor repo (dev variant builds the
113
128
  * 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") {
129
+ * /workspace. The build agent rides on `cfg.agent` (normalizeLaunchConfig
130
+ * guarantees a known value, defaulting to claude), so a single config object
131
+ * carries every launch choice. Decoupled exactly as cloud.js consumes them. */
132
+ function cloudUpArgs(cfg, instanceName, project) {
116
133
  const args = ["--instance-name", instanceName];
134
+ // Pass the authoritative GCP project (the dashboard's resolved PROJECT)
135
+ // explicitly so cloud:up never relies on the spawning shell's ambient
136
+ // `gcloud config get-value project`. Critically, a `--project` flag OVERRIDES
137
+ // a persisted .codeyam/cloud.json `project` (cloud.js merges
138
+ // `{ ...existing, ...flags }`), which a bare cloud:up reuses verbatim with no
139
+ // gcloud fallback — the sticky-wrong-project failure mode where a stale
140
+ // "gcp-proj" in cloud.json wedges every re-provision with a firewall-create
141
+ // PERMISSION_DENIED. Omitted when unset to preserve the pre-existing arg shape
142
+ // (cloud.js then falls back to the gcloud default, as before).
143
+ if (project) args.push("--project", project);
117
144
  // Greenfield (new folder) vs clone an existing repo.
118
145
  if (cfg.newProjectName) args.push("--new-project", cfg.newProjectName);
119
146
  else args.push("--repo", cfg.clientRepo);
120
- args.push("--agent", agent, "--editor-repo", CANONICAL_EDITOR_REPO, "--editor-variant", cfg.editorVariant);
147
+ args.push("--agent", cfg.agent || "claude", "--editor-repo", CANONICAL_EDITOR_REPO, "--editor-variant", cfg.editorVariant);
121
148
  if (!cfg.newProjectName && cfg.clientBranch) args.push("--branch", cfg.clientBranch);
122
149
  if (cfg.editorBranch) args.push("--editor-branch", cfg.editorBranch);
123
150
  // The dashboard opens its own per-VM tunnel, so cloud:up must NOT open one:
@@ -237,6 +264,40 @@ function distinctConfigs(history, limit = 12) {
237
264
  return out;
238
265
  }
239
266
 
267
+ /** Display-side companion to `distinctConfigs`: derive the two label strings the
268
+ * "recent launches" picker renders for one config, so two configs that differ
269
+ * only by build agent (or greenfield project name) look different instead of
270
+ * collapsing to identical rows. `distinctConfigs` de-dups on the FULL config, so
271
+ * the rows are genuinely distinct launches — the old inline label just never
272
+ * surfaced the distinguishing fields (agent, greenfield name).
273
+ *
274
+ * Returns { primary, secondary }:
275
+ * - primary: greenfield (`!clientRepo && newProjectName`) → `new: <name>` so
276
+ * two different greenfield projects no longer both read `(none)`;
277
+ * else `repoBasename(clientRepo)` + (`@<branch>` when set); else
278
+ * `(none)` when there's neither a repo nor a project name.
279
+ * - secondary: `editor <variant>` + (`@<editorBranch>` when set) + ` · <agent>`.
280
+ *
281
+ * Normalizes through EMPTY_CONFIG so a pre-`agent` history entry still renders
282
+ * `· claude`. Reuses `repoBasename` (cloud.js) so server/browser basename
283
+ * semantics stay single-sourced. Pure; no I/O. */
284
+ function recentLaunchDisplay(config) {
285
+ const cfg = { ...EMPTY_CONFIG, ...(config || {}) };
286
+ let primary;
287
+ if (!cfg.clientRepo && cfg.newProjectName) {
288
+ primary = `new: ${cfg.newProjectName}`;
289
+ } else if (cfg.clientRepo) {
290
+ primary = repoBasename(cfg.clientRepo) + (cfg.clientBranch ? `@${cfg.clientBranch}` : "");
291
+ } else {
292
+ primary = "(none)";
293
+ }
294
+ const secondary =
295
+ `editor ${cfg.editorVariant || "dev"}` +
296
+ (cfg.editorBranch ? `@${cfg.editorBranch}` : "") +
297
+ ` · ${cfg.agent || "claude"}`;
298
+ return { primary, secondary };
299
+ }
300
+
240
301
  /** The default config to pre-fill the launch form: the most recent launch's
241
302
  * config, or an empty config when there's no history. */
242
303
  function defaultConfig(history) {
@@ -591,7 +652,48 @@ function buildEnvApplyCommand({ gcloud, instance, zone, envFile, container, edit
591
652
  return `base64 < ${_shq(envFile)} | ${gcloud} compute ssh ${instance} --tunnel-through-iap --zone=${zone} --command=${_shq(sink)}`;
592
653
  }
593
654
 
655
+ /** Get authenticated Git URL for checking client branch. */
656
+ function getAuthRepoUrl(repo, token) {
657
+ if (!repo) return "";
658
+ if (!token) return repo;
659
+ const sshRegex = /^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/;
660
+ const sshMatch = repo.match(sshRegex);
661
+ if (sshMatch) {
662
+ return `https://x-access-token:${token}@github.com/${sshMatch[1]}/${sshMatch[2]}`;
663
+ }
664
+ if (repo.startsWith("https://github.com/")) {
665
+ return repo.replace(/^https:\/\/github\.com\//, `https://x-access-token:${token}@github.com/`);
666
+ }
667
+ return repo;
668
+ }
669
+
670
+ /** Interpret the output of git ls-remote to see if a branch exists. */
671
+ function interpretLsRemote({ stdout = "", code = 0, err = null, repo = "", branch = "" } = {}) {
672
+ const b = (branch || "").trim();
673
+ if (!b) {
674
+ return { ok: true, msg: "using repo default branch" };
675
+ }
676
+ if (err || code !== 0) {
677
+ const errMsg = (err && (err.message || String(err))) || "command failed";
678
+ return {
679
+ ok: false,
680
+ soft: true,
681
+ msg: `couldn't verify branch: ${errMsg.trim()}`
682
+ };
683
+ }
684
+ const trimmed = (stdout || "").trim();
685
+ if (trimmed) {
686
+ return { ok: true };
687
+ } else {
688
+ return {
689
+ ok: false,
690
+ msg: `branch '${b}' not found in ${repo || "repository"}`
691
+ };
692
+ }
693
+ }
694
+
594
695
  module.exports = {
696
+ AGENTS,
595
697
  EMPTY_CONFIG,
596
698
  normalizeLaunchConfig,
597
699
  validateLaunchConfig,
@@ -606,6 +708,7 @@ module.exports = {
606
708
  queuedStatusForAction,
607
709
  pushHistory,
608
710
  distinctConfigs,
711
+ recentLaunchDisplay,
609
712
  defaultConfig,
610
713
  configForVm,
611
714
  resolveCardIdentity,
@@ -619,4 +722,6 @@ module.exports = {
619
722
  editorSourceShellResolve,
620
723
  rebuildStrategy,
621
724
  decideStartJob,
725
+ getAuthRepoUrl,
726
+ interpretLsRemote,
622
727
  };
@@ -275,6 +275,76 @@ function pushRedirectMismatchIssue(issues, requestedUrl, frame, response, config
275
275
  );
276
276
  }
277
277
 
278
+ // Read-only page-state snapshot for `capture-state`: the full localStorage
279
+ // map, a bounded sample of visible text nodes (document order), and — when a
280
+ // selector is given — that element's text. Evaluated in-page against the
281
+ // settled frame so it reflects exactly what a real capture saw (the proxy
282
+ // already injected the scenario's seed into the served HTML). Every read is
283
+ // individually guarded so a sandboxed/cross-origin localStorage never throws
284
+ // the whole capture; the worst case is an empty section, not a failure.
285
+ async function dumpPageState(frame, selector) {
286
+ return frame.evaluate((sel) => {
287
+ const localStorage = {};
288
+ try {
289
+ for (let i = 0; i < window.localStorage.length; i++) {
290
+ const key = window.localStorage.key(i);
291
+ if (key != null) localStorage[key] = window.localStorage.getItem(key);
292
+ }
293
+ } catch (_) {
294
+ /* localStorage may be unavailable (sandboxed/opaque origin) */
295
+ }
296
+
297
+ const visibleText = [];
298
+ try {
299
+ // Reject text inside non-rendered tags (SCRIPT/STYLE/etc.) so an
300
+ // injected proxy script or inline CSS never masquerades as on-screen
301
+ // text — that noise is exactly what makes a state dump misleading.
302
+ const SKIP_TAGS = new Set([
303
+ "SCRIPT",
304
+ "STYLE",
305
+ "NOSCRIPT",
306
+ "TEMPLATE",
307
+ "HEAD",
308
+ "TITLE",
309
+ ]);
310
+ const walker = document.createTreeWalker(
311
+ document.body,
312
+ NodeFilter.SHOW_TEXT,
313
+ {
314
+ acceptNode(node) {
315
+ const parent = node.parentElement;
316
+ if (parent && SKIP_TAGS.has(parent.tagName)) {
317
+ return NodeFilter.FILTER_REJECT;
318
+ }
319
+ return (node.textContent || "").trim()
320
+ ? NodeFilter.FILTER_ACCEPT
321
+ : NodeFilter.FILTER_REJECT;
322
+ },
323
+ },
324
+ );
325
+ let node;
326
+ while ((node = walker.nextNode()) && visibleText.length < 40) {
327
+ const text = (node.textContent || "").replace(/\s+/g, " ").trim();
328
+ if (text) visibleText.push(text);
329
+ }
330
+ } catch (_) {
331
+ /* no body / detached document */
332
+ }
333
+
334
+ let selectorText = null;
335
+ if (sel) {
336
+ try {
337
+ const el = document.querySelector(sel);
338
+ if (el) selectorText = (el.textContent || "").replace(/\s+/g, " ").trim();
339
+ } catch (_) {
340
+ /* invalid selector — leave selectorText null */
341
+ }
342
+ }
343
+
344
+ return { localStorage, visibleText, selectorText };
345
+ }, selector || null);
346
+ }
347
+
278
348
  // `preflight` is injectable (defaulting to the real app-port reachability
279
349
  // check) so unit tests that mock the browser can stay network-free.
280
350
  async function runScenarioCheck(config, { preflight = assertAppPortReachable } = {}) {
@@ -460,13 +530,22 @@ async function runScenarioCheck(config, { preflight = assertAppPortReachable } =
460
530
  await page.screenshot({ path: outputPath, fullPage: false });
461
531
  }
462
532
 
463
- return buildResult({
533
+ const result = buildResult({
464
534
  loaded,
465
535
  hasContent,
466
536
  issues,
467
537
  outputPath,
468
538
  url: frame.url() || url,
469
539
  });
540
+
541
+ // `capture-state` mode: attach the read-only page-state snapshot so the
542
+ // backend can report localStorage + rendered text. Off by default, so a
543
+ // normal error-check capture is byte-for-byte unchanged.
544
+ if (config.captureState) {
545
+ result.state = await dumpPageState(frame, config.stateSelector);
546
+ }
547
+
548
+ return result;
470
549
  } catch (error) {
471
550
  pushIssue(
472
551
  issues,
@@ -506,6 +585,7 @@ async function main() {
506
585
 
507
586
  module.exports = {
508
587
  runScenarioCheck,
588
+ dumpPageState,
509
589
  readStackLoadingMarkers,
510
590
  applyBrowserState,
511
591
  isCrossOriginRequest,
@@ -48,6 +48,22 @@ function handleConsoleMessage(message) {
48
48
  return null;
49
49
  }
50
50
 
51
+ // Ignore the browser's blocked-script warning for sandboxed mockup-preview
52
+ // frames. Mockup previews render untrusted AI-generated HTML inside a
53
+ // `sandbox=""` iframe; the HTML-injection proxy injects an error-capture
54
+ // <script> tag, which the browser then refuses to run, emitting
55
+ // "Blocked script execution ... because the frame is sandboxed". That block
56
+ // is the capture's own injected script being denied — benign for capture
57
+ // purposes. Match narrowly on BOTH the block phrase and the "sandboxed"
58
+ // signature so a genuine non-sandbox CSP block ("Blocked script execution"
59
+ // without "sandboxed") still surfaces as a real issue.
60
+ if (
61
+ text.includes("Blocked script execution") &&
62
+ text.includes("sandboxed")
63
+ ) {
64
+ return null;
65
+ }
66
+
51
67
  return createIssue("console", text);
52
68
  }
53
69