@codeyam-editor/codeyam-editor
Advanced tools
| #!/usr/bin/env node | ||
| 'use strict'; | ||
| // npm/cloud-branch-switch.js — switch the whole fleet to a new branch in one command. | ||
| // | ||
| // Orchestrates: | ||
| // 1. Verify the new branch exists on origin. | ||
| // 2. Print the launchd repoint command (operator runs it; auto-mode classifier | ||
| // blocks ~/Library/LaunchAgents writes from Claude Code). | ||
| // 3. Fire the dashboard's Reconfigure action for each idle VM in the roster, | ||
| // passing the new branch as both clientBranch + editorBranch so no dashboard | ||
| // restart is needed for the operation itself. | ||
| // 4. Poll /data until all fired Reconfigure jobs reach a terminal state. | ||
| // 5. Run fixup-vm-creds on each successfully reconfigured VM (if the script exists). | ||
| // | ||
| // Usage: | ||
| // npm run cloud:branch-switch editor-improvements-N [--dry-run] [--force] | ||
| // | ||
| // Env overrides: | ||
| // URLS_FILE cloudflared URL JSON (default .codeyam/logs/cloudflared-urls.json) | ||
| // TOKEN_FILE dashboard bearer-token file | ||
| // VMS_STATE_FILE roster JSON (default .codeyam/logs/fleet-vms.json) | ||
| // DASH_PORT dashboard port for localhost fallback (default 8787) | ||
| // BRANCH_SWITCH_POLL_SEC /data poll interval in seconds (default 15) | ||
| // BRANCH_SWITCH_TIMEOUT_SEC per-job deadline (default 2400) | ||
| const { spawnSync } = require('child_process'); | ||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
| const http = require('http'); | ||
| const https = require('https'); | ||
| const { rootDir } = require('./utils'); | ||
| // Shared roster reader — the roster file is a fleet-wide contract, so its | ||
| // read+write logic lives in one module the CLI, the dashboard, and this | ||
| // orchestrator all import (see npm/fleet-roster.js). | ||
| const { readRoster } = require('./fleet-roster'); | ||
| const CANONICAL_EDITOR_REPO = 'https://github.com/codeyam-ai/codeyam-editor.git'; | ||
| const DASH_PORT = Number(process.env.DASH_PORT) || 8787; | ||
| const URLS_FILE = process.env.URLS_FILE | ||
| || path.join(rootDir, '.codeyam', 'logs', 'cloudflared-urls.json'); | ||
| const TOKEN_FILE = process.env.TOKEN_FILE | ||
| || path.join(rootDir, 'scripts', 'operator', 'fleet-dashboard', '.token'); | ||
| const POLL_SEC = Number(process.env.BRANCH_SWITCH_POLL_SEC) || 15; | ||
| const TIMEOUT_SEC = Number(process.env.BRANCH_SWITCH_TIMEOUT_SEC) || 2400; | ||
| const FIXUP_SCRIPT = path.join(rootDir, 'scripts', 'operator', 'fixup-vm-creds.sh'); | ||
| // ── Pure helpers (unit-tested in cloud-branch-switch.test.js) ────────────── | ||
| /** Parse argv slice for cloud:branch-switch. Returns { newBranch, dryRun, force }. */ | ||
| function parseBranchSwitchArgs(argv) { | ||
| const args = { newBranch: null, dryRun: false, force: false }; | ||
| for (const a of argv) { | ||
| if (a === '--dry-run') { args.dryRun = true; continue; } | ||
| if (a === '--force') { args.force = true; continue; } | ||
| if (a.startsWith('--')) throw new Error(`Unknown flag: ${a}`); | ||
| if (args.newBranch) { | ||
| throw new Error( | ||
| `Unexpected positional arg '${a}' (branch already set to '${args.newBranch}')`, | ||
| ); | ||
| } | ||
| args.newBranch = a; | ||
| } | ||
| return args; | ||
| } | ||
| /** True for non-empty branch names composed only of safe git-ref characters. */ | ||
| function isValidBranchName(branch) { | ||
| return typeof branch === 'string' && branch.length > 0 | ||
| && /^[a-zA-Z0-9._/-]+$/.test(branch); | ||
| } | ||
| /** Find a VM by number in the /data vms array (which is ordered, not keyed). */ | ||
| function findVm(vms, n) { | ||
| return Array.isArray(vms) ? vms.find((v) => v && v.n === n) : undefined; | ||
| } | ||
| /** | ||
| * True when a VM's active reconfigure job has reached a terminal state, OR no | ||
| * reconfigure job is present (a later job may have taken its slot, meaning the | ||
| * reconfigure completed). Never blocks indefinitely in the poller. | ||
| */ | ||
| function isReconfigureTerminal(vm) { | ||
| const job = vm && vm.job; | ||
| if (!job || job.action !== 'reconfigure') return true; | ||
| return job.state === 'done' || job.state === 'error'; | ||
| } | ||
| /** Extract the terminal result ('done' / 'error') for a VM's reconfigure job. */ | ||
| function reconfigureResult(vm) { | ||
| const job = vm && vm.job; | ||
| if (!job || job.action !== 'reconfigure') return 'done'; | ||
| return job.state === 'done' ? 'done' : 'error'; | ||
| } | ||
| /** True when the /data vms array shows an active session on this VM. */ | ||
| function vmHasActiveSession(vms, n) { | ||
| const vm = findVm(vms, n); | ||
| return !!(vm && vm.hasSession); | ||
| } | ||
| /** Current client branch for a VM, from cardIdentity (live wins over history). */ | ||
| function vmClientBranch(vms, n) { | ||
| const vm = findVm(vms, n); | ||
| return (vm && vm.cardIdentity && vm.cardIdentity.clientBranch) || null; | ||
| } | ||
| /** Build the /action/reconfigure query string for a VM + branch. */ | ||
| function buildReconfigureQuery(n, newBranch, force) { | ||
| const p = new URLSearchParams({ | ||
| vm: String(n), | ||
| clientRepo: CANONICAL_EDITOR_REPO, | ||
| clientBranch: newBranch, | ||
| editorVariant: 'dev', | ||
| editorBranch: newBranch, | ||
| }); | ||
| if (force) p.set('force', '1'); | ||
| return p.toString(); | ||
| } | ||
| // ── I/O helpers ───────────────────────────────────────────────────────────── | ||
| function readToken() { | ||
| try { return fs.readFileSync(TOKEN_FILE, 'utf8').trim(); } catch { return ''; } | ||
| } | ||
| function readDashboardBaseUrl() { | ||
| try { | ||
| const urls = JSON.parse(fs.readFileSync(URLS_FILE, 'utf8')); | ||
| const dash = urls && typeof urls.dashboard === 'string' ? urls.dashboard.trim() : ''; | ||
| if (dash) return dash; | ||
| } catch { /* fall through to localhost */ } | ||
| return `http://127.0.0.1:${DASH_PORT}`; | ||
| } | ||
| function branchExistsOnOrigin(branch) { | ||
| const r = spawnSync('git', ['ls-remote', '--heads', 'origin', branch], { | ||
| encoding: 'utf8', cwd: rootDir, | ||
| }); | ||
| if (r.error || r.status !== 0) return false; | ||
| return String(r.stdout || '').includes(`refs/heads/${branch}`); | ||
| } | ||
| // ── HTTP ──────────────────────────────────────────────────────────────────── | ||
| function httpFetch(urlStr, opts = {}) { | ||
| return new Promise((resolve, reject) => { | ||
| const parsed = new URL(urlStr); | ||
| const lib = parsed.protocol === 'https:' ? https : http; | ||
| const reqOpts = { | ||
| hostname: parsed.hostname, | ||
| port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), | ||
| path: parsed.pathname + parsed.search, | ||
| method: opts.method || 'GET', | ||
| headers: opts.headers || {}, | ||
| }; | ||
| const req = lib.request(reqOpts, (res) => { | ||
| let body = ''; | ||
| res.on('data', (chunk) => { body += chunk; }); | ||
| res.on('end', () => { | ||
| try { resolve({ status: res.statusCode, body: JSON.parse(body) }); } | ||
| catch { resolve({ status: res.statusCode, body }); } | ||
| }); | ||
| }); | ||
| req.setTimeout(opts.timeoutMs || 15000, () => { | ||
| req.destroy(); | ||
| reject(new Error(`Timeout fetching ${urlStr}`)); | ||
| }); | ||
| req.on('error', reject); | ||
| req.end(); | ||
| }); | ||
| } | ||
| function authHeaders(tok) { | ||
| return { Authorization: `Bearer ${tok}` }; | ||
| } | ||
| async function fetchDashboardData(baseUrl, tok) { | ||
| const r = await httpFetch(`${baseUrl}/data`, { headers: authHeaders(tok) }); | ||
| if (r.status !== 200 || !r.body || typeof r.body !== 'object') { | ||
| throw new Error(`Dashboard /data returned HTTP ${r.status}`); | ||
| } | ||
| return r.body; | ||
| } | ||
| async function postReconfigure(baseUrl, tok, n, newBranch, force) { | ||
| const q = buildReconfigureQuery(n, newBranch, force); | ||
| return httpFetch(`${baseUrl}/action/reconfigure?${q}`, { | ||
| method: 'POST', | ||
| headers: { ...authHeaders(tok), 'Content-Length': '0' }, | ||
| }); | ||
| } | ||
| // ── Job poller ─────────────────────────────────────────────────────────────── | ||
| function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } | ||
| /** Poll /data until every VM in `ns` has a terminal reconfigure job state. | ||
| * Returns an object mapping each n to 'done' or 'error' or 'timeout'. */ | ||
| async function waitForReconfigureJobs(baseUrl, tok, ns, pollSec, timeoutSec) { | ||
| const deadline = Date.now() + timeoutSec * 1000; | ||
| const pending = new Set(ns); | ||
| const results = {}; | ||
| while (pending.size > 0) { | ||
| if (Date.now() >= deadline) { | ||
| for (const n of pending) { | ||
| results[n] = 'timeout'; | ||
| process.stdout.write(` VM-${n}: TIMEOUT\n`); | ||
| } | ||
| break; | ||
| } | ||
| let data; | ||
| try { | ||
| data = await fetchDashboardData(baseUrl, tok); | ||
| } catch (e) { | ||
| process.stdout.write(` (poll error: ${e.message}; retrying in ${pollSec}s)\n`); | ||
| await sleep(pollSec * 1000); | ||
| continue; | ||
| } | ||
| for (const n of [...pending]) { | ||
| const vm = findVm(data.vms, n); | ||
| if (isReconfigureTerminal(vm)) { | ||
| results[n] = reconfigureResult(vm); | ||
| pending.delete(n); | ||
| process.stdout.write(` VM-${n}: ${results[n]}\n`); | ||
| } | ||
| } | ||
| if (pending.size > 0) { | ||
| const elapsed = Math.round((Date.now() - (deadline - timeoutSec * 1000)) / 1000); | ||
| process.stdout.write( | ||
| ` waiting (${[...pending].map((n) => `VM-${n}`).join(', ')} — +${elapsed}s)...\n`, | ||
| ); | ||
| await sleep(pollSec * 1000); | ||
| } | ||
| } | ||
| return results; | ||
| } | ||
| /** Classify each VM in roster as either to-switch or skip. | ||
| * Pure: takes already-loaded /data vms array and per-VM state; no I/O. */ | ||
| function classifyVmsForSwitch(roster, vms, newBranch, force) { | ||
| const toSwitch = []; | ||
| const skipped = []; | ||
| for (const n of roster) { | ||
| const currentBranch = vmClientBranch(vms, n); | ||
| if (currentBranch === newBranch) { | ||
| skipped.push({ n, reason: `already on ${newBranch}` }); | ||
| continue; | ||
| } | ||
| if (vmHasActiveSession(vms, n) && !force) { | ||
| skipped.push({ n, reason: 'has active session (use --force to override)' }); | ||
| continue; | ||
| } | ||
| toSwitch.push(n); | ||
| } | ||
| return { toSwitch, skipped }; | ||
| } | ||
| /** Fire Reconfigure for each VM in toSwitch. Returns { fired, rejected }. | ||
| * postFn is injected for testability; defaults to postReconfigure. */ | ||
| async function fireReconfigures(baseUrl, tok, toSwitch, newBranch, force, postFn = postReconfigure) { | ||
| const fired = []; | ||
| const rejected = []; | ||
| for (const n of toSwitch) { | ||
| try { | ||
| const r = await postFn(baseUrl, tok, n, newBranch, force); | ||
| if (r.body && r.body.ok === false) { | ||
| const why = r.body.msg || 'rejected by dashboard'; | ||
| console.warn(` VM-${n}: rejected — ${why}`); | ||
| rejected.push({ n, reason: why }); | ||
| } else { | ||
| const queued = r.body && r.body.queued ? ' (queued)' : ''; | ||
| console.log(` VM-${n}: accepted${queued}`); | ||
| fired.push(n); | ||
| } | ||
| } catch (e) { | ||
| console.warn(` VM-${n}: HTTP error — ${e.message}`); | ||
| rejected.push({ n, reason: e.message }); | ||
| } | ||
| } | ||
| return { fired, rejected }; | ||
| } | ||
| // ── Fixup ──────────────────────────────────────────────────────────────────── | ||
| function runFixupVmCreds(n) { | ||
| if (!fs.existsSync(FIXUP_SCRIPT)) return; | ||
| console.log(` fixup-vm-creds VM-${n}...`); | ||
| const r = spawnSync('bash', [FIXUP_SCRIPT, String(n)], { | ||
| stdio: 'inherit', cwd: rootDir, | ||
| }); | ||
| if (r.status !== 0) { | ||
| console.warn(` WARN: fixup-vm-creds exited ${r.status} for VM-${n}`); | ||
| } | ||
| } | ||
| // ── Main ───────────────────────────────────────────────────────────────────── | ||
| async function branchSwitchMain() { | ||
| let args; | ||
| try { | ||
| args = parseBranchSwitchArgs(process.argv.slice(2)); | ||
| } catch (e) { | ||
| console.error(`Error: ${e.message}`); | ||
| console.error('Usage: npm run cloud:branch-switch editor-improvements-N [--dry-run] [--force]'); | ||
| process.exit(1); | ||
| } | ||
| if (!args.newBranch) { | ||
| console.error('Usage: npm run cloud:branch-switch editor-improvements-N [--dry-run] [--force]'); | ||
| process.exit(1); | ||
| } | ||
| if (!isValidBranchName(args.newBranch)) { | ||
| console.error(`Error: invalid branch name '${args.newBranch}'`); | ||
| process.exit(1); | ||
| } | ||
| const dryRun = args.dryRun; | ||
| const force = args.force; | ||
| const newBranch = args.newBranch; | ||
| console.log( | ||
| `cloud:branch-switch ${newBranch}${dryRun ? ' --dry-run' : ''}${force ? ' --force' : ''}`, | ||
| ); | ||
| // Step 1: verify branch exists on origin | ||
| process.stdout.write('Step 1: verifying branch exists on origin... '); | ||
| if (!branchExistsOnOrigin(newBranch)) { | ||
| console.error(`\nError: '${newBranch}' does not exist on origin.`); | ||
| process.exit(1); | ||
| } | ||
| console.log('✓'); | ||
| // Step 2: print launchd repoint instruction | ||
| console.log('\nStep 2: launchd repoint (operator action — run once after the switch):'); | ||
| console.log(` BRANCH=${newBranch} bash scripts/operator/install-launchd-agents.sh`); | ||
| console.log(' (auto-mode classifier blocks ~/Library/LaunchAgents writes from Claude Code)'); | ||
| // Step 3: load dashboard state | ||
| const baseUrl = readDashboardBaseUrl(); | ||
| const tok = readToken(); | ||
| if (!tok) { | ||
| console.error(`\nError: no dashboard token at ${TOKEN_FILE}`); | ||
| process.exit(1); | ||
| } | ||
| console.log('\nStep 3: fetching dashboard /data...'); | ||
| let data; | ||
| try { | ||
| data = await fetchDashboardData(baseUrl, tok); | ||
| } catch (e) { | ||
| console.error(`Error: ${e.message}`); | ||
| console.error(` Dashboard: ${baseUrl}`); | ||
| process.exit(1); | ||
| } | ||
| const roster = readRoster(); | ||
| if (!roster.length) { | ||
| console.error('Error: roster is empty — no VMs to switch.'); | ||
| process.exit(1); | ||
| } | ||
| const { toSwitch, skipped } = classifyVmsForSwitch(roster, data.vms, newBranch, force); | ||
| console.log('\nPlan:'); | ||
| for (const n of toSwitch) { | ||
| const cur = vmClientBranch(data.vms, n) || '?'; | ||
| console.log(` VM-${n}: RECONFIGURE ${cur} → ${newBranch}`); | ||
| } | ||
| for (const s of skipped) console.log(` VM-${s.n}: SKIP — ${s.reason}`); | ||
| if (!toSwitch.length) { | ||
| console.log('\nNothing to do — all VMs already at target branch or skipped.'); | ||
| return; | ||
| } | ||
| if (dryRun) { | ||
| console.log('\n--dry-run: plan printed, no side effects.'); | ||
| return; | ||
| } | ||
| // Step 4: fire Reconfigure for each VM | ||
| console.log(`\nStep 4: firing Reconfigure on ${toSwitch.length} VM(s)...`); | ||
| const { fired, rejected } = await fireReconfigures(baseUrl, tok, toSwitch, newBranch, force); | ||
| for (const r of rejected) skipped.push(r); | ||
| if (!fired.length) { | ||
| console.log('\nNo VMs accepted Reconfigure.'); | ||
| return; | ||
| } | ||
| // Step 5: wait for jobs to complete | ||
| console.log(`\nStep 5: waiting for ${fired.length} job(s) (timeout ${TIMEOUT_SEC}s, poll ${POLL_SEC}s)...`); | ||
| const results = await waitForReconfigureJobs(baseUrl, tok, fired, POLL_SEC, TIMEOUT_SEC); | ||
| const succeeded = fired.filter((n) => results[n] === 'done'); | ||
| const failed = fired.filter((n) => results[n] !== 'done'); | ||
| // Step 6: fixup-vm-creds for successfully reconfigured VMs | ||
| if (succeeded.length && fs.existsSync(FIXUP_SCRIPT)) { | ||
| console.log('\nStep 6: running fixup-vm-creds on reconfigured VMs...'); | ||
| for (const n of succeeded) runFixupVmCreds(n); | ||
| } | ||
| console.log('\nSummary:'); | ||
| if (succeeded.length) console.log(` reconfigured: ${succeeded.map((n) => `VM-${n}`).join(', ')}`); | ||
| if (skipped.length) console.log(` skipped: ${skipped.map((s) => `VM-${s.n}`).join(', ')}`); | ||
| if (failed.length) console.log(` failed: ${failed.map((n) => `VM-${n} (${results[n]})`).join(', ')}`); | ||
| console.log(`\nDone. Remember to run:`); | ||
| console.log(` BRANCH=${newBranch} bash scripts/operator/install-launchd-agents.sh`); | ||
| if (failed.length) process.exit(1); | ||
| } | ||
| module.exports = { | ||
| parseBranchSwitchArgs, | ||
| isValidBranchName, | ||
| findVm, | ||
| isReconfigureTerminal, | ||
| reconfigureResult, | ||
| vmHasActiveSession, | ||
| vmClientBranch, | ||
| buildReconfigureQuery, | ||
| classifyVmsForSwitch, | ||
| fireReconfigures, | ||
| readRoster, | ||
| readToken, | ||
| readDashboardBaseUrl, | ||
| branchExistsOnOrigin, | ||
| }; | ||
| if (require.main === module) { | ||
| branchSwitchMain().catch((e) => { | ||
| console.error(`Error: ${e.message || e}`); | ||
| process.exit(1); | ||
| }); | ||
| } |
Sorry, the diff of this file is too big to display
| 'use strict'; | ||
| // Pure decision module for the VM root-disk guardrail. Given a single integer | ||
| // percent (from `df`'s Use%/Capacity column), classify the pressure into | ||
| // ok | warn | critical, and expose the allowlist of regenerable cache dirs the | ||
| // watchdog may auto-prune at the critical threshold. No I/O — the watchdog | ||
| // (bash) and the fleet dashboard (node) both consume these constants so the | ||
| // decision boundary is testable without a live VM, mirroring the | ||
| // pure-decision-vs-I/O split of npm/fleet-effect-status.js. | ||
| // | ||
| // Why two thresholds: warn (80%) is visibility only — a badge + log line so the | ||
| // operator sees pressure building. critical (90%) authorizes a bounded | ||
| // auto-prune of regenerable build caches, because a 100%-full disk cascades far | ||
| // worse (it truncated ~/.claude.json mid-write and OOM-killed the broker on the | ||
| // VM4 outage this guards against). | ||
| const DISK_WARN_PCT = 80; | ||
| const DISK_CRITICAL_PCT = 90; | ||
| // Allowlist of regenerable dev/build cache directories that are safe to delete | ||
| // on ANY stack because the toolchain rebuilds them on demand. This is an | ||
| // allowlist (known-safe), NOT a denylist, so an unknown stack's important files | ||
| // are never deleted — worst case we free less, never break a build. | ||
| // | ||
| // Stack assumption: each entry is a cache OUTPUT of a specific toolchain | ||
| // (.next/.turbo → Next.js/Turborepo, .svelte-kit → SvelteKit, .vite → Vite, | ||
| // .parcel-cache → Parcel, .angular/cache → Angular, node_modules/.cache → | ||
| // generic JS build caches). A project on none of these stacks simply has none | ||
| // of these dirs and the prune is a no-op. | ||
| // | ||
| // Deliberately disjoint from internal_paths.rs::ALWAYS_EXCLUDED_DIRS | ||
| // (node_modules, .codeyam, .git, target): those mean "don't index," NOT "safe | ||
| // to delete." node_modules is load-bearing and Rust target/ is expensive to | ||
| // rebuild, so neither appears here. | ||
| // | ||
| // Follow-up: a future migration can move this into stack.json so each project | ||
| // declares its own cache dirs; kept operator-side here to stay self-contained. | ||
| const PRUNABLE_CACHE_DIRS = [ | ||
| '.next/cache', | ||
| '.next/dev/cache', | ||
| 'node_modules/.cache', | ||
| '.turbo', | ||
| '.svelte-kit', | ||
| '.vite', | ||
| '.parcel-cache', | ||
| '.angular/cache', | ||
| ]; | ||
| // Classify an integer disk-usage percent into a pressure level. A non-numeric, | ||
| // non-finite, or out-of-range (<0 or >100) value is treated as a BAD READING | ||
| // and classified 'ok' with no action — a misread must never escalate to a | ||
| // destructive prune. | ||
| function classifyDiskPressure(usedPct) { | ||
| if (typeof usedPct !== 'number' || !Number.isFinite(usedPct)) return 'ok'; | ||
| if (usedPct < 0 || usedPct > 100) return 'ok'; | ||
| if (usedPct >= DISK_CRITICAL_PCT) return 'critical'; | ||
| if (usedPct >= DISK_WARN_PCT) return 'warn'; | ||
| return 'ok'; | ||
| } | ||
| // Parse the integer used-percent from a `df -P /` block. `df -P` (POSIX) emits a | ||
| // header line then one data line whose Capacity field is like "82%". We find the | ||
| // first NN% token on the data line, which is robust to a mount point containing | ||
| // spaces (which would shift positional fields). Returns null on unparseable | ||
| // input so the caller can fail safe rather than prune on garbage. | ||
| function parseDfUsedPct(dfOutput) { | ||
| const lines = String(dfOutput == null ? '' : dfOutput) | ||
| .split('\n') | ||
| .map((l) => l.trim()) | ||
| .filter((l) => l.length > 0); | ||
| // The data line is the second non-empty line (after the header). Fall back to | ||
| // scanning all lines so a header-less one-liner still parses. | ||
| const candidates = lines.length >= 2 ? [lines[1], ...lines] : lines; | ||
| for (const line of candidates) { | ||
| const m = line.match(/(\d+)%/); | ||
| if (m) { | ||
| const pct = parseInt(m[1], 10); | ||
| if (Number.isFinite(pct)) return pct; | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
| module.exports = { | ||
| DISK_WARN_PCT, | ||
| DISK_CRITICAL_PCT, | ||
| PRUNABLE_CACHE_DIRS, | ||
| classifyDiskPressure, | ||
| parseDfUsedPct, | ||
| }; |
| "use strict"; | ||
| /** | ||
| * Launcher-side mirror of `is_empty_project_dir` in | ||
| * `crates/codeyam-editor/src/commands/init.rs`. The Rust side decides | ||
| * which `/codeyam-…` skill the init binary points at; this JS mirror | ||
| * decides the same for the launcher's post-launch tail banner. Keep | ||
| * both in sync — the test `empty-project-dir.test.js` and Rust's | ||
| * `is_empty_project_dir_ignores_hidden_entries` / | ||
| * `is_empty_project_dir_detects_visible_entry` pin the contract. | ||
| * | ||
| * A "fresh" project directory has no non-hidden entries — only things | ||
| * like `.git/`, `.gitignore`, or other dotfiles. Hidden entries don't | ||
| * count as project content, so a dir holding only `.git/` and | ||
| * `.gitignore` (the "I just ran `git init`" case) still reads as empty. | ||
| * A read error returns false, matching the Rust `Err(_) => false` branch: | ||
| * a missing / unreadable dir defaults to the existing-project path rather | ||
| * than falsely scaffolding over nothing. | ||
| */ | ||
| const fs = require("node:fs"); | ||
| function isEmptyProjectDir(projectDir) { | ||
| let entries; | ||
| try { | ||
| entries = fs.readdirSync(projectDir); | ||
| } catch { | ||
| return false; | ||
| } | ||
| return entries.every((name) => name.startsWith(".")); | ||
| } | ||
| module.exports = { isEmptyProjectDir }; |
| 'use strict'; | ||
| // Pure helpers for the fleet dashboard's "Authenticate idle VMs" batch flow | ||
| // (scripts/operator/fleet-dashboard/server.js requires this). The decision of | ||
| // WHICH VMs a fleet-auth pass touches — and how to summarize an in-flight | ||
| // batch — lives here so server.js's HTTP orchestration stays thin and the | ||
| // guardrail logic is unit-testable in isolation (npm/fleet-auth.test.js). | ||
| // | ||
| // Why per-VM real `claude /login` and not one shared credential: copying a | ||
| // `.credentials.json` between VMs 401s (Claude rotates/binds the OAuth access | ||
| // token to its originating VM), and the `CLAUDE_CODE_OAUTH_TOKEN` setup-token | ||
| // is limited-scope + non-durable (lost on container recreate). So every VM | ||
| // needs its own interactive login — this batches the human step across the | ||
| // fleet (start every idle VM's login in parallel, approve them as one list) | ||
| // instead of distributing a single token. See docs/cloud-editor-stability.md | ||
| // "Fleet auth". | ||
| // Classify one VM for the fleet-auth pass. Returns { eligible, reason }. | ||
| // | ||
| // The guardrail mirrors Destroy / Reconfigure / roll: NEVER touch an active | ||
| // session (a mid-build agent must not have its editor driven into a login | ||
| // PTY), and never start an auth pass on a VM that already has a running or | ||
| // queued job. Only a reachable, idle VM the editor reports as `needs-login` is | ||
| // a target. An `unknown` / absent auth state is deliberately NOT a target — | ||
| // the probe is indeterminate (a transient failure on an otherwise-healthy VM), | ||
| // and driving a login we can't confirm is needed risks disrupting a working | ||
| // VM. `v` is the dashboard's per-VM poll state (`state.vms[n]`); `job` is | ||
| // `state.jobs[n]` (may be undefined). | ||
| function classifyAuthVm(v, job) { | ||
| if (job && (job.state === 'running' || job.state === 'queued')) { | ||
| return { eligible: false, reason: 'busy' }; | ||
| } | ||
| if (!v || !v.reachable) return { eligible: false, reason: 'unreachable' }; | ||
| if (v.hasSession) return { eligible: false, reason: 'active-session' }; | ||
| const state = v.providerAuth && v.providerAuth.state; | ||
| if (state === 'authenticated') return { eligible: false, reason: 'already-authenticated' }; | ||
| if (state === 'needs-login') return { eligible: true, reason: 'needs-login' }; | ||
| return { eligible: false, reason: 'auth-state-unknown' }; | ||
| } | ||
| // Select every VM the fleet-auth pass should drive a login on, plus the | ||
| // skipped VMs each with the reason it was excluded (surfaced to the operator | ||
| // so "why didn't VM-7 get authenticated?" is answerable without ssh). | ||
| // | ||
| // roster: number[] (the live VMS array). vms / jobs: maps keyed by VM number. | ||
| // Returns { targets: number[], skipped: [{ n, reason }] }, both sorted by n. | ||
| function selectAuthTargets({ roster, vms, jobs }) { | ||
| const targets = []; | ||
| const skipped = []; | ||
| for (const n of [...(roster || [])].sort((a, b) => a - b)) { | ||
| const c = classifyAuthVm((vms || {})[n], (jobs || {})[n]); | ||
| if (c.eligible) targets.push(n); | ||
| else skipped.push({ n, reason: c.reason }); | ||
| } | ||
| return { targets, skipped }; | ||
| } | ||
| // Aggregate an in-flight batch (map of n → { phase }) into counts for the | ||
| // operator report. Phases mirror the editor's /api/auth/status states | ||
| // ('starting' | 'url-ready' | 'authenticated' | 'failed') plus the | ||
| // dashboard-side 'submitting' while a pasted code is being delivered. Pure | ||
| // over the values; `done` is true once no VM is still mid-flight, which is | ||
| // what lets the UI stop polling / show a final "N of M authenticated" line. | ||
| function summarizeAuthBatch(batchVms) { | ||
| const counts = { total: 0, starting: 0, urlReady: 0, submitting: 0, authenticated: 0, failed: 0 }; | ||
| for (const n of Object.keys(batchVms || {})) { | ||
| const phase = batchVms[n] && batchVms[n].phase; | ||
| counts.total++; | ||
| if (phase === 'starting') counts.starting++; | ||
| else if (phase === 'url-ready') counts.urlReady++; | ||
| else if (phase === 'submitting') counts.submitting++; | ||
| else if (phase === 'authenticated') counts.authenticated++; | ||
| else if (phase === 'failed') counts.failed++; | ||
| } | ||
| // In-flight = still starting, or awaiting a pasted code, or submitting one. | ||
| // A `failed` VM is terminal until the operator restarts the batch, and an | ||
| // `authenticated` VM is done — so neither counts as in-flight. | ||
| counts.inFlight = counts.starting + counts.urlReady + counts.submitting; | ||
| counts.done = counts.total > 0 && counts.inFlight === 0; | ||
| return counts; | ||
| } | ||
| // Derive the fleet's current default Claude account (improve38): the account a | ||
| // newly launched VM should authenticate as. Defined as the account of the | ||
| // rostered VM whose auth was updated most recently, where "auth-update time" = | ||
| // max(dashboard-recorded login-completion `lastAuthAt[n]`, the editor-reported | ||
| // `providerAuth.lastRefreshAt`). Re-authenticating ANY existing VM thus | ||
| // re-points the default. Pure over the polled VM snapshot + the lastAuthAt map. | ||
| // | ||
| // Returns the winning VM's account object ({ email, organizationName, | ||
| // organizationType, subscriptionType }), or null when no rostered VM reports an | ||
| // account. Ties — and the no-timestamp case — resolve to the first such VM in | ||
| // `vms` order, so the result is deterministic. | ||
| function deriveDefaultAuthAccount(vms, lastAuthAt) { | ||
| const at = lastAuthAt || {}; | ||
| let best = null; | ||
| for (const v of vms || []) { | ||
| const pa = v && v.providerAuth; | ||
| const acct = pa && pa.account; | ||
| if (!acct || !acct.email) continue; | ||
| const refreshMs = pa.lastRefreshAt ? Date.parse(pa.lastRefreshAt) : NaN; | ||
| const loginMs = at[v.n] ? Date.parse(at[v.n]) : NaN; | ||
| const score = Math.max( | ||
| Number.isFinite(refreshMs) ? refreshMs : -Infinity, | ||
| Number.isFinite(loginMs) ? loginMs : -Infinity, | ||
| ); | ||
| if (!best || score > best.score) best = { account: acct, score }; | ||
| } | ||
| return best ? best.account : null; | ||
| } | ||
| module.exports = { classifyAuthVm, selectAuthTargets, summarizeAuthBatch, deriveDefaultAuthAccount }; |
| 'use strict'; | ||
| // Pure branch-resolution precedence for the fleet dashboard (improve39 A). | ||
| // | ||
| // The dashboard's "current fleet branch" used to go stale: server.js read a | ||
| // baked process.env.BRANCH (set once by the launchd plist at install time) and | ||
| // a hardcoded 'editor-improvements-30' literal, so a `git checkout` to a new | ||
| // fleet branch on the laptop was invisible until the operator re-ran | ||
| // install-launchd-agents.sh (which then risked taking the dashboard down — the | ||
| // (B) half of improve39). This module makes the resolution RULE a pure, | ||
| // unit-tested function (npm/fleet-branch.test.js); server.js owns the I/O — the | ||
| // execSync git read and the short-TTL memo on the ~3s /data poll path. | ||
| // | ||
| // Precedence: explicit env override -> live git HEAD -> fallback sentinel. | ||
| // An operator can still pin a branch with BRANCH=... in the plist/env, but with | ||
| // no pin the dashboard tracks the laptop checkout's live HEAD, so a branch bump | ||
| // is just a `git checkout` — no re-install. | ||
| // Returned when neither an explicit pin nor a live git read yields a branch — a | ||
| // clearly-not-a-branch marker, chosen over a stale literal like | ||
| // 'editor-improvements-30' that would silently point Reset/Update at a dead | ||
| // branch. Callers can surface it as "unknown" instead of acting on it. | ||
| const UNKNOWN_BRANCH = 'unknown-branch'; | ||
| /** Normalize a candidate branch value to a non-empty trimmed string, or null. | ||
| * Anything non-string, empty, or whitespace-only counts as "absent" so a | ||
| * failed git read (null) or an unset env var ('') falls through to the next | ||
| * source. */ | ||
| function cleanBranch(v) { | ||
| if (typeof v !== 'string') return null; | ||
| const t = v.trim(); | ||
| return t === '' ? null : t; | ||
| } | ||
| /** Resolve the fleet's active editor branch by precedence: | ||
| * | ||
| * explicit env override -> live git HEAD -> fallback. | ||
| * | ||
| * `fallback` defaults to the UNKNOWN_BRANCH sentinel, so a total failure | ||
| * (no pin, no git) yields a marker rather than a stale branch literal. | ||
| * Pure; no I/O. */ | ||
| function resolveFleetBranch({ envBranch, gitBranch, fallback } = {}) { | ||
| const pin = cleanBranch(envBranch); | ||
| if (pin) return pin; | ||
| const live = cleanBranch(gitBranch); | ||
| if (live) return live; | ||
| return cleanBranch(fallback) || UNKNOWN_BRANCH; | ||
| } | ||
| module.exports = { UNKNOWN_BRANCH, cleanBranch, resolveFleetBranch }; |
| 'use strict'; | ||
| // Pure alarm-only classification for the Fleet Dashboard's broker-death line | ||
| // (scripts/operator/fleet-dashboard/server.js requires this). Given the rolling | ||
| // `brokerHistory` forensics and the live tri-state `brokerStatus`, decide | ||
| // whether the newest death is a LIVE problem worth alarming about. No I/O — | ||
| // server.js does the polling. | ||
| // | ||
| // Contract (improve40): the death line alarms ONLY when the broker is provably | ||
| // down right now. For every other status it returns null and the card omits the | ||
| // row entirely. | ||
| // | ||
| // Why alarm-only: the live broker-status dot (renderBrokerStatus) already shows | ||
| // current health. The death line's only job is to EXPLAIN a broker that is down | ||
| // now (with the recorded reason / uptime / count). A death record for a broker | ||
| // that is back up is superseded history — the old muted "↩ broker recovered" | ||
| // footnote was noise. And a death we can't confirm (editor unreachable → | ||
| // 'unknown') must not over-claim: "absence of evidence is never down" is the | ||
| // established principle in npm/fleet-broker-status.js. The earlier | ||
| // recovered/active/unconfirmed tri-state collapses to a single down-only alarm. | ||
| // | ||
| // `brokerStatus` here is the DEBOUNCED tri-state from | ||
| // npm/fleet-broker-debounce.js, so a single transient probe timeout can never | ||
| // reach this as 'down'. | ||
| function classifyBrokerDeath(brokerHistory, brokerStatus) { | ||
| // Alarm only when the broker is confirmed down now. Every other status (up, | ||
| // unreachable, missing) → no death line. | ||
| if (brokerStatus !== 'down') return null; | ||
| // Common "broker never died" case → card renders nothing, unchanged. | ||
| if (!Array.isArray(brokerHistory) || brokerHistory.length === 0) return null; | ||
| const newest = brokerHistory[brokerHistory.length - 1]; | ||
| if (!newest) return null; | ||
| // Mirror renderBrokerDeath's existing reason fallback chain. | ||
| const reason = newest.reason || newest.signalName || ('exit ' + newest.exitCode); | ||
| return { | ||
| reason, | ||
| // Passed through verbatim (may be null/absent — the renderer guards both). | ||
| atUnixSecs: newest.atUnixSecs, | ||
| uptimeSeconds: newest.uptimeSeconds, | ||
| count: brokerHistory.length, | ||
| }; | ||
| } | ||
| module.exports = { classifyBrokerDeath }; |
| 'use strict'; | ||
| // Pure consecutive-failure debounce for the Fleet Dashboard's broker tri-state | ||
| // (scripts/operator/fleet-dashboard/server.js requires this). Given the raw | ||
| // single-poll status from npm/fleet-broker-status.js::deriveBrokerStatus and the | ||
| // per-VM debounce state from the previous poll, decide the status the card | ||
| // actually renders. No I/O — server.js does the polling and threads the state. | ||
| // | ||
| // The bug this fixes (observed live 2026-06-02, VM3): the dashboard polls each | ||
| // VM's broker every 3s, and the editor probes the broker with a single 200ms | ||
| // socket attempt. One transient timeout (IAP / SSH-tunnel congestion — the same | ||
| // class of hiccup behind the known false "Editor stopped") instantly flipped a | ||
| // healthy VM's card to 'down', which raised both the red broker-down dot and the | ||
| // 💀 death alarm, even though the broker was fine (the build tab opened and chat | ||
| // started immediately). The single probe is left as-is on the editor side; the | ||
| // right seam for absorbing a one-poll blip is here at the aggregation layer. | ||
| // | ||
| // Asymmetric by design: ONLY the flip to 'down' is debounced (it requires | ||
| // `threshold` consecutive 'down' polls). 'idle' / 'agent-live' / 'unknown' take | ||
| // effect immediately — we never want to DELAY clearing a false alarm, only | ||
| // delay raising one. And an unreachable editor ('unknown') breaks a pending | ||
| // down-streak rather than escalating it, mirroring fleet-broker-status.js's | ||
| // "absence of evidence is never down" rule. | ||
| // | ||
| // State shape carried across polls: { status, downStreak, lastConfirmed }. | ||
| // status — the debounced value the card renders this poll. | ||
| // downStreak — how many consecutive raw 'down' polls we've seen. | ||
| // lastConfirmed — the last raw 'idle'/'agent-live' we proved; held during a | ||
| // pending (sub-threshold) down-streak so the card stays calm. | ||
| // ~9s at the dashboard's 3s poll cadence: comfortably absorbs a transient probe | ||
| // timeout and the brief down-window of a routine restart-server (broker down for | ||
| // <3s between SIGTERM and respawn), while still surfacing a real outage quickly. | ||
| // Named so retuning is a one-line edit. | ||
| const DOWN_DEBOUNCE_THRESHOLD = 3; | ||
| function debounceBrokerStatus(prev, rawStatus, opts) { | ||
| const threshold = (opts && opts.threshold) || DOWN_DEBOUNCE_THRESHOLD; | ||
| const lastConfirmed = prev ? prev.lastConfirmed : undefined; | ||
| // Confirmed up: the daemon answered now. Render immediately, reset the streak, | ||
| // and remember this as the last-confirmed-up value. | ||
| if (rawStatus === 'idle' || rawStatus === 'agent-live') { | ||
| return { status: rawStatus, downStreak: 0, lastConfirmed: rawStatus }; | ||
| } | ||
| // Editor unreachable: we proved nothing. Never down — reset any pending | ||
| // down-streak and render 'unknown' (the card shows nothing for it). | ||
| if (rawStatus !== 'down') { | ||
| return { status: 'unknown', downStreak: 0, lastConfirmed }; | ||
| } | ||
| // Raw 'down': only a real outage if it persists. Count the streak. | ||
| const downStreak = (prev ? prev.downStreak : 0) + 1; | ||
| if (downStreak >= threshold) { | ||
| // Confirmed down — surface the alarm. | ||
| return { status: 'down', downStreak, lastConfirmed }; | ||
| } | ||
| // Pending (sub-threshold) down: hold the last confirmed up status if we have | ||
| // one, else 'unknown'. The card stays calm during the blip. | ||
| return { status: lastConfirmed || 'unknown', downStreak, lastConfirmed }; | ||
| } | ||
| module.exports = { debounceBrokerStatus, DOWN_DEBOUNCE_THRESHOLD }; |
| 'use strict'; | ||
| // Pure tri-state derivation for the Fleet Dashboard's per-VM broker indicator | ||
| // (scripts/operator/fleet-dashboard/server.js requires this). Given the raw | ||
| // /api/session-info poll merge `v`, collapse it into the one label the card | ||
| // renders. No I/O — server.js does the polling. | ||
| // | ||
| // The bug this fixes (observed live 2026-06-01): the old indicator was | ||
| // `v.reachable ? !!v.agentBrokerLive : null`, where `agentBrokerLive` means | ||
| // "a build-mode agent session is live in the broker" — NOT "broker daemon up". | ||
| // So a perfectly IDLE VM (healthy broker daemon, no parked build agent) reported | ||
| // `agentBrokerLive=false`, indistinguishable from a VM whose broker was actually | ||
| // DOWN. An operator killed a working broker chasing that phantom. The editor now | ||
| // also reports `brokerDaemonReachable` (the daemon answered its socket) and a | ||
| // derived `brokerStatus`; this module layers the dashboard's own reachability on | ||
| // top so the four states stay distinct: | ||
| // | ||
| // 'unknown' — editor unreachable (can't probe the broker at all), OR an | ||
| // older VM whose editor predates `brokerDaemonReachable`. Never | ||
| // rendered as a problem: absence of evidence is not "down". | ||
| // 'down' — daemon unreachable → the real broker problem, the state that | ||
| // had NO signal before. A VM here cannot start an agent session. | ||
| // 'idle' — daemon up, no build agent → a normal idle VM, not an error. | ||
| // 'agent-live' — a live build-mode agent session in the broker's build slot. | ||
| const BROKER_STATUSES = ['unknown', 'down', 'idle', 'agent-live']; | ||
| function deriveBrokerStatus(v) { | ||
| // Editor unreachable: we polled nothing, so the broker state is unknowable. | ||
| // Reporting 'down' here is exactly the conflation that misled operators — | ||
| // an unreachable editor is not a dead broker. | ||
| if (!v || !v.reachable) return 'unknown'; | ||
| // Prefer the editor's own tri-state when it reports one (newer VMs). | ||
| if (v.brokerStatus === 'down' || v.brokerStatus === 'idle' || v.brokerStatus === 'agent-live') { | ||
| return v.brokerStatus; | ||
| } | ||
| // Fallback derivation from the raw bits, for a VM reporting the bools but not | ||
| // the pre-derived `brokerStatus`. `agentBrokerLive` implies the daemon is up. | ||
| if (v.agentBrokerLive) return 'agent-live'; | ||
| if (v.brokerDaemonReachable === true) return 'idle'; | ||
| if (v.brokerDaemonReachable === false) return 'down'; | ||
| // Older VM: reachable editor, but no broker-reachability field at all. Treat | ||
| // as 'unknown' (graceful fallback) — never falsely 'down'. | ||
| return 'unknown'; | ||
| } | ||
| module.exports = { deriveBrokerStatus, BROKER_STATUSES }; |
| 'use strict'; | ||
| // Assemble the per-VM wire object the dashboard's /data response carries to the | ||
| // browser. This is the single source of truth for a card's transport shape: the | ||
| // card UI (scripts/operator/fleet-dashboard/public/index.html) reads `v.<field>` | ||
| // off each entry, so every field the card renders MUST be emitted here. | ||
| // | ||
| // It was split out of server.js's buildData() so the shape is unit-testable. | ||
| // The regression that motivated the split: `currentProvider` (the VM's | ||
| // configured build agent) was merged onto the poll state AND read by the card to | ||
| // render the 🤖 agent badge, but it was silently missing from this envelope — so | ||
| // it lived in server memory and never reached the browser, and the badge never | ||
| // appeared. A hand-maintained object literal inside a 197KB monolith has no | ||
| // guard against that drift; a pure function does. | ||
| // | ||
| // `v` is the merged poll state (state.vms[n]). `ctx` carries the derived, | ||
| // cross-VM, and side-effecting values buildData computes per VM (status, | ||
| // debounced broker tri-state, reconciled job, queue position, launch cfg, …) — | ||
| // the things this pure function can't know on its own. | ||
| function buildVmEnvelope(v, ctx) { | ||
| return { | ||
| n: ctx.n, | ||
| url: v.url || null, | ||
| reachable: !!v.reachable, | ||
| hasSession: !!v.hasSession, | ||
| // Three-state: true = broker confirmed up, false = confirmed down, null = | ||
| // editor unreachable so broker state is unknown. Never conflate "down" with | ||
| // "unreachable" — that mismatch is what made the card show "broker down" | ||
| // while a direct pgrep proved the broker was up. | ||
| brokerLive: v.reachable ? !!v.agentBrokerLive : null, | ||
| // Broker tri-state: 'down' | 'idle' | 'agent-live' | 'unknown' (derived | ||
| // upstream, debounced). Richer than brokerLive's is-an-agent-live boolean. | ||
| brokerStatus: ctx.brokerStatus, | ||
| awaitingInput: !!v.awaitingUserInput, | ||
| feature: v.feature || null, | ||
| step: v.step != null ? v.step : null, | ||
| mode: v.mode || null, | ||
| label: v.label || null, | ||
| status: ctx.status, | ||
| orphan: ctx.orphan || null, | ||
| degradedReasons: ctx.degradedReasons || [], | ||
| isHead: !!ctx.isHead, | ||
| // True when this VM holds the queue head but is unreachable (operator | ||
| // decides manually whether to release it). | ||
| headHolderUnreachable: !!ctx.headHolderUnreachable, | ||
| queuePos: ctx.queuePos != null ? ctx.queuePos : null, | ||
| queueLen: ctx.queueLen, | ||
| job: ctx.job || null, | ||
| config: ctx.config || null, | ||
| project: ctx.project != null ? ctx.project : null, | ||
| variant: ctx.variant != null ? ctx.variant : null, | ||
| cardIdentity: ctx.cardIdentity, | ||
| brokerHistory: Array.isArray(v.brokerHistory) ? v.brokerHistory : [], | ||
| brokerDeath: ctx.brokerDeath, | ||
| // Auth state + reason + token expiry. Only surfaced when the editor is | ||
| // reachable (an unreachable VM's cached auth is stale and misleading). | ||
| providerAuth: v.reachable && v.providerAuth ? v.providerAuth : null, | ||
| effects: v.effects || null, | ||
| // The VM's live-configured build agent (lowercase provider), reported by | ||
| // /api/session-info and merged (sticky) onto the poll state. Drives the | ||
| // card's 🤖 agent badge. null for an older VM whose editor predates the | ||
| // field (the card omits the badge rather than rendering a blank chip). | ||
| currentProvider: v.currentProvider || null, | ||
| }; | ||
| } | ||
| // The fields the card UI depends on being present in every /data VM entry. This | ||
| // is the contract `buildVmEnvelope` must satisfy: the regression that prompted | ||
| // this module was a field the card read but the envelope didn't emit, so the | ||
| // test asserts the envelope keys are a superset of this list. Adding a field the | ||
| // card renders means adding it here AND in buildVmEnvelope — the test fails loud | ||
| // if you do one without the other. | ||
| const REQUIRED_CARD_FIELDS = [ | ||
| 'n', 'url', 'reachable', 'hasSession', 'brokerLive', 'brokerStatus', | ||
| 'awaitingInput', 'feature', 'step', 'mode', 'label', 'status', 'orphan', | ||
| 'degradedReasons', 'isHead', 'headHolderUnreachable', 'queuePos', 'queueLen', | ||
| 'job', 'config', 'project', 'variant', 'cardIdentity', 'brokerHistory', | ||
| 'brokerDeath', 'providerAuth', 'effects', 'currentProvider', | ||
| ]; | ||
| module.exports = { buildVmEnvelope, REQUIRED_CARD_FIELDS }; |
| 'use strict'; | ||
| // Pure-ish helpers for the fleet dashboard's pending-job sidecar feature | ||
| // (scripts/operator/fleet-dashboard/server.js requires this). Sidecar | ||
| // serialization, regex parsing of filenames, gcloud-describe error | ||
| // classification, and the reap decision matrix all live here so the | ||
| // `server.js` orchestration code can be left thin and these pieces can be | ||
| // exercised in isolation (npm/fleet-cleanup.test.js). | ||
| // | ||
| // File-touching helpers take the directory as a PARAMETER so tests pass a | ||
| // throwaway tmpdir; the dashboard passes its fixed `PENDING_JOBS_DIR` constant. | ||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
| // Sidecar filename grammar: `<action>-<n>.json` where action ∈ {add, destroy}. | ||
| // Returns { action, n } on a valid match, null otherwise. Hoisted out of the | ||
| // inline regex so the parser is unit-testable on its own (junk filenames, tmp | ||
| // turds, missing numbers). | ||
| function parsePendingJobFilename(name) { | ||
| const m = String(name == null ? '' : name).match(/^(add|destroy)-(\d+)\.json$/); | ||
| if (!m) return null; | ||
| return { action: m[1], n: Number(m[2]) }; | ||
| } | ||
| // Atomically write a sidecar marking VM-n's <action> in flight. Best-effort: | ||
| // any I/O failure is logged, never thrown (the dashboard mustn't crash because | ||
| // .codeyam/logs is missing or read-only). | ||
| function persistPendingJob(dir, n, action, meta) { | ||
| try { | ||
| fs.mkdirSync(dir, { recursive: true }); | ||
| const f = path.join(dir, `${action}-${n}.json`); | ||
| const tmp = f + '.tmp'; | ||
| fs.writeFileSync(tmp, JSON.stringify({ n, action, startedAt: new Date().toISOString(), ...(meta || {}) })); | ||
| fs.renameSync(tmp, f); | ||
| } catch (e) { console.error('persistPendingJob:', e && e.message); } | ||
| } | ||
| // Remove a sidecar; a missing file is the already-cleared path, not an error. | ||
| function clearPendingJob(dir, n, action) { | ||
| try { fs.unlinkSync(path.join(dir, `${action}-${n}.json`)); } | ||
| catch (_) { /* missing is fine */ } | ||
| } | ||
| // Enumerate sidecars under `dir`. Returns one entry per RECOGNISED file — | ||
| // junk filenames are dropped silently so an operator's debug touch under | ||
| // pending-jobs/ can't crash the reap. Unparseable JSON meta degrades to `{}` | ||
| // (the sidecar's existence is what matters; meta is best-effort context). | ||
| function listPendingJobs(dir) { | ||
| let entries; | ||
| try { entries = fs.readdirSync(dir); } catch { return []; } | ||
| const out = []; | ||
| for (const name of entries) { | ||
| const parsed = parsePendingJobFilename(name); | ||
| if (!parsed) continue; | ||
| let meta = {}; | ||
| try { meta = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8')) || {}; } | ||
| catch { /* unparseable — treat as bare {action, n} */ } | ||
| out.push({ n: parsed.n, action: parsed.action, meta }); | ||
| } | ||
| return out; | ||
| } | ||
| // Classify the outcome of a `gcloud compute instances describe` exec callback. | ||
| // - err is null/undefined → instance exists (`true`) | ||
| // - stderr/err.message names "NOT_FOUND" / "was not found" / "not found" | ||
| // → instance is gone (`false`) | ||
| // - anything else (auth glitch, network blip, timeout) → uncertain (`null`) | ||
| // The `null` case is what makes the reap fail-soft: an uncertain probe leaves | ||
| // the sidecar in place so the next dashboard boot retries instead of clearing | ||
| // state on a flaky gcloud. | ||
| function classifyDescribeError(err, stderr) { | ||
| if (!err) return true; | ||
| const out = String(stderr == null ? '' : stderr) + ' ' + String((err && err.message) || ''); | ||
| if (/NOT_FOUND|was not found|not found/i.test(out)) return false; | ||
| return null; | ||
| } | ||
| // Decide what the startup reap should do for one sidecar. Pure: takes the | ||
| // decoded sidecar + GCP-existence verdict + (for adds) container-up verdict + | ||
| // (for up adds) project-wiring verdict, returns a tag the caller dispatches on. | ||
| // verdict when dispatch | ||
| // 'cleanup' destroy gone, OR add gone runDestroyCleanup / runAddCleanup | ||
| // 'reconcile-add' add up, container up, project wired (or unknown) ensure roster + recordLaunch, then clear | ||
| // 'half-provisioned' add up, container up, but project NOT wired surface needs-recovery card (finish-wire from cfg) | ||
| // 'recover-bootstrap' add instance up but container missing/down re-run cloud:up to finish the half-done bootstrap | ||
| // 'clear-only' destroy sidecar but instance still exists just clear the sidecar | ||
| // 'leave' gcloud uncertain (exists === null) leave for the next boot | ||
| // The (exists, containerUp) split distinguishes a finished add from a container- | ||
| // down half-provisioned one: a dashboard SIGKILL'd mid-`cloud:up` leaves a GCP | ||
| // instance with no docker/container, which the old (exists-only) matrix wrongly | ||
| // classified 'reconcile-add' — adding an unreachable VM to the roster. The | ||
| // `projectWired` axis closes the remaining gap (improve39 VM-7): an instance + | ||
| // container that ARE up but whose `/workspace` is unwired (no git remote, empty | ||
| // manifest) is NOT a finished add — it's half-provisioned and must surface a | ||
| // recoverable card, not be silently reconciled "done". Only a POSITIVE unwired | ||
| // signal (`projectWired === false`) downgrades to 'half-provisioned'; an | ||
| // unknown/unprobed wiring (`null`/`undefined`) stays 'reconcile-add' so a flaky | ||
| // probe can't strand a healthy VM. `containerUp`/`projectWired` are only consulted | ||
| // for an add whose instance exists; destroy and the instance-gone add path ignore | ||
| // them. Splitting the matrix out lets the table be exhaustively tested without any | ||
| // of the side-effects in server.js. | ||
| function reapVerdict({ action, exists, containerUp, projectWired }) { | ||
| if (exists === null) return 'leave'; | ||
| if (action === 'destroy') return exists ? 'clear-only' : 'cleanup'; | ||
| if (action === 'add') { | ||
| if (!exists) return 'cleanup'; | ||
| if (!containerUp) return 'recover-bootstrap'; | ||
| return projectWired === false ? 'half-provisioned' : 'reconcile-add'; | ||
| } | ||
| return 'leave'; | ||
| } | ||
| // Classify whether a VM's editor `/workspace` is actually wired to a client | ||
| // project, from a best-effort probe of two signals: the git remote URL and | ||
| // whether `package.json` carries a real `name`. Pure: server.js runs the SSH + | ||
| // `docker exec` probe and hands the raw stdout/err here so the decision is | ||
| // unit-testable without a live VM. | ||
| // - err present (ssh/docker blip, timeout) → null (unknown — do NOT | ||
| // strand a healthy VM on a flaky probe; reapVerdict treats null as wired) | ||
| // - a git remote OR a non-empty package.json name → true (wired) | ||
| // - neither (empty / no-git workspace) → false (half-provisioned — | ||
| // the VM-7 signature: `/workspace` with no remote and an empty manifest) | ||
| // The probe emits the two signals separated by the `---CY---` sentinel so this | ||
| // parser is independent of locale/line-ending noise in the ssh transport. | ||
| function classifyProjectWiring({ stdout, err } = {}) { | ||
| if (err) return null; | ||
| const parts = String(stdout == null ? '' : stdout).split('---CY---').map((s) => s.trim()); | ||
| const remote = parts[0] || ''; | ||
| const nameCount = Number((parts[1] || '0').trim()) || 0; | ||
| if (remote || nameCount > 0) return true; | ||
| return false; | ||
| } | ||
| // Durable exit marker for a detached `add` provision (improve40). The add's | ||
| // `cloud:up` shell is spawned in its OWN session (detached) so a dashboard | ||
| // SIGKILL — the 2GB launchd hard cap, exactly the kill that aborted the VM-7 | ||
| // add mid-flight — no longer reaches it; the provision keeps running. When that | ||
| // detached shell finally exits it writes `add-<n>.exit` (its trailing exit code | ||
| // + an ISO timestamp) into PENDING_JOBS_DIR, alongside the `add-<n>.json` | ||
| // sidecar. The marker is the GROUND-TRUTH completion signal the next boot reads | ||
| // instead of inferring `in-flight → error` from the lost child: a marker means | ||
| // "the provision finished while the dashboard was away," its code says whether | ||
| // it finished OK. The name is derived here so the shell `trap` (server.js) and | ||
| // the JS reader build the exact same path. | ||
| function exitMarkerName(n) { | ||
| return `add-${n}.exit`; | ||
| } | ||
| // Parse an exit-marker file body into { exitCode, finishedAt }, or null when the | ||
| // body is missing/blank/malformed. Format is two lines — the numeric exit code | ||
| // then an ISO timestamp — written by the shell trap. A non-integer first line is | ||
| // treated as no-marker (null) so a half-written / corrupt marker falls back to | ||
| // the gcloud reconcile path rather than being read as a bogus exit code. Pure: | ||
| // takes the text so it's unit-testable without touching disk. | ||
| function parseExitMarker(text) { | ||
| if (text == null) return null; | ||
| const lines = String(text).split('\n').map((s) => s.trim()).filter(Boolean); | ||
| if (!lines.length) return null; | ||
| const code = Number(lines[0]); | ||
| if (!Number.isInteger(code)) return null; | ||
| return { exitCode: code, finishedAt: lines[1] || null }; | ||
| } | ||
| // Read VM-n's exit marker from `dir`, or null when it's absent/unreadable/ | ||
| // malformed (the still-running and never-written cases both surface as null). | ||
| function readExitMarker(dir, n) { | ||
| try { return parseExitMarker(fs.readFileSync(path.join(dir, exitMarkerName(n)), 'utf8')); } | ||
| catch { return null; } | ||
| } | ||
| // Remove a consumed exit marker; a missing file is the already-cleared path, not | ||
| // an error (mirrors clearPendingJob). | ||
| function clearExitMarker(dir, n) { | ||
| try { fs.unlinkSync(path.join(dir, exitMarkerName(n))); } | ||
| catch (_) { /* missing is fine */ } | ||
| } | ||
| // Decide how a snapshot-restored `add` should be re-attached after a dashboard | ||
| // restart, BEFORE any gcloud probe. Pure: takes the durable signals the boot | ||
| // path gathers — whether the exit marker exists, its recorded code, and whether | ||
| // the detached provision child is still alive (recorded pid, `kill -0`). | ||
| // verdict when dispatch | ||
| // 'done' marker present, exit code 0 roster + recordLaunch, clear sidecar/marker | ||
| // 'error' marker present, non-zero exit code surface the real failure, clear sidecar/marker | ||
| // 'running' no marker yet, child still alive RE-ATTACH: keep polling, do NOT mark error | ||
| // 'reconcile' child gone, no usable marker fall through to fc.reapVerdict (gcloud probe) | ||
| // This is the durable fix's brain: a provision that survived the kill (still | ||
| // running, or finished while we were away) is classified from GROUND TRUTH, so | ||
| // it is never blanket-converted `in-flight → error` the way the snapshot-only | ||
| // path did (the improve39 VM-7 false `add-failed`). Only when the child is gone | ||
| // AND left no parseable marker do we fall back to inferring state from gcloud — | ||
| // `reconcile` hands that exact (exists, containerUp, projectWired) decision to | ||
| // fc.reapVerdict. A marker whose first line isn't an integer is treated as | ||
| // absent (parseExitMarker → null) so a corrupt marker reconciles rather than | ||
| // asserting a bogus code. The matrix is exhaustively tested without a live VM. | ||
| function reattachVerdict({ markerPresent, exitCode, childAlive } = {}) { | ||
| if (markerPresent) { | ||
| // Guard the null/undefined coercion BEFORE Number() — `Number(null)` is 0, | ||
| // which would masquerade as a clean exit. A marker with no parseable code | ||
| // (half-written / corrupt) must reconcile, not assert success. | ||
| const code = exitCode == null ? NaN : Number(exitCode); | ||
| if (Number.isInteger(code)) return code === 0 ? 'done' : 'error'; | ||
| return 'reconcile'; // marker present but no parseable code — let gcloud decide | ||
| } | ||
| if (childAlive) return 'running'; | ||
| return 'reconcile'; | ||
| } | ||
| // improve45 — map a reattachVerdict tag to the dashboard's NEXT action, now that | ||
| // a clean exit marker is NECESSARY BUT NOT SUFFICIENT to roster. A detached | ||
| // `cloud:up` can exit 0 AFTER the image build but BEFORE `docker compose up` | ||
| // ever runs — an ssh broken-pipe or dashboard SIGKILL landing in that window, | ||
| // with the wrapping EXIT trap still recording 0 (the live VM2 signature: built | ||
| // image, repo cloned, ZERO editor containers). So 'done' no longer means "roster | ||
| // it"; it means "confirm the container is actually up first." Both the clean-exit | ||
| // ('done') and the no-usable-marker ('reconcile') cases therefore route through | ||
| // the SAME gcloud container + wiring probe (reconcileViaGcloud → fc.reapVerdict), | ||
| // which rosters only a genuinely-up + wired add and routes a half-provisioned one | ||
| // to recover-bootstrap. 'error' (real non-zero exit) and 'running' (child still | ||
| // alive) are unchanged. reattachVerdict stays the honest classifier of the | ||
| // child's exit SIGNAL; this helper expresses how that signal is CONSUMED now that | ||
| // exit-0 no longer implies rostering. Pure: server.js maps the returned action to | ||
| // its side-effecting step, so the matrix is exhaustively testable without a VM. | ||
| // verdict next step meaning | ||
| // 'done' 'probe' confirm the container before rostering | ||
| // 'reconcile' 'probe' no usable marker — let the gcloud probe decide | ||
| // 'error' 'surface-error' real failure — show add-failed with the code | ||
| // 'running' 'watch' child still alive — keep polling for the marker | ||
| function reattachNextStep(verdict) { | ||
| if (verdict === 'error') return 'surface-error'; | ||
| if (verdict === 'running') return 'watch'; | ||
| return 'probe'; // 'done' and 'reconcile' both confirm via the gcloud probe | ||
| } | ||
| // Anti-loop budget for the `recover-bootstrap` path. Pure: given the count of | ||
| // recoveries ALREADY persisted on the sidecar, return the next attempt number, | ||
| // whether the reap should proceed (re-run cloud:up) or treat the VM as | ||
| // exhausted (leave the sidecar + surface a needs-manual-recovery card), and the | ||
| // cap. Keeps the off-by-one (prior 3 → attempt 4 → stop) out of server.js so it | ||
| // can be unit-tested without the side-effecting re-run. | ||
| function recoverBootstrapDecision({ recoverAttempts, max = 3 }) { | ||
| const prior = Number(recoverAttempts) || 0; | ||
| const attempt = prior + 1; | ||
| return { attempt, proceed: attempt <= max, max }; | ||
| } | ||
| // improve40 — select the orphaned `add` jobs that must be reconciled from the | ||
| // VM's REAL provision state even though their per-job sidecar is GONE. The | ||
| // durable re-attach path (sidecar + exit marker + recorded pid) cannot fire for | ||
| // these — a restart left only the snapshot record — so the snapshot job's | ||
| // stashed `config` is the sole cfg source and a live gcloud probe is the only | ||
| // ground truth. Pure: takes the restored jobs map + the set of `<action>-<n>` | ||
| // sidecar keys the sidecar reap already owns; returns the [{ n, cfg }] the boot | ||
| // path hands to reconcileViaGcloud. A job is selected when ALL hold: | ||
| // - it is an `add`, | ||
| // - it is flagged `restartOrphaned` (restoreFromSnapshot downgraded a | ||
| // running add → error because the dashboard restarted before it finished — | ||
| // this is the ONLY error state we re-probe; an add that failed IN-PROCESS | ||
| // carries a plain terminal `error` with no flag and stays surfaced), AND | ||
| // - NO `add-<n>` sidecar exists (a sidecar means the sidecar reap already | ||
| // owns it — never double-reconcile). | ||
| // `cfg` rides from the job's `config`; null when the snapshot never carried one | ||
| // (older snapshot) — the caller still probes, but a roster reconcile then can't | ||
| // recordLaunch, exactly mirroring the sidecar path's `meta.cfg`-absent case. | ||
| function orphanedAddsToReconcile({ jobs, sidecarKeys } = {}) { | ||
| const sidecars = sidecarKeys instanceof Set ? sidecarKeys : new Set(sidecarKeys || []); | ||
| const src = (jobs && typeof jobs === 'object') ? jobs : {}; | ||
| const out = []; | ||
| for (const [k, j] of Object.entries(src)) { | ||
| if (!j || typeof j !== 'object') continue; | ||
| if (j.action !== 'add') continue; | ||
| if (!j.restartOrphaned) continue; | ||
| if (sidecars.has(`add-${k}`)) continue; | ||
| out.push({ n: Number(k), cfg: j.config || null }); | ||
| } | ||
| return out; | ||
| } | ||
| // improve41 — the destroy-direction mirror of orphanedAddsToReconcile: decide | ||
| // what to do with a VM that is STILL in the roster but whose GCP instance state | ||
| // we have just re-probed. The bug this closes: a Destroy that races a dashboard | ||
| // restart can leave the instance deleted while the startup reap took the | ||
| // `clear-only` path (instance still present at reap time) and dropped the only | ||
| // sidecar — so nothing ever runs roster cleanup and the VM polls forever | ||
| // "unreachable." A sidecar-independent reconcile, keyed on REAL GCP existence | ||
| // rather than on a pending-job sidecar, catches it. Pure: server.js runs the | ||
| // gcloud describe (→ exists, via classifyDescribeError) and reads session / | ||
| // pending-add state, and hands the three facts here so the matrix is testable | ||
| // without a live VM. | ||
| // verdict when dispatch | ||
| // 'cleanup' instance gone AND the VM is idle runDestroyCleanup | ||
| // 'leave' exists uncertain (null), OR the VM is busy do nothing this pass | ||
| // 'keep' instance still exists do nothing (healthy) | ||
| // Fail-safe by construction: cleanup fires ONLY on a DEFINITIVE absence | ||
| // (exists === false) — a flaky/transient describe (null) is always 'leave', so a | ||
| // gcloud blip can never evict a healthy VM (mirroring reapVerdict's `leave` | ||
| // posture). "Busy" (a live session or an in-flight add to this number) also | ||
| // holds the entry for an operator rather than auto-removing — the shouldn't- | ||
| // happen-but-fail-safe case where a vanished instance still reports a session. | ||
| function reconcileRosteredVm({ exists, hasSession, hasPendingAdd } = {}) { | ||
| if (exists === null || exists === undefined) return "leave"; | ||
| if (exists) return "keep"; | ||
| if (hasSession || hasPendingAdd) return "leave"; | ||
| return "cleanup"; | ||
| } | ||
| module.exports = { | ||
| parsePendingJobFilename, | ||
| persistPendingJob, | ||
| clearPendingJob, | ||
| listPendingJobs, | ||
| classifyDescribeError, | ||
| reapVerdict, | ||
| reconcileRosteredVm, | ||
| orphanedAddsToReconcile, | ||
| classifyProjectWiring, | ||
| recoverBootstrapDecision, | ||
| exitMarkerName, | ||
| parseExitMarker, | ||
| readExitMarker, | ||
| clearExitMarker, | ||
| reattachVerdict, | ||
| reattachNextStep, | ||
| }; |
| 'use strict'; | ||
| // Pure predicate module for the Fleet Dashboard's per-button "will clicking | ||
| // this change the VM, or is it already in that state?" indicators | ||
| // (scripts/operator/fleet-dashboard/server.js requires this). Given an | ||
| // already-gathered FACTS object — no I/O, no git/docker exec; server.js does | ||
| // the impure fact-gathering — computeButtonEffects returns a per-button verdict | ||
| // map. Mirrors the pure-module-plus-colocated-vitest-test shape of | ||
| // npm/fleet-reset.js so the whole decision table is unit-testable without | ||
| // spawning a process. | ||
| // | ||
| // Each verdict is { effect, reason }: | ||
| // - 'changes' — clicking does something; `reason` says what. | ||
| // - 'noop' — already in that state; clicking changes nothing. | ||
| // - 'recommended' — soft signal (run-setup only): a reset/update landed after | ||
| // the last successful setup. NEVER a confident green no-op. | ||
| // - 'unknown' — not yet checked, VM unreachable, or a needed fact is | ||
| // missing. We never CLAIM a safe no-op we couldn't verify — | ||
| // silent failure here would be worse than no indicator. | ||
| // | ||
| // The predicate for each button is derived from what that button actually | ||
| // touches (its tooltip in public/index.html is the source of truth): the sync | ||
| // buttons key off a git commit comparison, the reset buttons off | ||
| // working-tree/session/`.codeyam` state, and rebuild off a build-commit | ||
| // comparison. | ||
| // Short sha for compact reason strings. Tolerates null / short input. | ||
| function short(sha) { | ||
| return sha ? String(sha).slice(0, 7) : '?'; | ||
| } | ||
| // "origin/<branch>" — or a bare "origin" when the branch is unknown. | ||
| function originRef(branch) { | ||
| return branch ? `origin/${branch}` : 'origin'; | ||
| } | ||
| // "N commit(s) behind origin/<branch>" with correct singular/plural. | ||
| function behindReason(behind, branch) { | ||
| return `${behind} commit${behind === 1 ? '' : 's'} behind ${originRef(branch)}`; | ||
| } | ||
| // Compose a short reason listing which signals make a reset non-trivial, so the | ||
| // operator sees WHAT a Reset would discard rather than a bare "changes". The | ||
| // `agent` (a live build agent in the broker) leads the list — it's the most | ||
| // consequential thing a reset's `pty-broker stop` would silently discard. | ||
| function resetReason({ agent, session, dirty, behind, codeyam }) { | ||
| const parts = []; | ||
| if (agent) parts.push('running build agent'); | ||
| if (dirty) parts.push('uncommitted changes'); | ||
| if (behind) parts.push('source behind origin'); | ||
| if (session) parts.push('active session'); | ||
| if (codeyam) parts.push('.codeyam state'); | ||
| return parts.length ? `will discard: ${parts.join(', ')}` : 'will reset state'; | ||
| } | ||
| // The action ids governed by an effect indicator — the same ids server.js / | ||
| // index.html use for the buttons. Reconfigure / Env / Destroy are deliberately | ||
| // absent (one-shots, not converge-to-a-state actions, so a no-op predicate is | ||
| // meaningless for them). | ||
| const ACTION_IDS = [ | ||
| 'update-client-source', | ||
| 'update-editor-source', | ||
| 'reset-editor-source', | ||
| 'reset-client', | ||
| 'rebuild-binary', | ||
| 'update-reset-everything', | ||
| 'run-setup', | ||
| ]; | ||
| // facts (a plain object gathered by server.js — no I/O): | ||
| // { | ||
| // reachable: boolean, | ||
| // hasSession: boolean, | ||
| // client: { headSha, originSha, behind, dirty } | null, // /workspace | ||
| // editor: { headSha, originSha, behind, dirty, buildCommit } | null, | ||
| // codeyamDirty: boolean, | ||
| // setupRanSince: boolean, // a setup completed after the last reset/update | ||
| // clientBranch: string | null, // for reason text only | ||
| // editorBranch: string | null, // for reason text only | ||
| // } | ||
| function computeButtonEffects(facts) { | ||
| const f = facts || {}; | ||
| // Unreachable VM (or no facts at all): every governed button is unknown. We | ||
| // could not reach the editor to gather facts, so never render a green no-op. | ||
| if (!f.reachable) { | ||
| const out = {}; | ||
| for (const id of ACTION_IDS) out[id] = { effect: 'unknown', reason: 'VM unreachable' }; | ||
| return out; | ||
| } | ||
| const client = f.client || null; | ||
| const editor = f.editor || null; | ||
| const hasSession = !!f.hasSession; | ||
| const codeyamDirty = !!f.codeyamDirty; | ||
| // A live build agent in the broker. A reset's first step is `pty-broker stop`, | ||
| // which kills it regardless of whether a workflow session is registered — so | ||
| // this is discardable work even when hasSession is false. Explicit-true only: | ||
| // a null/absent value (older or unreachable VM) must NOT flip a genuine noop. | ||
| const brokerLive = f.brokerLive === true; | ||
| // (1) Update client — ff-advance /workspace to origin tip. Changes iff behind. | ||
| const updateClient = client == null | ||
| ? { effect: 'unknown', reason: 'client state not checked' } | ||
| : client.behind > 0 | ||
| ? { effect: 'changes', reason: behindReason(client.behind, f.clientBranch) } | ||
| : { effect: 'noop', reason: 'up to date with ' + originRef(f.clientBranch) }; | ||
| // (2) Update editor — advance the editor source the binary builds from. | ||
| const updateEditor = editor == null | ||
| ? { effect: 'unknown', reason: 'editor state not checked' } | ||
| : editor.behind > 0 | ||
| ? { effect: 'changes', reason: behindReason(editor.behind, f.editorBranch) } | ||
| : { effect: 'noop', reason: 'up to date with ' + originRef(f.editorBranch) }; | ||
| // (5) Rebuild binary — advances source then compiles. Changes iff the running | ||
| // binary's build commit differs from the editor origin tip. Unknown when we | ||
| // don't have both commits to compare. | ||
| const rebuild = (editor == null || !editor.buildCommit || !editor.originSha) | ||
| ? { effect: 'unknown', reason: 'binary/source commit not checked' } | ||
| : editor.buildCommit !== editor.originSha | ||
| ? { effect: 'changes', reason: `binary at ${short(editor.buildCommit)}, ${originRef(f.editorBranch)} at ${short(editor.originSha)}` } | ||
| : { effect: 'noop', reason: 'binary built from ' + originRef(f.editorBranch) + ' tip' }; | ||
| // (3) Reset editor — discards uncommitted + session, advances source, wipes | ||
| // .codeyam. No-op ONLY when clean at origin tip with no session. | ||
| const resetEditor = editor == null | ||
| ? { effect: 'unknown', reason: 'editor state not checked' } | ||
| : (brokerLive || hasSession || editor.dirty || editor.behind > 0 || codeyamDirty) | ||
| ? { effect: 'changes', reason: resetReason({ agent: brokerLive, session: hasSession, dirty: editor.dirty, behind: editor.behind > 0, codeyam: codeyamDirty }) } | ||
| : { effect: 'noop', reason: 'already clean at origin tip, no session' }; | ||
| // (3b) Reset client — hard-clean /workspace + .codeyam, drop the session. | ||
| // No source-advance, so `behind` is NOT part of this predicate. | ||
| const resetClient = client == null | ||
| ? { effect: 'unknown', reason: 'client state not checked' } | ||
| : (brokerLive || client.dirty || codeyamDirty || hasSession) | ||
| ? { effect: 'changes', reason: resetReason({ agent: brokerLive, session: hasSession, dirty: client.dirty, behind: false, codeyam: codeyamDirty }) } | ||
| : { effect: 'noop', reason: 'already clean, no session' }; | ||
| // (4b) Update & Reset Everything — composite. Changes iff ANY component would | ||
| // change: rebuild-binary, reset-editor-source, or the client being | ||
| // behind/dirty. Reason summarizes which parts. | ||
| const everything = (() => { | ||
| if (editor == null || client == null) { | ||
| return { effect: 'unknown', reason: 'state not checked' }; | ||
| } | ||
| const parts = []; | ||
| if (rebuild.effect === 'changes') parts.push('rebuild'); | ||
| if (resetEditor.effect === 'changes') parts.push('reset editor'); | ||
| if (client.behind > 0 || client.dirty) parts.push('reset client'); | ||
| if (parts.length > 0) return { effect: 'changes', reason: parts.join(' + ') }; | ||
| // No part would change. But if a component was itself unverifiable, don't | ||
| // claim a confident no-op — fall back to unknown. | ||
| if (rebuild.effect === 'unknown' || resetEditor.effect === 'unknown') { | ||
| return { effect: 'unknown', reason: 'state not checked' }; | ||
| } | ||
| return { effect: 'noop', reason: 'everything already at origin tip, no session' }; | ||
| })(); | ||
| // (run-setup) Honest soft signal — there is no cheap upstream proof that | ||
| // `npm run setup` would change nothing (DB/migration state isn't | ||
| // introspected). `recommended` when a reset/update landed after the last | ||
| // successful setup; otherwise `unknown`. Never a confident green no-op. | ||
| const runSetup = f.setupRanSince | ||
| ? { effect: 'unknown', reason: 'no setup-affecting change since last setup' } | ||
| : { effect: 'recommended', reason: 'a reset/update landed since the last setup' }; | ||
| return { | ||
| 'update-client-source': updateClient, | ||
| 'update-editor-source': updateEditor, | ||
| 'reset-editor-source': resetEditor, | ||
| 'reset-client': resetClient, | ||
| 'rebuild-binary': rebuild, | ||
| 'update-reset-everything': everything, | ||
| 'run-setup': runSetup, | ||
| }; | ||
| } | ||
| module.exports = { computeButtonEffects, ACTION_IDS }; |
+125
| 'use strict'; | ||
| // Pure helpers for the fleet dashboard's per-VM environment-variable feature | ||
| // (scripts/operator/fleet-dashboard/server.js requires this). Parsing, | ||
| // validation, and formatting only — no I/O, no secret logging — so it stays | ||
| // unit-testable in isolation (npm/fleet-env.test.js), exactly like | ||
| // launch-config.js. | ||
| // A KEY must be a POSIX-ish env identifier: a letter or underscore followed by | ||
| // letters, digits, or underscores. dotenv / Next.js / Prisma all read this | ||
| // shape; rejecting anything else stops a fat-fingered line from writing a | ||
| // half-broken .env into the VM. | ||
| const KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; | ||
| // Env-var name the fleet dashboard sets on the `cloud:up` child to name the | ||
| // subset of `process.env` keys that came from the operator-supplied launch | ||
| // form (`.codeyam/logs/vm-env/<n>.env`). cloud:up reads it at | ||
| // project-bootstrap time (`npm/cloud.js::resolveSetupForwardEnv`) and | ||
| // forwards exactly those keys into the VM's bootstrap shell — so the | ||
| // bootstrap-time `npm run setup` has DB env on the FIRST try, instead of | ||
| // the post-add `reapplyVmEnv` race that left `db:push` failing fail-soft. | ||
| // One source of truth, imported on both sides — drift between dashboard | ||
| // and cloud:up would silently disable the forwarding. | ||
| const FORWARD_ENV_KEYS_ENV = "CY_FORWARD_ENV_KEYS"; | ||
| // Parse a `KEY=value`-per-line .env blob into a validated result. | ||
| // - Blank lines are dropped. | ||
| // - `#` comment lines are preserved (they carry no key). | ||
| // - Every other line must be `KEY=value`; only the FIRST `=` splits, so a | ||
| // value may itself contain `=` (e.g. a connection string) or be empty. | ||
| // | ||
| // Returns { ok, keys, content, error }: | ||
| // ok — true when every non-blank/non-comment line parsed | ||
| // keys — ordered declared keys, no values (safe to display / log) | ||
| // content — normalized text to write to .env (trailing newline), '' on error | ||
| // or when there are no keys | ||
| // error — message naming the 1-based line number on failure; it NEVER | ||
| // echoes the line's value, so a malformed secret can't leak | ||
| function parseEnvVars(text) { | ||
| const rawLines = String(text == null ? '' : text).split(/\r?\n/); | ||
| const fail = (error) => ({ ok: false, keys: [], content: '', error }); | ||
| const keys = []; | ||
| const out = []; | ||
| for (let i = 0; i < rawLines.length; i += 1) { | ||
| const trimmed = rawLines[i].trim(); | ||
| if (trimmed === '') continue; // drop blank lines | ||
| if (trimmed.startsWith('#')) { out.push(trimmed); continue; } // keep comments | ||
| const eq = trimmed.indexOf('='); | ||
| if (eq <= 0) return fail(`malformed env line ${i + 1} (expected KEY=value)`); | ||
| const key = trimmed.slice(0, eq).trim(); | ||
| const value = trimmed.slice(eq + 1); | ||
| if (!KEY_RE.test(key)) { | ||
| return fail(`invalid env key on line ${i + 1} (must match [A-Za-z_][A-Za-z0-9_]*)`); | ||
| } | ||
| if (keys.includes(key)) return fail(`duplicate env key "${key}" on line ${i + 1}`); | ||
| keys.push(key); | ||
| out.push(`${key}=${value}`); | ||
| } | ||
| return { ok: true, keys, content: keys.length ? `${out.join('\n')}\n` : '', error: null }; | ||
| } | ||
| // Keys only, for masked display / pre-fill — never returns values. Best-effort: | ||
| // returns [] for unparseable input rather than throwing. | ||
| function envKeys(text) { | ||
| const r = parseEnvVars(text); | ||
| return r.ok ? r.keys : []; | ||
| } | ||
| // True when the parsed env-text declares at least one of `triggerKeys`. Used by | ||
| // the fleet dashboard to decide whether to re-fire `npm run setup` after | ||
| // `/workspace/.env` lands in the container: bootstrap-time setup runs BEFORE | ||
| // env injection, so a project whose setup needs DB env (Prisma db:push, etc.) | ||
| // half-completes silently on first launch. Pure: takes the persisted env text | ||
| // + the trigger list and returns a bool — no I/O, the I/O caller passes the | ||
| // already-read text in. Unparseable input → no keys → false (matches envKeys). | ||
| function envHasSetupTrigger(text, triggerKeys) { | ||
| if (!Array.isArray(triggerKeys) || triggerKeys.length === 0) return false; | ||
| const declared = new Set(envKeys(text)); | ||
| return triggerKeys.some((k) => declared.has(k)); | ||
| } | ||
| // Parse a `KEY=value`-per-line .env blob into a `{KEY: value}` dict suitable | ||
| // for spreading into a child-process env. Returns null for unparseable input | ||
| // (matches envKeys's best-effort shape so callers can fall back without | ||
| // re-handling parse failures). Comment lines are dropped. The fleet dashboard | ||
| // uses this to read a VM's persisted env file (`.codeyam/logs/vm-env/<n>.env`) | ||
| // at `cloud:up`-launch time so the bootstrap-time `npm run setup` already has | ||
| // DB env on first try — instead of the post-add `reapplyVmEnv` race where | ||
| // setup fails fail-soft for lack of `DATABASE_URL`. | ||
| function envToDict(text) { | ||
| const r = parseEnvVars(text); | ||
| if (!r.ok) return null; | ||
| const dict = {}; | ||
| for (const line of r.content.split('\n')) { | ||
| if (line === '' || line.startsWith('#')) continue; | ||
| const eq = line.indexOf('='); | ||
| if (eq <= 0) continue; | ||
| dict[line.slice(0, eq)] = line.slice(eq + 1); | ||
| } | ||
| return dict; | ||
| } | ||
| // Wrap a parsed launch-form env dict in the cloud:up bootstrap-child-env | ||
| // shape: spreads `envDict` so the keys land in the child's `process.env`, and | ||
| // sets `CY_FORWARD_ENV_KEYS` to the comma-separated key list so | ||
| // `npm/cloud.js::resolveSetupForwardEnv` can whitelist exactly those keys | ||
| // when forwarding into the VM's bootstrap shell. Returns null on empty input | ||
| // so the dashboard's `startJob` call can omit `env` entirely (preserving | ||
| // `process.env` inheritance for the no-launch-form-env path). A non-object | ||
| // (programmer error) also returns null rather than throwing. | ||
| function buildBootstrapChildEnv(envDict) { | ||
| if (!envDict || typeof envDict !== "object") return null; | ||
| const keys = Object.keys(envDict); | ||
| if (keys.length === 0) return null; | ||
| return { ...envDict, [FORWARD_ENV_KEYS_ENV]: keys.join(",") }; | ||
| } | ||
| module.exports = { | ||
| parseEnvVars, | ||
| envKeys, | ||
| envHasSetupTrigger, | ||
| envToDict, | ||
| buildBootstrapChildEnv, | ||
| FORWARD_ENV_KEYS_ENV, | ||
| KEY_RE, | ||
| }; |
| // Group an already-sorted/filtered VM list by client project for the Fleet | ||
| // Dashboard's grouped layout. | ||
| // | ||
| // Returns an ORDERED array of { project, vms } groups: real project groups | ||
| // first, sorted case-insensitively by name (locale compare); the | ||
| // unknown/starting-up bucket (project === null) pinned LAST. Within each group | ||
| // the incoming order of `vms` is preserved untouched, so the caller's active | ||
| // Sort mode continues to drive within-group order — grouping never re-sorts the | ||
| // cards, it only partitions them. | ||
| // | ||
| // `projectKey(v)` resolves the group key for a VM and defaults to the same | ||
| // identity `card()` renders in the dashboard: | ||
| // (v.cardIdentity && v.cardIdentity.project) || v.project || null | ||
| // It is injectable so unit tests can drive grouping without constructing full | ||
| // card payloads. A null/empty key routes the VM into the trailing unknown | ||
| // bucket so VMs mid-provision (blank identity) never silently disappear. | ||
| // | ||
| // Pure; no I/O. | ||
| function defaultProjectKey(v) { | ||
| return (v && v.cardIdentity && v.cardIdentity.project) || (v && v.project) || null; | ||
| } | ||
| function groupVmsByProject(vms, projectKey = defaultProjectKey) { | ||
| // First pass: stable bucketing. `Map` preserves insertion order, so the | ||
| // first VM of each project fixes that group's relative position before we | ||
| // re-order — but we re-sort named groups below regardless, so insertion | ||
| // order only matters for tie-stability within equal-name groups (none here). | ||
| const named = new Map(); | ||
| let unknown = null; | ||
| for (const v of vms) { | ||
| const raw = projectKey(v); | ||
| const key = raw == null || raw === "" ? null : raw; | ||
| if (key === null) { | ||
| if (!unknown) unknown = { project: null, vms: [] }; | ||
| unknown.vms.push(v); | ||
| continue; | ||
| } | ||
| let group = named.get(key); | ||
| if (!group) { | ||
| group = { project: key, vms: [] }; | ||
| named.set(key, group); | ||
| } | ||
| group.vms.push(v); | ||
| } | ||
| const groups = Array.from(named.values()); | ||
| groups.sort((a, b) => | ||
| a.project.localeCompare(b.project, undefined, { sensitivity: "base" }) | ||
| ); | ||
| if (unknown) groups.push(unknown); | ||
| return groups; | ||
| } | ||
| module.exports = { groupVmsByProject, defaultProjectKey }; |
| 'use strict'; | ||
| // npm/fleet-job-badge.js — pure verdict for the fleet dashboard's failed-job | ||
| // badge reconciliation (improve39). A VM's last terminal-error heavy job | ||
| // (add/error, reset-all error, reset-editor error, …) used to stick on the | ||
| // dashboard card indefinitely — even after the VM was recovered and is healthy | ||
| // again — because the card renders the last job's error with no check against | ||
| // the VM's live state, so a recovered VM keeps screaming a red error and | ||
| // misleads the operator. This decides whether such a badge should be RECONCILED | ||
| // (presented as "recovered" rather than a live error) because the VM is | ||
| // demonstrably fine now. The live signals are gathered in server.js (the | ||
| // untestable server shell); the verdict lives here so the cases are unit-tested | ||
| // without a live VM. | ||
| // Should the VM's last terminal-error job badge be reconciled against live | ||
| // health — i.e. presented as "recovered" rather than a live red error? | ||
| // | ||
| // job the carried job record ({ state, action, ... }) or null | ||
| // reachable the VM's editor answered this poll (true = healthy / responding) | ||
| // drifted a project-dir-drift signal is present (reachable-but-unwired / | ||
| // half-provisioned add — genuinely needs-recovery, owned by the | ||
| // companion reconcile-inflight-jobs plan; never falsely cleared here) | ||
| // offBranch the VM's live client branch differs from its target branch | ||
| // degraded the job completed but a wiring sub-step is flagged degraded | ||
| // | ||
| // Returns true only when the job is a terminal error AND the VM is demonstrably | ||
| // fine (reachable, not drifted, on-branch, not degraded). An unreachable / | ||
| // drifted / off-branch VM keeps its error surfaced — a real problem must stay | ||
| // visible. | ||
| function shouldReconcileFailedBadge({ job, reachable, drifted, offBranch, degraded } = {}) { | ||
| if (!job || job.state !== 'error') return false; | ||
| if (!reachable) return false; | ||
| if (drifted) return false; | ||
| if (offBranch) return false; | ||
| if (degraded) return false; | ||
| return true; | ||
| } | ||
| // Given a reconcile verdict (from shouldReconcileFailedBadge) and whether the VM | ||
| // is now idle, decide whether the recovered badge should be FULLY CLEARED — the | ||
| // job entry dropped from state.jobs so the card goes clean — rather than merely | ||
| // muted as a "recovered (was: …)" note (improve41). | ||
| // | ||
| // badgeReconciled the shouldReconcileFailedBadge verdict for this VM | ||
| // idle the VM answered this poll, is not running a build session, | ||
| // and its broker is not actively serving an agent | ||
| // | ||
| // A recovered VM that is also idle has demonstrably finished recovering, so the | ||
| // stale error is dropped entirely. A recovered-but-not-yet-idle VM (mid-build, | ||
| // or a poll hasn't confirmed idle) keeps the muted note so the prior log stays | ||
| // visible during that transient window. Never clears a badge that wasn't first | ||
| // judged recovered — an unreachable / drifted / off-branch error stays surfaced. | ||
| function shouldClearReconciledBadge({ badgeReconciled, idle } = {}) { | ||
| return !!badgeReconciled && !!idle; | ||
| } | ||
| // Pure guard for the operator "Clear badge" action (improve41). Decides whether | ||
| // VM-n's persisted job entry may be dropped after an out-of-band recovery, | ||
| // independent of the server's state / fs I/O so the cases are unit-tested | ||
| // without a live dashboard. The shell (server.js) supplies the live signals and | ||
| // turns the verdict into the {ok,msg} response + the actual delete + snapshot. | ||
| // | ||
| // rostered the VM number is in the live roster | ||
| // job the carried job record ({ state, ... }) or null/undefined | ||
| // | ||
| // Only a TERMINAL job (error/done) on a rostered VM may be cleared, OR a terminal | ||
| // failed add (add/error) on a non-rostered VM (which deletes the card + add-N sidecar): | ||
| // any other job state / non-rostered combination is not clearable. | ||
| // Returns { ok, reason, cleanup } — reason is a stable code the shell maps to an | ||
| // operator message, and cleanup is the cleanup action to perform ('add-failed' vs 'badge-only'). | ||
| function canClearBadge({ rostered, job } = {}) { | ||
| if (!rostered) { | ||
| if (job && job.action === 'add' && job.state === 'error') { | ||
| return { ok: true, reason: null, cleanup: 'add-failed' }; | ||
| } | ||
| return { ok: false, reason: 'not-rostered' }; | ||
| } | ||
| if (!job) return { ok: false, reason: 'no-badge' }; | ||
| if (job.state !== 'error' && job.state !== 'done') return { ok: false, reason: 'job-active' }; | ||
| return { ok: true, reason: null, cleanup: 'badge-only' }; | ||
| } | ||
| module.exports = { shouldReconcileFailedBadge, shouldClearReconciledBadge, canClearBadge }; |
| 'use strict'; | ||
| // Pure helpers for the fleet dashboard's memory-aware drain + state-snapshot | ||
| // recovery (scripts/operator/fleet-dashboard/server.js requires this). The | ||
| // drain threshold check, snapshot envelope serialization, freshness check, and | ||
| // the running→error downgrade applied on restore all live here so server.js's | ||
| // orchestration stays thin and these pieces can be unit-tested in isolation | ||
| // (npm/fleet-memory.test.js). | ||
| // | ||
| // Defense in depth (improve37): the launchd plist caps the dashboard at 2GB | ||
| // RSS; this module's drain refuses NEW heavy jobs at 1.5GB (soft cap) so RSS | ||
| // can recover before the hard cap fires SIGKILL; the snapshot is the recovery | ||
| // rail when the hard cap fires anyway (or launchd reloads). The pure helpers | ||
| // here are the testable surface — server.js wires the I/O (setInterval, file | ||
| // reads/writes, process.memoryUsage()). | ||
| // Defaults the dashboard can override via env. Kept here so a test can assert | ||
| // against the same numbers the dashboard ships with. | ||
| const MEMORY_DEFAULTS = Object.freeze({ | ||
| drainThresholdMB: 1500, | ||
| drainCheckIntervalMs: 5000, | ||
| snapshotIntervalMs: 30_000, | ||
| snapshotMaxAgeMs: 5 * 60 * 1000, | ||
| }); | ||
| /** Should the dashboard refuse to admit new heavy jobs? | ||
| * | ||
| * Returns true when current RSS exceeds the soft drain threshold. A non- | ||
| * positive or non-finite threshold disables the drain (returns false) — a | ||
| * misconfigured env var degrades to "no drain" instead of silently rejecting | ||
| * every heavy job. Bytes on both sides so the caller doesn't have to pick a | ||
| * unit; server.js passes MB→bytes once at module init. | ||
| * | ||
| * Pure; no I/O. */ | ||
| function decideMemoryDrain({ rssBytes, drainThresholdBytes } = {}) { | ||
| const rss = Number(rssBytes); | ||
| const cap = Number(drainThresholdBytes); | ||
| if (!Number.isFinite(cap) || cap <= 0) return false; | ||
| if (!Number.isFinite(rss) || rss < 0) return false; | ||
| return rss > cap; | ||
| } | ||
| /** Build the serialized state snapshot envelope. | ||
| * | ||
| * Keeps only the fields the dashboard needs to recover after a launchd | ||
| * respawn: in-flight jobs, last VM poll merge, operator-visible orphans. | ||
| * Times come from the caller so the same input yields the same output | ||
| * (snapshot equality testable). Pure; no I/O. */ | ||
| function buildStateSnapshot({ jobs, vms, orphans, nowIso }) { | ||
| return { | ||
| version: 1, | ||
| timestamp: nowIso, | ||
| jobs: jobs || {}, | ||
| vms: vms || {}, | ||
| orphans: orphans || {}, | ||
| }; | ||
| } | ||
| /** Parse a buffer/string into a snapshot, or return null on any failure. | ||
| * Best-effort: a corrupt or partial file (mid-write crash) is silently | ||
| * treated as "no snapshot", which is the safer default than throwing on | ||
| * boot. */ | ||
| function decodeStateSnapshot(buf) { | ||
| if (buf == null) return null; | ||
| try { | ||
| const obj = JSON.parse(typeof buf === 'string' ? buf : buf.toString('utf8')); | ||
| if (!obj || typeof obj !== 'object') return null; | ||
| if (typeof obj.timestamp !== 'string') return null; | ||
| return obj; | ||
| } catch { return null; } | ||
| } | ||
| /** Reconcile a restored snapshot into a usable starting state. | ||
| * | ||
| * Returns null when the snapshot is missing, malformed, or older than | ||
| * maxAgeMs (stale snapshots are worse than no snapshot — pollVMs will | ||
| * repopulate from real data). Otherwise returns { jobs, vms, orphans } | ||
| * with these safety transforms applied to jobs: | ||
| * | ||
| * • `running` WITH a pending-job sidecar → `recovering` with a deferral | ||
| * marker, NOT `error`. A heavy job that stashed a recoverable sidecar | ||
| * (its cfg) must defer its final state to the startup reap, which | ||
| * actually checks gcloud — the blanket `error` was the improve39 VM-7 | ||
| * bug: a mid-add restart stranded an instance that was really up as a | ||
| * dead `add-failed`. `recovering` is an honest "the reap will resolve | ||
| * this" surface; the reap then supersedes it (reconcile/half-provisioned | ||
| * /cleanup) once it has the gcloud verdict. | ||
| * • `running` WITHOUT a sidecar → `error` with a log marker, because the | ||
| * child process died with the dashboard and there's nothing to recover | ||
| * from — un-recoverable history, the old behavior. | ||
| * • `queued` → dropped, because the in-memory pendingJobQueue array | ||
| * is gone, so promoteFromJobQueue can never start them. The operator | ||
| * will re-click (the queued click had done no work yet). | ||
| * | ||
| * `sidecarKeys` is the set of `${action}-${n}` strings the caller read from | ||
| * the pending-jobs dir (server.js passes it; tests pass a plain array/Set). | ||
| * Omitted → no job has a sidecar → every running job downgrades to `error` | ||
| * exactly as before, so existing callers/tests are unaffected. | ||
| * | ||
| * Pure; no I/O. */ | ||
| function restoreFromSnapshot({ snapshot, maxAgeMs, nowIso, sidecarKeys } = {}) { | ||
| if (!snapshot || typeof snapshot !== 'object') return null; | ||
| if (typeof snapshot.timestamp !== 'string') return null; | ||
| const ts = Date.parse(snapshot.timestamp); | ||
| const now = Date.parse(nowIso); | ||
| if (!Number.isFinite(ts) || !Number.isFinite(now)) return null; | ||
| const ageMs = now - ts; | ||
| const cap = Number(maxAgeMs); | ||
| if (!Number.isFinite(cap) || cap <= 0) return null; | ||
| if (ageMs > cap) return null; | ||
| const sidecars = sidecarKeys instanceof Set ? sidecarKeys : new Set(sidecarKeys || []); | ||
| const srcJobs = (snapshot.jobs && typeof snapshot.jobs === 'object') ? snapshot.jobs : {}; | ||
| const jobs = {}; | ||
| for (const [k, j] of Object.entries(srcJobs)) { | ||
| if (!j || typeof j !== 'object') continue; | ||
| if (j.state === 'queued') continue; | ||
| if (j.state === 'running') { | ||
| if (sidecars.has(`${j.action}-${k}`)) { | ||
| jobs[k] = { | ||
| ...j, | ||
| state: 'recovering', | ||
| log: `${j.log ? j.log + '\n' : ''}(snapshot-restored — deferring to startup reap)`, | ||
| }; | ||
| continue; | ||
| } | ||
| jobs[k] = { | ||
| ...j, | ||
| state: 'error', | ||
| finishedAt: nowIso, | ||
| // improve40: mark this as a restart-orphan, NOT a genuine in-process | ||
| // failure. The job was `running` when the restart downgraded it and has | ||
| // NO sidecar to reconcile from — so the boot path must probe the VM's | ||
| // REAL provision state (orphanedAddsToReconcile → gcloud reap) rather | ||
| // than leave a dead `add-failed` badge on a VM that may have actually | ||
| // provisioned (the VM-7 hole). An add that failed IN-PROCESS keeps its | ||
| // own terminal `error` with no flag, so it is never re-probed/cleared. | ||
| restartOrphaned: true, | ||
| log: `${j.log ? j.log + '\n' : ''}(snapshot-restored — dashboard restarted before this finished)`, | ||
| }; | ||
| continue; | ||
| } | ||
| jobs[k] = j; | ||
| } | ||
| return { | ||
| jobs, | ||
| vms: (snapshot.vms && typeof snapshot.vms === 'object') ? snapshot.vms : {}, | ||
| orphans: (snapshot.orphans && typeof snapshot.orphans === 'object') ? snapshot.orphans : {}, | ||
| }; | ||
| } | ||
| module.exports = { | ||
| MEMORY_DEFAULTS, | ||
| decideMemoryDrain, | ||
| buildStateSnapshot, | ||
| decodeStateSnapshot, | ||
| restoreFromSnapshot, | ||
| }; |
| 'use strict'; | ||
| /** | ||
| * Indexes the currently-reachable VMs by their live sessionId. | ||
| * @param {Array|Object} vms The merged VM states on the dashboard. | ||
| * @returns {Object} A map of sessionId -> VM number (n). | ||
| */ | ||
| function indexVmsBySessionId(vms) { | ||
| const map = {}; | ||
| if (!vms) return map; | ||
| const list = Array.isArray(vms) ? vms : Object.values(vms); | ||
| for (const vm of list) { | ||
| if (vm && vm.sessionId && vm.n !== undefined) { | ||
| map[vm.sessionId] = vm.n; | ||
| } | ||
| } | ||
| return map; | ||
| } | ||
| /** | ||
| * Computes which dependencies are unsatisfied (i.e. not in the completedSlugs set). | ||
| * @param {string[]} dependsOn Prerequisite slugs. | ||
| * @param {Set|string[]} completedSlugs Set or array of completed plan slugs. | ||
| * @returns {string[]} The unsatisfied dependency slugs. | ||
| */ | ||
| function unsatisfiedDeps(dependsOn, completedSlugs) { | ||
| const deps = Array.isArray(dependsOn) ? dependsOn : []; | ||
| const completedSet = completedSlugs instanceof Set ? completedSlugs : new Set(completedSlugs || []); | ||
| return deps.filter(dep => !completedSet.has(dep)); | ||
| } | ||
| /** | ||
| * Resolves claims and dependency states for each queued plan. | ||
| * @param {Object} params | ||
| * @param {Object[]} params.plans Array of PlanSummary objects from /api/plans. | ||
| * @param {Object[]} params.claims Array of PlanClaim objects from /api/plan-claims. | ||
| * @param {Set|string[]} params.completedSlugs Completed plan slugs. | ||
| * @param {Object} params.vmsBySessionId Map of sessionId -> VM number. | ||
| * @returns {Object[]} Annotated plan rows. | ||
| */ | ||
| function resolvePlanRows({ plans, claims, completedSlugs, vmsBySessionId }) { | ||
| const list = Array.isArray(plans) ? plans : []; | ||
| const claimsList = Array.isArray(claims) ? claims : []; | ||
| return list.map(plan => { | ||
| // 1. Extract plan fields | ||
| const slug = plan.slug; | ||
| const title = plan.title; | ||
| const mode = plan.mode || null; | ||
| const order = plan.order != null ? plan.order : null; | ||
| const prefix = plan.prefix || null; | ||
| // 2. Resolve dependency-blocked state | ||
| const dependsOn = Array.isArray(plan.depends_on) ? plan.depends_on : (plan.dependsOn ? plan.dependsOn : []); | ||
| const blockedBy = unsatisfiedDeps(dependsOn, completedSlugs); | ||
| // 3. Resolve claim state | ||
| const claim = claimsList.find(c => c.slug === slug); | ||
| let claimInfo = null; | ||
| if (claim) { | ||
| const claimedBy = claim.claimedBy || claim.claimed_by || {}; | ||
| const sessionId = claimedBy.sessionId || claimedBy.session_id || null; | ||
| const vm = (sessionId && vmsBySessionId && vmsBySessionId[sessionId] !== undefined) ? vmsBySessionId[sessionId] : null; | ||
| claimInfo = { | ||
| vm, | ||
| machine: claimedBy.machine || null, | ||
| agent: claimedBy.agentProvider || claimedBy.agent_provider || null, | ||
| sessionAlive: claim.sessionAlive !== undefined ? claim.sessionAlive : (claim.session_alive !== undefined ? claim.session_alive : null), | ||
| source: claim.source || null, | ||
| currentStep: claimedBy.currentStep || claimedBy.current_step || null, | ||
| }; | ||
| } | ||
| return { | ||
| slug, | ||
| title, | ||
| mode, | ||
| order, | ||
| prefix, | ||
| blockedBy, | ||
| claim: claimInfo | ||
| }; | ||
| }); | ||
| } | ||
| const REQUIRED_PLAN_ROW_FIELDS = [ | ||
| 'slug', 'title', 'mode', 'order', 'prefix', 'blockedBy', 'claim' | ||
| ]; | ||
| module.exports = { | ||
| indexVmsBySessionId, | ||
| unsatisfiedDeps, | ||
| resolvePlanRows, | ||
| REQUIRED_PLAN_ROW_FIELDS | ||
| }; |
| 'use strict'; | ||
| // npm/fleet-pool-policy.js — the warm pool's cost-control policy (improve38). | ||
| // | ||
| // Idle pool VMs cost money, so the pool's target size is not a constant — it's | ||
| // a function of the clock. During working hours the keeper holds `baseSize` | ||
| // claim-ready VMs; off-hours (and on an idle timer) it scales the target down, | ||
| // by default to zero, so an overnight pool doesn't bill for VMs nobody will | ||
| // claim. A claim against the resulting empty pool falls back to on-demand | ||
| // `fleet-ready` (slower, but it works) rather than failing — so scaling to zero | ||
| // is safe, not a service outage. | ||
| // | ||
| // This is the pure decision half, kept out of the keeper's untestable SSH loop: | ||
| // given a config and a clock, what should the pool target be right now? The | ||
| // keeper reads the answer and provisions/evicts toward it. | ||
| // Resolve the pool config from an env-like object (process.env in production, | ||
| // a literal in tests), applying defaults. All knobs are operator-tunable so the | ||
| // same keeper serves a 0-VM hobby fleet and a 20-VM team pool with no code | ||
| // change. | ||
| // POOL_TARGET base (working-hours) pool size default 4 | ||
| // POOL_OFFHOURS_TARGET pool size during off-hours default 0 | ||
| // POOL_OFFHOURS_START hour [0-23] off-hours begin (inclusive) default 20 | ||
| // POOL_OFFHOURS_END hour [0-23] off-hours end (exclusive) default 8 | ||
| // A start==end disables the off-hours window entirely (always baseSize). | ||
| function resolveConfig(env) { | ||
| const e = env || {}; | ||
| return { | ||
| baseSize: intOr(e.POOL_TARGET, 4), | ||
| offHoursSize: intOr(e.POOL_OFFHOURS_TARGET, 0), | ||
| offHoursStart: clampHour(e.POOL_OFFHOURS_START, 20), | ||
| offHoursEnd: clampHour(e.POOL_OFFHOURS_END, 8), | ||
| }; | ||
| } | ||
| // Non-negative integer or the default. Sizes can't be negative, so a negative | ||
| // (or non-numeric) value is rejected to the default rather than silently used. | ||
| function intOr(v, dflt) { | ||
| const n = Number(v); | ||
| return Number.isInteger(n) && n >= 0 ? n : dflt; | ||
| } | ||
| // Parse an hour into [0, 23], clamping out-of-range values both directions | ||
| // (a negative → 0, a too-large → 23) so a typo'd config indexes a real hour | ||
| // instead of wedging the window. A non-numeric value falls back to `dflt`. | ||
| function clampHour(v, dflt) { | ||
| const n = Number(v); | ||
| if (!Number.isInteger(n)) return dflt; | ||
| if (n < 0) return 0; | ||
| if (n > 23) return 23; | ||
| return n; | ||
| } | ||
| // Is `hour` within the off-hours window [start, end)? The window wraps midnight | ||
| // when start > end (the common case: 20:00 → 08:00 spans the night). start==end | ||
| // means "no off-hours window" → always false. | ||
| function isOffHours(hour, start, end) { | ||
| if (start === end) return false; | ||
| if (start < end) return hour >= start && hour < end; // same-day window | ||
| return hour >= start || hour < end; // wraps midnight | ||
| } | ||
| // The pool target size for the given clock: offHoursSize inside the off-hours | ||
| // window, baseSize otherwise. `date` is a Date (defaults to now) — the local | ||
| // hour drives the decision, matching the operator's working day. | ||
| function desiredPoolSize(config, date) { | ||
| const cfg = config && typeof config.baseSize === 'number' ? config : resolveConfig(config); | ||
| const d = date || new Date(); | ||
| return isOffHours(d.getHours(), cfg.offHoursStart, cfg.offHoursEnd) | ||
| ? cfg.offHoursSize | ||
| : cfg.baseSize; | ||
| } | ||
| module.exports = { | ||
| resolveConfig, | ||
| isOffHours, | ||
| desiredPoolSize, | ||
| }; |
| 'use strict'; | ||
| // npm/fleet-pool.js — the warm-VM-pool state layer (improve38). | ||
| // | ||
| // Standing up a VM on demand pays for an image pull, a branch rebrand and — | ||
| // the slow part — an interactive auth approval. The warm pool removes that | ||
| // latency from the request path: keep N pre-provisioned, pre-authed, READY | ||
| // idle VMs and CLAIM them in seconds (rebrand-only, no OAuth). This module is | ||
| // the state seam for that pool — it answers "which VMs are idle-and-claimable | ||
| // vs already-assigned" and performs the ATOMIC claim that guarantees two | ||
| // operators never grab the same VM. | ||
| // | ||
| // Why a SEPARATE file from the roster (`fleet-vms.json`): the roster is a | ||
| // shared contract — a plain JSON array of VM-number integers that the | ||
| // dashboard, vm-watchdog, cloudflared-monitor and the bash publishers all | ||
| // read. Widening it to carry per-VM status would break every reader (they | ||
| // `jq -r '.[]'` it as scalars). So pool membership lives alongside the roster | ||
| // in `.codeyam/logs/fleet-pool.json`, keyed by VM number, and the roster keeps | ||
| // meaning "these VM numbers exist". A VM can be in the roster without being in | ||
| // the pool (e.g. an operator's hand-driven VM); the pool only tracks the warm | ||
| // set. | ||
| // | ||
| // On-disk shape (tolerant: a missing or malformed file is the empty pool): | ||
| // { "vms": { "<n>": { status, branch, freshAt, claimedAt } } } | ||
| // status: "pool" idle, claim-ready | ||
| // "assigned" claimed by an operator, rebranded to `branch` | ||
| // branch: the branch the VM is on (base branch while pooled) | ||
| // freshAt: ISO timestamp of the last successful auth/health refresh — | ||
| // drives evict-before-claim of a member whose token lapsed | ||
| // claimedAt: ISO timestamp of the claim (null while pooled) | ||
| // | ||
| // The pure functions (normalize / select / refill / staleness) operate on a | ||
| // plain state object and return a new one — no I/O — so they unit-test without | ||
| // touching disk. readState/writeState/withLock add the atomic file layer, and | ||
| // main() is the CLI seam the bash orchestrators (fleet-claim.sh, | ||
| // fleet-pool-keeper.sh) call instead of re-implementing the logic in shell. | ||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
| const { rootDir } = require('./utils'); | ||
| const POOL_STATE_FILE = process.env.POOL_STATE_FILE | ||
| || path.join(rootDir, '.codeyam', 'logs', 'fleet-pool.json'); | ||
| const POOL = 'pool'; | ||
| const ASSIGNED = 'assigned'; | ||
| // ── Pure state helpers ────────────────────────────────────────────────────── | ||
| // Coerce arbitrary parsed JSON into the canonical `{ vms: { <n>: entry } }` | ||
| // shape. Drops entries whose key is not a positive integer or whose status is | ||
| // unrecognized, so a truncated write or a hand-edit never feeds a malformed | ||
| // entry into the claim path. Returned object is a fresh copy — callers mutate | ||
| // it freely. | ||
| function normalizeState(raw) { | ||
| const out = { vms: {} }; | ||
| const vms = raw && typeof raw === 'object' ? raw.vms : null; | ||
| if (!vms || typeof vms !== 'object') return out; | ||
| for (const key of Object.keys(vms)) { | ||
| const n = Number(key); | ||
| if (!Number.isInteger(n) || n <= 0) continue; | ||
| const e = vms[key] || {}; | ||
| const status = e.status === ASSIGNED ? ASSIGNED : e.status === POOL ? POOL : null; | ||
| if (!status) continue; | ||
| out.vms[n] = { | ||
| status, | ||
| branch: typeof e.branch === 'string' ? e.branch : null, | ||
| freshAt: typeof e.freshAt === 'string' ? e.freshAt : null, | ||
| claimedAt: typeof e.claimedAt === 'string' ? e.claimedAt : null, | ||
| }; | ||
| } | ||
| return out; | ||
| } | ||
| // VM numbers in a given status, sorted ascending. Ascending order makes claim | ||
| // selection deterministic (lowest-numbered idle VM first) so tests and two | ||
| // concurrent callers agree on which VM a claim takes. | ||
| function membersByStatus(state, status) { | ||
| return Object.keys(state.vms) | ||
| .map(Number) | ||
| .filter((n) => state.vms[n].status === status) | ||
| .sort((a, b) => a - b); | ||
| } | ||
| function poolMembers(state) { | ||
| return membersByStatus(state, POOL); | ||
| } | ||
| function assignedMembers(state) { | ||
| return membersByStatus(state, ASSIGNED); | ||
| } | ||
| // True when a pool entry's auth/health is older than `ttlMs` (its token may | ||
| // have lapsed). An entry with no `freshAt` is treated as stale — "freshness | ||
| // not proven" fails closed, mirroring fleet-ready's auth gate. Only meaningful | ||
| // for pooled VMs; an assigned VM is the operator's concern, not the keeper's. | ||
| function isStale(entry, nowMs, ttlMs) { | ||
| if (!entry || entry.status !== POOL) return false; | ||
| if (!entry.freshAt) return true; | ||
| const t = Date.parse(entry.freshAt); | ||
| if (Number.isNaN(t)) return true; | ||
| return nowMs - t > ttlMs; | ||
| } | ||
| // Pool VM numbers whose freshness has lapsed — the evict-before-claim set the | ||
| // keeper drops (and replaces) so a claim never hands a caller a stale VM. | ||
| function staleMembers(state, nowMs, ttlMs) { | ||
| return poolMembers(state).filter((n) => isStale(state.vms[n], nowMs, ttlMs)); | ||
| } | ||
| // How many NEW pool VMs the keeper must provision to reach `target` idle | ||
| // members. Assigned VMs do not count toward the pool — once claimed they're | ||
| // gone from the warm set until released — so a claim naturally triggers a | ||
| // refill. Never negative (an over-target pool is shrunk by policy, not here). | ||
| function refillCount(state, target) { | ||
| const have = poolMembers(state).length; | ||
| return Math.max(0, target - have); | ||
| } | ||
| // The heart of the claim: deterministically move up to `count` idle pool VMs to | ||
| // `assigned` on `branch`. Pure — returns { next, claimed, shortfall } without | ||
| // touching disk; the caller persists `next` under the lock. Lowest-numbered | ||
| // VMs are taken first. `shortfall` (requested minus granted) is the signal the | ||
| // orchestrator uses to fall back to on-demand provisioning when the pool can't | ||
| // satisfy the whole request — an empty pool yields claimed=[] , shortfall=count | ||
| // rather than an error. | ||
| function selectForClaim(state, count, branch, nowIso) { | ||
| const next = normalizeState(state); | ||
| const idle = poolMembers(next); | ||
| const take = idle.slice(0, Math.max(0, count)); | ||
| for (const n of take) { | ||
| next.vms[n] = { | ||
| ...next.vms[n], | ||
| status: ASSIGNED, | ||
| branch, | ||
| claimedAt: nowIso, | ||
| }; | ||
| } | ||
| return { next, claimed: take, shortfall: Math.max(0, count - take.length) }; | ||
| } | ||
| // Register (or overwrite) a VM in the pool state. The keeper calls this after | ||
| // provisioning a fresh READY VM (status=pool) and the claim path can re-add an | ||
| // assigned VM. `freshAt` defaults to now for a pool member so a just-verified | ||
| // VM isn't immediately stale. | ||
| function applyAdd(state, n, status, branch, nowIso) { | ||
| const next = normalizeState(state); | ||
| const st = status === ASSIGNED ? ASSIGNED : POOL; | ||
| next.vms[n] = { | ||
| status: st, | ||
| branch: branch || null, | ||
| freshAt: st === POOL ? nowIso : (next.vms[n] && next.vms[n].freshAt) || null, | ||
| claimedAt: st === ASSIGNED ? nowIso : null, | ||
| }; | ||
| return next; | ||
| } | ||
| // Return an assigned VM to the idle pool (operator finished with it). A no-op | ||
| // for an absent VM. `freshAt` is reset to now: a just-released VM was live and | ||
| // authed, so it's claim-ready again without waiting for the keeper's refresh. | ||
| function applyRelease(state, n, nowIso) { | ||
| const next = normalizeState(state); | ||
| if (!next.vms[n]) return next; | ||
| next.vms[n] = { | ||
| ...next.vms[n], | ||
| status: POOL, | ||
| freshAt: nowIso, | ||
| claimedAt: null, | ||
| }; | ||
| return next; | ||
| } | ||
| // Drop a VM from the pool state entirely (the keeper evicts a stale/unhealthy | ||
| // member before replacing it). A no-op for an absent VM. | ||
| function applyEvict(state, n) { | ||
| const next = normalizeState(state); | ||
| delete next.vms[n]; | ||
| return next; | ||
| } | ||
| // Stamp a pool member as freshly auth/health-verified. The keeper calls this | ||
| // after a successful credential refresh so the member's eviction clock resets. | ||
| function applyMarkFresh(state, n, nowIso) { | ||
| const next = normalizeState(state); | ||
| if (!next.vms[n]) return next; | ||
| next.vms[n] = { ...next.vms[n], freshAt: nowIso }; | ||
| return next; | ||
| } | ||
| // ── Atomic file layer ─────────────────────────────────────────────────────── | ||
| // Synchronous millisecond sleep without a busy CPU spin — used by withLock's | ||
| // retry. Atomics.wait blocks the (single) thread for `ms`; a fresh 4-byte | ||
| // SharedArrayBuffer that nothing else holds means the wait always times out | ||
| // rather than being woken early. | ||
| function sleepSync(ms) { | ||
| Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); | ||
| } | ||
| // Run `fn` while holding a cross-PROCESS mutex around the pool-state file, so a | ||
| // read-modify-write (the claim) is atomic even when two operators run | ||
| // `fleet-claim` at the same moment. The lock is a directory next to the state | ||
| // file: `mkdir` is atomic on POSIX, so exactly one process wins the create and | ||
| // the rest retry. A lock older than `staleMs` is assumed abandoned (a crashed | ||
| // claimer) and stolen, so a dead process can't wedge the pool forever. | ||
| function withLock(fn, { retries = 100, delayMs = 25, staleMs = 30000 } = {}) { | ||
| const lockDir = `${POOL_STATE_FILE}.lock`; | ||
| fs.mkdirSync(path.dirname(POOL_STATE_FILE), { recursive: true }); | ||
| let held = false; | ||
| for (let i = 0; i < retries && !held; i += 1) { | ||
| try { | ||
| fs.mkdirSync(lockDir); | ||
| held = true; | ||
| } catch (err) { | ||
| if (err.code !== 'EEXIST') throw err; | ||
| // Steal an abandoned lock (claimer crashed mid-operation). | ||
| try { | ||
| const age = Date.now() - fs.statSync(lockDir).mtimeMs; | ||
| if (age > staleMs) { | ||
| fs.rmdirSync(lockDir); | ||
| continue; // retry the mkdir immediately | ||
| } | ||
| } catch { /* lock vanished between stat and now — just retry */ } | ||
| sleepSync(delayMs); | ||
| } | ||
| } | ||
| if (!held) throw new Error(`fleet-pool: could not acquire lock ${lockDir} after ${retries} tries`); | ||
| try { | ||
| return fn(); | ||
| } finally { | ||
| try { fs.rmdirSync(lockDir); } catch { /* already gone */ } | ||
| } | ||
| } | ||
| // Read + normalize the pool state. A missing or malformed file is the empty | ||
| // pool `{ vms: {} }` (the documented tolerant contract), so a truncated write | ||
| // never throws into the keeper's reconcile loop. | ||
| function readState() { | ||
| try { | ||
| return normalizeState(JSON.parse(fs.readFileSync(POOL_STATE_FILE, 'utf8'))); | ||
| } catch { | ||
| return { vms: {} }; | ||
| } | ||
| } | ||
| // Persist `state` atomically: write a pid-tagged temp file then `rename` over | ||
| // the target so a concurrent reader never observes a half-written file (same | ||
| // discipline as fleet-roster.writeRoster). Returns the normalized state. | ||
| function writeState(state) { | ||
| const next = normalizeState(state); | ||
| fs.mkdirSync(path.dirname(POOL_STATE_FILE), { recursive: true }); | ||
| const tmp = `${POOL_STATE_FILE}.${process.pid}.tmp`; | ||
| fs.writeFileSync(tmp, JSON.stringify(next)); | ||
| fs.renameSync(tmp, POOL_STATE_FILE); | ||
| return next; | ||
| } | ||
| module.exports = { | ||
| POOL_STATE_FILE, | ||
| POOL, | ||
| ASSIGNED, | ||
| normalizeState, | ||
| poolMembers, | ||
| assignedMembers, | ||
| isStale, | ||
| staleMembers, | ||
| refillCount, | ||
| selectForClaim, | ||
| applyAdd, | ||
| applyRelease, | ||
| applyEvict, | ||
| applyMarkFresh, | ||
| withLock, | ||
| readState, | ||
| writeState, | ||
| main, | ||
| }; | ||
| // ── CLI seam ──────────────────────────────────────────────────────────────── | ||
| // The bash orchestrators call these subcommands instead of re-deriving the | ||
| // logic in shell. Each mutating subcommand runs its read-modify-write inside | ||
| // withLock so concurrent callers serialize. Output is line-oriented and | ||
| // machine-parseable (space-separated VM numbers / a single integer) so the | ||
| // shell side stays a thin `$(node fleet-pool.js ...)` capture. | ||
| function parsePoolFlags(argv) { | ||
| const flags = {}; | ||
| for (let i = 0; i < argv.length; i += 1) { | ||
| const a = argv[i]; | ||
| if (a.startsWith('--')) { | ||
| const key = a.slice(2); | ||
| const val = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[(i += 1)] : 'true'; | ||
| flags[key] = val; | ||
| } | ||
| } | ||
| return flags; | ||
| } | ||
| function main(argv) { | ||
| const [cmd, ...rest] = argv; | ||
| const flags = parsePoolFlags(rest); | ||
| const now = flags.now || new Date().toISOString(); | ||
| switch (cmd) { | ||
| case 'list': | ||
| process.stdout.write(`${JSON.stringify(readState(), null, 2)}\n`); | ||
| return 0; | ||
| case 'pool-members': | ||
| process.stdout.write(`${poolMembers(readState()).join(' ')}\n`); | ||
| return 0; | ||
| case 'assigned-members': | ||
| process.stdout.write(`${assignedMembers(readState()).join(' ')}\n`); | ||
| return 0; | ||
| case 'refill-count': { | ||
| const target = Number(flags.target); | ||
| if (!Number.isInteger(target) || target < 0) { | ||
| process.stderr.write('fleet-pool: refill-count needs --target <non-negative int>\n'); | ||
| return 2; | ||
| } | ||
| process.stdout.write(`${refillCount(readState(), target)}\n`); | ||
| return 0; | ||
| } | ||
| case 'stale': { | ||
| const ttl = Number(flags['ttl-seconds']); | ||
| if (!Number.isInteger(ttl) || ttl < 0) { | ||
| process.stderr.write('fleet-pool: stale needs --ttl-seconds <non-negative int>\n'); | ||
| return 2; | ||
| } | ||
| const out = staleMembers(readState(), Date.parse(now), ttl * 1000); | ||
| process.stdout.write(`${out.join(' ')}\n`); | ||
| return 0; | ||
| } | ||
| case 'claim': { | ||
| const count = Number(flags.count); | ||
| const branch = flags.branch; | ||
| if (!Number.isInteger(count) || count <= 0 || !branch || branch === 'true') { | ||
| process.stderr.write('fleet-pool: claim needs --count <pos int> --branch <name>\n'); | ||
| return 2; | ||
| } | ||
| const result = withLock(() => { | ||
| const { next, claimed, shortfall } = selectForClaim(readState(), count, branch, now); | ||
| writeState(next); | ||
| return { claimed, shortfall }; | ||
| }); | ||
| // VM numbers on stdout (the orchestrator rebrands each); shortfall on | ||
| // stderr so an empty/short pool is visible without polluting the capture. | ||
| process.stdout.write(`${result.claimed.join(' ')}\n`); | ||
| if (result.shortfall > 0) { | ||
| process.stderr.write(`fleet-pool: shortfall ${result.shortfall} (pool could not satisfy full claim)\n`); | ||
| } | ||
| return 0; | ||
| } | ||
| case 'add': { | ||
| const vm = Number(flags.vm); | ||
| if (!Number.isInteger(vm) || vm <= 0) { | ||
| process.stderr.write('fleet-pool: add needs --vm <pos int>\n'); | ||
| return 2; | ||
| } | ||
| const status = flags.status === ASSIGNED ? ASSIGNED : POOL; | ||
| withLock(() => writeState(applyAdd(readState(), vm, status, flags.branch, now))); | ||
| return 0; | ||
| } | ||
| case 'release': { | ||
| const vm = Number(flags.vm); | ||
| if (!Number.isInteger(vm) || vm <= 0) { | ||
| process.stderr.write('fleet-pool: release needs --vm <pos int>\n'); | ||
| return 2; | ||
| } | ||
| withLock(() => writeState(applyRelease(readState(), vm, now))); | ||
| return 0; | ||
| } | ||
| case 'evict': { | ||
| const vm = Number(flags.vm); | ||
| if (!Number.isInteger(vm) || vm <= 0) { | ||
| process.stderr.write('fleet-pool: evict needs --vm <pos int>\n'); | ||
| return 2; | ||
| } | ||
| withLock(() => writeState(applyEvict(readState(), vm))); | ||
| return 0; | ||
| } | ||
| case 'mark-fresh': { | ||
| const vm = Number(flags.vm); | ||
| if (!Number.isInteger(vm) || vm <= 0) { | ||
| process.stderr.write('fleet-pool: mark-fresh needs --vm <pos int>\n'); | ||
| return 2; | ||
| } | ||
| withLock(() => writeState(applyMarkFresh(readState(), vm, now))); | ||
| return 0; | ||
| } | ||
| default: | ||
| process.stderr.write( | ||
| 'fleet-pool: usage: <list|pool-members|assigned-members|refill-count|stale|claim|add|release|evict|mark-fresh> [--flags]\n', | ||
| ); | ||
| return 2; | ||
| } | ||
| } | ||
| if (require.main === module) { | ||
| process.exit(main(process.argv.slice(2))); | ||
| } |
| 'use strict'; | ||
| // Pure helpers for the fleet dashboard's commit-queue auto-release logic | ||
| // (scripts/operator/fleet-dashboard/server.js requires this). The decision | ||
| // of whether to auto-release a queue head lives here so server.js's | ||
| // orchestration stays thin and this piece can be unit-tested in isolation | ||
| // (npm/fleet-queue.test.js). | ||
| // | ||
| // Auto-release fires only on the deterministic "owning VM left the fleet" | ||
| // signal — a VM that is absent from the roster AND has no in-flight | ||
| // provisioning job. The ambiguous "in roster but unreachable" case is | ||
| // explicitly out of scope: a transient blip could trigger a spurious | ||
| // release. That case is surfaced as a badge for the operator to act on. | ||
| /** Decide whether the current queue head should be auto-released. | ||
| * | ||
| * Conditions that must ALL hold: | ||
| * 1. The head entry's `vm` field is non-null (the machine name matched the | ||
| * fleet's INSTANCE_PREFIX-…-<n> shape via vmFromMachine). An | ||
| * unattributable head (vm===null) is never auto-released. | ||
| * 2. That VM number is absent from the live `vms` roster array. | ||
| * 3. There is no in-flight or queued provisioning job for that number | ||
| * (guards the Add→addVm gap where a VM is being created but not yet | ||
| * rostered). Also guards known-orphan VM numbers. | ||
| * | ||
| * Returns { release: false } or { release: true, uuid, vm, reason }. | ||
| * Pure; no I/O. | ||
| */ | ||
| function decideAutoReleaseGoneHead({ queue, vms, jobs, orphans }) { | ||
| const head = queue && queue[0]; | ||
| if (!head || head.vm == null) return { release: false }; | ||
| const n = head.vm; | ||
| if (vms.includes(n)) return { release: false }; | ||
| const job = jobs[n]; | ||
| const provisioning = job && (job.state === 'queued' || job.state === 'running') | ||
| && /add|cloud:up/.test(job.action || ''); | ||
| if (provisioning || orphans[n]) return { release: false }; | ||
| return { release: true, uuid: head.uuid, vm: n, reason: `owning VM-${n} left the fleet` }; | ||
| } | ||
| module.exports = { decideAutoReleaseGoneHead }; |
| 'use strict'; | ||
| // npm/fleet-reconcile.js — pure reconciliation helpers for the fleet dashboard's | ||
| // "unrostered VM" safety net (improve38). The dashboard lists running GCP | ||
| // instances and surfaces any that aren't in the roster (provisioned out-of-band | ||
| // — raw `gcloud`, or a `cloud:up` whose roster write failed). The gcloud I/O | ||
| // lives in server.js (the untestable server shell); the pure parse + set | ||
| // difference live here so they're unit-tested without invoking gcloud. | ||
| // Parse `gcloud compute instances list --format='value(name)'` output into the | ||
| // sorted, de-duped fleet VM numbers it names — instances whose name matches | ||
| // `<prefix><n>`. Non-matching lines (other instances, blank lines, gcloud/SSH | ||
| // warning noise) are ignored. | ||
| function parseRunningFleetVms(stdout, prefix) { | ||
| const re = new RegExp(`^${String(prefix).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\d+)$`); | ||
| const ns = String(stdout || '') | ||
| .split('\n') | ||
| .map((line) => { | ||
| const m = line.trim().match(re); | ||
| return m ? Number(m[1]) : null; | ||
| }) | ||
| .filter((n) => n != null); | ||
| return [...new Set(ns)].sort((a, b) => a - b); | ||
| } | ||
| // Set difference: the `running` VM numbers that are NOT already accounted for in | ||
| // `shown` (the roster plus any provisioning/orphan numbers already rendered as a | ||
| // card). Returns a sorted, de-duped array — the numbers that need an Adopt card. | ||
| function computeUnrostered(running, shown) { | ||
| const accounted = new Set(shown); | ||
| return [...new Set(running)] | ||
| .filter((n) => !accounted.has(n)) | ||
| .sort((a, b) => a - b); | ||
| } | ||
| // Map a single degraded reason to the live health signal that resolves it. | ||
| // Returns true only when that signal now passes; a reason with no mapped signal | ||
| // returns false (retained — a genuine config problem, never a false-green). | ||
| // Start with the two observed classes (improve41); extend the table as new | ||
| // degraded reasons gain a corresponding live signal. | ||
| function reasonClearedByHealth(reason, { reachableServing, authed }) { | ||
| const r = String(reason || ''); | ||
| if (/claude auth|claude\.json|login/i.test(r)) return authed; | ||
| if (/container|never came up|provisioning failed/i.test(r)) return reachableServing; | ||
| return false; | ||
| } | ||
| // Given a terminal add-job badge and the VM's live health, return the corrected | ||
| // badge — or null when nothing changes. Conservative by construction: clears a | ||
| // degraded reason only when its live health signal now passes; flips a bare | ||
| // `error` add to `done` when the VM is reachable + serving; leaves any reason | ||
| // with no health signal intact (no false-green). The poll loop supplies the live | ||
| // facts (I/O at the edge) and applies the result; the verdict itself is pure. | ||
| // job: { state: 'done'|'error', degraded, degradedReasons } | ||
| // health: { reachable, editorHttpOk, providerAuthState } | ||
| // → null | { state, degraded, degradedReasons, transition } | ||
| function reconcileTerminalAddBadge({ job, health } = {}) { | ||
| if (!job || !health) return null; | ||
| const reachableServing = !!(health.reachable && health.editorHttpOk); | ||
| const authed = health.providerAuthState === 'authenticated'; | ||
| // Bare `error` add (no degraded reasons): flip to `done` once the VM is | ||
| // reachable + serving — the VM3 case (a manual `cloud:up` recovered the editor | ||
| // outside the dashboard's view). | ||
| if (job.state === 'error') { | ||
| const reasons = job.degradedReasons || []; | ||
| if (reasons.length === 0 && reachableServing) { | ||
| return { state: 'done', degraded: false, degradedReasons: [], transition: 'error→done (editor 200)' }; | ||
| } | ||
| return null; | ||
| } | ||
| // Terminal `done` that is degraded: clear each reason whose live signal now | ||
| // passes; retain the rest. Drop `degraded` only when no reasons remain. | ||
| if (job.state === 'done' && job.degraded) { | ||
| const reasons = job.degradedReasons || []; | ||
| const remaining = reasons.filter((reason) => !reasonClearedByHealth(reason, { reachableServing, authed })); | ||
| if (remaining.length === reasons.length) return null; // nothing cleared → no-op | ||
| const cleared = reasons.length - remaining.length; | ||
| return { | ||
| state: 'done', | ||
| degraded: remaining.length > 0, | ||
| degradedReasons: remaining, | ||
| transition: remaining.length > 0 | ||
| ? `degraded: cleared ${cleared} reason(s), ${remaining.length} remain` | ||
| : 'degraded→clear (health restored)', | ||
| }; | ||
| } | ||
| return null; | ||
| } | ||
| module.exports = { parseRunningFleetVms, computeUnrostered, reconcileTerminalAddBadge }; |
| 'use strict'; | ||
| // Pure bash-script builders for the fleet dashboard's RESET actions | ||
| // (scripts/operator/fleet-dashboard/server.js requires this). String | ||
| // construction only — no I/O, no gcloud/docker exec — so the reset *recipe* | ||
| // (what the remote bash actually does) stays unit-testable in isolation | ||
| // (npm/fleet-reset.test.js), exactly like launch-config.js / fleet-env.js. | ||
| // The dispatch (gcloud ssh → docker exec → startJob) stays in server.js as | ||
| // platform-glue; only the decision-bearing script shape lives here. | ||
| // The `.codeyam` deep-clean preserve→pause→wipe→restore sequence, shared by every | ||
| // reset that hard-cleans a /workspace. `git reset --hard` + `session-reset` | ||
| // leave the git-IGNORED runtime state behind (workflow step files, | ||
| // scenario-*.js, server-state, logs, caches), so we explicitly wipe and | ||
| // restore the tracked `.codeyam`. Two gitignored files MUST survive the wipe: | ||
| // - editor.local.json — previewOrigin + the agent API key (a blind wipe | ||
| // breaks Live Preview + auth). | ||
| // - server-state.json — the running pid + control port the restart's verify | ||
| // reads. | ||
| // Both are cp'd aside, `.codeyam` is wiped, restored from the branch (tracked, | ||
| // e.g. self-host) or re-`init`'d (greenfield, where `.codeyam` is gitignored), | ||
| // then the two files are copied back. Returned as step strings the caller | ||
| // joins into its own `&&`-chain (the surrounding `( …; true )` guards keep each | ||
| // best-effort cp from breaking the chain when a file is absent). | ||
| // | ||
| // First step: release any commit-queue entries this VM owns. The wipe | ||
| // regenerates `.codeyam/run/queue-origin-id`, which would otherwise orphan | ||
| // the prior session's entry (different origin_id, same machine — invisible | ||
| // to the two-key adoption path until the next enqueue's same-machine guard | ||
| // reaps it). Routing through `branch-queue release-mine` makes the | ||
| // queue-side cleanup intentional and visible in the queue log instead. | ||
| // Best-effort: a no-remote / queue-disabled project legitimately exits | ||
| // non-zero, and we should not block the reset. | ||
| // | ||
| // Before the wipe we QUIESCE ALL WRITERS: park the editor server | ||
| // (`stop-server --hold-for-restart`) AND stop the PTY broker | ||
| // (`pty-broker stop`). Parking only the editor server is insufficient — the | ||
| // broker is a SEPARATE process (and hooks / agent subprocesses can fire), so | ||
| // it keeps re-creating `.codeyam/logs/` and `.codeyam/run/` between when the | ||
| // wipe enumerates the dir and when it removes it. That race surfaced live | ||
| // (VM-1, 2026-06-01) as `rm: cannot remove '.codeyam': Directory not empty`, | ||
| // aborting the whole reset. Both quiesce calls are fail-soft `|| true` so a | ||
| // server/broker that's already dead (e.g. the reset is rerun after a partial | ||
| // failure) doesn't block the chain. | ||
| // | ||
| // The wipe itself is RETRY-RESILIENT instead of a single `rm -rf .codeyam` | ||
| // that aborts the &&-chain on one transient re-creation: a bounded loop of | ||
| // `find .codeyam -mindepth 1 -delete` empties the contents and tolerates a | ||
| // brief re-create, then proceeds even if `.codeyam` is still non-empty after | ||
| // best-effort (a soft retry, never a hard failure — `git checkout`/`init` | ||
| // repopulates over whatever remains). Emptying contents (not removing the dir) | ||
| // also sidesteps the `rmdir` race entirely. | ||
| // | ||
| // The destructive region (park → broker-stop → wipe → restore) is made | ||
| // FAILURE-SAFE with an EXIT trap installed before the park and disarmed after | ||
| // a successful restore. If ANY step in that region aborts the chain, the trap | ||
| // still restores tracked `.codeyam` (or re-inits), copies the two preserved | ||
| // infra files back, and runs `restart-server --force-in-container` before | ||
| // exiting non-zero — so a failed deep-clean self-recovers to a working | ||
| // (un-reset, but UP) VM instead of leaving it parked-and-down with a | ||
| // half-wiped `.codeyam`, and still reports the failure. The happy path is | ||
| // unchanged: the restore steps run, the trap is disarmed, and the caller's | ||
| // own trailing `restart-server` resurrects the parked server. | ||
| function codeyamDeepCleanSteps(E) { | ||
| // Restore primitives shared by the happy path and the trap recovery body. | ||
| const restoreCodeyam = `git checkout HEAD -- .codeyam 2>/dev/null || ${E} init`; | ||
| const restoreElj = | ||
| 'test -f /tmp/cy-elj.bak && mkdir -p .codeyam && cp /tmp/cy-elj.bak .codeyam/editor.local.json'; | ||
| const restoreSs = | ||
| 'test -f /tmp/cy-ss.bak && mkdir -p .codeyam && cp /tmp/cy-ss.bak .codeyam/server-state.json'; | ||
| // Recovery body: restore .codeyam + the two infra backups, then resurrect the | ||
| // parked server. Runs ONLY on the failure path (the happy path's own restore | ||
| // steps remove the backups; recovery here intentionally leaves them in place | ||
| // because on a mid-region abort those steps never ran). | ||
| const recover = | ||
| `${restoreCodeyam}; ( ${restoreElj}; true ); ( ${restoreSs}; true ); ` + | ||
| `${E} editor restart-server --force-in-container || true`; | ||
| return [ | ||
| `( ${E} editor branch-queue release-mine --yes 2>/dev/null; true )`, | ||
| '( cp .codeyam/editor.local.json /tmp/cy-elj.bak 2>/dev/null; true )', | ||
| '( cp .codeyam/server-state.json /tmp/cy-ss.bak 2>/dev/null; true )', | ||
| // A brace group (NOT a subshell) so the function + trap persist into the | ||
| // current shell where the rest of the &&-chain runs. | ||
| `{ __cy_recover() { ${recover}; }; trap '__cy_recover; exit 1' EXIT; }`, | ||
| `(${E} editor stop-server --hold-for-restart --force-in-container || true)`, | ||
| `(${E} editor pty-broker stop || true)`, | ||
| '( for i in 1 2 3 4 5; do find .codeyam -mindepth 1 -delete 2>/dev/null; [ -z "$(ls -A .codeyam 2>/dev/null)" ] && break; sleep 0.2; done; true )', | ||
| `( ${restoreCodeyam} )`, | ||
| `( ${restoreElj} && rm -f /tmp/cy-elj.bak; true )`, | ||
| `( ${restoreSs} && rm -f /tmp/cy-ss.bak; true )`, | ||
| // Restore succeeded — disarm the failure-safe trap so normal completion | ||
| // (and the caller's trailing session-reset + restart) doesn't re-trigger it. | ||
| 'trap - EXIT', | ||
| ]; | ||
| } | ||
| // Belt-and-suspenders broker-ensure, appended after the trailing | ||
| // `restart-server` in every reset recipe. The PRIMARY fix lives in the | ||
| // editor server's `ensure_broker_running`: it now `rm -f`s the stale | ||
| // socket and retries the spawn, so the reexec'd `start` (which | ||
| // restart-server triggers) reliably respawns the broker with no | ||
| // down-window. This step is the safety net for an OLDER in-container | ||
| // binary whose `restart-server` predates that fix — it mirrors the | ||
| // vm-watchdog's recovery so a reset still ends broker-up regardless of | ||
| // which binary is installed. | ||
| // | ||
| // Shape: status-gated and idempotent. `pty-broker status` exits non-zero | ||
| // only when no broker is reachable; ONLY then does the `||` branch fire | ||
| // (`rm -f` the stale socket — mirroring the watchdog — then `nohup` a | ||
| // detached daemon). When a broker is already alive the status succeeds | ||
| // and the respawn branch never runs, so a healthy broker is NEVER killed. | ||
| // Wrapped in `( …; true )` so it can never break the reset's &&-chain | ||
| // (a down broker that fails to respawn must not abort the rest of the | ||
| // reset). The socket glob + `pty-broker daemon` invocation match the | ||
| // watchdog (`scripts/operator/vm-watchdog.sh`) and the per-project | ||
| // `$TMPDIR`/`/tmp` socket layout (`socket_path_for`). | ||
| function codeyamBrokerEnsureStep(E) { | ||
| return ( | ||
| `( ${E} editor pty-broker status >/dev/null 2>&1 || ` + | ||
| `( rm -f /tmp/codeyam-pty-broker-*.sock; ` + | ||
| `nohup ${E} editor pty-broker daemon </dev/null >/tmp/broker-spawn.log 2>&1 & disown ); ` + | ||
| 'true )' | ||
| ); | ||
| } | ||
| // Build the in-container bash for "Reset client" — hard-clean the CLIENT | ||
| // project (/workspace) on ANY VM shape. The decisions encoded here: | ||
| // - Reset to HEAD, NOT origin: `git reset --hard` (bare) discards uncommitted | ||
| // churn but PRESERVES committed work. Advancing to origin tip is the | ||
| // separate "Update client" action — this must never `git fetch`/reset to | ||
| // `origin/<branch>`. | ||
| // - `git reset --hard` leaves untracked files, so node_modules + a per-VM | ||
| // .env survive (no forced re-install / lost secrets). | ||
| // - Stop the broker FIRST so abandoned agent WIP can't be re-written to disk | ||
| // mid-reset; drop a stale LOCAL credential.helper (fragility #29) so the | ||
| // re-seed + the agent's git ops fall back to the working system helper. | ||
| // - Re-seed the git credential cache AFTER the restart so the agent can | ||
| // pull/push immediately; the re-seed is a self-contained subshell whose | ||
| // own `|| echo` never masks an earlier &&-chain failure. | ||
| // `E` is the in-container editor binary; `gitReseedBody` is the re-seed bash | ||
| // body (passed in so this stays a pure function of its inputs). | ||
| function resetClientScript({ E, gitReseedBody }) { | ||
| return [ | ||
| 'cd /workspace', | ||
| `command -v ${E} >/dev/null`, | ||
| 'echo ">> [1/5] stopping agent session"', | ||
| `(${E} editor pty-broker stop || true)`, | ||
| '(git config --local --unset-all credential.helper || true)', | ||
| 'echo ">> [2/5] discarding uncommitted changes (git reset --hard to HEAD; keeps committed work + untracked files)"', | ||
| 'git reset --hard', | ||
| 'echo ">> [3/5] deep-cleaning .codeyam (clears ignored runtime state; keeps editor.local.json + server-state.json)"', | ||
| ...codeyamDeepCleanSteps(E), | ||
| `${E} editor session-reset`, | ||
| 'echo ">> [4/5] restarting server to respawn the broker (no rebuild)"', | ||
| `(${E} editor restart-server --force-in-container || true)`, | ||
| // Belt-and-suspenders: ensure a broker is live even on an older binary | ||
| // whose restart-server doesn't yet respawn it. Idempotent / status-gated. | ||
| codeyamBrokerEnsureStep(E), | ||
| 'echo ">> [5/5] re-seeding git credential cache"', | ||
| `( ( ${gitReseedBody} ) || echo "WARN git cache re-seed failed" )`, | ||
| ].join(' && '); | ||
| } | ||
| // Build the in-container bash for "Reset editor" — the editor-state recipe: | ||
| // stop the agent, drop a stale local credential.helper, advance the VM's branch | ||
| // to origin tip (skipped on a greenfield local-only repo), deep-clean .codeyam | ||
| // (preserving editor.local.json + server-state.json via codeyamDeepCleanSteps), | ||
| // run session-reset, and restart the server. This is the SAME &&-chain | ||
| // previously inlined in actionResetEditorSource in scripts/operator/ | ||
| // fleet-dashboard/server.js — extracted so the standalone Reset editor action | ||
| // AND the composite Update & Reset Everything action share one definition and | ||
| // it gets unit-test coverage like the other reset builders. | ||
| // | ||
| // Binary-existence gate FIRST so a broken binary aborts BEFORE the destructive | ||
| // `git reset --hard` / `rm -rf .codeyam`. The composite action relies on this | ||
| // same gate as its safety contract — a failed rebuild that leaves no binary | ||
| // will short-circuit the trailing recipe before any wipe happens. | ||
| function resetEditorStateScript({ E }) { | ||
| return [ | ||
| 'cd /workspace', | ||
| `command -v ${E} >/dev/null`, | ||
| // Tolerate a missing broker: `pty-broker stop` exits non-zero when none is | ||
| // running; the subshell + `|| true` keeps it non-fatal without swallowing | ||
| // the binary-existence gate above. | ||
| 'echo ">> [1/4] stopping agent session + clearing workflow state"', | ||
| `(${E} editor pty-broker stop || true)`, | ||
| // Drop a stale LOCAL credential.helper (fragility #29) so `git fetch` falls | ||
| // back to the working system helper. No-op when none is set. | ||
| '(git config --local --unset-all credential.helper || true)', | ||
| // Advance the VM's own branch to origin (skip on a greenfield local-only repo | ||
| // with no remote). fetch + `reset --hard origin/<branch>`, NOT `pull --ff-only` | ||
| // (which dies "Cannot fast-forward to multiple branches" on some VM states). | ||
| 'if git remote | grep -qx origin; then ' + | ||
| 'BR=$(git branch --show-current); ' + | ||
| 'echo ">> [2/4] advancing source on $BR to origin tip"; ' + | ||
| 'git reset --hard && git fetch origin "$BR" && git reset --hard "origin/$BR"; ' + | ||
| 'else echo ">> [2/4] no origin remote (greenfield project) — skipping source advance"; fi', | ||
| // Deep-clean .codeyam: `git reset --hard` + session-reset leave git-IGNORED / | ||
| // untracked state (workflow + design-step state, server-state, logs, caches). | ||
| // Preserve editor.local.json (previewOrigin + agent API key — a blind wipe | ||
| // breaks Live Preview + auth) and server-state.json (runtime pid + control | ||
| // port the restart's verify reads), wipe .codeyam, restore it from the branch | ||
| // (tracked, e.g. self-host) or re-init (greenfield), then put both files back. | ||
| 'echo ">> [3/4] deep-cleaning .codeyam (clears ignored state; keeps editor.local.json + server-state.json)"', | ||
| ...codeyamDeepCleanSteps(E), | ||
| `${E} editor session-reset`, | ||
| // No rebuild here (that's Rebuild binary). Restart the server in place to | ||
| // respawn the broker we stopped: restart-server SIGUSR1s pid 1 and the | ||
| // docker-exec returns cleanly, so `|| true` keeps a broker-down VM green. | ||
| 'echo ">> [4/4] restarting server to respawn the broker (no rebuild)"', | ||
| `(${E} editor restart-server --force-in-container || true)`, | ||
| // Belt-and-suspenders: ensure a broker is live even on an older binary | ||
| // whose restart-server doesn't yet respawn it. Idempotent / status-gated. | ||
| codeyamBrokerEnsureStep(E), | ||
| ].join(' && '); | ||
| } | ||
| module.exports = { | ||
| codeyamDeepCleanSteps, | ||
| codeyamBrokerEnsureStep, | ||
| resetClientScript, | ||
| resetEditorStateScript, | ||
| }; |
| 'use strict'; | ||
| // npm/fleet-roster.js — the fleet roster (`.codeyam/logs/fleet-vms.json`) is a | ||
| // shared contract: the dashboard, the CLI provisioner (`cloud:up`), the | ||
| // `cloud:branch-switch` orchestrator, the vm-watchdog, and the bash tunnel | ||
| // publishers (`vm-urls.sh` / `cloudflared-monitor.sh`) all read it to learn | ||
| // which VMs exist. Before improve38 the *writer* lived only inside the dashboard | ||
| // process (`server.js::saveVmsState`), so a VM provisioned by the CLI never | ||
| // appeared until someone hand-edited the file. This module is the single | ||
| // read+write seam every Node consumer shares, so the writer is no longer locked | ||
| // inside the dashboard. | ||
| // | ||
| // File format (unchanged): a JSON array of VM-number integers. Readers tolerate | ||
| // a missing or malformed file by treating it as the empty roster. | ||
| // | ||
| // Env override: `VMS_STATE_FILE` relocates the file (honored by server.js and | ||
| // cloud-branch-switch.js already); resolved once at module load. | ||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
| const { rootDir } = require('./utils'); | ||
| const VMS_STATE_FILE = process.env.VMS_STATE_FILE | ||
| || path.join(rootDir, '.codeyam', 'logs', 'fleet-vms.json'); | ||
| // Canonical roster shape: a sorted, de-duped set of integers. Every read and | ||
| // write funnels through here so on-disk content stays normalized regardless of | ||
| // which consumer last wrote it (or hand-edited it). | ||
| function normalizeRoster(arr) { | ||
| return [...new Set((arr || []).filter(Number.isInteger))].sort((a, b) => a - b); | ||
| } | ||
| // Read the roster, returning a normalized integer array. A missing or malformed | ||
| // file is treated as the empty roster `[]` (the documented contract the previous | ||
| // `readRoster`/`loadVmsState` duplicates shared) so a truncated write or an | ||
| // absent file never throws into a consumer's poll loop. | ||
| function readRoster() { | ||
| try { | ||
| const data = JSON.parse(fs.readFileSync(VMS_STATE_FILE, 'utf8')); | ||
| if (Array.isArray(data)) return normalizeRoster(data); | ||
| } catch { /* missing or malformed — treated as empty */ } | ||
| return []; | ||
| } | ||
| // Persist `arr` as the roster, atomically: write a temp file then `rename` it | ||
| // over the target so a concurrent reader never observes a half-written file. | ||
| // The temp name carries the pid so two *processes* writing at once (CLI + | ||
| // dashboard) don't clobber each other's temp file before their renames land. | ||
| // Returns the normalized roster that was written. | ||
| function writeRoster(arr) { | ||
| const next = normalizeRoster(arr); | ||
| fs.mkdirSync(path.dirname(VMS_STATE_FILE), { recursive: true }); | ||
| const tmp = `${VMS_STATE_FILE}.${process.pid}.tmp`; | ||
| fs.writeFileSync(tmp, JSON.stringify(next)); | ||
| fs.renameSync(tmp, VMS_STATE_FILE); // atomic replace | ||
| return next; | ||
| } | ||
| // Append `n` to the roster if absent (idempotent), returning the new roster. | ||
| // A double-roster — e.g. the CLI rosters on `cloud:up` and the dashboard's Add | ||
| // onSuccess rosters again — is a harmless no-op, which is exactly what closes | ||
| // the race where one writer's roster write could fail. | ||
| function addToRoster(n) { | ||
| return writeRoster([...readRoster(), n]); | ||
| } | ||
| // Remove `n` from the roster, returning the new roster. Removing an absent | ||
| // entry is a no-op. | ||
| function removeFromRoster(n) { | ||
| return writeRoster(readRoster().filter((x) => x !== n)); | ||
| } | ||
| module.exports = { | ||
| VMS_STATE_FILE, | ||
| normalizeRoster, | ||
| readRoster, | ||
| writeRoster, | ||
| addToRoster, | ||
| removeFromRoster, | ||
| }; |
| "use strict"; | ||
| // Pure helpers for the fleet-dashboard's parametrized launch + cached history. | ||
| // | ||
| // Dependency-free and unit-tested (npm/launch-config.test.js) so the dashboard | ||
| // server (scripts/operator/fleet-dashboard/server.js) can require them without | ||
| // pulling logic into its poll/HTTP path. A "launch config" is the operator's | ||
| // choice of WHICH client project (repo/branch) + WHICH editor (variant/branch) | ||
| // runs on a VM; the history is the cache that makes the last choice the default | ||
| // and past launches one click to re-run. | ||
| const { CANONICAL_EDITOR_REPO, EDITOR_VARIANTS, repoBasename, AGENT_ENV_VARS } = require("./cloud"); | ||
| /** The build agents a VM's editor terminal can run, as the keys of cloud.js's | ||
| * AGENT_ENV_VARS. Deriving the selectable set from that map keeps it a single | ||
| * source of truth with the agent→API-key-env-var mapping (cloud.js) and the | ||
| * gcp-bootstrap CLI installer, so adding an agent is still a one-line change in | ||
| * AGENT_ENV_VARS rather than a list to keep in sync here. */ | ||
| const AGENTS = Object.freeze(Object.keys(AGENT_ENV_VARS)); | ||
| const EMPTY_CONFIG = Object.freeze({ | ||
| clientRepo: "", | ||
| clientBranch: "", | ||
| // Greenfield: when set (and clientRepo empty), launch against a NEW empty | ||
| // folder of this name instead of cloning a repo. | ||
| newProjectName: "", | ||
| editorVariant: "dev", | ||
| editorBranch: "", | ||
| // Build agent provider the VM's editor terminal runs (claude | codex | gemini). | ||
| // Defaults to claude; cloudUpArgs forwards it as cloud:up's --agent flag. | ||
| agent: "claude", | ||
| }); | ||
| /** Coerce a raw form/query object into the canonical launch-config shape: trim | ||
| * strings, default the variant to a known value. Never throws — validation is | ||
| * a separate, explicit step. */ | ||
| function normalizeLaunchConfig(input = {}) { | ||
| const str = (v) => (typeof v === "string" ? v.trim() : ""); | ||
| const editorVariant = EDITOR_VARIANTS.includes(input.editorVariant) ? input.editorVariant : "dev"; | ||
| // Coerce an unknown/absent agent to the default rather than rejecting — the | ||
| // form's agent selector only ever submits a known value, so an out-of-set | ||
| // agent is a stale query param, not an error. | ||
| const agent = AGENTS.includes(input.agent) ? input.agent : "claude"; | ||
| return { | ||
| clientRepo: str(input.clientRepo), | ||
| clientBranch: str(input.clientBranch), | ||
| newProjectName: str(input.newProjectName), | ||
| editorVariant, | ||
| editorBranch: str(input.editorBranch), | ||
| agent, | ||
| }; | ||
| } | ||
| /** Validate a normalized config. Returns { ok, msg } so the HTTP layer can | ||
| * surface the reason without a try/catch. */ | ||
| function validateLaunchConfig(cfg) { | ||
| const hasRepo = !!(cfg && cfg.clientRepo); | ||
| const hasNew = !!(cfg && cfg.newProjectName); | ||
| if (hasRepo && hasNew) { | ||
| return { ok: false, msg: "specify either a client repo or a new-project name, not both" }; | ||
| } | ||
| if (!hasRepo && !hasNew) { | ||
| return { ok: false, msg: "a client repo or a new-project name is required" }; | ||
| } | ||
| if (hasRepo && !/^(https?:\/\/|git@)/.test(cfg.clientRepo)) { | ||
| return { ok: false, msg: "client repo must be an https:// or git@ URL" }; | ||
| } | ||
| if (hasNew && !/^[A-Za-z0-9._-]+$/.test(cfg.newProjectName)) { | ||
| return { ok: false, msg: "new-project name may contain only letters, numbers, dot, dash, underscore" }; | ||
| } | ||
| if (!EDITOR_VARIANTS.includes(cfg.editorVariant)) { | ||
| return { ok: false, msg: `unknown editor variant: ${cfg.editorVariant}` }; | ||
| } | ||
| return { ok: true }; | ||
| } | ||
| /** Gate mirror of cloud.js::assertVariantProvisionable — which editor variants | ||
| * can be provisioned onto a cloud VM. All three variants now provision: `dev` | ||
| * builds the editor image from source, while `staging`/`production` run the | ||
| * published npm package (gcp-bootstrap pulls/builds Dockerfile.npm keyed off | ||
| * the variant). An unknown variant is excluded so a typo never reaches a | ||
| * launch. Pure; no I/O. */ | ||
| function isVariantProvisionable(variant) { | ||
| return EDITOR_VARIANTS.includes(variant); | ||
| } | ||
| /** Gate for the in-place BINARY-REBUILD operations (the dashboard's "Rebuild | ||
| * binary" / "Update & Reset Everything" actions, and reconfigure's trailing | ||
| * binary refresh). Historically distinct from `isVariantProvisionable` because a | ||
| * packaged variant's rebuild is an npm-reinstall (rebuildStrategy === | ||
| * "npm-reinstall") and that host-script path was not wired — so this stayed | ||
| * dev-only and packaged rebuild buttons failed loud. With the npm-reinstall | ||
| * branch now wired into `rebuildBinaryHostScript` (it rebuilds the Dockerfile.npm | ||
| * image at the variant's dist-tag instead of source-building Dockerfile), every | ||
| * provisionable variant is also rebuildable, so this now mirrors | ||
| * `isVariantProvisionable` — only an unknown variant is rejected. Pure; no I/O. */ | ||
| function isVariantRebuildable(variant) { | ||
| return isVariantProvisionable(variant); | ||
| } | ||
| /** Map an editor variant to the npm dist-tag its package was published under. | ||
| * CI publishes `codeyam.com` → "latest" (production) and `staging.codeyam.com` | ||
| * → "staging" (see .github/workflows/cicd.yml "Set release variant"). `dev` has | ||
| * no published package (it builds the image from source), so return null and let | ||
| * callers reject it. This is the single source of truth for the variant→dist-tag | ||
| * contract — the Dockerfile.npm build-arg, publish-editor-image.sh's `--from-npm` | ||
| * mode, and the future reconfigure path all derive from it so they can't drift. | ||
| * Pure; no I/O. */ | ||
| function variantNpmDistTag(variant) { | ||
| if (variant === "production") return "latest"; | ||
| if (variant === "staging") return "staging"; | ||
| return null; // dev / unknown — no packaged dist-tag | ||
| } | ||
| /** Map a packaged editor variant to the GHCR image-tag suffix the packaged | ||
| * image is pushed under (`<registry>/codeyam-editor-npm:<suffix>`). Lives beside | ||
| * `variantNpmDistTag` so the registry naming convention and the dist-tag mapping | ||
| * stay one reviewable unit; `scripts/publish-editor-image.sh --from-npm` asserts | ||
| * the literal string this returns. `dev` has no packaged image (built from | ||
| * source), so return null. Pure; no I/O. */ | ||
| function variantImageTag(variant) { | ||
| if (variant === "production") return "production-latest"; | ||
| if (variant === "staging") return "staging-latest"; | ||
| return null; // dev / unknown — no packaged image | ||
| } | ||
| /** Build the `cloud:up` flag array for a launch config + instance name. The | ||
| * editor source is the canonical codeyam-editor repo (dev variant builds the | ||
| * image from editorBranch); the client repo/branch is what lands in | ||
| * /workspace. The build agent rides on `cfg.agent` (normalizeLaunchConfig | ||
| * guarantees a known value, defaulting to claude), so a single config object | ||
| * carries every launch choice. Decoupled exactly as cloud.js consumes them. */ | ||
| function cloudUpArgs(cfg, instanceName, project) { | ||
| const args = ["--instance-name", instanceName]; | ||
| // Pass the authoritative GCP project (the dashboard's resolved PROJECT) | ||
| // explicitly so cloud:up never relies on the spawning shell's ambient | ||
| // `gcloud config get-value project`. Critically, a `--project` flag OVERRIDES | ||
| // a persisted .codeyam/cloud.json `project` (cloud.js merges | ||
| // `{ ...existing, ...flags }`), which a bare cloud:up reuses verbatim with no | ||
| // gcloud fallback — the sticky-wrong-project failure mode where a stale | ||
| // "gcp-proj" in cloud.json wedges every re-provision with a firewall-create | ||
| // PERMISSION_DENIED. Omitted when unset to preserve the pre-existing arg shape | ||
| // (cloud.js then falls back to the gcloud default, as before). | ||
| if (project) args.push("--project", project); | ||
| // Greenfield (new folder) vs clone an existing repo. | ||
| if (cfg.newProjectName) args.push("--new-project", cfg.newProjectName); | ||
| else args.push("--repo", cfg.clientRepo); | ||
| args.push("--agent", cfg.agent || "claude", "--editor-repo", CANONICAL_EDITOR_REPO, "--editor-variant", cfg.editorVariant); | ||
| if (!cfg.newProjectName && cfg.clientBranch) args.push("--branch", cfg.clientBranch); | ||
| if (cfg.editorBranch) args.push("--editor-branch", cfg.editorBranch); | ||
| // The dashboard opens its own per-VM tunnel, so cloud:up must NOT open one: | ||
| // it lets cloud:up exit cleanly and avoids parallel batches colliding on the | ||
| // fixed local ports 14199/5173 (the cause of multi-add "false failures"). | ||
| args.push("--no-tunnel"); | ||
| return args; | ||
| } | ||
| /** Classify one container-up wait tick into a terminal/continue decision from | ||
| * what's been observed so far. This is the race-semantics seam fragility #33 | ||
| * got wrong (`scripts/operator/fleet-dashboard/server.js::addVm`): the wait | ||
| * must poll container status on its own cadence and treat ANY single `Up` as | ||
| * terminal success. | ||
| * | ||
| * Rules: | ||
| * - everSeenUp ⇒ "up" any single `Up` is terminal — | ||
| * transient probe blips before it | ||
| * are irrelevant. | ||
| * - instanceGone ⇒ "failed" fast-bail: the VM is gone, no | ||
| * point waiting out the deadline. | ||
| * - deadlineSec>0 & elapsed>=it ⇒ "failed" genuine timeout, no `Up` ever seen | ||
| * (a non-positive deadline means no | ||
| * deadline set yet, so never fail). | ||
| * - otherwise ⇒ "waiting" | ||
| * | ||
| * Deliberately NOT an input: whether `cloud:up` is still alive (its `UP_PID`). | ||
| * Under `--no-tunnel` cloud:up exits cleanly the instant the container is up | ||
| * (see cloudUpArgs), so its death is the NORMAL success path and must never | ||
| * decide failure. The bash loop mirrors this — `kill -0 $UP_PID` is logged as | ||
| * informational only. Callers may pass `upPidAlive` for readable test intent; | ||
| * it is ignored by design. */ | ||
| function classifyContainerWait({ everSeenUp = false, instanceGone = false, elapsedSec = 0, deadlineSec = 0 } = {}) { | ||
| if (everSeenUp) return "up"; | ||
| if (instanceGone) return "failed"; | ||
| if (deadlineSec > 0 && elapsedSec >= deadlineSec) return "failed"; | ||
| return "waiting"; | ||
| } | ||
| /** Decide whether a newly-reserved Add slot should start immediately or be | ||
| * queued, given the current in-flight count + the configured cap. Pure: pulls | ||
| * no globals, has no side effects. | ||
| * | ||
| * Returns "start" when there is headroom under maxConcurrency; "queue" | ||
| * otherwise. A non-positive or non-numeric maxConcurrency falls back to | ||
| * unbounded ("start" always) — the historical behavior before throttling | ||
| * existed, so a misconfigured env var degrades to "no throttle" instead of | ||
| * silently freezing all adds. | ||
| * | ||
| * The caller is responsible for the side effects: pushing to a pending | ||
| * queue + posting a `queued` job entry on "queue", or starting addVm on | ||
| * "start". Decoupling the decision from the I/O makes it unit-testable | ||
| * against arbitrary (cap, active) tuples — the test signal the OOM bug | ||
| * needed and the queue never had. */ | ||
| function decideAddSlot({ maxConcurrency, activeCount = 0 } = {}) { | ||
| const cap = Number(maxConcurrency); | ||
| if (!Number.isFinite(cap) || cap <= 0) return "start"; | ||
| return Number(activeCount) < cap ? "start" : "queue"; | ||
| } | ||
| /** Closed set of "heavy" action names — those that invoke `npm run cloud:up` | ||
| * (over gcloud-ssh) and so contend for the shared spawn budget that prevents | ||
| * the dashboard's launchd-managed node process from OOMing under burst. The | ||
| * set is the single source of truth shared between server.js's throttle gate | ||
| * (tryStartOrQueue) and the per-action queued-status mapping below; adding a | ||
| * new heavy action means editing both this set and queuedStatusForAction. | ||
| * Other actions (set-env, run-setup, reset-*, update-*, destroy) are a single | ||
| * ssh round-trip with no long-running child and bypass the throttle entirely | ||
| * — they can never OOM the dashboard on burst. */ | ||
| const HEAVY_ACTIONS = Object.freeze(["add", "rebuild", "reconfigure", "reset-all", "fleet-ready"]); | ||
| /** Map a heavy action to its per-action "queued" UI status string. Used by | ||
| * server.js's deriveStatus to render the throttled-but-not-yet-started card | ||
| * with a status that matches the eventual running status family | ||
| * (add → add-queued / adding, rebuild → rebuild-queued / rebuilding, | ||
| * reconfigure → reconfigure-queued / reconfiguring). Returns null for any | ||
| * action not in HEAVY_ACTIONS (so the caller can fall through to the | ||
| * generic running-state branch). Pure; no I/O. */ | ||
| function queuedStatusForAction(action) { | ||
| switch (action) { | ||
| case "add": return "add-queued"; | ||
| case "rebuild": return "rebuild-queued"; | ||
| case "reconfigure": return "reconfigure-queued"; | ||
| case "reset-all": return "reset-all-queued"; | ||
| // fleet-ready runs cloud:up/redeploy children, and deriveStatus already | ||
| // renders a *running* fleet-ready job as "rebuilding"; its queued card | ||
| // mirrors that same family rather than introducing an unstyled | ||
| // fleet-ready-queued status the dashboard CSS has no class for. | ||
| case "fleet-ready": return "rebuild-queued"; | ||
| default: return null; | ||
| } | ||
| } | ||
| /** Push a launch entry onto the newest-first history, capped to `cap`. No | ||
| * cross-VM dedup at write time — the full log is what powers per-VM config | ||
| * lookups (configForVm); the "recent launches" picker de-dups at read time | ||
| * (distinctConfigs). Returns a new array; does not mutate the input. */ | ||
| function pushHistory(history, entry, cap = 30) { | ||
| const arr = Array.isArray(history) ? history.slice() : []; | ||
| arr.unshift(entry); | ||
| return arr.slice(0, Math.max(1, cap)); | ||
| } | ||
| /** Distinct configs, newest-first, for the "recent launches" picker (so the | ||
| * same config launched on several VMs shows once — the most-recent | ||
| * occurrence). */ | ||
| function distinctConfigs(history, limit = 12) { | ||
| const seen = new Set(); | ||
| const out = []; | ||
| for (const e of Array.isArray(history) ? history : []) { | ||
| const k = JSON.stringify(e && e.config); | ||
| if (seen.has(k)) continue; | ||
| seen.add(k); | ||
| out.push(e); | ||
| if (out.length >= limit) break; | ||
| } | ||
| return out; | ||
| } | ||
| /** Display-side companion to `distinctConfigs`: derive the two label strings the | ||
| * "recent launches" picker renders for one config, so two configs that differ | ||
| * only by build agent (or greenfield project name) look different instead of | ||
| * collapsing to identical rows. `distinctConfigs` de-dups on the FULL config, so | ||
| * the rows are genuinely distinct launches — the old inline label just never | ||
| * surfaced the distinguishing fields (agent, greenfield name). | ||
| * | ||
| * Returns { primary, secondary }: | ||
| * - primary: greenfield (`!clientRepo && newProjectName`) → `new: <name>` so | ||
| * two different greenfield projects no longer both read `(none)`; | ||
| * else `repoBasename(clientRepo)` + (`@<branch>` when set); else | ||
| * `(none)` when there's neither a repo nor a project name. | ||
| * - secondary: `editor <variant>` + (`@<editorBranch>` when set) + ` · <agent>`. | ||
| * | ||
| * Normalizes through EMPTY_CONFIG so a pre-`agent` history entry still renders | ||
| * `· claude`. Reuses `repoBasename` (cloud.js) so server/browser basename | ||
| * semantics stay single-sourced. Pure; no I/O. */ | ||
| function recentLaunchDisplay(config) { | ||
| const cfg = { ...EMPTY_CONFIG, ...(config || {}) }; | ||
| let primary; | ||
| if (!cfg.clientRepo && cfg.newProjectName) { | ||
| primary = `new: ${cfg.newProjectName}`; | ||
| } else if (cfg.clientRepo) { | ||
| primary = repoBasename(cfg.clientRepo) + (cfg.clientBranch ? `@${cfg.clientBranch}` : ""); | ||
| } else { | ||
| primary = "(none)"; | ||
| } | ||
| const secondary = | ||
| `editor ${cfg.editorVariant || "dev"}` + | ||
| (cfg.editorBranch ? `@${cfg.editorBranch}` : "") + | ||
| ` · ${cfg.agent || "claude"}`; | ||
| return { primary, secondary }; | ||
| } | ||
| /** The default config to pre-fill the launch form: the most recent launch's | ||
| * config, or an empty config when there's no history. */ | ||
| function defaultConfig(history) { | ||
| const h = Array.isArray(history) && history.length ? history[0] : null; | ||
| return h && h.config ? { ...EMPTY_CONFIG, ...h.config } : { ...EMPTY_CONFIG }; | ||
| } | ||
| /** The most recent config used on a specific VM number (for the per-card | ||
| * Reconfigure form + the card's project/variant display). null when unknown | ||
| * (e.g. a VM launched before this feature existed). */ | ||
| function configForVm(history, n) { | ||
| const h = (Array.isArray(history) ? history : []).find((e) => e && e.vm === n); | ||
| return h && h.config ? { ...EMPTY_CONFIG, ...h.config } : null; | ||
| } | ||
| /** Resolve the card's display identity, preferring LIVE truth (the editor's | ||
| * `/api/session-info` `clientProject` + `editorIdentity`) over STALE launch | ||
| * history. This is the fix for fragility #33's lying label: a CLI branch | ||
| * switch (`git checkout -B … && rebuild-self`) never updates launch history, | ||
| * so a history-sourced label keeps showing the old branch — but the editor's | ||
| * live self-report tracks the actual branch. | ||
| * | ||
| * `live` is the polled VM object ({ clientProject?, editorIdentity? }); an | ||
| * older VM whose editor predates this feature reports neither, so the result | ||
| * falls back to `historyCfg` (a launch-config shape) field-by-field and | ||
| * `source` flags the card as showing inferred-not-live data. | ||
| * | ||
| * Returns { project, clientBranch, editorLabel, source }: | ||
| * - project bare project name (no @branch) — live name wins, else the | ||
| * history repo basename / new-project name. | ||
| * - clientBranch live client branch wins, else historyCfg.clientBranch; null | ||
| * when neither (e.g. a non-git client project). | ||
| * - editorLabel from editorIdentity — the branch when kind === "branch", | ||
| * "npm v<version>" when kind === "npm"; else the history | ||
| * variant(@branch) fallback. | ||
| * - source "live" when editorIdentity drove editorLabel, else | ||
| * "history" — the affordance the card uses to mark inferred | ||
| * data. Pure; no I/O. */ | ||
| function resolveCardIdentity(live, historyCfg) { | ||
| const cfg = historyCfg || {}; | ||
| const lcp = live && live.clientProject; | ||
| const lei = live && live.editorIdentity; | ||
| const historyName = cfg.clientRepo ? repoBasename(cfg.clientRepo) : (cfg.newProjectName || ""); | ||
| const project = (lcp && lcp.name) || historyName || null; | ||
| const clientBranch = (lcp && lcp.branch) || cfg.clientBranch || null; | ||
| let editorLabel; | ||
| let source; | ||
| if (lei && lei.kind === "branch" && lei.branch) { | ||
| editorLabel = lei.branch; | ||
| source = "live"; | ||
| } else if (lei && lei.kind === "npm") { | ||
| editorLabel = "npm v" + (lei.version || "?"); | ||
| source = "live"; | ||
| } else { | ||
| const variant = cfg.editorVariant || "dev"; | ||
| editorLabel = variant + (cfg.editorBranch ? "@" + cfg.editorBranch : ""); | ||
| source = "history"; | ||
| } | ||
| return { project, clientBranch, editorLabel, source }; | ||
| } | ||
| /** Sticky-merge a VM poll's `clientProject` against the last-known-good value | ||
| * so a transient null poll (timeout / partial probe) does not clobber a | ||
| * populated identity. The live `clientProject.branch` is the fleet | ||
| * classifier's first-choice branch source (`plan_actions` in | ||
| * fleet-roll-to-branch.sh); dropping it to null on a missed cycle would | ||
| * regress classification to the stale launch-history branch. Mirrors how the | ||
| * other live fields tolerate a missed poll: a populated `prev` survives a | ||
| * null/absent fresh poll, and a populated fresh poll always wins. Pure; no I/O. | ||
| * | ||
| * @param prev the previous `state.vms[n].clientProject` (null/undefined ok) | ||
| * @param info the fresh poll result object (null when the probe failed) | ||
| * @returns the clientProject to store: fresh value when present, else the | ||
| * last-known-good `prev`, else null. */ | ||
| function stickyClientProject(prev, info) { | ||
| const fresh = info && info.clientProject; | ||
| if (fresh) return fresh; | ||
| return prev || null; | ||
| } | ||
| /** Classify a VM's project KIND as `"self-host"` vs `"guest"` from the live | ||
| * identity reported in `/api/session-info` (`clientProject.repo`), with | ||
| * launch-history `clientRepo` as a fallback when live is absent. A guest is | ||
| * any VM whose repo basename differs from `CANONICAL_EDITOR_REPO`'s | ||
| * (`codeyam-editor`) — Margo today, future guests tomorrow. | ||
| * | ||
| * The dashboard surfaces the result with a small `🛡 guest` chip so the | ||
| * "leave-the-margo-one-alone"-style caveats become a visible guardrail at | ||
| * glance instead of a memo the operator has to remember. The kind is a | ||
| * *type*, not an alert — the status badge remains the primary signal. | ||
| * | ||
| * Defaults: both signals missing → `"self-host"` (the dominant fleet shape; | ||
| * a falsely visible "guest" badge on a real self-host VM would be more | ||
| * confusing than a missing one). Empty-string repos collapse to the same | ||
| * unknown-→-self-host default. Reuses `CANONICAL_EDITOR_REPO` so the | ||
| * self-host identity stays a single source of truth shared with | ||
| * `cloudUpArgs`. Pure; no I/O. */ | ||
| function classifyProjectKind(live, historyCfg) { | ||
| const cfg = historyCfg || {}; | ||
| const lcp = live && live.clientProject; | ||
| const liveRepo = lcp && typeof lcp.repo === "string" ? lcp.repo : ""; | ||
| const repoUrl = liveRepo || (typeof cfg.clientRepo === "string" ? cfg.clientRepo : ""); | ||
| if (!repoUrl) return "self-host"; | ||
| const canonical = repoBasename(CANONICAL_EDITOR_REPO); | ||
| return repoBasename(repoUrl) === canonical ? "self-host" : "guest"; | ||
| } | ||
| /** Resolve where a VM's codeyam-editor SOURCE lives, given its shape. This is | ||
| * the load-bearing per-VM-shape branch for the dashboard's "update editor | ||
| * source" + "rebuild binary" actions: the editor source — and how you rebuild | ||
| * from it — differs between a self-host VM (the client project IS | ||
| * codeyam-editor) and a non-self-host one (editing some other project). | ||
| * | ||
| * - Self-host: the editor source IS the agent's workspace, so it's `/workspace` | ||
| * INSIDE the container; the rebuild runs there in place (`rebuild-self`). | ||
| * - Non-self-host: the editor binary comes from the `codeyam-editor:local` | ||
| * image, built on the VM HOST. `scripts/gcp-bootstrap.sh` clones the editor | ||
| * repo to `$HOME/codeyam-editor` and builds the image from there, so that is | ||
| * the authoritative image source. A self-host VM ALSO carries a creds-bearing | ||
| * workspace clone at `$HOME/projects/codeyam-editor` (docs/cloud-editor-stability.md | ||
| * "VM-side paths"); the image-rebuild flow prefers it WHEN PRESENT (it can | ||
| * `git pull` private repos), else falls back to the canonical image clone. | ||
| * The caller checks `path` then `fallbackPath` on disk at runtime. | ||
| * | ||
| * Stack assumption: this encodes the cloud-VM editor-source layout (Docker | ||
| * image + ~/projects mount). A non-Docker editor host would need its own | ||
| * resolver branch. Pure; no I/O. */ | ||
| function resolveEditorSource({ selfHost = false, home = "$HOME" } = {}) { | ||
| if (selfHost) { | ||
| return { selfHost: true, location: "container", path: "/workspace", fallbackPath: null }; | ||
| } | ||
| return { | ||
| selfHost: false, | ||
| location: "host", | ||
| path: `${home}/projects/codeyam-editor`, | ||
| fallbackPath: `${home}/codeyam-editor`, | ||
| }; | ||
| } | ||
| /** Emit the shell snippet that resolves the host editor-source clone ON THE | ||
| * REMOTE VM, assigning the chosen path to `$ESRC`. Prefer the credentialed | ||
| * projects clone (`src.path`); fall back to the image clone (`src.fallbackPath`) | ||
| * only when the primary has no `.git`. | ||
| * | ||
| * CRITICAL — the paths must EXPAND on the VM, not on the laptop. `resolveEditorSource` | ||
| * returns `$HOME`-relative CONSTANTS (the dashboard doesn't know the VM's home dir), | ||
| * so they go into DOUBLE quotes here, never `shq()`/single-quotes. Single-quoting a | ||
| * `$HOME`-bearing path defeats remote expansion: `cd "$ESRC"` then fails with | ||
| * `cd: $HOME/...: No such file or directory` on every non-self-host VM (the improve34 | ||
| * bug). These paths are fixed constants, not user input — no injection surface, so | ||
| * `shq()` (which exists to neutralize user input) is the wrong tool. Pure; no I/O. | ||
| * | ||
| * The primary clone is kept only when it is actually USABLE as an editor source, | ||
| * not merely when a `.git` directory exists. A half-cloned / corrupt tree has a | ||
| * `.git` dir yet can't build — `git rev-parse` fails and there's no Dockerfile, so | ||
| * `docker build "$ESRC"` dies on `open Dockerfile: no such file or directory` (the | ||
| * improve39 VM-9 bug). The gate therefore requires a valid work tree AND a | ||
| * Dockerfile; otherwise it falls back to the canonical image clone | ||
| * `$HOME/codeyam-editor` (the authoritative source provisioning built the image | ||
| * from). This preserves the prefer-credentialed-clone behavior, but only when the | ||
| * credentialed clone actually works. */ | ||
| function editorSourceShellResolve(src) { | ||
| const primary = `ESRC="${src.path}"`; | ||
| if (!src.fallbackPath) return primary; | ||
| return `${primary}; { git -C "$ESRC" rev-parse --is-inside-work-tree >/dev/null 2>&1 && [ -f "$ESRC/Dockerfile" ]; } || ESRC="${src.fallbackPath}"`; | ||
| } | ||
| /** Choose HOW to rebuild the editor binary on a VM, from its shape + the editor | ||
| * variant. The three mechanisms map 1:1 to the EDITOR_VARIANTS contract: | ||
| * - "rebuild-self" — self-host `dev`: compile in place inside the container | ||
| * (`rebuild-self --force-in-container`); /workspace is the | ||
| * editor source. | ||
| * - "image-rebuild" — non-self-host `dev`: the binary ships in the | ||
| * `codeyam-editor:local` image, so it needs a host-side | ||
| * `docker build` + container recreate. | ||
| * - "npm-reinstall" — a packaged variant (`staging`/`production`): the binary | ||
| * is the published editor npm package, so a rebuild is a | ||
| * reinstall, not a compile. | ||
| * Pure; no I/O. `rebuildBinaryHostScript` consumes this to pick its build line | ||
| * (the npm-reinstall branch rebuilds the Dockerfile.npm image at the variant's | ||
| * dist-tag); the self-host axis is detected at runtime | ||
| * on the VM (the `[ -f /workspace/Cargo.toml ]` signal is authoritative even | ||
| * for VMs the dashboard has no launch-history record for). */ | ||
| function rebuildStrategy({ selfHost = false, editorVariant = "dev" } = {}) { | ||
| if (editorVariant !== "dev") return "npm-reinstall"; | ||
| return selfHost ? "rebuild-self" : "image-rebuild"; | ||
| } | ||
| /** Decide whether a new per-VM job may start, given the VM's currently-tracked | ||
| * job. `state.jobs[n]` is a SINGLE slot per VM, so a second `startJob` would | ||
| * OVERWRITE an in-flight job's tracker — the bug where `reapplyVmEnv`'s | ||
| * follow-on `set-env` clobbered a still-running Rebuild's card, so the operator | ||
| * saw a `set-env` completion instead of the rebuild they asked for, with no | ||
| * error surfaced. Any job already `running` blocks a new one: | ||
| * - `idle` — no in-flight job (or the slot's job already finished); | ||
| * the caller starts normally. | ||
| * - `same-action` — a job with the SAME action is already running; reject | ||
| * (the pre-existing duplicate-click guard). | ||
| * - `different-action`— a DIFFERENT action is running; reject rather than | ||
| * clobber its tracker. Operators sequence the two jobs | ||
| * manually (the rare legitimate case). | ||
| * Returns `{ decision: 'start' }` or | ||
| * `{ decision: 'reject', reason, runningAction }` so the caller builds the | ||
| * user-facing message (the two reject reasons word it differently). Pure; no | ||
| * I/O — `existing` is the job record, `requested` the action about to start. */ | ||
| function decideStartJob({ existing, requested } = {}) { | ||
| if (!existing || existing.state !== "running") return { decision: "start" }; | ||
| const runningAction = existing.action; | ||
| if (runningAction === requested) { | ||
| return { decision: "reject", reason: "same-action", runningAction }; | ||
| } | ||
| return { decision: "reject", reason: "different-action", runningAction }; | ||
| } | ||
| /** Decide where a freshly-added VM's credentials come from. The dashboard's | ||
| * default is a container→container copy from a live donor VM, but a full | ||
| * teardown empties the roster and leaves no donor — so Add must fall back to | ||
| * operator-configured sources. Precedence, highest first: | ||
| * - "donor" — a reachable donor exists; copy claude + git from it (the | ||
| * unchanged default). `donor` = the chosen VM number. | ||
| * - "host-stash" — no donor, but CLAUDE_CREDS_SRC points at a known-good | ||
| * .credentials.json to push into the new container. | ||
| * - "token" — no donor and no stash, but GITHUB_TOKEN is set (cloud:up | ||
| * seeds git creds at boot). claude auth is NOT seeded. | ||
| * - "none" — nothing available; wire + roster only, claude + git both | ||
| * unseeded. | ||
| * Modes "token" and "none" carry no claude auth, so the caller marks the VM | ||
| * add-degraded with a `claude /login` remediation. `reachableDonors` must be | ||
| * pre-filtered to VMs that actually answered (a destroyed-but-rostered orphan | ||
| * is not a donor — fragility #24). Pure; no I/O. */ | ||
| function resolveCredsSource({ reachableDonors = [], claudeCredsSrc = "", githubToken = "" } = {}) { | ||
| const donor = Array.isArray(reachableDonors) && reachableDonors.length ? reachableDonors[0] : null; | ||
| if (donor != null) return { mode: "donor", donor }; | ||
| if (claudeCredsSrc) return { mode: "host-stash", donor: null }; | ||
| if (githubToken) return { mode: "token", donor: null }; | ||
| return { mode: "none", donor: null }; | ||
| } | ||
| // Single-quote a string for safe inclusion in a remote shell command. Mirrors | ||
| // the server.js helper so callers don't have to thread one through. | ||
| function _shq(s) { return `'${String(s).replace(/'/g, `'\\''`)}'`; } | ||
| /** Build the shell steps that seed a freshly-added VM's claude + git credentials, | ||
| * for the resolved `mode` (see `resolveCredsSource`). Returns an array of shell | ||
| * strings the caller newline-joins into the larger provisioning script — each | ||
| * step is independent; a `CY_DEGRADED <message>` from one does not block the | ||
| * next, and the dashboard's job-attribution path lifts those markers onto the | ||
| * card as `degradedReasons`. | ||
| * | ||
| * Donor mode (the default) copies, container→container from the donor VM: | ||
| * 1. host-file git credentials → `$HOME/.codeyam-cloud-git-credentials` | ||
| * 2. claude `.credentials.json` → `/root/.claude/.credentials.json` | ||
| * 3. claude `.claude.json` → `/root/.claude.json` | ||
| * Step 3 (onboarding state — `hasCompletedOnboarding` + sibling keys) is what | ||
| * keeps the first interactive `claude` TUI from popping the welcome "Select | ||
| * login method" menu; without it the non-interactive `claude --print` probe | ||
| * still passes (so `/api/config` reports `authenticated`), but the Build tab | ||
| * PTY stalls at the picker. Failure of any single step emits its own | ||
| * `CY_DEGRADED` marker — the credentials copy is the load-bearing one; the | ||
| * onboarding-state copy is recoverable via `claude /login`. | ||
| * | ||
| * host-stash / token / none modes don't have a donor `.claude.json` to copy — | ||
| * for now those paths still land degraded for the same reason; a separate plan | ||
| * can extend host-stash with a sibling onboarding-stash env knob. | ||
| * | ||
| * Pure; no I/O. */ | ||
| function buildCredsSteps({ mode, refInst = "", inst, container, gcloud, zone, n, claudeCredsSrc = "" } = {}) { | ||
| const claudeSink = `docker exec -i ${container} bash -c "umask 077; base64 -d > /root/.claude/.credentials.json && chmod 600 /root/.claude/.credentials.json"`; | ||
| const claudeJsonSink = `docker exec -i ${container} bash -c "umask 077; base64 -d > /root/.claude.json && chmod 600 /root/.claude.json"`; | ||
| const loginHint = `run: docker exec -it ${container} claude /login on ${inst}`; | ||
| if (mode === "donor") { | ||
| return [ | ||
| `echo "copying git creds from ${refInst}..."`, | ||
| `${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)"`, | ||
| `echo "copying claude auth from ${refInst} (container->container)..."`, | ||
| `${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}"`, | ||
| `echo "copying claude.json (onboarding state) from ${refInst} (container->container)..."`, | ||
| `${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"`, | ||
| ]; | ||
| } | ||
| if (mode === "host-stash") { | ||
| return [ | ||
| `echo "no reachable donor — seeding claude auth from host stash..."`, | ||
| `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}"`, | ||
| ]; | ||
| } | ||
| if (mode === "token") { | ||
| return [ | ||
| `echo "no reachable donor and no CLAUDE_CREDS_SRC — git creds seeded by cloud:up; claude auth NOT seeded. ${loginHint}"`, | ||
| ]; | ||
| } | ||
| return [ | ||
| `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"`, | ||
| ]; | ||
| } | ||
| /** Build the two host-side shell snippets that PRESERVE the container's | ||
| * /root/.claude.json (claude onboarding state) across a non-self-host Rebuild's | ||
| * container recreate. The whole /root/.claude DIRECTORY is bind-mounted from the | ||
| * host ($HOME/.codeyam-cloud-claude → /root/.claude, see docker-compose.cloud.yml), | ||
| * so /root/.claude/.credentials.json survives `docker compose down && up`. But | ||
| * /root/.claude.json is a SIBLING in the container's EPHEMERAL fs — NOT in the | ||
| * bind mount — so a recreate resets it to the image baseline, dropping the | ||
| * operator's `hasCompletedOnboarding`. The first interactive `claude` TUI then | ||
| * pops the "Select login method" welcome menu and the Build tab is blocked, | ||
| * even though the non-interactive `claude --print` auth probe still passes | ||
| * (improve36). The good copy is still in the LIVE container right up until | ||
| * `docker compose down`, so we capture it to a host temp file, then restore it | ||
| * into the freshly-recreated container — no donor required. | ||
| * | ||
| * Returns { capture, restore }, each an array of shell lines for the on-host | ||
| * rebuild script (run via gcloud-ssh, direct `docker exec` — same execution | ||
| * context as gitReseedHostStep). `capture` runs BEFORE `docker compose down`; | ||
| * `restore` runs AFTER `up`, BEFORE the editor verify probe. The lines share a | ||
| * `bakVar` shell variable holding the temp-file path, so they MUST be spread | ||
| * into the SAME bash invocation. A failed restore emits a `CY_DEGRADED` marker | ||
| * (the dashboard lifts it onto the card as a degradedReason) with a | ||
| * `claude /login` remediation — the rebuild itself still succeeded, so the job | ||
| * lands `done`, not `error`. Pure; no I/O. */ | ||
| function buildClaudeJsonPreserveSteps({ container, n = "", bakVar = "CY_CLAUDE_JSON_BAK" } = {}) { | ||
| const loginHint = `run: docker exec -it ${container} claude /login`; | ||
| const capture = [ | ||
| `${bakVar}=$(mktemp)`, | ||
| `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`, | ||
| ]; | ||
| const restore = [ | ||
| `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`, | ||
| `rm -f "$${bakVar}"`, | ||
| ]; | ||
| return { capture, restore }; | ||
| } | ||
| /** Build the shell command that base64-pipes a VM's persisted env over ssh + | ||
| * docker-exec stdin into the container's /workspace/.env (umask 077, chmod | ||
| * 600), chowns it to the workspace owner so the bind-mounted host file is | ||
| * readable by `docker compose --force-recreate` (Rebuild / Reconfigure / | ||
| * Add — host-side, as the unprivileged SSH user, not root), and restarts | ||
| * the dev server. The docker exec runs as root inside the container, so | ||
| * without the chown the file lands `root:root 0600` on the host bind-mount | ||
| * and the next compose recreate fails `open .env: permission denied` | ||
| * (fragility — improve36). `chown --reference=/workspace` inherits whatever | ||
| * uid the image's entrypoint assigned to the workspace (currently 1003 = | ||
| * the host SSH user), so the fix is robust against future UID changes. | ||
| * Values stay out of argv/logs: they ride as base64 over the ssh + docker | ||
| * exec stdin, never as CLI arguments. Pure; no I/O. */ | ||
| function buildEnvApplyCommand({ gcloud, instance, zone, envFile, container, editorBin } = {}) { | ||
| 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"`; | ||
| return `base64 < ${_shq(envFile)} | ${gcloud} compute ssh ${instance} --tunnel-through-iap --zone=${zone} --command=${_shq(sink)}`; | ||
| } | ||
| /** Get authenticated Git URL for checking client branch. */ | ||
| function getAuthRepoUrl(repo, token) { | ||
| if (!repo) return ""; | ||
| if (!token) return repo; | ||
| const sshRegex = /^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/; | ||
| const sshMatch = repo.match(sshRegex); | ||
| if (sshMatch) { | ||
| return `https://x-access-token:${token}@github.com/${sshMatch[1]}/${sshMatch[2]}`; | ||
| } | ||
| if (repo.startsWith("https://github.com/")) { | ||
| return repo.replace(/^https:\/\/github\.com\//, `https://x-access-token:${token}@github.com/`); | ||
| } | ||
| return repo; | ||
| } | ||
| /** Interpret the output of git ls-remote to see if a branch exists. */ | ||
| function interpretLsRemote({ stdout = "", code = 0, err = null, repo = "", branch = "" } = {}) { | ||
| const b = (branch || "").trim(); | ||
| if (!b) { | ||
| return { ok: true, msg: "using repo default branch" }; | ||
| } | ||
| if (err || code !== 0) { | ||
| const errMsg = (err && (err.message || String(err))) || "command failed"; | ||
| return { | ||
| ok: false, | ||
| soft: true, | ||
| msg: `couldn't verify branch: ${errMsg.trim()}` | ||
| }; | ||
| } | ||
| const trimmed = (stdout || "").trim(); | ||
| if (trimmed) { | ||
| return { ok: true }; | ||
| } else { | ||
| return { | ||
| ok: false, | ||
| msg: `branch '${b}' not found in ${repo || "repository"}` | ||
| }; | ||
| } | ||
| } | ||
| module.exports = { | ||
| AGENTS, | ||
| EMPTY_CONFIG, | ||
| normalizeLaunchConfig, | ||
| validateLaunchConfig, | ||
| isVariantProvisionable, | ||
| isVariantRebuildable, | ||
| variantNpmDistTag, | ||
| variantImageTag, | ||
| cloudUpArgs, | ||
| classifyContainerWait, | ||
| decideAddSlot, | ||
| HEAVY_ACTIONS, | ||
| queuedStatusForAction, | ||
| pushHistory, | ||
| distinctConfigs, | ||
| recentLaunchDisplay, | ||
| defaultConfig, | ||
| configForVm, | ||
| resolveCardIdentity, | ||
| stickyClientProject, | ||
| classifyProjectKind, | ||
| resolveCredsSource, | ||
| buildCredsSteps, | ||
| buildClaudeJsonPreserveSteps, | ||
| buildEnvApplyCommand, | ||
| resolveEditorSource, | ||
| editorSourceShellResolve, | ||
| rebuildStrategy, | ||
| decideStartJob, | ||
| getAuthRepoUrl, | ||
| interpretLsRemote, | ||
| }; |
| "use strict"; | ||
| /** | ||
| * Launcher-side mirror of `start_preflight` in | ||
| * `crates/codeyam-editor/src/commands/start.rs`. The Rust side is the source | ||
| * of truth for the guidance strings; if it changes, update this file in the | ||
| * same commit and keep the `crates/codeyam-editor/tests/start_preflight_exit_code.rs` | ||
| * test in sync. | ||
| * | ||
| * Mirrors the heuristic in `migration_status::is_legacy_config`: a | ||
| * `.codeyam/config.json` whose JSON has a top-level `webapps` key marks a | ||
| * legacy project. Anything else with no `editor.json` is greenfield. | ||
| * | ||
| * Rust-side behavior note (improve39): `start_preflight` no longer bails on an | ||
| * EMPTY greenfield folder — it auto-inits in place and boots the editor's wired | ||
| * scaffold flow. It still bails (exit 2) for an existing-source `none` folder | ||
| * (the `greenfieldGuidance` text below) and for legacy. This module is consumed | ||
| * by the launcher's bail/guidance path only; `classifyProject` does not itself | ||
| * decide empty-vs-non-empty (the launcher's `planPreflightAction` / | ||
| * `postLaunchInitBanner` in editor-dev.js own that split and intentionally | ||
| * auto-init all `none` projects as a local-dev convenience). | ||
| */ | ||
| const fs = require("node:fs"); | ||
| const path = require("node:path"); | ||
| function greenfieldGuidance(projectDir) { | ||
| return ( | ||
| `No codeyam-editor project detected in ${projectDir}.\n\n` + | ||
| "Run `codeyam-editor init` to install the agent bundle, then\n" + | ||
| "open Claude Code and invoke `/codeyam-onboard` to set up this project." | ||
| ); | ||
| } | ||
| function legacyGuidance(projectDir) { | ||
| return ( | ||
| `Detected legacy codeyam project in ${projectDir}.\n\n` + | ||
| "Run `codeyam-editor init` to install the agent bundle (it will skip\n" + | ||
| "editor.json creation so the legacy classification is preserved), then\n" + | ||
| "open Claude Code and invoke `/codeyam-onboard` to migrate." | ||
| ); | ||
| } | ||
| function isLegacyConfig(configPath) { | ||
| let raw; | ||
| try { | ||
| raw = fs.readFileSync(configPath, "utf8"); | ||
| } catch { | ||
| return false; | ||
| } | ||
| let parsed; | ||
| try { | ||
| parsed = JSON.parse(raw); | ||
| } catch { | ||
| return false; | ||
| } | ||
| return parsed && typeof parsed === "object" && "webapps" in parsed; | ||
| } | ||
| /** | ||
| * Classify a project directory as `modern`, `legacy`, or `none`, mirroring | ||
| * the Rust `classify` / `start_preflight` pair. `guidance` is the exact | ||
| * stderr text to print before exiting with code 2. | ||
| */ | ||
| function classifyProject(projectDir) { | ||
| const editorJson = path.join(projectDir, ".codeyam", "editor.json"); | ||
| if (fs.existsSync(editorJson)) { | ||
| return { kind: "modern" }; | ||
| } | ||
| const legacyConfig = path.join(projectDir, ".codeyam", "config.json"); | ||
| if (isLegacyConfig(legacyConfig)) { | ||
| return { kind: "legacy", guidance: legacyGuidance(projectDir) }; | ||
| } | ||
| return { kind: "none", guidance: greenfieldGuidance(projectDir) }; | ||
| } | ||
| module.exports = { classifyProject }; |
| const { createIssue } = require("./scenario-issues"); | ||
| function getInitScript() { | ||
| return ` | ||
| window.__codeyamUnhandledRejections = []; | ||
| window.addEventListener("unhandledrejection", (event) => { | ||
| const reason = event.reason; | ||
| const message = | ||
| reason instanceof Error ? reason.message : String(reason); | ||
| window.__codeyamUnhandledRejections.push(message); | ||
| }); | ||
| // Stub WebSocket during capture to prevent terminal reconnection spam. | ||
| window.WebSocket = class StubWebSocket { | ||
| static CONNECTING = 0; | ||
| static OPEN = 1; | ||
| static CLOSING = 2; | ||
| static CLOSED = 3; | ||
| readyState = 3; | ||
| onopen = null; | ||
| onclose = null; | ||
| onerror = null; | ||
| onmessage = null; | ||
| send() {} | ||
| close() {} | ||
| addEventListener() {} | ||
| removeEventListener() {} | ||
| dispatchEvent() { return false; } | ||
| constructor() { | ||
| setTimeout(() => { | ||
| if (this.onerror) this.onerror(new Event("error")); | ||
| if (this.onclose) this.onclose(new CloseEvent("close")); | ||
| }, 0); | ||
| } | ||
| }; | ||
| `; | ||
| } | ||
| function handleConsoleMessage(message) { | ||
| if (message.type() !== "error") return null; | ||
| const text = message.text(); | ||
| // Ignore known dev-server WebSocket/HMR errors from Vite proxy | ||
| if ( | ||
| text.includes("WebSocket connection to") || | ||
| text.includes("Unsupported Media Type") | ||
| ) { | ||
| return null; | ||
| } | ||
| // Ignore the browser's blocked-script warning for sandboxed mockup-preview | ||
| // frames. Mockup previews render untrusted AI-generated HTML inside a | ||
| // `sandbox=""` iframe; the HTML-injection proxy injects an error-capture | ||
| // <script> tag, which the browser then refuses to run, emitting | ||
| // "Blocked script execution ... because the frame is sandboxed". That block | ||
| // is the capture's own injected script being denied — benign for capture | ||
| // purposes. Match narrowly on BOTH the block phrase and the "sandboxed" | ||
| // signature so a genuine non-sandbox CSP block ("Blocked script execution" | ||
| // without "sandboxed") still surfaces as a real issue. | ||
| if ( | ||
| text.includes("Blocked script execution") && | ||
| text.includes("sandboxed") | ||
| ) { | ||
| return null; | ||
| } | ||
| return createIssue("console", text); | ||
| } | ||
| function handlePageError(error) { | ||
| return createIssue("pageerror", error.message || String(error)); | ||
| } | ||
| function handleRequestFailed(request) { | ||
| const errorText = request.failure()?.errorText || "Request failed"; | ||
| // Filter benign request cancellations. `net::ERR_ABORTED` is what Playwright | ||
| // emits when a request is in-flight at the moment the page is closed (or the | ||
| // iframe is destroyed). For scenarios whose pages fetch large payloads (the | ||
| // editor's own EditorShell mounts a 2.2MB `/api/tests` fetch), the | ||
| // browser.close() at the end of capture races the fetch and produces this | ||
| // event AFTER the screenshot has already been taken — there is no real | ||
| // failure to surface. Genuine network failures arrive under different | ||
| // codes (net::ERR_CONNECTION_REFUSED, net::ERR_NAME_NOT_RESOLVED, etc.) | ||
| // and continue to be reported. | ||
| if (errorText.includes("net::ERR_ABORTED")) { | ||
| return null; | ||
| } | ||
| return createIssue("requestfailed", errorText, { url: request.url() }); | ||
| } | ||
| module.exports = { | ||
| getInitScript, | ||
| handleConsoleMessage, | ||
| handlePageError, | ||
| handleRequestFailed, | ||
| }; |
| const fs = require("fs"); | ||
| const path = require("path"); | ||
| const { createIssue } = require("./scenario-issues"); | ||
| // Hydration / interactivity probe for the headless capture browser. | ||
| // | ||
| // Background: a page can return HTTP 200, render visible content, log no | ||
| // console errors, and still be completely dead — the client framework never | ||
| // hydrated, so every button and handler is inert. The status / render / | ||
| // console / image gates all pass and the broken page sails through to the | ||
| // user (the catalog whose filter buttons did nothing). This probe asserts the | ||
| // page actually became interactive before the capture gate reports success, | ||
| // so a non-hydrating page can no longer pass `client-errors`. | ||
| // | ||
| // Stack assumption: WHICH framework's hydration we look for is data-driven | ||
| // from `.codeyam/stack.json` (or an explicit override) — never hardcoded into | ||
| // the capture flow. Stacks with no client runtime (backend services, CLIs, | ||
| // static HTML) are a documented no-op pass. Frameworks for which we have no | ||
| // reliable in-page attachment signal are ALSO a conservative pass: we never | ||
| // flag a page we cannot prove is dead, so a healthy Svelte / Solid / vanilla | ||
| // page is never a false negative. A new framework detector is a new entry in | ||
| // `detectors` below plus a `KNOWN_FRAMEWORKS` mapping — not an edit here. | ||
| // Frameworks we have an in-page attachment detector for. Inference only | ||
| // resolves to one of these; an unrecognised framework yields `null`, which | ||
| // the caller treats as "cannot determine" (conservative pass). | ||
| const KNOWN_FRAMEWORKS = ["react", "vue"]; | ||
| // Read `.codeyam/stack.json` relative to the capture script's cwd (the project | ||
| // dir — `scenario_check.rs` sets `.current_dir(project_dir)`). Never throws: a | ||
| // missing or malformed file yields `null` so the probe degrades to a no-op | ||
| // rather than breaking a capture. | ||
| function readStackJson() { | ||
| try { | ||
| const raw = fs.readFileSync(path.join(".codeyam", "stack.json"), "utf8"); | ||
| return JSON.parse(raw); | ||
| } catch (_) { | ||
| return null; | ||
| } | ||
| } | ||
| // Scan a stack descriptor's identity fields for a framework we can probe. | ||
| // Pure — no I/O — so the matching rules are unit-tested without a stack.json. | ||
| function inferFramework(stack) { | ||
| if (!stack) return null; | ||
| const haystack = [ | ||
| stack.id, | ||
| stack.name, | ||
| ...(Array.isArray(stack.technologies) ? stack.technologies : []), | ||
| ] | ||
| .filter((s) => typeof s === "string") | ||
| .join(" ") | ||
| .toLowerCase(); | ||
| for (const fw of KNOWN_FRAMEWORKS) { | ||
| if (haystack.includes(fw)) return fw; | ||
| } | ||
| return null; | ||
| } | ||
| // Decide, from a stack descriptor, whether the capture should expect a | ||
| // hydrated client runtime and which framework to probe for. Pure. | ||
| // | ||
| // `capture.interactivity === false` is an explicit opt-out for stacks that | ||
| // render no client runtime. `capture.interactivity.framework` is an explicit | ||
| // override when inference can't see the framework in the identity fields. | ||
| // Otherwise we infer: a known client framework, or `routing.type === | ||
| // "client-side"`, implies a runtime that must hydrate; backend / static / CLI | ||
| // stacks match neither and no-op. | ||
| function resolveInteractivityExpectation(stack) { | ||
| const capture = (stack && stack.capture) || {}; | ||
| if (capture.interactivity === false) { | ||
| return { expectInteractive: false, framework: null }; | ||
| } | ||
| const explicit = | ||
| capture.interactivity && typeof capture.interactivity === "object" | ||
| ? capture.interactivity.framework | ||
| : null; | ||
| if (typeof explicit === "string" && explicit.length > 0) { | ||
| return { expectInteractive: true, framework: explicit.toLowerCase() }; | ||
| } | ||
| const framework = inferFramework(stack); | ||
| const routingType = | ||
| stack && stack.routing && typeof stack.routing.type === "string" | ||
| ? stack.routing.type | ||
| : null; | ||
| const expectInteractive = framework != null || routingType === "client-side"; | ||
| return { expectInteractive, framework }; | ||
| } | ||
| // Run the in-page detection inside the loaded frame. Read-only: it inspects | ||
| // DOM-node properties left by a framework's hydration but never clicks or | ||
| // mutates anything, so it is safe to run before the screenshot is taken. | ||
| // | ||
| // Returns `{ controlCount, frameworkAttached }` where `frameworkAttached` is | ||
| // `true` (runtime demonstrably attached), `false` (framework-owned controls | ||
| // exist but no attachment signal — the dead-hydration case), or `null` (no | ||
| // detector for this framework, OR every interactive control is delegated to a | ||
| // terminal/canvas widget with no hydration marker → cannot judge). | ||
| async function collectHydrationState(frame, { framework } = {}) { | ||
| return frame.evaluate((fw) => { | ||
| const SELECTOR = | ||
| 'button, [role="button"], a[href], input:not([type="hidden"]), select, textarea, summary, [onclick]'; | ||
| // Roots of third-party terminal/canvas widgets that mount imperatively and | ||
| // wire their own (non-framework) event handlers — xterm's `.xterm`, any | ||
| // `<canvas>`, or an element a component explicitly flags terminal-backed. | ||
| // Interactive controls inside these (e.g. xterm's hidden helper | ||
| // `<textarea>`) never carry framework attachment keys even on a fully | ||
| // hydrated page, so judging hydration from them yields a false "not | ||
| // interactive" verdict. Stack assumption: `.xterm` is xterm-specific; the | ||
| // `<canvas>` and `[data-terminal-backed]` entries are framework-agnostic | ||
| // escape hatches any widget-embedding component can use. | ||
| const WIDGET_ROOT_SELECTOR = ".xterm, canvas, [data-terminal-backed]"; | ||
| const controls = Array.from(document.querySelectorAll(SELECTOR)); | ||
| const inWidget = (el) => | ||
| !!el && | ||
| typeof el.closest === "function" && | ||
| el.closest(WIDGET_ROOT_SELECTOR) != null; | ||
| // Framework-owned controls: those NOT delegated to a terminal/canvas | ||
| // widget. Only these can prove or disprove that the framework hydrated. | ||
| const frameworkControls = controls.filter((el) => !inWidget(el)); | ||
| // Per-framework attachment detectors. Each returns true when the framework | ||
| // has demonstrably attached its client runtime to a live node. The | ||
| // presence of a detector for `fw` is itself the signal that we CAN judge | ||
| // this framework; the default (no detector) means "cannot determine". | ||
| const detectors = { | ||
| react: (els) => | ||
| els.some( | ||
| (el) => | ||
| !!el && | ||
| Object.keys(el).some( | ||
| (k) => | ||
| k.startsWith("__reactFiber$") || | ||
| k.startsWith("__reactProps$") || | ||
| k.startsWith("__reactContainer$"), | ||
| ), | ||
| ), | ||
| vue: (els) => | ||
| els.some( | ||
| (el) => | ||
| !!el && | ||
| (el.__vue__ != null || | ||
| el.__vnode != null || | ||
| el.__vueParentComponent != null), | ||
| ) || !!document.querySelector("[data-v-app]"), | ||
| }; | ||
| const detector = detectors[fw]; | ||
| // Terminal/canvas scenarios prove hydration with an explicit, framework- | ||
| // rendered `data-codeyam-hydrated` marker. It counts only when the | ||
| // framework actually attached to it — a marker that exists in static HTML | ||
| // but never hydrated fails the same detector and does not count. | ||
| const markerEl = document.querySelector("[data-codeyam-hydrated]"); | ||
| const markerAttached = detector | ||
| ? detector(markerEl ? [markerEl] : []) | ||
| : false; | ||
| let frameworkAttached; | ||
| if (!detector) { | ||
| // No detector for this framework — cannot judge (conservative pass). | ||
| frameworkAttached = null; | ||
| } else if (detector(frameworkControls) || markerAttached) { | ||
| // A genuinely framework-owned control attached, or an explicit hydration | ||
| // marker proves the framework ran — definitively hydrated. | ||
| frameworkAttached = true; | ||
| } else if (frameworkControls.length === 0) { | ||
| // Controls exist but every one lives inside a terminal/canvas widget and | ||
| // there is no marker — we cannot prove the page is dead, so never flag. | ||
| frameworkAttached = null; | ||
| } else { | ||
| // Framework-owned controls rendered but none attached — the dead-island | ||
| // signal the gate exists to catch. | ||
| frameworkAttached = false; | ||
| } | ||
| return { controlCount: controls.length, frameworkAttached }; | ||
| }, framework || null); | ||
| } | ||
| // Turn a collected state into a `hydration` issue, or `null` to pass. Pure, so | ||
| // every branch is unit-tested without a browser. | ||
| // | ||
| // Pass when: no client runtime expected (no-op stacks); no interactive control | ||
| // to probe (a static content page in a client app is legitimately inert); the | ||
| // framework attached; OR we couldn't determine attachment (`null` — never | ||
| // false-positive). Flag only the proven-dead case: controls exist AND the | ||
| // framework's runtime is demonstrably not attached. | ||
| function interpretHydration({ | ||
| expectInteractive, | ||
| controlCount, | ||
| frameworkAttached, | ||
| framework, | ||
| url, | ||
| }) { | ||
| if (!expectInteractive) return null; | ||
| if (!(controlCount > 0)) return null; | ||
| if (frameworkAttached !== false) return null; | ||
| const fw = framework || "the client framework"; | ||
| const plural = controlCount === 1 ? "" : "s"; | ||
| return createIssue( | ||
| "hydration", | ||
| `Page rendered ${controlCount} interactive control${plural} but ${fw} never attached ` + | ||
| `event handlers — the page is not interactive (hydration did not run). Client JS may ` + | ||
| `not be executing; check the preview proxy and the browser console. Run ` + | ||
| "`codeyam-editor editor diagnose-preview --path <route>` to pinpoint a proxy " + | ||
| `HTML-injection blocker.`, | ||
| { url: url ?? null }, | ||
| ); | ||
| } | ||
| // Orchestrator called from the capture flow: resolve the expectation (from the | ||
| // project's stack.json unless the caller injects a `stack`), short-circuit when | ||
| // no client runtime is expected, collect the in-page state, and interpret it. | ||
| // Never throws — a probe failure must not break an otherwise-good capture. | ||
| async function probeInteractivity(frame, { url, stack } = {}) { | ||
| const descriptor = stack !== undefined ? stack : readStackJson(); | ||
| const { expectInteractive, framework } = | ||
| resolveInteractivityExpectation(descriptor); | ||
| if (!expectInteractive) return null; | ||
| let state; | ||
| try { | ||
| state = await collectHydrationState(frame, { framework }); | ||
| } catch (_) { | ||
| return null; | ||
| } | ||
| return interpretHydration({ | ||
| expectInteractive: true, | ||
| controlCount: state.controlCount, | ||
| frameworkAttached: state.frameworkAttached, | ||
| framework, | ||
| url, | ||
| }); | ||
| } | ||
| module.exports = { | ||
| KNOWN_FRAMEWORKS, | ||
| readStackJson, | ||
| inferFramework, | ||
| resolveInteractivityExpectation, | ||
| collectHydrationState, | ||
| interpretHydration, | ||
| probeInteractivity, | ||
| }; |
| function createIssue(kind, message, extra = {}) { | ||
| const issue = { | ||
| kind, | ||
| message, | ||
| url: extra.url ?? null, | ||
| status: extra.status ?? null, | ||
| }; | ||
| if (extra.matchedPattern != null) issue.matchedPattern = extra.matchedPattern; | ||
| if (extra.contextSnippet != null) issue.contextSnippet = extra.contextSnippet; | ||
| return issue; | ||
| } | ||
| function pushIssue(issues, issue) { | ||
| const key = JSON.stringify(issue); | ||
| if (!issues.some((existing) => JSON.stringify(existing) === key)) { | ||
| issues.push(issue); | ||
| } | ||
| } | ||
| function buildResult({ loaded, hasContent, issues, outputPath, url }) { | ||
| return { | ||
| ok: loaded && hasContent && issues.length === 0, | ||
| loaded, | ||
| hasContent, | ||
| url, | ||
| outputPath: outputPath ?? null, | ||
| issues, | ||
| }; | ||
| } | ||
| module.exports = { | ||
| createIssue, | ||
| pushIssue, | ||
| buildResult, | ||
| }; |
| const LOADING_MARKERS = [ | ||
| "Loading scenario...", | ||
| "Loading tests...", | ||
| "Loading scenarios...", | ||
| "disconnected", | ||
| ]; | ||
| // `extraMarkers` are project-supplied (from stack.json `capture.loadingMarkers`) | ||
| // because an app's own loading copy ("Loading…", "Please wait") is | ||
| // app-specific and must NOT be hardcoded into the shared harness — only the | ||
| // four codeyam-harness markers above are universal. Matching is | ||
| // case-insensitive so "Loading…" and "loading…" both count; without the | ||
| // project markers a stable app loading screen looks "ready" to | ||
| // waitForStablePage and gets captured mid-hydration. | ||
| function hasLoadingMarkers(text, extraMarkers = []) { | ||
| if (!text) return false; | ||
| const lower = text.toLowerCase(); | ||
| const markers = LOADING_MARKERS.concat( | ||
| Array.isArray(extraMarkers) ? extraMarkers : [], | ||
| ); | ||
| return markers.some( | ||
| (marker) => marker && lower.includes(String(marker).toLowerCase()), | ||
| ); | ||
| } | ||
| function hasRenderableContent(state) { | ||
| if (!state) return false; | ||
| if ( | ||
| state.rootChildCount > 0 || | ||
| state.rootTextLength > 0 || | ||
| state.bodyTextLength > 0 | ||
| ) { | ||
| return true; | ||
| } | ||
| if ((state.loadedImageCount || 0) > 0) return true; | ||
| if ((state.mediaBboxCount || 0) > 0) return true; | ||
| return false; | ||
| } | ||
| function describeBlankReason(state) { | ||
| if (!state) return "no content state collected"; | ||
| const parts = []; | ||
| if (!(state.bodyTextLength > 0)) { | ||
| parts.push("no text"); | ||
| } | ||
| const imageCount = state.imageCount || 0; | ||
| const loadedImageCount = state.loadedImageCount || 0; | ||
| if (imageCount > 0 && loadedImageCount === 0) { | ||
| parts.push(`${imageCount} unloaded image${imageCount === 1 ? "" : "s"}`); | ||
| } else if (imageCount === 0) { | ||
| parts.push("no images"); | ||
| } | ||
| if (!((state.mediaBboxCount || 0) > 0)) { | ||
| parts.push("no svg/canvas/video"); | ||
| } | ||
| return parts.join(", "); | ||
| } | ||
| function shouldStopWaitingForImages(images, options = {}) { | ||
| const { elapsedMs = 0, overallTimeoutMs = 5000 } = options; | ||
| if (!Array.isArray(images) || images.length === 0) return true; | ||
| if (elapsedMs >= overallTimeoutMs) return true; | ||
| return images.every((img) => img && img.complete === true); | ||
| } | ||
| const ERROR_PATTERNS = [ | ||
| "not found in registry", | ||
| "Component not found", | ||
| "Scenario Error", | ||
| ]; | ||
| function hasErrorPatterns(text) { | ||
| return ERROR_PATTERNS.some((pattern) => text.includes(pattern)); | ||
| } | ||
| function findErrorPattern(text) { | ||
| if (!text) return null; | ||
| for (const pattern of ERROR_PATTERNS) { | ||
| if (text.includes(pattern)) return pattern; | ||
| } | ||
| return null; | ||
| } | ||
| const ERROR_CONTEXT_RADIUS = 60; | ||
| function buildErrorContextSnippet(text, pattern) { | ||
| if (!text || !pattern) return null; | ||
| const index = text.indexOf(pattern); | ||
| if (index < 0) return null; | ||
| const start = Math.max(0, index - ERROR_CONTEXT_RADIUS); | ||
| const end = Math.min(text.length, index + pattern.length + ERROR_CONTEXT_RADIUS); | ||
| const slice = text.slice(start, end).replace(/\s+/g, " ").trim(); | ||
| const prefix = start > 0 ? "…" : ""; | ||
| const suffix = end < text.length ? "…" : ""; | ||
| return `${prefix}${slice}${suffix}`; | ||
| } | ||
| module.exports = { | ||
| hasLoadingMarkers, | ||
| hasRenderableContent, | ||
| describeBlankReason, | ||
| shouldStopWaitingForImages, | ||
| hasErrorPatterns, | ||
| findErrorPattern, | ||
| buildErrorContextSnippet, | ||
| ERROR_PATTERNS, | ||
| ERROR_CONTEXT_RADIUS, | ||
| }; |
| function normalizeMockCandidates(url) { | ||
| try { | ||
| const parsed = new URL(url); | ||
| return [url, `${parsed.pathname}${parsed.search}`, parsed.pathname]; | ||
| } catch { | ||
| return [url]; | ||
| } | ||
| } | ||
| function findHttpMock(httpMocks, request) { | ||
| const method = request.method().toUpperCase(); | ||
| const candidates = normalizeMockCandidates(request.url()); | ||
| for (const candidate of candidates) { | ||
| const key = `${method} ${candidate}`; | ||
| if (httpMocks[key]) { | ||
| return httpMocks[key]; | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
| // The set of path/URL targets declared by the mock keys. A key is | ||
| // `"<METHOD> <target>"` (e.g. `"GET /api/plans"`); we strip the method so the | ||
| // route matcher can decide whether a request *could* match any mock without | ||
| // knowing the method (the handler re-checks method via findHttpMock). | ||
| function mockedTargets(httpMocks) { | ||
| const targets = new Set(); | ||
| for (const key of Object.keys(httpMocks)) { | ||
| const spaceIdx = key.indexOf(" "); | ||
| if (spaceIdx === -1) continue; | ||
| targets.add(key.slice(spaceIdx + 1)); | ||
| } | ||
| return targets; | ||
| } | ||
| // True when a request URL matches one of the declared mock targets. Accepts | ||
| // either a string or a WHATWG URL (Playwright's URL-matcher passes a URL). | ||
| function requestTargetsMock(targets, url) { | ||
| const href = typeof url === "string" ? url : url.href; | ||
| return normalizeMockCandidates(href).some((candidate) => | ||
| targets.has(candidate), | ||
| ); | ||
| } | ||
| async function attachHttpMocks(page, httpMocks) { | ||
| if (!httpMocks || Object.keys(httpMocks).length === 0) return; | ||
| // Intercept ONLY requests whose path matches a declared mock — not every | ||
| // request. A blanket `page.route("**/*")` intercepts the dev server's ESM | ||
| // module/script/style requests too, and routing them through | ||
| // `route.continue()` breaks Vite dev-mode module loading: the lazy app | ||
| // chunks never resolve and the SPA renders blank. Scoping the matcher to | ||
| // mocked targets lets those requests load natively while still mocking the API. | ||
| const targets = mockedTargets(httpMocks); | ||
| await page.route( | ||
| (url) => requestTargetsMock(targets, url), | ||
| async (route) => { | ||
| const mock = findHttpMock(httpMocks, route.request()); | ||
| if (!mock) { | ||
| await route.continue(); | ||
| return; | ||
| } | ||
| const headers = { ...(mock.headers || {}) }; | ||
| let body; | ||
| if (mock.body !== undefined) { | ||
| body = | ||
| typeof mock.body === "string" | ||
| ? mock.body | ||
| : JSON.stringify(mock.body); | ||
| const hasContentType = Object.keys(headers).some( | ||
| (key) => key.toLowerCase() === "content-type", | ||
| ); | ||
| if (!hasContentType) { | ||
| headers["content-type"] = "application/json"; | ||
| } | ||
| } | ||
| await route.fulfill({ | ||
| status: mock.status || 200, | ||
| headers, | ||
| body, | ||
| }); | ||
| }, | ||
| ); | ||
| // Disable the in-page fetch mock by returning an empty active-mocks.json. | ||
| // The HTML injects a script that synchronously loads this file and | ||
| // monkey-patches window.fetch, which would bypass Playwright's route | ||
| // interception. This route is registered AFTER the mock matcher so it takes | ||
| // priority for that path (Playwright uses LIFO ordering for route handlers). | ||
| await page.route("**/active-mocks.json", async (route) => { | ||
| await route.fulfill({ | ||
| status: 200, | ||
| headers: { "content-type": "application/json" }, | ||
| body: "[]", | ||
| }); | ||
| }); | ||
| } | ||
| module.exports = { | ||
| normalizeMockCandidates, | ||
| findHttpMock, | ||
| mockedTargets, | ||
| requestTargetsMock, | ||
| attachHttpMocks, | ||
| }; |
| const { | ||
| hasLoadingMarkers, | ||
| shouldStopWaitingForImages, | ||
| } = require("./scenario-metrics"); | ||
| const fs = require("fs"); | ||
| // PROTOTYPE (improve35 capture diagnostics): append a per-phase timing line to | ||
| // a file so we can see WHERE a slow/timed-out capture spends its budget — even | ||
| // when the editor kills this script on timeout (stderr is lost then, but the | ||
| // file survives because each line is flushed synchronously). The cwd is the | ||
| // project dir (scenario_check.rs sets `.current_dir(project_dir)`), so this | ||
| // lands at `<project>/.codeyam/logs/capture-timing.log`. Diagnostics only — | ||
| // never throws, never affects the capture result. | ||
| function logCaptureTiming(phase, data) { | ||
| try { | ||
| const line = `[${new Date().toISOString()}] [capture-timing] phase=${phase} ${JSON.stringify( | ||
| data, | ||
| )}\n`; | ||
| fs.appendFileSync(".codeyam/logs/capture-timing.log", line); | ||
| } catch (_) { | ||
| /* diagnostics must never break a capture */ | ||
| } | ||
| } | ||
| const net = require("net"); | ||
| // Resolve a URL to the TCP {host, port} a pre-flight connect should target, or | ||
| // null when there is nothing to pre-check — an unparseable URL, or a non-http(s) | ||
| // target like `data:`/blank that the capture renders with no network origin. | ||
| // Pure (no socket) so the parse, protocol gate, and default-port rules are | ||
| // unit-tested without opening a connection. | ||
| function resolveTcpTarget(url) { | ||
| let parsed; | ||
| try { | ||
| parsed = new URL(url); | ||
| } catch (_) { | ||
| return null; | ||
| } | ||
| if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; | ||
| return { | ||
| host: parsed.hostname, | ||
| port: Number(parsed.port) || (parsed.protocol === "https:" ? 443 : 80), | ||
| }; | ||
| } | ||
| // Fast pre-flight (improve35): is anything accepting TCP on the app port? A | ||
| // refused connection (the editor's reverse proxy is down) fails in milliseconds; | ||
| // without this, the iframe's `waitForLoadState("load")` hangs the FULL 30s on a | ||
| // dead origin (the observed capture failure — capture-timing showed | ||
| // navigate-iframe elapsedMs=30004, status=null). A connection that ACCEPTS is | ||
| // good enough to proceed — a slow HTTP response *after* connect is a cold | ||
| // compile, handled by the normal load wait + the editor's retry. So we only bail | ||
| // on a hard refusal/timeout, never on slowness. | ||
| async function assertAppPortReachable(url, { timeoutMs = 2500 } = {}) { | ||
| const target = resolveTcpTarget(url); | ||
| if (!target) return; // non-http target (data:, blank) — nothing to pre-check | ||
| const { host, port } = target; | ||
| const started = Date.now(); | ||
| await new Promise((resolve, reject) => { | ||
| const socket = net.connect({ host, port }); | ||
| const finish = (err) => { | ||
| socket.destroy(); | ||
| const elapsedMs = Date.now() - started; | ||
| logCaptureTiming("app-port-reachable", { | ||
| host, | ||
| port, | ||
| reachable: !err, | ||
| elapsedMs, | ||
| error: err ? err.message : null, | ||
| }); | ||
| if (err) reject(err); | ||
| else resolve(); | ||
| }; | ||
| socket.setTimeout(timeoutMs, () => | ||
| finish( | ||
| new Error( | ||
| `app port unreachable: TCP connect to ${host}:${port} timed out after ${timeoutMs}ms — the editor's reverse proxy is not accepting connections (proxy down?)`, | ||
| ), | ||
| ), | ||
| ); | ||
| socket.once("connect", () => finish()); | ||
| socket.once("error", (e) => | ||
| finish( | ||
| new Error( | ||
| `app port unreachable: ${host}:${port} ${e.code || e.message} — is the editor's reverse proxy up? (a capture cannot render a dead app port)`, | ||
| ), | ||
| ), | ||
| ); | ||
| }); | ||
| } | ||
| function escapeHtmlAttribute(value) { | ||
| return String(value).replaceAll("&", "&").replaceAll('"', """); | ||
| } | ||
| // The harness background defaults to transparent so the iframe's own | ||
| // <body> background paints through — matching what users see in the Live | ||
| // Preview. Callers (via scenario-check.js) pass a concrete color when the | ||
| // UI has detected a background it wants the capture to paint behind the | ||
| // iframe, e.g. `var(--bg-deep)` from the editor shell. | ||
| function buildIframeHarness(url, { background = "transparent" } = {}) { | ||
| const escapedUrl = escapeHtmlAttribute(url); | ||
| const bg = String(background); | ||
| return `<!doctype html> | ||
| <html> | ||
| <head> | ||
| <meta charset="utf-8"> | ||
| <style> | ||
| html, body { | ||
| margin: 0; | ||
| width: 100%; | ||
| height: 100%; | ||
| overflow: hidden; | ||
| background: ${bg}; | ||
| } | ||
| iframe { | ||
| display: block; | ||
| width: 100%; | ||
| height: 100%; | ||
| border: 0; | ||
| background: ${bg}; | ||
| } | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <iframe id="scenario-frame" title="Scenario Preview" src="${escapedUrl}"></iframe> | ||
| </body> | ||
| </html>`; | ||
| } | ||
| async function collectContentState(target) { | ||
| return target.evaluate(() => { | ||
| const root = document.getElementById("root"); | ||
| const imgs = Array.from(document.images || []); | ||
| const loadedImageCount = imgs.filter( | ||
| (img) => img.complete && img.naturalWidth > 0, | ||
| ).length; | ||
| const mediaSelectors = ["svg", "canvas", "video"]; | ||
| let mediaBboxCount = 0; | ||
| for (const selector of mediaSelectors) { | ||
| const nodes = document.querySelectorAll(selector); | ||
| for (const node of nodes) { | ||
| const rect = node.getBoundingClientRect(); | ||
| if (rect.width > 0 && rect.height > 0) { | ||
| mediaBboxCount += 1; | ||
| } | ||
| } | ||
| } | ||
| return { | ||
| bodyTextLength: document.body ? document.body.innerText.trim().length : 0, | ||
| rootChildCount: root ? root.childElementCount : 0, | ||
| rootTextLength: root ? (root.textContent || "").trim().length : 0, | ||
| imageCount: imgs.length, | ||
| loadedImageCount, | ||
| mediaBboxCount, | ||
| }; | ||
| }); | ||
| } | ||
| async function collectImageStates(target) { | ||
| return target.evaluate(() => | ||
| Array.from(document.images || []).map((img) => ({ | ||
| complete: img.complete, | ||
| naturalWidth: img.naturalWidth, | ||
| src: img.currentSrc || img.src || "", | ||
| })), | ||
| ); | ||
| } | ||
| async function waitForImagesSettled( | ||
| target, | ||
| { overallTimeoutMs = 5000, pollIntervalMs = 100 } = {}, | ||
| ) { | ||
| const started = Date.now(); | ||
| let images = await collectImageStates(target); | ||
| while ( | ||
| !shouldStopWaitingForImages(images, { | ||
| elapsedMs: Date.now() - started, | ||
| overallTimeoutMs, | ||
| }) | ||
| ) { | ||
| await new Promise((r) => setTimeout(r, pollIntervalMs)); | ||
| images = await collectImageStates(target); | ||
| } | ||
| const elapsedMs = Date.now() - started; | ||
| const allComplete = images.every((img) => img && img.complete === true); | ||
| const incompleteSrcs = images | ||
| .filter((img) => !img || img.complete !== true || !(img.naturalWidth > 0)) | ||
| .map((img) => (img && img.src) || "") | ||
| .slice(0, 6); | ||
| logCaptureTiming("images-settled", { | ||
| elapsedMs, | ||
| settled: allComplete, | ||
| total: images.length, | ||
| incompleteCount: incompleteSrcs.length, | ||
| incompleteSrcs, | ||
| overallTimeoutMs, | ||
| }); | ||
| return { settled: allComplete, images, elapsedMs }; | ||
| } | ||
| async function waitForAnimationsSettled( | ||
| target, | ||
| { timeoutMs = 2000, pollIntervalMs = 100 } = {}, | ||
| ) { | ||
| const started = Date.now(); | ||
| while (Date.now() - started < timeoutMs) { | ||
| const runningCount = await target.evaluate(() => | ||
| document | ||
| .getAnimations() | ||
| .filter((a) => a.playState === "running").length, | ||
| ); | ||
| if (runningCount === 0) { | ||
| return { settled: true, elapsedMs: Date.now() - started }; | ||
| } | ||
| await new Promise((r) => setTimeout(r, pollIntervalMs)); | ||
| } | ||
| return { settled: false, elapsedMs: Date.now() - started }; | ||
| } | ||
| // Track in-flight network requests on a Playwright page so a capture can wait | ||
| // for client-side data fetches to settle before screenshotting. The | ||
| // resource-timing API (`performance.getEntriesByType("resource")`) only records | ||
| // COMPLETED requests, so it cannot see a fetch that is still in flight — the | ||
| // exact window where a client-fetch page shows a loading skeleton. We count | ||
| // request starts against finishes/failures instead. Returns a live view: | ||
| // `inFlight()` is the current outstanding count and `lastActivityMs()` is the | ||
| // timestamp of the most recent request start OR completion (0 when the page has | ||
| // made no requests since the tracker attached). Attach BEFORE navigation so | ||
| // every request is counted. Stack-agnostic — it observes raw HTTP activity, not | ||
| // any framework's fetch wrapper. | ||
| function createNetworkTracker(page) { | ||
| let inFlight = 0; | ||
| let lastActivityMs = 0; | ||
| const bump = () => { | ||
| lastActivityMs = Date.now(); | ||
| }; | ||
| if (page && typeof page.on === "function") { | ||
| page.on("request", () => { | ||
| inFlight += 1; | ||
| bump(); | ||
| }); | ||
| const settle = () => { | ||
| inFlight = Math.max(0, inFlight - 1); | ||
| bump(); | ||
| }; | ||
| page.on("requestfinished", settle); | ||
| page.on("requestfailed", settle); | ||
| } | ||
| return { | ||
| inFlight: () => inFlight, | ||
| lastActivityMs: () => lastActivityMs, | ||
| }; | ||
| } | ||
| // Bounded network-quiet wait: after the DOM is stable a client-side data fetch | ||
| // can still be in flight — the loading skeleton is gone but the fetched rows | ||
| // haven't replaced it yet, so a screenshot here catches the in-between frame. | ||
| // Wait until no request has been outstanding for `quietWindowMs`, hard-capped at | ||
| // `overallTimeoutMs`. Two properties matter: | ||
| // - A page that made NO requests (lastActivityMs stays 0) is already quiet and | ||
| // returns on the first poll, so server-rendered captures incur no extra wait. | ||
| // - A streaming / long-poll endpoint that never goes idle hits the cap and the | ||
| // caller captures anyway — the wait can never hang the capture. | ||
| async function waitForNetworkQuiet( | ||
| tracker, | ||
| { quietWindowMs = 500, overallTimeoutMs = 5000, pollIntervalMs = 100 } = {}, | ||
| ) { | ||
| const started = Date.now(); | ||
| while (Date.now() - started < overallTimeoutMs) { | ||
| const idleForMs = Date.now() - tracker.lastActivityMs(); | ||
| if (tracker.inFlight() === 0 && idleForMs >= quietWindowMs) { | ||
| const elapsedMs = Date.now() - started; | ||
| logCaptureTiming("network-quiet", { outcome: "quiet", elapsedMs }); | ||
| return { quiet: true, elapsedMs }; | ||
| } | ||
| await new Promise((r) => setTimeout(r, pollIntervalMs)); | ||
| } | ||
| const elapsedMs = Date.now() - started; | ||
| logCaptureTiming("network-quiet", { | ||
| outcome: "capped", | ||
| elapsedMs, | ||
| inFlight: tracker.inFlight(), | ||
| }); | ||
| return { quiet: false, elapsedMs }; | ||
| } | ||
| // `loadingMarkers` are the project's app-specific loading strings (from | ||
| // stack.json `capture.loadingMarkers`); they extend the codeyam-harness | ||
| // defaults so a stable-but-still-loading app screen counts as "not ready" | ||
| // and the loop keeps waiting instead of capturing the loading flash. | ||
| async function waitForStablePage(page, target, timeoutMs = 10000, loadingMarkers = []) { | ||
| const started = Date.now(); | ||
| let lastHtml = ""; | ||
| let stableCount = 0; | ||
| let lastHadLoadingMarkers = false; | ||
| let lastHtmlChanged = false; | ||
| while (Date.now() - started < timeoutMs) { | ||
| await page.waitForTimeout(500); | ||
| const pageState = await target.evaluate(() => { | ||
| const getById = document.getElementById; | ||
| const root = | ||
| typeof getById === "function" ? document.getElementById("root") : null; | ||
| return { | ||
| bodyText: document.body?.innerText ?? "", | ||
| html: document.body?.innerHTML ?? "", | ||
| // Whether the SPA mount point exists AND has painted anything. An | ||
| // existing-but-empty `<div id="root">` is the pre-paint window of a | ||
| // slow-first-paint scenario (e.g. a live-session app that spends a few | ||
| // seconds connecting before the gate UI mounts). `rootExists` lets us | ||
| // distinguish that from a mid-redirect/teardown `null` body, where | ||
| // there is no root to wait on and a stable-empty page is legitimately | ||
| // settled. | ||
| rootExists: !!root, | ||
| rootChildCount: root ? root.childElementCount : 0, | ||
| }; | ||
| }); | ||
| lastHadLoadingMarkers = hasLoadingMarkers(pageState.bodyText, loadingMarkers); | ||
| lastHtmlChanged = pageState.html !== lastHtml; | ||
| // A mounted-but-unpainted root is "still loading", not "settled": the HTML | ||
| // can sit byte-stable for a second or two while the SPA boots, which would | ||
| // otherwise satisfy the stability check and capture a blank frame before | ||
| // first paint. Treat an existing root with zero children AND no body text | ||
| // as not-ready so the loop keeps polling until the app actually paints (or | ||
| // the overall timeout fires, by which point real content is present). A | ||
| // `null` body (rootExists=false) is unaffected — it stays trivially stable. | ||
| const rootUnpainted = | ||
| pageState.rootExists && | ||
| pageState.rootChildCount === 0 && | ||
| (pageState.bodyText ?? "").trim().length === 0; | ||
| if (!lastHadLoadingMarkers && !lastHtmlChanged && !rootUnpainted) { | ||
| stableCount += 1; | ||
| if (stableCount >= 2) { | ||
| const remaining = () => Math.max(0, timeoutMs - (Date.now() - started)); | ||
| await waitForAnimationsSettled(target, { | ||
| timeoutMs: Math.min(2000, remaining()), | ||
| }); | ||
| await waitForImagesSettled(target, { overallTimeoutMs: remaining() }); | ||
| logCaptureTiming("stable-page", { | ||
| outcome: "stabilized", | ||
| elapsedMs: Date.now() - started, | ||
| }); | ||
| return; | ||
| } | ||
| } else { | ||
| stableCount = 0; | ||
| } | ||
| lastHtml = pageState.html; | ||
| } | ||
| // Hit the cap without stabilizing — record WHY: a persistent loading marker | ||
| // (app stuck) vs HTML still mutating each poll (HMR / animation / re-render). | ||
| logCaptureTiming("stable-page", { | ||
| outcome: "timed-out", | ||
| elapsedMs: Date.now() - started, | ||
| timeoutMs, | ||
| lastHadLoadingMarkers, | ||
| lastHtmlStillChanging: lastHtmlChanged, | ||
| }); | ||
| } | ||
| // `preflight` is injectable so unit tests that drive a mock page can stay | ||
| // network-free; production callers use the default real reachability check. | ||
| async function loadScenarioInIframe( | ||
| page, | ||
| url, | ||
| { background, preflight = assertAppPortReachable } = {}, | ||
| ) { | ||
| await preflight(url); | ||
| const navStarted = Date.now(); | ||
| const responsePromise = page | ||
| .waitForResponse( | ||
| (response) => | ||
| response.request().resourceType() === "document" && | ||
| response.url() === url, | ||
| { timeout: 30000 }, | ||
| ) | ||
| .catch(() => null); | ||
| await page.setContent(buildIframeHarness(url, { background }), { | ||
| waitUntil: "domcontentloaded", | ||
| }); | ||
| const frameHandle = await page.waitForSelector("#scenario-frame", { | ||
| state: "attached", | ||
| timeout: 30000, | ||
| }); | ||
| const frame = await frameHandle.contentFrame(); | ||
| if (!frame) { | ||
| throw new Error("Scenario iframe did not attach"); | ||
| } | ||
| await frame.waitForLoadState("load", { timeout: 30000 }); | ||
| const response = await responsePromise; | ||
| logCaptureTiming("navigate-iframe", { | ||
| elapsedMs: Date.now() - navStarted, | ||
| status: response ? response.status() : null, | ||
| url, | ||
| }); | ||
| return { frame, response }; | ||
| } | ||
| // Load the scenario as a top-level navigation instead of embedding it in | ||
| // the iframe harness. A top-level document is a first-party context, so a | ||
| // `SameSite=Lax` session cookie is sent on the navigation — which is what | ||
| // auth-gated application routes need to render the authenticated page | ||
| // rather than redirecting to /login. The returned `frame` is the page's | ||
| // main frame so callers can treat it uniformly with the iframe path | ||
| // (`frame.url()`, `frame.evaluate(...)`, `waitForStablePage(page, frame)`). | ||
| async function loadScenarioTopLevel( | ||
| page, | ||
| url, | ||
| { preflight = assertAppPortReachable } = {}, | ||
| ) { | ||
| await preflight(url); | ||
| const navStarted = Date.now(); | ||
| const response = await page.goto(url, { | ||
| waitUntil: "load", | ||
| timeout: 30000, | ||
| }); | ||
| logCaptureTiming("navigate-toplevel", { | ||
| elapsedMs: Date.now() - navStarted, | ||
| status: response ? response.status() : null, | ||
| url, | ||
| }); | ||
| return { frame: page.mainFrame(), response }; | ||
| } | ||
| // Collect up to 20 distinct visible labels of interactive elements on the | ||
| // page — buttons, links, role=button, form controls, <summary>, and anything | ||
| // with an onclick. Used to build an ACTIONABLE error when an interaction's | ||
| // target matches nothing: the agent reliably knows a label it rendered, so | ||
| // listing the real candidates turns a silent blank capture into a "did you | ||
| // mean one of these?" hint. Pure read (no clicks); falls back to value / | ||
| // aria-label / placeholder when an element has no text. | ||
| async function collectInteractiveLabels(frame) { | ||
| return frame.evaluate(() => { | ||
| const selector = | ||
| "button, a[href], [role=button], input, select, textarea, summary, [onclick]"; | ||
| const nodes = Array.from(document.querySelectorAll(selector)); | ||
| const labels = nodes | ||
| .map((node) => { | ||
| const text = | ||
| (node.innerText || node.textContent || "").trim() || | ||
| (typeof node.value === "string" ? node.value.trim() : "") || | ||
| (node.getAttribute && node.getAttribute("aria-label")) || | ||
| (node.getAttribute && node.getAttribute("placeholder")) || | ||
| ""; | ||
| return String(text).trim(); | ||
| }) | ||
| .filter((label) => label.length > 0); | ||
| return Array.from(new Set(labels)).slice(0, 20); | ||
| }); | ||
| } | ||
| // Drive a single user-style interaction against the settled frame before the | ||
| // screenshot, so an interactive state (expanded accordion, open modal, filled | ||
| // field) can be captured without editing app source. | ||
| // | ||
| // The target is matched by visible `text` (preferred — the agent reliably | ||
| // knows the label it rendered) or a CSS `selector`. `action` is click / fill / | ||
| // press; `value` carries the text for `fill` or the key for `press` (e.g. | ||
| // `Enter`). On a no-match target this THROWS with the list of candidate | ||
| // interactive labels — the capture script's outer catch turns that into a | ||
| // failed capture with an actionable message, never a silent blank screenshot. | ||
| async function performInteraction(frame, interaction, { timeoutMs = 5000 } = {}) { | ||
| const { action, selector, text, value } = interaction || {}; | ||
| let locator; | ||
| let targetDesc; | ||
| if (typeof text === "string" && text.length > 0) { | ||
| locator = frame.getByText(text, { exact: false }).first(); | ||
| targetDesc = `text "${text}"`; | ||
| } else if (typeof selector === "string" && selector.length > 0) { | ||
| locator = frame.locator(selector).first(); | ||
| targetDesc = `selector "${selector}"`; | ||
| } else { | ||
| throw new Error( | ||
| "preview-interact: interaction requires a `text` or `selector` target", | ||
| ); | ||
| } | ||
| const matchCount = await locator.count(); | ||
| if (matchCount === 0) { | ||
| const candidates = await collectInteractiveLabels(frame); | ||
| const candidateList = | ||
| candidates.length > 0 ? candidates.join(", ") : "(none found on page)"; | ||
| throw new Error( | ||
| `preview-interact: no element matched ${targetDesc}. ` + | ||
| `Candidate interactive labels: ${candidateList}`, | ||
| ); | ||
| } | ||
| switch (action) { | ||
| case "click": | ||
| await locator.click({ timeout: timeoutMs }); | ||
| break; | ||
| case "fill": | ||
| await locator.fill(value ?? "", { timeout: timeoutMs }); | ||
| break; | ||
| case "press": | ||
| await locator.press(value || "Enter", { timeout: timeoutMs }); | ||
| break; | ||
| default: | ||
| throw new Error( | ||
| `preview-interact: unknown action "${action}" (expected click | fill | press)`, | ||
| ); | ||
| } | ||
| } | ||
| module.exports = { | ||
| logCaptureTiming, | ||
| resolveTcpTarget, | ||
| assertAppPortReachable, | ||
| escapeHtmlAttribute, | ||
| buildIframeHarness, | ||
| collectContentState, | ||
| collectImageStates, | ||
| waitForImagesSettled, | ||
| waitForAnimationsSettled, | ||
| createNetworkTracker, | ||
| waitForNetworkQuiet, | ||
| waitForStablePage, | ||
| loadScenarioInIframe, | ||
| loadScenarioTopLevel, | ||
| collectInteractiveLabels, | ||
| performInteraction, | ||
| }; |
+117
| 'use strict'; | ||
| // Deterministic fleet-ingress URL derivation. | ||
| // | ||
| // The fleet used to reach every VM (editor + Live Preview) and the operator | ||
| // dashboard through cloudflared *quick*-tunnels whose `*.trycloudflare.com` | ||
| // hostnames rotate, rate-limit, and flap — so URLs lived in a churny | ||
| // `cloudflared-urls.json` that a monitor had to keep rewriting and pushing | ||
| // around the fleet. This module replaces that churn with a pure function: a | ||
| // VM's URLs are *derived* from its number plus one owned domain, so they never | ||
| // rotate and nothing has to be recorded, probed, or respawned. | ||
| // | ||
| // Pure logic only — no I/O, no env reads beyond the explicit `env` argument | ||
| // callers pass in — so it stays unit-testable in isolation (npm/vm-urls.test.js), | ||
| // exactly like launch-config.js / fleet-env.js. The bash side | ||
| // (scripts/operator/vm-urls.sh, named-tunnel-config.sh) derives the SAME shapes; | ||
| // the test guards them against drift. | ||
| // | ||
| // Stack note: this is fleet-operator infrastructure (the cloud GCE fleet that | ||
| // runs codeyam-editor itself), not a per-app-stack assumption — the hostnames | ||
| // front whatever dev server a VM's project serves, on any port, via the | ||
| // named-tunnel ingress rules. A project that never joins the fleet never sets | ||
| // FLEET_DOMAIN and never derives a URL here; the legacy quick-tunnel path stays | ||
| // the unchanged fallback for that case (see callers' `domain ? … : legacy`). | ||
| // Env var naming the operator's owned Cloudflare (or equivalent) domain that the | ||
| // named tunnels are routed under, e.g. `fleet.example.com`. Single source of | ||
| // truth shared by cloud.js, the dashboard, and the bash scripts (which read the | ||
| // same env var) so all sides derive identical hostnames. Unset ⇒ no fleet | ||
| // domain configured ⇒ callers fall back to the legacy quick-tunnel path. | ||
| const FLEET_DOMAIN_ENV = 'FLEET_DOMAIN'; | ||
| // Validate an owned-domain string: one or more dot-separated DNS labels | ||
| // (letters/digits/hyphen, not leading/trailing hyphen), e.g. `fleet.example.com`. | ||
| // A bad value would bake a malformed hostname into a tunnel-ingress config or a | ||
| // previewOrigin and silently wedge the iframe, so we reject it loudly at the | ||
| // derivation boundary instead. | ||
| const DOMAIN_RE = /^(?!-)[a-z0-9-]+(?<!-)(\.(?!-)[a-z0-9-]+(?<!-))+$/; | ||
| /** Resolve the configured fleet domain from an env-like object, or null when | ||
| * none is set. Trims surrounding whitespace and lowercases (DNS is | ||
| * case-insensitive; downstream string compares are not). Returns null for a | ||
| * missing OR blank value so a caller can branch on `domain ? named : legacy` | ||
| * without distinguishing the two. Throws on a present-but-malformed value — | ||
| * a typo'd domain must fail loud, not derive a dead hostname. */ | ||
| function resolveFleetDomain(env = {}) { | ||
| const raw = env[FLEET_DOMAIN_ENV]; | ||
| if (raw == null) return null; | ||
| const domain = String(raw).trim().toLowerCase(); | ||
| if (domain.length === 0) return null; | ||
| if (!DOMAIN_RE.test(domain)) { | ||
| throw new Error( | ||
| `${FLEET_DOMAIN_ENV}="${raw}" is not a valid domain ` + | ||
| '(expected e.g. fleet.example.com)', | ||
| ); | ||
| } | ||
| return domain; | ||
| } | ||
| /** Validate a fleet VM number: a positive integer. The hostname scheme keys | ||
| * every surface off this single number, so a non-integer / non-positive value | ||
| * would produce `vm-NaN.editor.<domain>` — reject it here. Returns the | ||
| * normalized integer. */ | ||
| function normalizeVmNumber(n) { | ||
| const num = Number(n); | ||
| if (!Number.isInteger(num) || num <= 0) { | ||
| throw new Error(`VM number must be a positive integer, got: ${n}`); | ||
| } | ||
| return num; | ||
| } | ||
| /** The bare per-VM hostnames (no scheme), derived from the VM number + domain. | ||
| * `editor` fronts the in-VM editor control API; `preview` fronts the VM's dev | ||
| * server (the Live Preview). These are what the named-tunnel ingress rules and | ||
| * the DNS CNAME records are keyed on — see named-tunnel-config.sh. */ | ||
| function vmHostnames(n, domain) { | ||
| const num = normalizeVmNumber(n); | ||
| if (!domain) throw new Error('vmHostnames requires a fleet domain'); | ||
| return { | ||
| editor: `vm-${num}.editor.${domain}`, | ||
| preview: `vm-${num}.preview.${domain}`, | ||
| }; | ||
| } | ||
| /** The per-VM public HTTPS URLs, derived from the VM number + domain. The | ||
| * deterministic replacement for `cloudflared-urls.json[n]` / | ||
| * `cloudflared-urls.json[n-preview]` — same shape the dashboard surfaced from | ||
| * the monitor's recordings, now with no recording, probing, or rotation. */ | ||
| function vmUrls(n, domain) { | ||
| const host = vmHostnames(n, domain); | ||
| return { | ||
| editor: `https://${host.editor}`, | ||
| preview: `https://${host.preview}`, | ||
| }; | ||
| } | ||
| /** The bare dashboard hostname (no scheme), `dashboard.<domain>`. The fixed host | ||
| * the operator dashboard's own named tunnel routes to — retiring the rotating | ||
| * `*.trycloudflare.com` / ngrok-free dashboard URL. */ | ||
| function dashboardHostname(domain) { | ||
| if (!domain) throw new Error('dashboardHostname requires a fleet domain'); | ||
| return `dashboard.${domain}`; | ||
| } | ||
| /** The dashboard's fixed public HTTPS URL, `https://dashboard.<domain>`. */ | ||
| function dashboardUrl(domain) { | ||
| return `https://${dashboardHostname(domain)}`; | ||
| } | ||
| module.exports = { | ||
| FLEET_DOMAIN_ENV, | ||
| resolveFleetDomain, | ||
| normalizeVmNumber, | ||
| vmHostnames, | ||
| vmUrls, | ||
| dashboardHostname, | ||
| dashboardUrl, | ||
| }; |
| import{j as e}from"./markdown-v0F0UEt3.js";import{b as a}from"./react-CSS0HapR.js";import{u as T}from"./useEvents-DggWrtHe.js";function B({connected:t}){return e.jsx("span",{className:`inline-block rounded-full px-2 py-0.5 text-xs font-medium text-[var(--text-primary)] ${t?"bg-[var(--accent-green)]":"bg-[var(--accent-red)]"}`,children:t?"connected":"reconnecting…"})}function M(){const[t,s]=a.useState(null),[n,r]=a.useState(!0),{events:l}=T(),o=a.useCallback(async()=>{try{const c=await(await fetch("/api/inspector/status")).json();s(c)}catch{}finally{r(!1)}},[]);return a.useEffect(()=>{o()},[o]),a.useEffect(()=>{const i=l[l.length-1];i&&i.type==="scenario_switch"&&o()},[l,o]),{scenario:(t==null?void 0:t.scenario)??null,previewScenario:(t==null?void 0:t.previewScenario)??null,routes:(t==null?void 0:t.routes)??[],routeCount:(t==null?void 0:t.routeCount)??0,loading:n,refetch:o}}function k({title:t}){return e.jsx("h2",{style:{fontSize:14,fontWeight:600,color:"#f0f6fc",margin:"0 0 8px 0",borderBottom:"1px solid #30363d",paddingBottom:8},children:t})}function f({color:t,children:s}){return e.jsx("span",{style:{background:`${t}22`,color:t,border:`1px solid ${t}55`,borderRadius:4,padding:"2px 8px",fontSize:11,fontWeight:600,letterSpacing:"0.5px",whiteSpace:"nowrap"},children:s})}const P={GET:"#D7FF63",POST:"#238636",PUT:"#d29922",DELETE:"#da3633",PATCH:"#bc8cff"};function F({method:t}){const s=P[t.toUpperCase()]??"#8b949e";return e.jsx("span",{style:{color:s,fontWeight:600,fontSize:11,minWidth:48,display:"inline-block"},children:t.toUpperCase()})}function E({color:t,children:s}){return e.jsx("div",{style:{padding:"12px 16px",borderRadius:6,border:`1px solid ${t}44`,background:`${t}11`,color:t},children:s})}const h={padding:"8px 12px",textAlign:"center",color:"#8b949e",fontWeight:500,fontSize:11,textTransform:"uppercase",letterSpacing:"0.5px"},u={padding:"6px 12px"},w={background:"#161b22",border:"1px solid #30363d",borderRadius:6,color:"#c9d1d9",padding:"6px 12px",fontSize:13,fontFamily:"inherit"},L={...w,appearance:"none",paddingRight:24,backgroundImage:`url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%238b949e' d='M6 8L1 3h10z'/%3E%3C/svg%3E")`,backgroundRepeat:"no-repeat",backgroundPosition:"right 8px center"};function z({routes:t}){return e.jsxs("table",{style:{width:"100%",borderCollapse:"collapse",fontSize:12},children:[e.jsx("thead",{children:e.jsxs("tr",{style:{borderBottom:"1px solid #30363d"},children:[e.jsx("th",{style:h,children:"Method"}),e.jsx("th",{style:{...h,textAlign:"left"},children:"Pattern"}),e.jsx("th",{style:h,children:"Status"})]})}),e.jsx("tbody",{children:t.map((s,n)=>e.jsxs("tr",{style:{borderBottom:"1px solid #21262d"},children:[e.jsx("td",{style:{...u,textAlign:"center"},children:e.jsx(F,{method:s.method})}),e.jsx("td",{style:u,children:s.pattern}),e.jsx("td",{style:{...u,textAlign:"center",color:"#8b949e"},children:s.status})]},n))})]})}function O(){const{scenario:t,previewScenario:s,routes:n,routeCount:r,loading:l}=M();return e.jsxs("section",{children:[e.jsx(k,{title:"Active Scenario"}),l?e.jsx(E,{color:"#8b949e",children:"Loading..."}):t?e.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:12},children:[e.jsxs("div",{style:{display:"flex",alignItems:"center",gap:8},children:[e.jsx(f,{color:"#238636",children:"ACTIVE"}),e.jsx("span",{style:{color:"#f0f6fc",fontWeight:500},children:t.name}),e.jsxs("span",{style:{color:"#8b949e"},children:["(",t.slug,")"]})]}),e.jsxs("div",{style:{color:"#8b949e",fontSize:12},children:[r," mock route",r!==1?"s":""," loaded"]}),n.length>0&&e.jsx(z,{routes:n})]}):s?e.jsxs("div",{style:{display:"flex",flexDirection:"column",gap:12},children:[e.jsxs("div",{style:{display:"flex",alignItems:"center",gap:8},children:[e.jsx(f,{color:"#D7FF63",children:"PREVIEW"}),e.jsx("span",{style:{color:"#f0f6fc",fontWeight:500},children:s.name}),e.jsxs("span",{style:{color:"#8b949e"},children:["(",s.slug,")"]})]}),e.jsx("div",{style:{color:"#8b949e",fontSize:12},children:"Component preview — no HTTP mocks active"})]}):e.jsx(E,{color:"#d29922",children:"No scenario active — all requests pass through to upstream"})]})}function A(t){try{return new URL(t).pathname}catch{return t}}function D({textUrlFilter:t,onTextUrlFilterChange:s,methodFilter:n,onMethodFilterChange:r,sourceFilter:l,onSourceFilterChange:o}){return e.jsxs("div",{style:{display:"flex",gap:8,marginBottom:12,alignItems:"center"},children:[e.jsx("input",{type:"text",placeholder:"Filter by URL...",value:t,onChange:i=>s(i.target.value),style:{...w,flex:1,minWidth:0}}),e.jsxs("select",{value:n,onChange:i=>r(i.target.value),style:L,children:[e.jsx("option",{value:"ALL",children:"All Methods"}),e.jsx("option",{value:"GET",children:"GET"}),e.jsx("option",{value:"POST",children:"POST"}),e.jsx("option",{value:"PUT",children:"PUT"}),e.jsx("option",{value:"DELETE",children:"DELETE"}),e.jsx("option",{value:"PATCH",children:"PATCH"})]}),e.jsxs("select",{value:l,onChange:i=>o(i.target.value),style:L,children:[e.jsx("option",{value:"ALL",children:"All Sources"}),e.jsx("option",{value:"MOCKED",children:"Mocked"}),e.jsx("option",{value:"PASSTHROUGH",children:"Passthrough"})]})]})}const I={color:"#8b949e",fontSize:11,fontWeight:600,textTransform:"uppercase",letterSpacing:"0.5px",marginBottom:4},R={background:"#161b22",border:"1px solid #30363d",borderRadius:4,padding:"8px 12px",margin:0,fontSize:11,color:"#c9d1d9",fontFamily:"monospace",maxHeight:200,overflow:"auto",whiteSpace:"pre-wrap",wordBreak:"break-all"};function v({label:t,children:s}){return e.jsxs("div",{style:{marginBottom:12},children:[e.jsx("div",{style:I,children:t}),s]})}function H({event:t}){let s=[];try{const o=new URL(t.url,"http://localhost");s=Array.from(o.searchParams.entries())}catch{}const n=t.requestHeaders&&Object.keys(t.requestHeaders).length>0,r=t.requestBody!=null,l=t.responseBody!=null;return e.jsxs("div",{style:{background:"#0d1117",padding:"12px 16px",borderTop:"1px solid #30363d"},children:[s.length>0&&e.jsx(v,{label:"Query Parameters",children:e.jsx("div",{style:{display:"grid",gridTemplateColumns:"auto 1fr",gap:"2px 12px",fontSize:12},children:s.map(([o,i],c)=>e.jsxs("div",{style:{display:"contents"},children:[e.jsx("span",{style:{color:"#D7FF63",fontFamily:"monospace"},children:o}),e.jsx("span",{style:{color:"#c9d1d9",fontFamily:"monospace"},children:i})]},c))})}),n&&e.jsx(v,{label:"Request Headers",children:e.jsx("pre",{style:R,children:Object.entries(t.requestHeaders).map(([o,i])=>`${o}: ${i}`).join(` | ||
| `)})}),r&&e.jsx(v,{label:"Request Body",children:e.jsx("pre",{style:R,children:typeof t.requestBody=="string"?t.requestBody:JSON.stringify(t.requestBody,null,2)})}),l&&e.jsx(v,{label:"Response Body",children:e.jsx("pre",{style:R,children:typeof t.responseBody=="string"?t.responseBody:JSON.stringify(t.responseBody,null,2)})}),s.length===0&&!n&&!r&&!l&&e.jsx("div",{style:{color:"#8b949e",fontSize:12},children:"No additional details available for this request"})]})}function U({event:t}){const[s,n]=a.useState(!1),r=new Date(t.timestampMs).toLocaleTimeString(),l=t.mocked;return e.jsxs(e.Fragment,{children:[e.jsxs("tr",{onClick:()=>n(!s),style:{borderBottom:"1px solid #21262d",background:l?"transparent":"rgba(210, 153, 34, 0.05)",cursor:"pointer"},children:[e.jsxs("td",{style:{...u,color:"#8b949e",textAlign:"center"},children:[e.jsx("span",{style:{display:"inline-block",width:12,marginRight:4,fontSize:10,color:"#8b949e",transition:"transform 0.15s",transform:s?"rotate(90deg)":"rotate(0deg)"},children:"▶"}),r]}),e.jsx("td",{style:{...u,textAlign:"center"},children:e.jsx(F,{method:t.method})}),e.jsx("td",{style:{...u,maxWidth:400,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},children:t.url}),e.jsx("td",{style:{...u,textAlign:"center",color:"#8b949e"},children:t.status??"---"}),e.jsxs("td",{style:{...u,textAlign:"center",color:"#8b949e"},children:[t.durationMs,"ms"]}),e.jsx("td",{style:{...u,textAlign:"center"},children:l?t.mockSource==="default"?e.jsx(f,{color:"#1f6feb",children:"MOCKED (default)"}):e.jsx(f,{color:"#238636",children:"MOCKED (scenario)"}):e.jsx(f,{color:"#d29922",children:"PASSTHROUGH"})})]}),s&&e.jsx("tr",{children:e.jsx("td",{colSpan:6,style:{padding:0},children:e.jsx(H,{event:t})})})]})}function W({events:t}){return e.jsx("div",{style:{maxHeight:300,overflowY:"auto",border:"1px solid #30363d",borderRadius:6},children:e.jsxs("table",{style:{width:"100%",borderCollapse:"collapse",fontSize:12},children:[e.jsx("thead",{children:e.jsxs("tr",{style:{borderBottom:"1px solid #30363d",position:"sticky",top:0,background:"#0d1117"},children:[e.jsx("th",{style:h,children:"Time"}),e.jsx("th",{style:h,children:"Method"}),e.jsx("th",{style:{...h,textAlign:"left"},children:"URL"}),e.jsx("th",{style:h,children:"Status"}),e.jsx("th",{style:h,children:"Duration"}),e.jsx("th",{style:h,children:"Source"})]})}),e.jsx("tbody",{children:t.map(s=>e.jsx(U,{event:s},s.id))})]})})}function q(t){if(t===void 0)return"checked";if(t==="checked")return"excluded"}function $({chips:t,chipStates:s,onChipStatesChange:n}){if(t.length===0)return null;const r=o=>{const i=new Map(s),c=q(i.get(o));c===void 0?i.delete(o):i.set(o,c),n(i)},l=()=>{n(new Map)};return e.jsxs("div",{style:{display:"flex",flexWrap:"wrap",gap:6,marginBottom:12},children:[s.size>0&&e.jsx("button",{onClick:l,style:{background:"#da363322",color:"#da3633",border:"1px solid #da363355",borderRadius:4,padding:"2px 8px",fontSize:11,fontWeight:600,cursor:"pointer",fontFamily:"inherit"},children:"Clear all"}),t.map(({path:o,count:i})=>{const c=s.get(o),d=c==="checked",g=c==="excluded";let j="#30363d",b="#8b949e",S="#30363d",y="";return d?(j="#D7FF6322",b="#D7FF63",S="#D7FF6355",y="✓ "):g&&(j="#da363322",b="#da3633",S="#da363355",y="✗ "),e.jsxs("button",{onClick:()=>r(o),style:{background:j,color:b,border:`1px solid ${S}`,borderRadius:4,padding:"2px 8px",fontSize:11,fontFamily:"monospace",cursor:"pointer",whiteSpace:"nowrap"},children:[y,o,e.jsxs("span",{style:{marginLeft:4,opacity:.6,fontFamily:"inherit"},children:["(",i,")"]})]},o)})]})}function N(){const{events:t}=T(),[s,n]=a.useState(""),[r,l]=a.useState(()=>new Map),[o,i]=a.useState("ALL"),[c,d]=a.useState("ALL"),g=t.filter(p=>p.type==="http_request"),j=a.useMemo(()=>{const p=new Map;for(const x of g){const m=A(x.url);p.set(m,(p.get(m)||0)+1)}return Array.from(p.entries()).sort((x,m)=>m[1]-x[1]).slice(0,10).map(([x,m])=>({path:x,count:m}))},[g]),b=a.useMemo(()=>Array.from(r.values()).some(p=>p==="checked"),[r]),y=g.filter(p=>{if(s&&!p.url.toLowerCase().includes(s.toLowerCase()))return!1;const x=A(p.url);return!(r.get(x)==="excluded"||b&&r.get(x)!=="checked"||o!=="ALL"&&p.method.toUpperCase()!==o||c==="MOCKED"&&!p.mocked||c==="PASSTHROUGH"&&p.mocked)}).slice(-50).reverse();return e.jsxs("section",{children:[e.jsx(k,{title:"Live Traffic"}),e.jsx(D,{textUrlFilter:s,onTextUrlFilterChange:n,methodFilter:o,onMethodFilterChange:i,sourceFilter:c,onSourceFilterChange:p=>d(p)}),e.jsx($,{chips:j,chipStates:r,onChipStatesChange:l}),y.length===0?e.jsx(E,{color:"#8b949e",children:g.length>0?"No matching traffic — try adjusting the filters":"No HTTP traffic yet — requests will appear here in real-time"}):e.jsx(W,{events:y})]})}function G(){const[t,s]=a.useState(null),[n,r]=a.useState(!1);return{explain:a.useCallback(async(o,i)=>{r(!0);try{const d=await(await fetch("/api/inspector/explain",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({method:o,url:i})})).json();s(d)}catch{}finally{r(!1)}},[]),result:t,loading:n}}function _({result:t}){return e.jsxs("div",{style:{border:"1px solid #30363d",borderRadius:6,padding:12},children:[e.jsxs("div",{style:{display:"flex",alignItems:"center",gap:8,marginBottom:12},children:[e.jsxs("span",{style:{color:"#f0f6fc",fontWeight:500},children:[t.method," ",t.url]}),t.wouldMatch?e.jsx(f,{color:"#238636",children:"WOULD MATCH"}):e.jsx(f,{color:"#da3633",children:"NO MATCH"})]}),t.attempts.length===0?e.jsx("div",{style:{color:"#8b949e"},children:"No routes loaded to match against"}):e.jsx("div",{style:{display:"flex",flexDirection:"column",gap:6},children:t.attempts.map((s,n)=>e.jsxs("div",{style:{display:"flex",alignItems:"center",gap:8,padding:"4px 8px",borderRadius:4,background:s.failure?"transparent":"rgba(35, 134, 54, 0.1)"},children:[e.jsx("span",{style:{color:s.failure?"#da3633":"#238636",fontWeight:600,width:16},children:s.failure?"x":"v"}),e.jsx(F,{method:s.method}),e.jsx("span",{children:s.pattern}),s.failure&&e.jsx("span",{style:{color:"#8b949e",fontSize:11},children:s.failure.reason==="methodMismatch"?`method: expected ${s.failure.expected}, got ${s.failure.actual}`:`path "${s.failure.path}" does not match "${s.failure.pattern}"`})]},n))})]})}function K(){const{explain:t,result:s,loading:n}=G(),[r,l]=a.useState("GET"),[o,i]=a.useState(""),c=d=>{d.preventDefault(),o.trim()&&t(r,o.trim())};return e.jsxs("section",{children:[e.jsx(k,{title:"Match Explainer"}),e.jsxs("form",{onSubmit:c,style:{display:"flex",gap:8,marginBottom:12},children:[e.jsx("select",{value:r,onChange:d=>l(d.target.value),style:{...w,width:100},children:["GET","POST","PUT","DELETE","PATCH"].map(d=>e.jsx("option",{value:d,children:d},d))}),e.jsx("input",{type:"text",value:o,onChange:d=>i(d.target.value),placeholder:"/api/users",style:{...w,flex:1}}),e.jsx("button",{type:"submit",disabled:n||!o.trim(),style:{background:"#238636",color:"#fff",border:"none",borderRadius:6,padding:"6px 16px",cursor:"pointer",fontSize:12,fontWeight:500,opacity:n||!o.trim()?.5:1},children:n?"Checking...":"Explain"})]}),s&&e.jsx(_,{result:s})]})}const C={"/api/health":"Health check — confirms the proxy and mock engine are running","/api/events":"SSE stream of real-time events (traffic, errors) powering this Inspector","/api/config":"Returns current CodeYam configuration for the project","/api/editor-dev-server":"Polls/controls the dev server status (start, stop, restart)","/api/inspector/status":"Returns active scenario and loaded mock routes","/api/inspector/explain":"Tests which mock route would match a given method + URL","/api/scenarios":"Lists all defined scenarios with metadata","/api/tests":"Returns the AI-maintained test registry joined with per-test-evidence, grouped by file","/api/test-results":"Returns latest test execution results","/api/screenshots/*":"Serves captured scenario screenshots","/api/session-info":"Returns current editor session metadata","/api/data-structure":"Returns the app's data structure definition","/api/dependency-graph":"Returns the project's component dependency graph","/api/app-route-entities":"Lists app routes and their associated entities","/api/glossary":"Returns the project's component glossary","/api/step":"Returns current editor workflow step","/api/editor-commands":"Lists available editor commands"};function J(t){if(C[t])return C[t];for(const[s,n]of Object.entries(C))if(s.endsWith("/*")){const r=s.slice(0,-1);if(t.startsWith(r))return n}}function V(t){const s=new Set([...t,...Object.keys(C)]),n=[];for(const r of Array.from(s).sort()){if(r.endsWith("/*"))continue;const l=J(r);n.push({path:r,description:l??"Application endpoint"})}return n}function Y(){const{events:t}=T(),s=a.useMemo(()=>{const n=[];for(const r of t)r.type==="http_request"&&n.push(A(r.url));return V(n)},[t]);return s.length===0?null:e.jsxs("section",{children:[e.jsx(k,{title:"URL Glossary"}),e.jsx("div",{style:{border:"1px solid #30363d",borderRadius:6,overflow:"hidden"},children:e.jsxs("table",{style:{width:"100%",borderCollapse:"collapse",fontSize:12},children:[e.jsx("thead",{children:e.jsxs("tr",{style:{borderBottom:"1px solid #30363d",background:"#161b22"},children:[e.jsx("th",{style:{padding:"6px 12px",textAlign:"left",color:"#8b949e",fontWeight:500,fontSize:11,textTransform:"uppercase",letterSpacing:"0.5px",width:"30%"},children:"Path"}),e.jsx("th",{style:{padding:"6px 12px",textAlign:"left",color:"#8b949e",fontWeight:500,fontSize:11,textTransform:"uppercase",letterSpacing:"0.5px"},children:"Description"})]})}),e.jsx("tbody",{children:s.map(({path:n,description:r})=>e.jsxs("tr",{style:{borderBottom:"1px solid #21262d"},children:[e.jsx("td",{style:{padding:"4px 12px",fontFamily:"monospace",color:"#D7FF63",fontSize:11},children:n}),e.jsx("td",{style:{padding:"4px 12px",color:"#8b949e"},children:r})]},n))})]})})]})}function Q(){const{connected:t}=T();return e.jsxs("div",{style:{background:"#0d1117",color:"#c9d1d9",minHeight:"100vh",fontFamily:"'SF Mono', 'Fira Code', 'Cascadia Code', Menlo, monospace",fontSize:13,padding:24,display:"flex",flexDirection:"column",gap:24},children:[e.jsxs("header",{style:{display:"flex",alignItems:"center",gap:12},children:[e.jsx("h1",{style:{fontSize:18,fontWeight:600,color:"#f0f6fc",margin:0},children:"Container Inspector"}),e.jsx("span",{style:{color:"#8b949e",fontSize:12},children:"Read-only view of proxy and mock engine state"}),e.jsx(B,{connected:t})]}),e.jsx(O,{}),e.jsx(N,{}),e.jsx(K,{}),e.jsx(Y,{})]})}const te=Object.freeze(Object.defineProperty({__proto__:null,ContainerInspector:Q},Symbol.toStringTag,{value:"Module"}));export{f as B,Q as C,U as I,F as M,k as S,E as a,Y as b,$ as c,W as d,D as e,H as f,_ as g,K as h,N as i,z as j,O as k,B as l,te as m,h as t}; |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
| import{j as r,M as a,r as c}from"./markdown-v0F0UEt3.js";const i={color:"var(--text-secondary)",fontSize:"var(--text-sm, 14px)",lineHeight:1.6},s={...i,fontSize:"var(--text-xs, 12px)",lineHeight:1.5};function x({children:l,variant:d="default"}){const n=d==="compact",t=n?"0.375rem":"0.5rem";return r.jsx("div",{className:"markdown-content",style:n?s:i,children:r.jsx(a,{remarkPlugins:[c],components:{h1:({children:e})=>r.jsx("h1",{style:{fontSize:n?"1rem":"1.125rem",fontWeight:600,color:"var(--text-primary)",margin:`${t} 0`},children:e}),h2:({children:e})=>r.jsx("h2",{style:{fontSize:n?"0.875rem":"1rem",fontWeight:600,color:"var(--text-primary)",margin:`${t} 0`},children:e}),h3:({children:e})=>r.jsx("h3",{style:{fontSize:n?"0.8125rem":"0.875rem",fontWeight:600,color:"var(--text-primary)",margin:`${t} 0`},children:e}),p:({children:e})=>r.jsx("p",{style:{margin:`${t} 0`},children:e}),ul:({children:e})=>r.jsx("ul",{style:{margin:`${t} 0`,paddingLeft:"1.25rem",listStyleType:"disc"},children:e}),ol:({children:e})=>r.jsx("ol",{style:{margin:`${t} 0`,paddingLeft:"1.25rem",listStyleType:"decimal"},children:e}),li:({children:e})=>r.jsx("li",{style:{marginBottom:"0.125rem"},children:e}),code:({className:e,children:o})=>(e==null?void 0:e.includes("language-"))?r.jsx("code",{className:e,style:{fontFamily:"'IBM Plex Mono', monospace",fontSize:"0.8125rem"},children:o}):r.jsx("code",{style:{fontFamily:"'IBM Plex Mono', monospace",fontSize:"0.85em",background:"var(--bg-input)",padding:"0.1em 0.35em",borderRadius:"3px",color:"var(--accent-orange)"},children:o}),pre:({children:e})=>r.jsx("pre",{style:{background:"var(--bg-input)",borderRadius:"6px",padding:n?"0.5rem":"0.75rem",margin:`${t} 0`,overflowX:"auto",fontSize:"0.8125rem",lineHeight:1.5,border:"1px solid var(--border-subtle)"},children:e}),a:({href:e,children:o})=>r.jsx("a",{href:e,style:{color:"var(--accent-blue)",textDecoration:"underline",textUnderlineOffset:"2px"},target:"_blank",rel:"noopener noreferrer",children:o}),strong:({children:e})=>r.jsx("strong",{style:{fontWeight:600,color:"var(--text-primary)"},children:e}),blockquote:({children:e})=>r.jsx("blockquote",{style:{borderLeft:"3px solid var(--accent-blue)",paddingLeft:"0.75rem",margin:`${t} 0`,color:"var(--text-muted)",fontStyle:"italic"},children:e}),table:({children:e})=>r.jsx("div",{style:{overflowX:"auto",margin:`${t} 0`},children:r.jsx("table",{style:{borderCollapse:"collapse",width:"100%",fontSize:"0.8125rem"},children:e})}),th:({children:e})=>r.jsx("th",{style:{borderBottom:"2px solid var(--border-default)",padding:"0.375rem 0.5rem",textAlign:"left",fontWeight:600,color:"var(--text-primary)"},children:e}),td:({children:e})=>r.jsx("td",{style:{borderBottom:"1px solid var(--border-subtle)",padding:"0.375rem 0.5rem"},children:e})},children:l})})}export{x as M}; |
Sorry, the diff of this file is too big to display
| function at(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var de={exports:{}},E={};/** | ||
| * @license React | ||
| * react.production.js | ||
| * | ||
| * Copyright (c) Meta Platforms, Inc. and affiliates. | ||
| * | ||
| * This source code is licensed under the MIT license found in the | ||
| * LICENSE file in the root directory of this source tree. | ||
| */var xe;function ot(){if(xe)return E;xe=1;var e=Symbol.for("react.transitional.element"),t=Symbol.for("react.portal"),r=Symbol.for("react.fragment"),n=Symbol.for("react.strict_mode"),a=Symbol.for("react.profiler"),u=Symbol.for("react.consumer"),s=Symbol.for("react.context"),d=Symbol.for("react.forward_ref"),l=Symbol.for("react.suspense"),o=Symbol.for("react.memo"),f=Symbol.for("react.lazy"),h=Symbol.for("react.activity"),y=Symbol.iterator;function v(i){return i===null||typeof i!="object"?null:(i=y&&i[y]||i["@@iterator"],typeof i=="function"?i:null)}var w={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},g=Object.assign,p={};function C(i,m,_){this.props=i,this.context=m,this.refs=p,this.updater=_||w}C.prototype.isReactComponent={},C.prototype.setState=function(i,m){if(typeof i!="object"&&typeof i!="function"&&i!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,i,m,"setState")},C.prototype.forceUpdate=function(i){this.updater.enqueueForceUpdate(this,i,"forceUpdate")};function S(){}S.prototype=C.prototype;function x(i,m,_){this.props=i,this.context=m,this.refs=p,this.updater=_||w}var $=x.prototype=new S;$.constructor=x,g($,C.prototype),$.isPureReactComponent=!0;var O=Array.isArray;function D(){}var P={H:null,A:null,T:null,S:null},B=Object.prototype.hasOwnProperty;function j(i,m,_){var R=_.ref;return{$$typeof:e,type:i,key:m,ref:R!==void 0?R:null,props:_}}function V(i,m){return j(i.type,m,i.props)}function J(i){return typeof i=="object"&&i!==null&&i.$$typeof===e}function fe(i){var m={"=":"=0",":":"=2"};return"$"+i.replace(/[=:]/g,function(_){return m[_]})}var X=/\/+/g;function z(i,m){return typeof i=="object"&&i!==null&&i.key!=null?fe(""+i.key):m.toString(36)}function M(i){switch(i.status){case"fulfilled":return i.value;case"rejected":throw i.reason;default:switch(typeof i.status=="string"?i.then(D,D):(i.status="pending",i.then(function(m){i.status==="pending"&&(i.status="fulfilled",i.value=m)},function(m){i.status==="pending"&&(i.status="rejected",i.reason=m)})),i.status){case"fulfilled":return i.value;case"rejected":throw i.reason}}throw i}function K(i,m,_,R,T){var b=typeof i;(b==="undefined"||b==="boolean")&&(i=null);var L=!1;if(i===null)L=!0;else switch(b){case"bigint":case"string":case"number":L=!0;break;case"object":switch(i.$$typeof){case e:case t:L=!0;break;case f:return L=i._init,K(L(i._payload),m,_,R,T)}}if(L)return T=T(i),L=R===""?"."+z(i,0):R,O(T)?(_="",L!=null&&(_=L.replace(X,"$&/")+"/"),K(T,m,_,"",function(nt){return nt})):T!=null&&(J(T)&&(T=V(T,_+(T.key==null||i&&i.key===T.key?"":(""+T.key).replace(X,"$&/")+"/")+L)),m.push(T)),1;L=0;var H=R===""?".":R+":";if(O(i))for(var N=0;N<i.length;N++)R=i[N],b=H+z(R,N),L+=K(R,m,_,b,T);else if(N=v(i),typeof N=="function")for(i=N.call(i),N=0;!(R=i.next()).done;)R=R.value,b=H+z(R,N++),L+=K(R,m,_,b,T);else if(b==="object"){if(typeof i.then=="function")return K(M(i),m,_,R,T);throw m=String(i),Error("Objects are not valid as a React child (found: "+(m==="[object Object]"?"object with keys {"+Object.keys(i).join(", ")+"}":m)+"). If you meant to render a collection of children, use an array instead.")}return L}function ne(i,m,_){if(i==null)return i;var R=[],T=0;return K(i,R,"","",function(b){return m.call(_,b,T++)}),R}function tt(i){if(i._status===-1){var m=i._result;m=m(),m.then(function(_){(i._status===0||i._status===-1)&&(i._status=1,i._result=_)},function(_){(i._status===0||i._status===-1)&&(i._status=2,i._result=_)}),i._status===-1&&(i._status=0,i._result=m)}if(i._status===1)return i._result.default;throw i._result}var Se=typeof reportError=="function"?reportError:function(i){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var m=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof i=="object"&&i!==null&&typeof i.message=="string"?String(i.message):String(i),error:i});if(!window.dispatchEvent(m))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",i);return}console.error(i)},rt={map:ne,forEach:function(i,m,_){ne(i,function(){m.apply(this,arguments)},_)},count:function(i){var m=0;return ne(i,function(){m++}),m},toArray:function(i){return ne(i,function(m){return m})||[]},only:function(i){if(!J(i))throw Error("React.Children.only expected to receive a single React element child.");return i}};return E.Activity=h,E.Children=rt,E.Component=C,E.Fragment=r,E.Profiler=a,E.PureComponent=x,E.StrictMode=n,E.Suspense=l,E.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=P,E.__COMPILER_RUNTIME={__proto__:null,c:function(i){return P.H.useMemoCache(i)}},E.cache=function(i){return function(){return i.apply(null,arguments)}},E.cacheSignal=function(){return null},E.cloneElement=function(i,m,_){if(i==null)throw Error("The argument must be a React element, but you passed "+i+".");var R=g({},i.props),T=i.key;if(m!=null)for(b in m.key!==void 0&&(T=""+m.key),m)!B.call(m,b)||b==="key"||b==="__self"||b==="__source"||b==="ref"&&m.ref===void 0||(R[b]=m[b]);var b=arguments.length-2;if(b===1)R.children=_;else if(1<b){for(var L=Array(b),H=0;H<b;H++)L[H]=arguments[H+2];R.children=L}return j(i.type,T,R)},E.createContext=function(i){return i={$$typeof:s,_currentValue:i,_currentValue2:i,_threadCount:0,Provider:null,Consumer:null},i.Provider=i,i.Consumer={$$typeof:u,_context:i},i},E.createElement=function(i,m,_){var R,T={},b=null;if(m!=null)for(R in m.key!==void 0&&(b=""+m.key),m)B.call(m,R)&&R!=="key"&&R!=="__self"&&R!=="__source"&&(T[R]=m[R]);var L=arguments.length-2;if(L===1)T.children=_;else if(1<L){for(var H=Array(L),N=0;N<L;N++)H[N]=arguments[N+2];T.children=H}if(i&&i.defaultProps)for(R in L=i.defaultProps,L)T[R]===void 0&&(T[R]=L[R]);return j(i,b,T)},E.createRef=function(){return{current:null}},E.forwardRef=function(i){return{$$typeof:d,render:i}},E.isValidElement=J,E.lazy=function(i){return{$$typeof:f,_payload:{_status:-1,_result:i},_init:tt}},E.memo=function(i,m){return{$$typeof:o,type:i,compare:m===void 0?null:m}},E.startTransition=function(i){var m=P.T,_={};P.T=_;try{var R=i(),T=P.S;T!==null&&T(_,R),typeof R=="object"&&R!==null&&typeof R.then=="function"&&R.then(D,Se)}catch(b){Se(b)}finally{m!==null&&_.types!==null&&(m.types=_.types),P.T=m}},E.unstable_useCacheRefresh=function(){return P.H.useCacheRefresh()},E.use=function(i){return P.H.use(i)},E.useActionState=function(i,m,_){return P.H.useActionState(i,m,_)},E.useCallback=function(i,m){return P.H.useCallback(i,m)},E.useContext=function(i){return P.H.useContext(i)},E.useDebugValue=function(){},E.useDeferredValue=function(i,m){return P.H.useDeferredValue(i,m)},E.useEffect=function(i,m){return P.H.useEffect(i,m)},E.useEffectEvent=function(i){return P.H.useEffectEvent(i)},E.useId=function(){return P.H.useId()},E.useImperativeHandle=function(i,m,_){return P.H.useImperativeHandle(i,m,_)},E.useInsertionEffect=function(i,m){return P.H.useInsertionEffect(i,m)},E.useLayoutEffect=function(i,m){return P.H.useLayoutEffect(i,m)},E.useMemo=function(i,m){return P.H.useMemo(i,m)},E.useOptimistic=function(i,m){return P.H.useOptimistic(i,m)},E.useReducer=function(i,m,_){return P.H.useReducer(i,m,_)},E.useRef=function(i){return P.H.useRef(i)},E.useState=function(i){return P.H.useState(i)},E.useSyncExternalStore=function(i,m,_){return P.H.useSyncExternalStore(i,m,_)},E.useTransition=function(){return P.H.useTransition()},E.version="19.2.4",E}var Pe;function Ie(){return Pe||(Pe=1,de.exports=ot()),de.exports}var he={exports:{}},A={};/** | ||
| * @license React | ||
| * react-dom.production.js | ||
| * | ||
| * Copyright (c) Meta Platforms, Inc. and affiliates. | ||
| * | ||
| * This source code is licensed under the MIT license found in the | ||
| * LICENSE file in the root directory of this source tree. | ||
| */var Te;function it(){if(Te)return A;Te=1;var e=Ie();function t(l){var o="https://react.dev/errors/"+l;if(1<arguments.length){o+="?args[]="+encodeURIComponent(arguments[1]);for(var f=2;f<arguments.length;f++)o+="&args[]="+encodeURIComponent(arguments[f])}return"Minified React error #"+l+"; visit "+o+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function r(){}var n={d:{f:r,r:function(){throw Error(t(522))},D:r,C:r,L:r,m:r,X:r,S:r,M:r},p:0,findDOMNode:null},a=Symbol.for("react.portal");function u(l,o,f){var h=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:a,key:h==null?null:""+h,children:l,containerInfo:o,implementation:f}}var s=e.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function d(l,o){if(l==="font")return"";if(typeof o=="string")return o==="use-credentials"?o:""}return A.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=n,A.createPortal=function(l,o){var f=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!o||o.nodeType!==1&&o.nodeType!==9&&o.nodeType!==11)throw Error(t(299));return u(l,o,null,f)},A.flushSync=function(l){var o=s.T,f=n.p;try{if(s.T=null,n.p=2,l)return l()}finally{s.T=o,n.p=f,n.d.f()}},A.preconnect=function(l,o){typeof l=="string"&&(o?(o=o.crossOrigin,o=typeof o=="string"?o==="use-credentials"?o:"":void 0):o=null,n.d.C(l,o))},A.prefetchDNS=function(l){typeof l=="string"&&n.d.D(l)},A.preinit=function(l,o){if(typeof l=="string"&&o&&typeof o.as=="string"){var f=o.as,h=d(f,o.crossOrigin),y=typeof o.integrity=="string"?o.integrity:void 0,v=typeof o.fetchPriority=="string"?o.fetchPriority:void 0;f==="style"?n.d.S(l,typeof o.precedence=="string"?o.precedence:void 0,{crossOrigin:h,integrity:y,fetchPriority:v}):f==="script"&&n.d.X(l,{crossOrigin:h,integrity:y,fetchPriority:v,nonce:typeof o.nonce=="string"?o.nonce:void 0})}},A.preinitModule=function(l,o){if(typeof l=="string")if(typeof o=="object"&&o!==null){if(o.as==null||o.as==="script"){var f=d(o.as,o.crossOrigin);n.d.M(l,{crossOrigin:f,integrity:typeof o.integrity=="string"?o.integrity:void 0,nonce:typeof o.nonce=="string"?o.nonce:void 0})}}else o==null&&n.d.M(l)},A.preload=function(l,o){if(typeof l=="string"&&typeof o=="object"&&o!==null&&typeof o.as=="string"){var f=o.as,h=d(f,o.crossOrigin);n.d.L(l,f,{crossOrigin:h,integrity:typeof o.integrity=="string"?o.integrity:void 0,nonce:typeof o.nonce=="string"?o.nonce:void 0,type:typeof o.type=="string"?o.type:void 0,fetchPriority:typeof o.fetchPriority=="string"?o.fetchPriority:void 0,referrerPolicy:typeof o.referrerPolicy=="string"?o.referrerPolicy:void 0,imageSrcSet:typeof o.imageSrcSet=="string"?o.imageSrcSet:void 0,imageSizes:typeof o.imageSizes=="string"?o.imageSizes:void 0,media:typeof o.media=="string"?o.media:void 0})}},A.preloadModule=function(l,o){if(typeof l=="string")if(o){var f=d(o.as,o.crossOrigin);n.d.m(l,{as:typeof o.as=="string"&&o.as!=="script"?o.as:void 0,crossOrigin:f,integrity:typeof o.integrity=="string"?o.integrity:void 0})}else n.d.m(l)},A.requestFormReset=function(l){n.d.r(l)},A.unstable_batchedUpdates=function(l,o){return l(o)},A.useFormState=function(l,o,f){return s.H.useFormState(l,o,f)},A.useFormStatus=function(){return s.H.useHostTransitionStatus()},A.version="19.2.4",A}var be;function ut(){if(be)return he.exports;be=1;function e(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(e)}catch(t){console.error(t)}}return e(),he.exports=it(),he.exports}var c=Ie();const Mr=at(c);/** | ||
| * react-router v7.13.2 | ||
| * | ||
| * Copyright (c) Remix Software Inc. | ||
| * | ||
| * This source code is licensed under the MIT license found in the | ||
| * LICENSE.md file in the root directory of this source tree. | ||
| * | ||
| * @license MIT | ||
| */var Le="popstate";function Oe(e){return typeof e=="object"&&e!=null&&"pathname"in e&&"search"in e&&"hash"in e&&"state"in e&&"key"in e}function lt(e={}){function t(n,a){var o;let u=(o=a.state)==null?void 0:o.masked,{pathname:s,search:d,hash:l}=u||n.location;return ge("",{pathname:s,search:d,hash:l},a.state&&a.state.usr||null,a.state&&a.state.key||"default",u?{pathname:n.location.pathname,search:n.location.search,hash:n.location.hash}:void 0)}function r(n,a){return typeof a=="string"?a:Z(a)}return ct(t,r,null,e)}function k(e,t){if(e===!1||e===null||typeof e>"u")throw new Error(t)}function F(e,t){if(!e){typeof console<"u"&&console.warn(t);try{throw new Error(t)}catch{}}}function st(){return Math.random().toString(36).substring(2,10)}function $e(e,t){return{usr:e.state,key:e.key,idx:t,masked:e.unstable_mask?{pathname:e.pathname,search:e.search,hash:e.hash}:void 0}}function ge(e,t,r=null,n,a){return{pathname:typeof e=="string"?e:e.pathname,search:"",hash:"",...typeof t=="string"?ee(t):t,state:r,key:t&&t.key||n||st(),unstable_mask:a}}function Z({pathname:e="/",search:t="",hash:r=""}){return t&&t!=="?"&&(e+=t.charAt(0)==="?"?t:"?"+t),r&&r!=="#"&&(e+=r.charAt(0)==="#"?r:"#"+r),e}function ee(e){let t={};if(e){let r=e.indexOf("#");r>=0&&(t.hash=e.substring(r),e=e.substring(0,r));let n=e.indexOf("?");n>=0&&(t.search=e.substring(n),e=e.substring(0,n)),e&&(t.pathname=e)}return t}function ct(e,t,r,n={}){let{window:a=document.defaultView,v5Compat:u=!1}=n,s=a.history,d="POP",l=null,o=f();o==null&&(o=0,s.replaceState({...s.state,idx:o},""));function f(){return(s.state||{idx:null}).idx}function h(){d="POP";let p=f(),C=p==null?null:p-o;o=p,l&&l({action:d,location:g.location,delta:C})}function y(p,C){d="PUSH";let S=Oe(p)?p:ge(g.location,p,C);o=f()+1;let x=$e(S,o),$=g.createHref(S.unstable_mask||S);try{s.pushState(x,"",$)}catch(O){if(O instanceof DOMException&&O.name==="DataCloneError")throw O;a.location.assign($)}u&&l&&l({action:d,location:g.location,delta:1})}function v(p,C){d="REPLACE";let S=Oe(p)?p:ge(g.location,p,C);o=f();let x=$e(S,o),$=g.createHref(S.unstable_mask||S);s.replaceState(x,"",$),u&&l&&l({action:d,location:g.location,delta:0})}function w(p){return ft(p)}let g={get action(){return d},get location(){return e(a,s)},listen(p){if(l)throw new Error("A history only accepts one active listener");return a.addEventListener(Le,h),l=p,()=>{a.removeEventListener(Le,h),l=null}},createHref(p){return t(a,p)},createURL:w,encodeLocation(p){let C=w(p);return{pathname:C.pathname,search:C.search,hash:C.hash}},push:y,replace:v,go(p){return s.go(p)}};return g}function ft(e,t=!1){let r="http://localhost";typeof window<"u"&&(r=window.location.origin!=="null"?window.location.origin:window.location.href),k(r,"No window.location.(origin|href) available to create URL");let n=typeof e=="string"?e:Z(e);return n=n.replace(/ $/,"%20"),!t&&n.startsWith("//")&&(n=r+n),new URL(n,r)}function Me(e,t,r="/"){return dt(e,t,r,!1)}function dt(e,t,r,n){let a=typeof t=="string"?ee(t):t,u=W(a.pathname||"/",r);if(u==null)return null;let s=He(e);ht(s);let d=null;for(let l=0;d==null&&l<s.length;++l){let o=St(u);d=Ct(s[l],o,n)}return d}function He(e,t=[],r=[],n="",a=!1){let u=(s,d,l=a,o)=>{let f={relativePath:o===void 0?s.path||"":o,caseSensitive:s.caseSensitive===!0,childrenIndex:d,route:s};if(f.relativePath.startsWith("/")){if(!f.relativePath.startsWith(n)&&l)return;k(f.relativePath.startsWith(n),`Absolute route path "${f.relativePath}" nested under path "${n}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),f.relativePath=f.relativePath.slice(n.length)}let h=U([n,f.relativePath]),y=r.concat(f);s.children&&s.children.length>0&&(k(s.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${h}".`),He(s.children,t,y,h,l)),!(s.path==null&&!s.index)&&t.push({path:h,score:Rt(h,s.index),routesMeta:y})};return e.forEach((s,d)=>{var l;if(s.path===""||!((l=s.path)!=null&&l.includes("?")))u(s,d);else for(let o of Ue(s.path))u(s,d,!0,o)}),t}function Ue(e){let t=e.split("/");if(t.length===0)return[];let[r,...n]=t,a=r.endsWith("?"),u=r.replace(/\?$/,"");if(n.length===0)return a?[u,""]:[u];let s=Ue(n.join("/")),d=[];return d.push(...s.map(l=>l===""?u:[u,l].join("/"))),a&&d.push(...s),d.map(l=>e.startsWith("/")&&l===""?"/":l)}function ht(e){e.sort((t,r)=>t.score!==r.score?r.score-t.score:wt(t.routesMeta.map(n=>n.childrenIndex),r.routesMeta.map(n=>n.childrenIndex)))}var mt=/^:[\w-]+$/,pt=3,yt=2,gt=1,vt=10,Et=-2,ke=e=>e==="*";function Rt(e,t){let r=e.split("/"),n=r.length;return r.some(ke)&&(n+=Et),t&&(n+=yt),r.filter(a=>!ke(a)).reduce((a,u)=>a+(mt.test(u)?pt:u===""?gt:vt),n)}function wt(e,t){return e.length===t.length&&e.slice(0,-1).every((n,a)=>n===t[a])?e[e.length-1]-t[t.length-1]:0}function Ct(e,t,r=!1){let{routesMeta:n}=e,a={},u="/",s=[];for(let d=0;d<n.length;++d){let l=n[d],o=d===n.length-1,f=u==="/"?t:t.slice(u.length)||"/",h=ue({path:l.relativePath,caseSensitive:l.caseSensitive,end:o},f),y=l.route;if(!h&&o&&r&&!n[n.length-1].route.index&&(h=ue({path:l.relativePath,caseSensitive:l.caseSensitive,end:!1},f)),!h)return null;Object.assign(a,h.params),s.push({params:a,pathname:U([u,h.pathname]),pathnameBase:bt(U([u,h.pathnameBase])),route:y}),h.pathnameBase!=="/"&&(u=U([u,h.pathnameBase]))}return s}function ue(e,t){typeof e=="string"&&(e={path:e,caseSensitive:!1,end:!0});let[r,n]=_t(e.path,e.caseSensitive,e.end),a=t.match(r);if(!a)return null;let u=a[0],s=u.replace(/(.)\/+$/,"$1"),d=a.slice(1);return{params:n.reduce((o,{paramName:f,isOptional:h},y)=>{if(f==="*"){let w=d[y]||"";s=u.slice(0,u.length-w.length).replace(/(.)\/+$/,"$1")}const v=d[y];return h&&!v?o[f]=void 0:o[f]=(v||"").replace(/%2F/g,"/"),o},{}),pathname:u,pathnameBase:s,pattern:e}}function _t(e,t=!1,r=!0){F(e==="*"||!e.endsWith("*")||e.endsWith("/*"),`Route path "${e}" will be treated as if it were "${e.replace(/\*$/,"/*")}" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to "${e.replace(/\*$/,"/*")}".`);let n=[],a="^"+e.replace(/\/*\*?$/,"").replace(/^\/*/,"/").replace(/[\\.*+^${}|()[\]]/g,"\\$&").replace(/\/:([\w-]+)(\?)?/g,(s,d,l,o,f)=>{if(n.push({paramName:d,isOptional:l!=null}),l){let h=f.charAt(o+s.length);return h&&h!=="/"?"/([^\\/]*)":"(?:/([^\\/]*))?"}return"/([^\\/]+)"}).replace(/\/([\w-]+)\?(\/|$)/g,"(/$1)?$2");return e.endsWith("*")?(n.push({paramName:"*"}),a+=e==="*"||e==="/*"?"(.*)$":"(?:\\/(.+)|\\/*)$"):r?a+="\\/*$":e!==""&&e!=="/"&&(a+="(?:(?=\\/|$))"),[new RegExp(a,t?void 0:"i"),n]}function St(e){try{return e.split("/").map(t=>decodeURIComponent(t).replace(/\//g,"%2F")).join("/")}catch(t){return F(!1,`The URL path "${e}" could not be decoded because it is a malformed URL segment. This is probably due to a bad percent encoding (${t}).`),e}}function W(e,t){if(t==="/")return e;if(!e.toLowerCase().startsWith(t.toLowerCase()))return null;let r=t.endsWith("/")?t.length-1:t.length,n=e.charAt(r);return n&&n!=="/"?null:e.slice(r)||"/"}var xt=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;function Pt(e,t="/"){let{pathname:r,search:n="",hash:a=""}=typeof e=="string"?ee(e):e,u;return r?(r=r.replace(/\/\/+/g,"/"),r.startsWith("/")?u=Ae(r.substring(1),"/"):u=Ae(r,t)):u=t,{pathname:u,search:Lt(n),hash:Ot(a)}}function Ae(e,t){let r=t.replace(/\/+$/,"").split("/");return e.split("/").forEach(a=>{a===".."?r.length>1&&r.pop():a!=="."&&r.push(a)}),r.length>1?r.join("/"):"/"}function me(e,t,r,n){return`Cannot include a '${e}' character in a manually specified \`to.${t}\` field [${JSON.stringify(n)}]. Please separate it out to the \`to.${r}\` field. Alternatively you may provide the full path as a string in <Link to="..."> and the router will parse it for you.`}function Tt(e){return e.filter((t,r)=>r===0||t.route.path&&t.route.path.length>0)}function Fe(e){let t=Tt(e);return t.map((r,n)=>n===t.length-1?r.pathname:r.pathnameBase)}function ve(e,t,r,n=!1){let a;typeof e=="string"?a=ee(e):(a={...e},k(!a.pathname||!a.pathname.includes("?"),me("?","pathname","search",a)),k(!a.pathname||!a.pathname.includes("#"),me("#","pathname","hash",a)),k(!a.search||!a.search.includes("#"),me("#","search","hash",a)));let u=e===""||a.pathname==="",s=u?"/":a.pathname,d;if(s==null)d=r;else{let h=t.length-1;if(!n&&s.startsWith("..")){let y=s.split("/");for(;y[0]==="..";)y.shift(),h-=1;a.pathname=y.join("/")}d=h>=0?t[h]:"/"}let l=Pt(a,d),o=s&&s!=="/"&&s.endsWith("/"),f=(u||s===".")&&r.endsWith("/");return!l.pathname.endsWith("/")&&(o||f)&&(l.pathname+="/"),l}var U=e=>e.join("/").replace(/\/\/+/g,"/"),bt=e=>e.replace(/\/+$/,"").replace(/^\/*/,"/"),Lt=e=>!e||e==="?"?"":e.startsWith("?")?e:"?"+e,Ot=e=>!e||e==="#"?"":e.startsWith("#")?e:"#"+e,$t=class{constructor(e,t,r,n=!1){this.status=e,this.statusText=t||"",this.internal=n,r instanceof Error?(this.data=r.toString(),this.error=r):this.data=r}};function kt(e){return e!=null&&typeof e.status=="number"&&typeof e.statusText=="string"&&typeof e.internal=="boolean"&&"data"in e}function At(e){return e.map(t=>t.route.path).filter(Boolean).join("/").replace(/\/\/*/g,"/")||"/"}var Be=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u";function je(e,t){let r=e;if(typeof r!="string"||!xt.test(r))return{absoluteURL:void 0,isExternal:!1,to:r};let n=r,a=!1;if(Be)try{let u=new URL(window.location.href),s=r.startsWith("//")?new URL(u.protocol+r):new URL(r),d=W(s.pathname,t);s.origin===u.origin&&d!=null?r=d+s.search+s.hash:a=!0}catch{F(!1,`<Link to="${r}"> contains an invalid URL which will probably break when clicked - please update to a valid URL path.`)}return{absoluteURL:n,isExternal:a,to:r}}Object.getOwnPropertyNames(Object.prototype).sort().join("\0");var We=["POST","PUT","PATCH","DELETE"];new Set(We);var Dt=["GET",...We];new Set(Dt);var G=c.createContext(null);G.displayName="DataRouter";var le=c.createContext(null);le.displayName="DataRouterState";var Nt=c.createContext(!1),Ye=c.createContext({isTransitioning:!1});Ye.displayName="ViewTransition";var It=c.createContext(new Map);It.displayName="Fetchers";var Mt=c.createContext(null);Mt.displayName="Await";var I=c.createContext(null);I.displayName="Navigation";var se=c.createContext(null);se.displayName="Location";var Y=c.createContext({outlet:null,matches:[],isDataRoute:!1});Y.displayName="Route";var Ee=c.createContext(null);Ee.displayName="RouteError";var qe="REACT_ROUTER_ERROR",Ht="REDIRECT",Ut="ROUTE_ERROR_RESPONSE";function Ft(e){if(e.startsWith(`${qe}:${Ht}:{`))try{let t=JSON.parse(e.slice(28));if(typeof t=="object"&&t&&typeof t.status=="number"&&typeof t.statusText=="string"&&typeof t.location=="string"&&typeof t.reloadDocument=="boolean"&&typeof t.replace=="boolean")return t}catch{}}function Bt(e){if(e.startsWith(`${qe}:${Ut}:{`))try{let t=JSON.parse(e.slice(40));if(typeof t=="object"&&t&&typeof t.status=="number"&&typeof t.statusText=="string")return new $t(t.status,t.statusText,t.data)}catch{}}function jt(e,{relative:t}={}){k(te(),"useHref() may be used only in the context of a <Router> component.");let{basename:r,navigator:n}=c.useContext(I),{hash:a,pathname:u,search:s}=re(e,{relative:t}),d=u;return r!=="/"&&(d=u==="/"?r:U([r,u])),n.createHref({pathname:d,search:s,hash:a})}function te(){return c.useContext(se)!=null}function q(){return k(te(),"useLocation() may be used only in the context of a <Router> component."),c.useContext(se).location}var ze="You should call navigate() in a React.useEffect(), not when your component is first rendered.";function Ke(e){c.useContext(I).static||c.useLayoutEffect(e)}function Wt(){let{isDataRoute:e}=c.useContext(Y);return e?rr():Yt()}function Yt(){k(te(),"useNavigate() may be used only in the context of a <Router> component.");let e=c.useContext(G),{basename:t,navigator:r}=c.useContext(I),{matches:n}=c.useContext(Y),{pathname:a}=q(),u=JSON.stringify(Fe(n)),s=c.useRef(!1);return Ke(()=>{s.current=!0}),c.useCallback((l,o={})=>{if(F(s.current,ze),!s.current)return;if(typeof l=="number"){r.go(l);return}let f=ve(l,JSON.parse(u),a,o.relative==="path");e==null&&t!=="/"&&(f.pathname=f.pathname==="/"?t:U([t,f.pathname])),(o.replace?r.replace:r.push)(f,o.state,o)},[t,r,u,a,e])}c.createContext(null);function re(e,{relative:t}={}){let{matches:r}=c.useContext(Y),{pathname:n}=q(),a=JSON.stringify(Fe(r));return c.useMemo(()=>ve(e,JSON.parse(a),n,t==="path"),[e,a,n,t])}function qt(e,t,r){k(te(),"useRoutes() may be used only in the context of a <Router> component.");let{navigator:n}=c.useContext(I),{matches:a}=c.useContext(Y),u=a[a.length-1],s=u?u.params:{},d=u?u.pathname:"/",l=u?u.pathnameBase:"/",o=u&&u.route;{let p=o&&o.path||"";Ve(d,!o||p.endsWith("*")||p.endsWith("*?"),`You rendered descendant <Routes> (or called \`useRoutes()\`) at "${d}" (under <Route path="${p}">) but the parent route path has no trailing "*". This means if you navigate deeper, the parent won't match anymore and therefore the child routes will never render. | ||
| Please change the parent <Route path="${p}"> to <Route path="${p==="/"?"*":`${p}/*`}">.`)}let f=q(),h;h=f;let y=h.pathname||"/",v=y;if(l!=="/"){let p=l.replace(/^\//,"").split("/");v="/"+y.replace(/^\//,"").split("/").slice(p.length).join("/")}let w=Me(e,{pathname:v});return F(o||w!=null,`No routes matched location "${h.pathname}${h.search}${h.hash}" `),F(w==null||w[w.length-1].route.element!==void 0||w[w.length-1].route.Component!==void 0||w[w.length-1].route.lazy!==void 0,`Matched leaf route at location "${h.pathname}${h.search}${h.hash}" does not have an element or Component. This means it will render an <Outlet /> with a null value by default resulting in an "empty" page.`),Jt(w&&w.map(p=>Object.assign({},p,{params:Object.assign({},s,p.params),pathname:U([l,n.encodeLocation?n.encodeLocation(p.pathname.replace(/%/g,"%25").replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:p.pathname]),pathnameBase:p.pathnameBase==="/"?l:U([l,n.encodeLocation?n.encodeLocation(p.pathnameBase.replace(/%/g,"%25").replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:p.pathnameBase])})),a,r)}function zt(){let e=tr(),t=kt(e)?`${e.status} ${e.statusText}`:e instanceof Error?e.message:JSON.stringify(e),r=e instanceof Error?e.stack:null,n="rgba(200,200,200, 0.5)",a={padding:"0.5rem",backgroundColor:n},u={padding:"2px 4px",backgroundColor:n},s=null;return console.error("Error handled by React Router default ErrorBoundary:",e),s=c.createElement(c.Fragment,null,c.createElement("p",null,"💿 Hey developer 👋"),c.createElement("p",null,"You can provide a way better UX than this when your app throws errors by providing your own ",c.createElement("code",{style:u},"ErrorBoundary")," or"," ",c.createElement("code",{style:u},"errorElement")," prop on your route.")),c.createElement(c.Fragment,null,c.createElement("h2",null,"Unexpected Application Error!"),c.createElement("h3",{style:{fontStyle:"italic"}},t),r?c.createElement("pre",{style:a},r):null,s)}var Kt=c.createElement(zt,null),Ge=class extends c.Component{constructor(e){super(e),this.state={location:e.location,revalidation:e.revalidation,error:e.error}}static getDerivedStateFromError(e){return{error:e}}static getDerivedStateFromProps(e,t){return t.location!==e.location||t.revalidation!=="idle"&&e.revalidation==="idle"?{error:e.error,location:e.location,revalidation:e.revalidation}:{error:e.error!==void 0?e.error:t.error,location:t.location,revalidation:e.revalidation||t.revalidation}}componentDidCatch(e,t){this.props.onError?this.props.onError(e,t):console.error("React Router caught the following error during render",e)}render(){let e=this.state.error;if(this.context&&typeof e=="object"&&e&&"digest"in e&&typeof e.digest=="string"){const r=Bt(e.digest);r&&(e=r)}let t=e!==void 0?c.createElement(Y.Provider,{value:this.props.routeContext},c.createElement(Ee.Provider,{value:e,children:this.props.component})):this.props.children;return this.context?c.createElement(Gt,{error:e},t):t}};Ge.contextType=Nt;var pe=new WeakMap;function Gt({children:e,error:t}){let{basename:r}=c.useContext(I);if(typeof t=="object"&&t&&"digest"in t&&typeof t.digest=="string"){let n=Ft(t.digest);if(n){let a=pe.get(t);if(a)throw a;let u=je(n.location,r);if(Be&&!pe.get(t))if(u.isExternal||n.reloadDocument)window.location.href=u.absoluteURL||u.to;else{const s=Promise.resolve().then(()=>window.__reactRouterDataRouter.navigate(u.to,{replace:n.replace}));throw pe.set(t,s),s}return c.createElement("meta",{httpEquiv:"refresh",content:`0;url=${u.absoluteURL||u.to}`})}}return e}function Vt({routeContext:e,match:t,children:r}){let n=c.useContext(G);return n&&n.static&&n.staticContext&&(t.route.errorElement||t.route.ErrorBoundary)&&(n.staticContext._deepestRenderedBoundaryId=t.route.id),c.createElement(Y.Provider,{value:e},r)}function Jt(e,t=[],r){let n=r==null?void 0:r.state;if(e==null){if(!n)return null;if(n.errors)e=n.matches;else if(t.length===0&&!n.initialized&&n.matches.length>0)e=n.matches;else return null}let a=e,u=n==null?void 0:n.errors;if(u!=null){let f=a.findIndex(h=>h.route.id&&(u==null?void 0:u[h.route.id])!==void 0);k(f>=0,`Could not find a matching route for errors on route IDs: ${Object.keys(u).join(",")}`),a=a.slice(0,Math.min(a.length,f+1))}let s=!1,d=-1;if(r&&n){s=n.renderFallback;for(let f=0;f<a.length;f++){let h=a[f];if((h.route.HydrateFallback||h.route.hydrateFallbackElement)&&(d=f),h.route.id){let{loaderData:y,errors:v}=n,w=h.route.loader&&!y.hasOwnProperty(h.route.id)&&(!v||v[h.route.id]===void 0);if(h.route.lazy||w){r.isStatic&&(s=!0),d>=0?a=a.slice(0,d+1):a=[a[0]];break}}}}let l=r==null?void 0:r.onError,o=n&&l?(f,h)=>{var y,v;l(f,{location:n.location,params:((v=(y=n.matches)==null?void 0:y[0])==null?void 0:v.params)??{},unstable_pattern:At(n.matches),errorInfo:h})}:void 0;return a.reduceRight((f,h,y)=>{let v,w=!1,g=null,p=null;n&&(v=u&&h.route.id?u[h.route.id]:void 0,g=h.route.errorElement||Kt,s&&(d<0&&y===0?(Ve("route-fallback",!1,"No `HydrateFallback` element provided to render during initial hydration"),w=!0,p=null):d===y&&(w=!0,p=h.route.hydrateFallbackElement||null)));let C=t.concat(a.slice(0,y+1)),S=()=>{let x;return v?x=g:w?x=p:h.route.Component?x=c.createElement(h.route.Component,null):h.route.element?x=h.route.element:x=f,c.createElement(Vt,{match:h,routeContext:{outlet:f,matches:C,isDataRoute:n!=null},children:x})};return n&&(h.route.ErrorBoundary||h.route.errorElement||y===0)?c.createElement(Ge,{location:n.location,revalidation:n.revalidation,component:g,error:v,children:S(),routeContext:{outlet:null,matches:C,isDataRoute:!0},onError:o}):S()},null)}function Re(e){return`${e} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function Xt(e){let t=c.useContext(G);return k(t,Re(e)),t}function Qt(e){let t=c.useContext(le);return k(t,Re(e)),t}function Zt(e){let t=c.useContext(Y);return k(t,Re(e)),t}function we(e){let t=Zt(e),r=t.matches[t.matches.length-1];return k(r.route.id,`${e} can only be used on routes that contain a unique "id"`),r.route.id}function er(){return we("useRouteId")}function tr(){var n;let e=c.useContext(Ee),t=Qt("useRouteError"),r=we("useRouteError");return e!==void 0?e:(n=t.errors)==null?void 0:n[r]}function rr(){let{router:e}=Xt("useNavigate"),t=we("useNavigate"),r=c.useRef(!1);return Ke(()=>{r.current=!0}),c.useCallback(async(a,u={})=>{F(r.current,ze),r.current&&(typeof a=="number"?await e.navigate(a):await e.navigate(a,{fromRouteId:t,...u}))},[e,t])}var De={};function Ve(e,t,r){!t&&!De[e]&&(De[e]=!0,F(!1,r))}c.memo(nr);function nr({routes:e,future:t,state:r,isStatic:n,onError:a}){return qt(e,void 0,{state:r,isStatic:n,onError:a})}function ar({basename:e="/",children:t=null,location:r,navigationType:n="POP",navigator:a,static:u=!1,unstable_useTransitions:s}){k(!te(),"You cannot render a <Router> inside another <Router>. You should never have more than one in your app.");let d=e.replace(/^\/*/,"/"),l=c.useMemo(()=>({basename:d,navigator:a,static:u,unstable_useTransitions:s,future:{}}),[d,a,u,s]);typeof r=="string"&&(r=ee(r));let{pathname:o="/",search:f="",hash:h="",state:y=null,key:v="default",unstable_mask:w}=r,g=c.useMemo(()=>{let p=W(o,d);return p==null?null:{location:{pathname:p,search:f,hash:h,state:y,key:v,unstable_mask:w},navigationType:n}},[d,o,f,h,y,v,n,w]);return F(g!=null,`<Router basename="${d}"> is not able to match the URL "${o}${f}${h}" because it does not start with the basename, so the <Router> won't render anything.`),g==null?null:c.createElement(I.Provider,{value:l},c.createElement(se.Provider,{children:t,value:g}))}var oe="get",ie="application/x-www-form-urlencoded";function ce(e){return typeof HTMLElement<"u"&&e instanceof HTMLElement}function or(e){return ce(e)&&e.tagName.toLowerCase()==="button"}function ir(e){return ce(e)&&e.tagName.toLowerCase()==="form"}function ur(e){return ce(e)&&e.tagName.toLowerCase()==="input"}function lr(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}function sr(e,t){return e.button===0&&(!t||t==="_self")&&!lr(e)}var ae=null;function cr(){if(ae===null)try{new FormData(document.createElement("form"),0),ae=!1}catch{ae=!0}return ae}var fr=new Set(["application/x-www-form-urlencoded","multipart/form-data","text/plain"]);function ye(e){return e!=null&&!fr.has(e)?(F(!1,`"${e}" is not a valid \`encType\` for \`<Form>\`/\`<fetcher.Form>\` and will default to "${ie}"`),null):e}function dr(e,t){let r,n,a,u,s;if(ir(e)){let d=e.getAttribute("action");n=d?W(d,t):null,r=e.getAttribute("method")||oe,a=ye(e.getAttribute("enctype"))||ie,u=new FormData(e)}else if(or(e)||ur(e)&&(e.type==="submit"||e.type==="image")){let d=e.form;if(d==null)throw new Error('Cannot submit a <button> or <input type="submit"> without a <form>');let l=e.getAttribute("formaction")||d.getAttribute("action");if(n=l?W(l,t):null,r=e.getAttribute("formmethod")||d.getAttribute("method")||oe,a=ye(e.getAttribute("formenctype"))||ye(d.getAttribute("enctype"))||ie,u=new FormData(d,e),!cr()){let{name:o,type:f,value:h}=e;if(f==="image"){let y=o?`${o}.`:"";u.append(`${y}x`,"0"),u.append(`${y}y`,"0")}else o&&u.append(o,h)}}else{if(ce(e))throw new Error('Cannot submit element that is not <form>, <button>, or <input type="submit|image">');r=oe,n=null,a=ie,s=e}return u&&a==="text/plain"&&(s=u,u=void 0),{action:n,method:r.toLowerCase(),encType:a,formData:u,body:s}}Object.getOwnPropertyNames(Object.prototype).sort().join("\0");function Ce(e,t){if(e===!1||e===null||typeof e>"u")throw new Error(t)}function hr(e,t,r,n){let a=typeof e=="string"?new URL(e,typeof window>"u"?"server://singlefetch/":window.location.origin):e;return r?a.pathname.endsWith("/")?a.pathname=`${a.pathname}_.${n}`:a.pathname=`${a.pathname}.${n}`:a.pathname==="/"?a.pathname=`_root.${n}`:t&&W(a.pathname,t)==="/"?a.pathname=`${t.replace(/\/$/,"")}/_root.${n}`:a.pathname=`${a.pathname.replace(/\/$/,"")}.${n}`,a}async function mr(e,t){if(e.id in t)return t[e.id];try{let r=await import(e.module);return t[e.id]=r,r}catch(r){return console.error(`Error loading route module \`${e.module}\`, reloading page...`),console.error(r),window.__reactRouterContext&&window.__reactRouterContext.isSpaMode,window.location.reload(),new Promise(()=>{})}}function pr(e){return e==null?!1:e.href==null?e.rel==="preload"&&typeof e.imageSrcSet=="string"&&typeof e.imageSizes=="string":typeof e.rel=="string"&&typeof e.href=="string"}async function yr(e,t,r){let n=await Promise.all(e.map(async a=>{let u=t.routes[a.route.id];if(u){let s=await mr(u,r);return s.links?s.links():[]}return[]}));return Rr(n.flat(1).filter(pr).filter(a=>a.rel==="stylesheet"||a.rel==="preload").map(a=>a.rel==="stylesheet"?{...a,rel:"prefetch",as:"style"}:{...a,rel:"prefetch"}))}function Ne(e,t,r,n,a,u){let s=(l,o)=>r[o]?l.route.id!==r[o].route.id:!0,d=(l,o)=>{var f;return r[o].pathname!==l.pathname||((f=r[o].route.path)==null?void 0:f.endsWith("*"))&&r[o].params["*"]!==l.params["*"]};return u==="assets"?t.filter((l,o)=>s(l,o)||d(l,o)):u==="data"?t.filter((l,o)=>{var h;let f=n.routes[l.route.id];if(!f||!f.hasLoader)return!1;if(s(l,o)||d(l,o))return!0;if(l.route.shouldRevalidate){let y=l.route.shouldRevalidate({currentUrl:new URL(a.pathname+a.search+a.hash,window.origin),currentParams:((h=r[0])==null?void 0:h.params)||{},nextUrl:new URL(e,window.origin),nextParams:l.params,defaultShouldRevalidate:!0});if(typeof y=="boolean")return y}return!0}):[]}function gr(e,t,{includeHydrateFallback:r}={}){return vr(e.map(n=>{let a=t.routes[n.route.id];if(!a)return[];let u=[a.module];return a.clientActionModule&&(u=u.concat(a.clientActionModule)),a.clientLoaderModule&&(u=u.concat(a.clientLoaderModule)),r&&a.hydrateFallbackModule&&(u=u.concat(a.hydrateFallbackModule)),a.imports&&(u=u.concat(a.imports)),u}).flat(1))}function vr(e){return[...new Set(e)]}function Er(e){let t={},r=Object.keys(e).sort();for(let n of r)t[n]=e[n];return t}function Rr(e,t){let r=new Set;return new Set(t),e.reduce((n,a)=>{let u=JSON.stringify(Er(a));return r.has(u)||(r.add(u),n.push({key:u,link:a})),n},[])}function Je(){let e=c.useContext(G);return Ce(e,"You must render this element inside a <DataRouterContext.Provider> element"),e}function wr(){let e=c.useContext(le);return Ce(e,"You must render this element inside a <DataRouterStateContext.Provider> element"),e}var _e=c.createContext(void 0);_e.displayName="FrameworkContext";function Xe(){let e=c.useContext(_e);return Ce(e,"You must render this element inside a <HydratedRouter> element"),e}function Cr(e,t){let r=c.useContext(_e),[n,a]=c.useState(!1),[u,s]=c.useState(!1),{onFocus:d,onBlur:l,onMouseEnter:o,onMouseLeave:f,onTouchStart:h}=t,y=c.useRef(null);c.useEffect(()=>{if(e==="render"&&s(!0),e==="viewport"){let g=C=>{C.forEach(S=>{s(S.isIntersecting)})},p=new IntersectionObserver(g,{threshold:.5});return y.current&&p.observe(y.current),()=>{p.disconnect()}}},[e]),c.useEffect(()=>{if(n){let g=setTimeout(()=>{s(!0)},100);return()=>{clearTimeout(g)}}},[n]);let v=()=>{a(!0)},w=()=>{a(!1),s(!1)};return r?e!=="intent"?[u,y,{}]:[u,y,{onFocus:Q(d,v),onBlur:Q(l,w),onMouseEnter:Q(o,v),onMouseLeave:Q(f,w),onTouchStart:Q(h,v)}]:[!1,y,{}]}function Q(e,t){return r=>{e&&e(r),r.defaultPrevented||t(r)}}function _r({page:e,...t}){let{router:r}=Je(),n=c.useMemo(()=>Me(r.routes,e,r.basename),[r.routes,e,r.basename]);return n?c.createElement(xr,{page:e,matches:n,...t}):null}function Sr(e){let{manifest:t,routeModules:r}=Xe(),[n,a]=c.useState([]);return c.useEffect(()=>{let u=!1;return yr(e,t,r).then(s=>{u||a(s)}),()=>{u=!0}},[e,t,r]),n}function xr({page:e,matches:t,...r}){let n=q(),{future:a,manifest:u,routeModules:s}=Xe(),{basename:d}=Je(),{loaderData:l,matches:o}=wr(),f=c.useMemo(()=>Ne(e,t,o,u,n,"data"),[e,t,o,u,n]),h=c.useMemo(()=>Ne(e,t,o,u,n,"assets"),[e,t,o,u,n]),y=c.useMemo(()=>{if(e===n.pathname+n.search+n.hash)return[];let g=new Set,p=!1;if(t.forEach(S=>{var $;let x=u.routes[S.route.id];!x||!x.hasLoader||(!f.some(O=>O.route.id===S.route.id)&&S.route.id in l&&(($=s[S.route.id])!=null&&$.shouldRevalidate)||x.hasClientLoader?p=!0:g.add(S.route.id))}),g.size===0)return[];let C=hr(e,d,a.unstable_trailingSlashAwareDataRequests,"data");return p&&g.size>0&&C.searchParams.set("_routes",t.filter(S=>g.has(S.route.id)).map(S=>S.route.id).join(",")),[C.pathname+C.search]},[d,a.unstable_trailingSlashAwareDataRequests,l,n,u,f,t,e,s]),v=c.useMemo(()=>gr(h,u),[h,u]),w=Sr(h);return c.createElement(c.Fragment,null,y.map(g=>c.createElement("link",{key:g,rel:"prefetch",as:"fetch",href:g,...r})),v.map(g=>c.createElement("link",{key:g,rel:"modulepreload",href:g,...r})),w.map(({key:g,link:p})=>c.createElement("link",{key:g,nonce:r.nonce,...p,crossOrigin:p.crossOrigin??r.crossOrigin})))}function Pr(...e){return t=>{e.forEach(r=>{typeof r=="function"?r(t):r!=null&&(r.current=t)})}}var Tr=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u";try{Tr&&(window.__reactRouterVersion="7.13.2")}catch{}function Hr({basename:e,children:t,unstable_useTransitions:r,window:n}){let a=c.useRef();a.current==null&&(a.current=lt({window:n,v5Compat:!0}));let u=a.current,[s,d]=c.useState({action:u.action,location:u.location}),l=c.useCallback(o=>{r===!1?d(o):c.startTransition(()=>d(o))},[r]);return c.useLayoutEffect(()=>u.listen(l),[u,l]),c.createElement(ar,{basename:e,children:t,location:s.location,navigationType:s.action,navigator:u,unstable_useTransitions:r})}var Qe=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i,Ze=c.forwardRef(function({onClick:t,discover:r="render",prefetch:n="none",relative:a,reloadDocument:u,replace:s,unstable_mask:d,state:l,target:o,to:f,preventScrollReset:h,viewTransition:y,unstable_defaultShouldRevalidate:v,...w},g){let{basename:p,navigator:C,unstable_useTransitions:S}=c.useContext(I),x=typeof f=="string"&&Qe.test(f),$=je(f,p);f=$.to;let O=jt(f,{relative:a}),D=q(),P=null;if(d){let M=ve(d,[],D.unstable_mask?D.unstable_mask.pathname:"/",!0);p!=="/"&&(M.pathname=M.pathname==="/"?p:U([p,M.pathname])),P=C.createHref(M)}let[B,j,V]=Cr(n,w),J=$r(f,{replace:s,unstable_mask:d,state:l,target:o,preventScrollReset:h,relative:a,viewTransition:y,unstable_defaultShouldRevalidate:v,unstable_useTransitions:S});function fe(M){t&&t(M),M.defaultPrevented||J(M)}let X=!($.isExternal||u),z=c.createElement("a",{...w,...V,href:(X?P:void 0)||$.absoluteURL||O,onClick:X?fe:t,ref:Pr(g,j),target:o,"data-discover":!x&&r==="render"?"true":void 0});return B&&!x?c.createElement(c.Fragment,null,z,c.createElement(_r,{page:O})):z});Ze.displayName="Link";var br=c.forwardRef(function({"aria-current":t="page",caseSensitive:r=!1,className:n="",end:a=!1,style:u,to:s,viewTransition:d,children:l,...o},f){let h=re(s,{relative:o.relative}),y=q(),v=c.useContext(le),{navigator:w,basename:g}=c.useContext(I),p=v!=null&&Ir(h)&&d===!0,C=w.encodeLocation?w.encodeLocation(h).pathname:h.pathname,S=y.pathname,x=v&&v.navigation&&v.navigation.location?v.navigation.location.pathname:null;r||(S=S.toLowerCase(),x=x?x.toLowerCase():null,C=C.toLowerCase()),x&&g&&(x=W(x,g)||x);const $=C!=="/"&&C.endsWith("/")?C.length-1:C.length;let O=S===C||!a&&S.startsWith(C)&&S.charAt($)==="/",D=x!=null&&(x===C||!a&&x.startsWith(C)&&x.charAt(C.length)==="/"),P={isActive:O,isPending:D,isTransitioning:p},B=O?t:void 0,j;typeof n=="function"?j=n(P):j=[n,O?"active":null,D?"pending":null,p?"transitioning":null].filter(Boolean).join(" ");let V=typeof u=="function"?u(P):u;return c.createElement(Ze,{...o,"aria-current":B,className:j,ref:f,style:V,to:s,viewTransition:d},typeof l=="function"?l(P):l)});br.displayName="NavLink";var Lr=c.forwardRef(({discover:e="render",fetcherKey:t,navigate:r,reloadDocument:n,replace:a,state:u,method:s=oe,action:d,onSubmit:l,relative:o,preventScrollReset:f,viewTransition:h,unstable_defaultShouldRevalidate:y,...v},w)=>{let{unstable_useTransitions:g}=c.useContext(I),p=Dr(),C=Nr(d,{relative:o}),S=s.toLowerCase()==="get"?"get":"post",x=typeof d=="string"&&Qe.test(d),$=O=>{if(l&&l(O),O.defaultPrevented)return;O.preventDefault();let D=O.nativeEvent.submitter,P=(D==null?void 0:D.getAttribute("formmethod"))||s,B=()=>p(D||O.currentTarget,{fetcherKey:t,method:P,navigate:r,replace:a,state:u,relative:o,preventScrollReset:f,viewTransition:h,unstable_defaultShouldRevalidate:y});g&&r!==!1?c.startTransition(()=>B()):B()};return c.createElement("form",{ref:w,method:S,action:C,onSubmit:n?l:$,...v,"data-discover":!x&&e==="render"?"true":void 0})});Lr.displayName="Form";function Or(e){return`${e} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function et(e){let t=c.useContext(G);return k(t,Or(e)),t}function $r(e,{target:t,replace:r,unstable_mask:n,state:a,preventScrollReset:u,relative:s,viewTransition:d,unstable_defaultShouldRevalidate:l,unstable_useTransitions:o}={}){let f=Wt(),h=q(),y=re(e,{relative:s});return c.useCallback(v=>{if(sr(v,t)){v.preventDefault();let w=r!==void 0?r:Z(h)===Z(y),g=()=>f(e,{replace:w,unstable_mask:n,state:a,preventScrollReset:u,relative:s,viewTransition:d,unstable_defaultShouldRevalidate:l});o?c.startTransition(()=>g()):g()}},[h,f,y,r,n,a,t,e,u,s,d,l,o])}var kr=0,Ar=()=>`__${String(++kr)}__`;function Dr(){let{router:e}=et("useSubmit"),{basename:t}=c.useContext(I),r=er(),n=e.fetch,a=e.navigate;return c.useCallback(async(u,s={})=>{let{action:d,method:l,encType:o,formData:f,body:h}=dr(u,t);if(s.navigate===!1){let y=s.fetcherKey||Ar();await n(y,r,s.action||d,{unstable_defaultShouldRevalidate:s.unstable_defaultShouldRevalidate,preventScrollReset:s.preventScrollReset,formData:f,body:h,formMethod:s.method||l,formEncType:s.encType||o,flushSync:s.flushSync})}else await a(s.action||d,{unstable_defaultShouldRevalidate:s.unstable_defaultShouldRevalidate,preventScrollReset:s.preventScrollReset,formData:f,body:h,formMethod:s.method||l,formEncType:s.encType||o,replace:s.replace,state:s.state,fromRouteId:r,flushSync:s.flushSync,viewTransition:s.viewTransition})},[n,a,t,r])}function Nr(e,{relative:t}={}){let{basename:r}=c.useContext(I),n=c.useContext(Y);k(n,"useFormAction must be used inside a RouteContext");let[a]=n.matches.slice(-1),u={...re(e||".",{relative:t})},s=q();if(e==null){u.search=s.search;let d=new URLSearchParams(u.search),l=d.getAll("index");if(l.some(f=>f==="")){d.delete("index"),l.filter(h=>h).forEach(h=>d.append("index",h));let f=d.toString();u.search=f?`?${f}`:""}}return(!e||e===".")&&a.route.index&&(u.search=u.search?u.search.replace(/^\?/,"?index&"):"?index"),r!=="/"&&(u.pathname=u.pathname==="/"?r:U([r,u.pathname])),Z(u)}function Ir(e,{relative:t}={}){let r=c.useContext(Ye);k(r!=null,"`useViewTransitionState` must be used within `react-router-dom`'s `RouterProvider`. Did you accidentally import `RouterProvider` from `react-router`?");let{basename:n}=et("useViewTransitionState"),a=re(e,{relative:t});if(!r.isTransitioning)return!1;let u=W(r.currentLocation.pathname,n)||r.currentLocation.pathname,s=W(r.nextLocation.pathname,n)||r.nextLocation.pathname;return ue(a.pathname,s)!=null||ue(a.pathname,u)!=null}var Ur=ut();export{Hr as B,Mr as R,ut as a,c as b,Ur as c,at as g,Ie as r}; |
| /** | ||
| * Copyright (c) 2014 The xterm.js authors. All rights reserved. | ||
| * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License) | ||
| * https://github.com/chjj/term.js | ||
| * @license MIT | ||
| * | ||
| * Permission is hereby granted, free of charge, to any person obtaining a copy | ||
| * of this software and associated documentation files (the "Software"), to deal | ||
| * in the Software without restriction, including without limitation the rights | ||
| * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
| * copies of the Software, and to permit persons to whom the Software is | ||
| * furnished to do so, subject to the following conditions: | ||
| * | ||
| * The above copyright notice and this permission notice shall be included in | ||
| * all copies or substantial portions of the Software. | ||
| * | ||
| * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
| * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
| * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
| * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
| * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
| * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN | ||
| * THE SOFTWARE. | ||
| * | ||
| * Originally forked from (with the author's permission): | ||
| * Fabrice Bellard's javascript vt100 for jslinux: | ||
| * http://bellard.org/jslinux/ | ||
| * Copyright (c) 2011 Fabrice Bellard | ||
| * The original design remains. The terminal itself | ||
| * has been extended to include xterm CSI codes, among | ||
| * other features. | ||
| */.xterm{cursor:text;position:relative;user-select:none;-ms-user-select:none;-webkit-user-select:none}.xterm.focus,.xterm:focus{outline:none}.xterm .xterm-helpers{position:absolute;top:0;z-index:5}.xterm .xterm-helper-textarea{padding:0;border:0;margin:0;position:absolute;opacity:0;left:-9999em;top:0;width:0;height:0;z-index:-5;white-space:nowrap;overflow:hidden;resize:none}.xterm .composition-view{background:#000;color:#fff;display:none;position:absolute;white-space:nowrap;z-index:1}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{background-color:#000;overflow-y:scroll;cursor:default;position:absolute;right:0;left:0;top:0;bottom:0}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{position:absolute;left:0;top:0}.xterm .xterm-scroll-area{visibility:hidden}.xterm-char-measure-element{display:inline-block;visibility:hidden;position:absolute;top:0;left:-9999em;line-height:normal}.xterm.enable-mouse-events{cursor:default}.xterm.xterm-cursor-pointer,.xterm .xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility:not(.debug),.xterm .xterm-message{position:absolute;left:0;top:0;bottom:0;right:0;z-index:10;color:transparent;pointer-events:none}.xterm .xterm-accessibility-tree:not(.debug) *::selection{color:transparent}.xterm .xterm-accessibility-tree{-webkit-user-select:text;user-select:text;white-space:pre}.xterm .live-region{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}.xterm-dim{opacity:1!important}.xterm-underline-1{text-decoration:underline}.xterm-underline-2{text-decoration:double underline}.xterm-underline-3{text-decoration:wavy underline}.xterm-underline-4{text-decoration:dotted underline}.xterm-underline-5{text-decoration:dashed underline}.xterm-overline{text-decoration:overline}.xterm-overline.xterm-underline-1{text-decoration:overline underline}.xterm-overline.xterm-underline-2{text-decoration:overline double underline}.xterm-overline.xterm-underline-3{text-decoration:overline wavy underline}.xterm-overline.xterm-underline-4{text-decoration:overline dotted underline}.xterm-overline.xterm-underline-5{text-decoration:overline dashed underline}.xterm-strikethrough{text-decoration:line-through}.xterm-screen .xterm-decoration-container .xterm-decoration{z-index:6;position:absolute}.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer{z-index:7}.xterm-decoration-overview-ruler{z-index:8;position:absolute;top:0;right:0;pointer-events:none}.xterm-decoration-top{z-index:2;position:relative} |
Sorry, the diff of this file is too big to display
| import{j as c}from"./markdown-v0F0UEt3.js";import{b as o}from"./react-CSS0HapR.js";import{S as m}from"./ScenarioDataPanel-xWYGZOpo.js";import"./useEvents-DggWrtHe.js";import"./xterm--24IGk-x.js";import"./index-BuK0lizt.js";function S({slug:t}){const[r,i]=o.useState(void 0);return o.useEffect(()=>{let n=!1;return fetch(`/api/scenarios/${encodeURIComponent(t)}`).then(e=>e.ok?e.json():null).then(e=>{if(n||!e)return;const a=typeof e=="object"&&e!==null&&"name"in e?String(e.name):void 0;i(a),a&&(document.title=`${a} · data`)}).catch(()=>{}),()=>{n=!0}},[t]),c.jsx(m,{slug:t,scenarioName:r,variant:"fullPage"})}export{S as ScenarioDataPanelFullPage}; |
Sorry, the diff of this file is too big to display
| import{j as e}from"./markdown-v0F0UEt3.js";import{b as d}from"./react-CSS0HapR.js";import{M as I}from"./Markdown-BHBqz-Mj.js";function E({workflow:n,selectedSlug:r,onSelectSlug:t}){return e.jsx("div",{style:{padding:"4px 8px 16px",flex:1},children:n.steps.map(o=>o.kind==="slug"?e.jsx(k,{position:o.position,slug:o.slug,label:o.label,description:o.description,isSelected:r===o.slug,onSelect:()=>t(o.slug)},`slug-${o.position}-${o.slug}`):e.jsx(D,{position:o.position,ifKey:o.ifKey,arms:o.arms,selectedSlug:r,onSelectSlug:t},`branch-${o.position}`))})}function k({position:n,slug:r,label:t,description:o,isSelected:s,onSelect:i,indent:l=0,branchHint:a}){return e.jsxs("button",{onClick:i,style:{display:"flex",alignItems:"flex-start",gap:8,padding:"7px 8px",paddingLeft:8+l,borderRadius:4,border:"none",cursor:"pointer",textAlign:"left",fontSize:13,width:"100%",background:s?"var(--bg-input)":"transparent",color:s?"var(--accent-active)":"var(--text-secondary)",fontWeight:s?600:400},children:[e.jsx("span",{style:{width:22,textAlign:"right",color:"var(--text-muted)",fontSize:11,fontFamily:"'IBM Plex Mono', monospace",flexShrink:0,paddingTop:1},children:n}),e.jsxs("span",{style:{flex:1,display:"flex",flexDirection:"column"},children:[e.jsxs("span",{children:[t,a&&e.jsx("span",{style:{marginLeft:6,fontSize:10,color:"var(--text-dim)",fontFamily:"'IBM Plex Mono', monospace"},children:a})]}),e.jsx("span",{style:{fontSize:11,color:o?"var(--text-muted)":"var(--accent-error, #d44)",marginTop:2,fontWeight:400},children:o??"(missing description)"}),e.jsx("span",{style:{fontSize:10,color:"var(--text-dim)",marginTop:1,fontFamily:"'IBM Plex Mono', monospace"},children:r})]})]})}function D({position:n,ifKey:r,arms:t,selectedSlug:o,onSelectSlug:s}){return e.jsxs("div",{children:[e.jsxs("div",{style:{padding:"4px 8px",fontSize:11,color:"var(--text-dim)",fontStyle:"italic"},children:["Branch on ",e.jsx("code",{children:r})," (position ",n,")"]}),t.map(i=>e.jsx(k,{position:n,slug:i.slug,label:i.label,description:i.description,isSelected:o===i.slug,onSelect:()=>s(i.slug),indent:20,branchHint:`(${i.outcome})`},`${n}-${i.slug}`))]})}const F=["provider","feature","dimension","screenSizes","hasProject","isResuming","designSystem","stack","needsScaffold","projectDescription","startCommand","hasGlossary","hasUserPrompt","selectedPlan","needsInitialStartingSurface","port","controlPort"],L={provider:"Provider",feature:"Feature Name",dimension:"Dimension",screenSizes:"Screen Sizes",hasProject:"Has Project",isResuming:"Is Resuming",designSystem:"Design System",stack:"Stack Config",needsScaffold:"Needs Scaffold",projectDescription:"Project Description",startCommand:"Start Command",hasGlossary:"Has Glossary",hasUserPrompt:"Has User Prompt",selectedPlan:"Selected Plan",needsInitialStartingSurface:"Needs Initial Starting Surface",port:"Port",controlPort:"Control Port"};function V({visibleVariables:n,vars:r,onVarChange:t}){return n.length===0?e.jsx("div",{style:{padding:"12px 20px",borderBottom:"1px solid var(--border-default)",fontSize:12,color:"var(--text-muted)",fontStyle:"italic"},children:"This step has no input variables — its rendered output is the same in every context."}):e.jsx("div",{style:{padding:"12px 20px",borderBottom:"1px solid var(--border-default)",display:"grid",gridTemplateColumns:"repeat(auto-fill, minmax(220px, 1fr))",gap:12},children:n.filter(o=>F.includes(o)).map(o=>e.jsx(A,{fieldKey:o,value:r[o],onChange:s=>t(o,s)},o))})}function A({fieldKey:n,value:r,onChange:t}){const o=L[n],s=typeof r=="boolean",i=typeof r=="number",l=Array.isArray(r);return e.jsxs("label",{style:{display:"flex",flexDirection:"column",gap:4,fontSize:11,color:"var(--text-muted)"},children:[e.jsx("span",{children:o}),s?e.jsx("input",{type:"checkbox",checked:r,onChange:a=>t(a.target.checked),style:{alignSelf:"flex-start"}}):i?e.jsx("input",{type:"number",value:r,onChange:a=>t(Number(a.target.value)),style:y()}):l?e.jsx("input",{type:"text",value:r.join(", "),onChange:a=>t(a.target.value.split(",").map(f=>f.trim()).filter(Boolean)),placeholder:"comma-separated",style:y()}):e.jsx("input",{type:"text",value:typeof r=="string"?r:"",onChange:a=>t(a.target.value||null),placeholder:"(unset)",style:y()})]})}function y(){return{background:"var(--bg-input)",border:"1px solid var(--border-default)",borderRadius:3,padding:"4px 6px",fontSize:12,color:"var(--text-primary)",fontFamily:"'IBM Plex Mono', monospace"}}function W({rendered:n,renderError:r,renderLoading:t,selectedSlug:o,selectedDescription:s,visibleVariables:i,vars:l,onVarChange:a}){return e.jsxs("div",{style:{flex:1,display:"flex",flexDirection:"column",overflow:"hidden"},children:[e.jsx(T,{rendered:n,selectedSlug:o,selectedDescription:s}),e.jsx(V,{visibleVariables:i,vars:l,onVarChange:a}),e.jsx($,{rendered:n,renderError:r,renderLoading:t})]})}function T({rendered:n,selectedSlug:r,selectedDescription:t}){return e.jsxs("div",{style:{padding:"16px 20px 12px",borderBottom:"1px solid var(--border-default)"},children:[e.jsx("div",{style:{fontSize:11,color:"var(--text-dim)",fontFamily:"'IBM Plex Mono', monospace",marginBottom:4},children:r??"no step selected"}),e.jsx("h2",{style:{fontSize:18,fontWeight:600,color:"var(--text-primary)",margin:0},children:(n==null?void 0:n.label)??"—"}),e.jsx("p",{style:{fontSize:13,color:t?"var(--text-muted)":"var(--accent-error, #d44)",margin:"6px 0 0"},children:t??"(missing description)"})]})}function $({rendered:n,renderError:r,renderLoading:t}){return e.jsx("div",{style:{flex:1,overflow:"auto",padding:"16px 20px"},children:r?e.jsx(S,{message:r,kind:"render"}):n?e.jsx("div",{style:{opacity:t?.6:1},children:e.jsx(I,{children:n.instructions})}):e.jsx("div",{style:{color:"var(--text-muted)",fontSize:13},children:t?"Rendering…":"Select a step."})})}function N(){return{provider:"claude",port:3e3,controlPort:14199,dimension:"Desktop",screenSizes:["Desktop"],hasProject:!1,isResuming:!1,needsScaffold:!1,startCommand:"npm run dev",hasGlossary:!1,hasUserPrompt:!1,selectedPlan:null,needsInitialStartingSurface:!1}}function _(){const[n,r]=d.useState("ui"),[t,o]=d.useState(null),[s,i]=d.useState(null),[l,a]=d.useState(null),[f,P]=d.useState(N),[w,g]=d.useState(null),[B,m]=d.useState(null),[z,b]=d.useState(!1),v=d.useCallback(async x=>{i(null),o(null),g(null),m(null),a(null);try{const u=await(await fetch(`/api/workflow?mode=${x}`)).json();if("error"in u){i(u.error);return}o(u);const p=H(u);a(p)}catch(c){i(c instanceof Error?c.message:String(c))}},[]);d.useEffect(()=>{v(n)},[n,v]);const j=d.useCallback(async(x,c,u)=>{b(!0),m(null);try{const h=await(await fetch("/api/workflow/render",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({slug:x,mode:c,vars:u})})).json();if("error"in h){g(null),m(h.error);return}g(h)}catch(p){g(null),m(p instanceof Error?p.message:String(p))}finally{b(!1)}},[]);d.useEffect(()=>{l&&j(l,n,f)},[l,n,f,j]);const C=l?O(t,l):null,M=t&&l?U(t,l):[],R=t?[...M,...t.globalVariables??[]]:[];return e.jsxs("div",{style:{display:"flex",height:"100vh",background:"var(--bg-deep)",color:"var(--text-secondary)",fontFamily:"'IBM Plex Sans', system-ui, sans-serif"},children:[e.jsx(G,{mode:n,workflow:t,loadError:s,selectedSlug:l,onModeChange:r,onSelectSlug:a}),e.jsx(W,{rendered:w,renderError:B,renderLoading:z,selectedSlug:l,selectedDescription:C,visibleVariables:R,vars:f,onVarChange:(x,c)=>P(u=>({...u,[x]:c}))})]})}function H(n){for(const r of n.steps){if(r.kind==="slug")return r.slug;if(r.kind==="branch"&&r.arms.length>0)return r.arms[0].slug}return null}function O(n,r){if(!n)return null;for(const t of n.steps){if(t.kind==="slug"&&t.slug===r)return t.description;if(t.kind==="branch"){const o=t.arms.find(s=>s.slug===r);if(o)return o.description}}return null}function U(n,r){for(const t of n.steps){if(t.kind==="slug"&&t.slug===r)return t.variables??[];if(t.kind==="branch"){const o=t.arms.find(s=>s.slug===r);if(o)return o.variables??[]}}return[]}function G({mode:n,workflow:r,loadError:t,selectedSlug:o,onModeChange:s,onSelectSlug:i}){return e.jsxs("div",{style:{width:360,minWidth:360,borderRight:"1px solid var(--border-default)",display:"flex",flexDirection:"column",overflow:"auto"},children:[e.jsx(K,{}),e.jsx(J,{mode:n,onChange:s}),t?e.jsx(S,{message:t,kind:"load"}):r?e.jsx(E,{workflow:r,selectedSlug:o,onSelectSlug:i}):e.jsx("div",{style:{padding:16,color:"var(--text-muted)"},children:"Loading…"})]})}function K(){return e.jsxs("div",{style:{padding:"16px 16px 12px",borderBottom:"1px solid var(--border-default)"},children:[e.jsx("h1",{style:{fontSize:16,fontWeight:600,color:"var(--text-primary)",margin:0},children:"Workflow Viewer"}),e.jsx("p",{style:{fontSize:12,color:"var(--text-muted)",margin:"4px 0 0"},children:"Live data from the server. Click a step to see its instructions and edit variables to see how the rendered text changes."})]})}function J({mode:n,onChange:r}){return e.jsx("div",{style:{padding:"12px 16px 8px"},children:e.jsx("div",{style:{display:"flex",gap:4},children:["ui","backend"].map(t=>e.jsx("button",{onClick:()=>r(t),style:{padding:"5px 12px",borderRadius:4,border:"none",cursor:"pointer",fontSize:12,fontWeight:500,flex:1,background:n===t?"var(--accent-active)":"var(--bg-input)",color:n===t?"#000":"var(--text-secondary)"},children:t==="ui"?"UI":"Backend"},t))})})}function S({message:n,kind:r}){return e.jsxs("div",{style:{margin:16,padding:12,borderRadius:4,background:"rgba(220, 60, 60, 0.1)",border:"1px solid rgba(220, 60, 60, 0.3)",color:"#f99",fontSize:12,fontFamily:"'IBM Plex Mono', monospace",whiteSpace:"pre-wrap"},children:[e.jsx("div",{style:{fontWeight:600,marginBottom:4},children:r==="load"?"Failed to load workflow":"Failed to render step"}),n]})}const X=Object.freeze(Object.defineProperty({__proto__:null,ErrorBanner:S,StepsSimulator:_},Symbol.toStringTag,{value:"Module"}));export{W as S,V as a,E as b,_ as c,X as d}; |
| import{b as t}from"./react-CSS0HapR.js";const m=["http_request","db_query","process_output","scenario_switch","health_check","hook_execution","error"],h=new Set(m);function T(c){if(!c)return null;try{const n=JSON.parse(c);return typeof n!="object"||n===null||typeof n.type!="string"||!h.has(n.type)?null:n}catch{return null}}const v=1e3,d=2e3,y=3e4;function C(c="/api/events"){const[n,f]=t.useState([]),[p,E]=t.useState(!1),e=t.useRef(null),o=t.useRef(null),l=t.useRef(0),a=t.useCallback(()=>{e.current&&(e.current.close(),e.current=null);const s=new EventSource(c);e.current=s,s.onopen=()=>{E(!0),l.current=0},s.onmessage=r=>{const u=T(r.data);u&&f(S=>{const i=[...S,u];return i.length>v?i.slice(-500):i})},s.onerror=()=>{E(!1),s.close(),e.current=null,l.current+=1;const r=l.current,u=Math.min(d*2**(r-1),y);(r===1||r%5===0)&&console.warn(`[events] SSE disconnected (attempt #${r}); reconnecting in ${u}ms`),o.current=setTimeout(a,u)}},[c]);t.useEffect(()=>(a(),()=>{e.current&&(e.current.close(),e.current=null),o.current&&(clearTimeout(o.current),o.current=null)}),[a]);const _=t.useCallback(()=>f([]),[]);return{events:n,connected:p,clear:_}}export{C as u}; |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
| <!DOCTYPE html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="UTF-8" /> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||
| <title>CodeYam</title> | ||
| <link rel="icon" type="image/x-icon" href="/favicon.ico" /> | ||
| <script type="module" crossorigin src="/assets/index-BuK0lizt.js"></script> | ||
| <link rel="modulepreload" crossorigin href="/assets/react-CSS0HapR.js"> | ||
| <link rel="modulepreload" crossorigin href="/assets/markdown-v0F0UEt3.js"> | ||
| <link rel="stylesheet" crossorigin href="/assets/index-BGqairFV.css"> | ||
| </head> | ||
| <body> | ||
| <div id="root"></div> | ||
| </body> | ||
| </html> |
+5
-2
| #!/usr/bin/env node | ||
| const { spawn } = require("child_process"); | ||
| const { ensureBinary, uiDistDir } = require("./utils"); | ||
| const { ensureBinary } = require("./utils"); | ||
| const binary = ensureBinary(); | ||
| // The Rust binary embeds `ui/dist` at build time, so no `CODEYAM_EDITOR_UI_DIR` | ||
| // is required. Devs who want to override the bundle can still export the env | ||
| // var manually — the binary's resolver checks it first. | ||
| const child = spawn(binary, process.argv.slice(2), { | ||
| stdio: "inherit", | ||
| cwd: process.cwd(), | ||
| env: { ...process.env, CODEYAM_EDITOR_UI_DIR: uiDistDir() }, | ||
| env: process.env, | ||
| }); | ||
@@ -13,0 +16,0 @@ |
+39
-5
@@ -11,8 +11,29 @@ #!/usr/bin/env node | ||
| const { ensureServer, rootDir } = require("./utils"); | ||
| const { | ||
| ensureServer, | ||
| rootDir, | ||
| killChildProcess, | ||
| isInteractiveLauncher, | ||
| logLauncherTeardownSignal, | ||
| readMergedEditorConfig, | ||
| resolveLauncherPort, | ||
| } = require("./utils"); | ||
| const { open } = require("./open"); | ||
| const PORT = 14199; | ||
| /** | ||
| * npm wrapper entry point for the container binary: ensures the Rust | ||
| * editor server is running and opens the container monitoring UI in | ||
| * a browser. | ||
| */ | ||
| async function main() { | ||
| // Same resolution order as the editor launcher: env var | ||
| // (CODEYAM_EDITOR_PORT) → merged editor.json + editor.local.json | ||
| // (proxy.controlPort) → hard fallback (14199). | ||
| const merged = readMergedEditorConfig(rootDir); | ||
| const PORT = resolveLauncherPort( | ||
| process.env.CODEYAM_EDITOR_PORT, | ||
| merged.proxy && merged.proxy.controlPort, | ||
| 14199, | ||
| ); | ||
| async function main() { | ||
| const serverChild = await ensureServer(PORT); | ||
@@ -26,4 +47,13 @@ const url = `http://localhost:${PORT}`; | ||
| process.on("SIGINT", () => { | ||
| // The container launcher is inherently headless: a SIGINT here is stray | ||
| // (the server is torn down via API shutdown / orchestration, never an | ||
| // interactive Ctrl+C), so do NOT forward it — the detached server stays | ||
| // alive. Record the signal's process-group forensics for root-cause tracing. | ||
| const interactive = isInteractiveLauncher(); | ||
| logLauncherTeardownSignal("SIGINT", rootDir, { interactive, forwarded: interactive }); | ||
| if (!interactive) { | ||
| return; | ||
| } | ||
| clearInterval(keepAlive); | ||
| if (serverChild) serverChild.kill("SIGINT"); | ||
| killChildProcess(serverChild); | ||
| process.exit(0); | ||
@@ -33,2 +63,6 @@ }); | ||
| main(); | ||
| module.exports = { main }; | ||
| if (require.main === module) { | ||
| main(); | ||
| } |
+419
-69
@@ -17,13 +17,45 @@ #!/usr/bin/env node | ||
| * codeyam-editor-dev editor advance | ||
| * | ||
| * The wrapper is symlinked twice by scripts/bootstrap.sh: | ||
| * codeyam-editor-dev → this file (preserves the -dev branding; | ||
| * scaffolded content emits "codeyam-editor-dev"). | ||
| * codeyam-editor → this file (shadows the canonical name on PATH | ||
| * so hooks and skills that emit `codeyam-editor` still get the | ||
| * auto-rebuild behavior on local dev; scaffolded content emits the | ||
| * canonical "codeyam-editor"). | ||
| * The invocation name is read from `process.argv[1]` to decide which | ||
| * branding the Rust binary should use via `CODEYAM_CLI`. | ||
| */ | ||
| const { execSync, spawn } = require("child_process"); | ||
| const { execSync, spawn, spawnSync } = require("child_process"); | ||
| const path = require("path"); | ||
| const fs = require("fs"); | ||
| const { ensureServer, restartServer, waitForPort, ensureBinary, uiDistDir, rootDir } = require("./utils"); | ||
| const { | ||
| ensureServer, | ||
| restartServer, | ||
| waitForPort, | ||
| ensureBinary, | ||
| rootDir, | ||
| killChildProcess, | ||
| isInteractiveLauncher, | ||
| logLauncherTeardownSignal, | ||
| readMergedEditorConfig, | ||
| resolveLauncherPort, | ||
| staleBinaryBanner, | ||
| enforceFreshBinary, | ||
| } = require("./utils"); | ||
| const { open } = require("./open"); | ||
| const { classifyProject } = require("./preflight-guidance"); | ||
| const { isEmptyProjectDir } = require("./empty-project-dir"); | ||
| // Tell the Rust binary to emit "codeyam-editor-dev" in all instructions, | ||
| // permissions, and installed files instead of "codeyam-editor". | ||
| process.env.CODEYAM_CLI = "codeyam-editor-dev"; | ||
| // When invoked through the `codeyam-editor-dev` symlink, tell the Rust | ||
| // binary to emit "codeyam-editor-dev" in all scaffolded commands, | ||
| // permissions, and installed files. When invoked through the | ||
| // `codeyam-editor` symlink, leave CODEYAM_CLI unset so the binary | ||
| // emits the canonical "codeyam-editor" — matching the cloud install | ||
| // path and the new hook/skill convention. The runtime cost of the | ||
| // rebuild-on-passthrough behavior is identical for both names. | ||
| const invokedAs = path.basename(process.argv[1] || ""); | ||
| if (invokedAs === "codeyam-editor-dev") { | ||
| process.env.CODEYAM_CLI = "codeyam-editor-dev"; | ||
| } | ||
@@ -33,5 +65,9 @@ const args = process.argv.slice(2); | ||
| /** Check if the first non-flag argument is a known subcommand. */ | ||
| function hasSubcommand() { | ||
| for (const arg of args) { | ||
| /** | ||
| * Check if the first non-flag argument is a known subcommand. | ||
| * Accepts an explicit argv array so the helper is testable without mutating | ||
| * process.argv (the module-level `args` const is the production default). | ||
| */ | ||
| function hasSubcommand(argv = args) { | ||
| for (const arg of argv) { | ||
| if (arg.startsWith("-")) continue; | ||
@@ -43,3 +79,7 @@ return KNOWN_SUBCOMMANDS.includes(arg); | ||
| /** Build the Rust binary from the editor repo. */ | ||
| /** | ||
| * Build the Rust binary from the editor repo. Returns true on success, | ||
| * false if `cargo build` failed. This function only reports the outcome — | ||
| * the caller decides whether a failed build is fatal (see staleBinaryBanner). | ||
| */ | ||
| function buildBinary() { | ||
@@ -49,76 +89,386 @@ console.error("Building Rust binary..."); | ||
| execSync("cargo build", { stdio: "inherit", cwd: rootDir }); | ||
| return true; | ||
| } catch { | ||
| console.error("Failed to build Rust binary. Continuing with existing build (if any)..."); | ||
| return false; | ||
| } | ||
| } | ||
| // --- Passthrough mode: build binary, then forward to it --- | ||
| if (hasSubcommand()) { | ||
| buildBinary(); | ||
| const binary = ensureBinary(); | ||
| const child = spawn(binary, args, { | ||
| stdio: "inherit", | ||
| cwd: process.cwd(), | ||
| env: { ...process.env, CODEYAM_EDITOR_UI_DIR: uiDistDir() }, | ||
| }); | ||
| child.on("exit", (code) => { | ||
| process.exit(code ?? 1); | ||
| }); | ||
| } else { | ||
| // --- Launcher mode: build everything, start server, open browser --- | ||
| const SERVER_PORT = 14199; | ||
| const shouldRestart = args.includes("--restart"); | ||
| /** | ||
| * Decide whether the dev wrapper should rebuild the Rust binary (and the UI, | ||
| * for the launcher path) before forwarding to it. The answer is always yes: | ||
| * `codeyam-editor-dev` ALWAYS rebuilds before running. Running fresh source is | ||
| * the entire purpose of the dev wrapper — if a developer wants a frozen tool | ||
| * that never rebuilds, the cargo-installed `codeyam-editor` binary is exactly | ||
| * that. | ||
| * | ||
| * `cargo build` is idempotent: a sub-second no-op that does not relink when | ||
| * nothing changed. So always-building costs nothing for a developer who only | ||
| * uses the editor as a tool against their own app (the binary never changes → | ||
| * the server-side stale-binary watcher never fires → zero mid-session | ||
| * restarts), and produces a rebuild+restart only when editor source actually | ||
| * changed — which is exactly when the developer wants the new code live. | ||
| * | ||
| * The injected cwd / repoRoot / env are retained so the contract — "rebuild | ||
| * regardless of where you run it or what env is set" — is demonstrable in a | ||
| * unit test across the dimensions that used to gate the decision (self-host | ||
| * vs. external project, `CODEYAM_DEV_REBUILD` set or not). They no longer | ||
| * influence the result. | ||
| */ | ||
| function shouldRebuildOnPassthrough() { | ||
| return true; | ||
| } | ||
| function readProjectPort() { | ||
| try { | ||
| const configPath = path.join(process.cwd(), ".codeyam", "editor.json"); | ||
| const config = JSON.parse(fs.readFileSync(configPath, "utf8")); | ||
| return config.port || 3000; | ||
| } catch { | ||
| return 3000; | ||
| } | ||
| /** | ||
| * Returns true when the merged project config has a non-empty start | ||
| * command — either at `apps[0].startCommand` (per-app, preferred) or at | ||
| * the top-level `startCommand` (legacy default). Mirrors the Rust | ||
| * `resolve_start_command` precedence in | ||
| * `crates/codeyam-editor/src/commands/start.rs`. | ||
| * | ||
| * When neither slot has a non-empty string, the editor server runs in | ||
| * "editor UI only" mode and the launcher must skip the dev-server port | ||
| * wait — otherwise the user stares at "Waiting for dev server on port | ||
| * N..." for the full 15s timeout. | ||
| */ | ||
| function hasStartCommand(mergedConfig) { | ||
| if (mergedConfig == null || typeof mergedConfig !== "object") return false; | ||
| const apps = Array.isArray(mergedConfig.apps) ? mergedConfig.apps : null; | ||
| if (apps && apps.length > 0) { | ||
| const cmd = apps[0] && apps[0].startCommand; | ||
| if (typeof cmd === "string" && cmd.trim() !== "") return true; | ||
| } | ||
| const topLevel = mergedConfig.startCommand; | ||
| return typeof topLevel === "string" && topLevel.trim() !== ""; | ||
| } | ||
| async function main() { | ||
| const projectDir = process.cwd(); | ||
| console.error(`Editor repo: ${rootDir}`); | ||
| console.error(`Target project: ${projectDir}`); | ||
| /** | ||
| * Decide what the launcher should do given a `classifyProject` result. | ||
| * Pure — no fs, no spawn, no process.exit — so `launcherMain`'s branching | ||
| * is unit-testable without building a binary or spawning a server (the same | ||
| * policy/classification split as `classifyPortHolder` in utils.js). | ||
| * | ||
| * modern → { action: "proceed" } launch as-is | ||
| * none → { action: "init" } greenfield: init, then launch | ||
| * legacy → { action: "bail", guidance, code: 2 } print guidance and exit | ||
| * | ||
| * Greenfield auto-inits because the dev launcher is a one-shot "build | ||
| * everything, run the editor against this project" tool — running it in a | ||
| * fresh dir should just work. Legacy bails instead: `cmd_init`'s legacy | ||
| * branch deliberately skips `editor.json` creation so `/codeyam-onboard` | ||
| * can migrate from the preserved classification, so auto-running init would | ||
| * strand them. The Rust-side `start_preflight` stays the safety net for | ||
| * direct `codeyam-editor start` calls; this launcher composes init + start. | ||
| * | ||
| * Intentional divergence (improve39): this launcher auto-inits ALL `none` | ||
| * projects (empty or existing-source); the Rust `start_preflight` now | ||
| * auto-inits only the EMPTY case and still bails (exit 2) on existing-source. | ||
| * That gap is pre-existing and deliberate — the dev launcher is a local | ||
| * convenience, not the direct-`start` contract. | ||
| */ | ||
| function planPreflightAction(classification) { | ||
| if (classification.kind === "legacy") { | ||
| return { action: "bail", guidance: classification.guidance, code: 2 }; | ||
| } | ||
| if (classification.kind === "none") { | ||
| return { action: "init" }; | ||
| } | ||
| return { action: "proceed" }; | ||
| } | ||
| buildBinary(); | ||
| /** | ||
| * Pick the launcher's post-launch tail banner — printed right next to the | ||
| * `Editor UI: http://…` line where the user is actually looking, after the | ||
| * editor server is up. Pure (no fs, no spawn) so the three-arm decision is | ||
| * unit-testable without spawning a binary, mirroring `planPreflightAction`. | ||
| * | ||
| * Skill assumption: /codeyam-editor scaffolds a project from scratch; | ||
| * /codeyam-onboard migrates an existing source tree. Empty greenfield | ||
| * (no non-hidden entries before init) must route to the scaffold path; | ||
| * existing greenfield routes to the migration path. Mirrors the same | ||
| * split in `cmd_init` (init.rs:10-24) — keep both in sync. Do NOT | ||
| * "simplify" by collapsing the branches; the skill each path names is the | ||
| * whole point. | ||
| * | ||
| * action !== "init" → null (modern: proceed, no banner; | ||
| * legacy already bailed earlier) | ||
| * action "init", wasEmpty → greenfield message. /codeyam-editor is driven | ||
| * from inside the browser UI, never hand-invoked, | ||
| * so the wording follows `browserOpened`: if the | ||
| * launcher actually opened a tab, promise it; if | ||
| * not (e.g. --restart attach), point at the URL | ||
| * so the user still has a path forward. The | ||
| * banner must never promise a tab that didn't | ||
| * appear. | ||
| * action "init", !wasEmpty → /codeyam-onboard migrate-existing | ||
| */ | ||
| function postLaunchInitBanner({ preflightAction, wasEmpty, browserOpened, url }) { | ||
| if (preflightAction !== "init") return null; | ||
| if (wasEmpty) { | ||
| return browserOpened | ||
| ? "\nA browser window is opening where you can begin your project." | ||
| : `\nOpen ${url} in your browser to begin your project.`; | ||
| } | ||
| return ( | ||
| "\nInitialized .codeyam/ in this directory. To populate scenarios, " + | ||
| "open Claude Code here and run `/codeyam-onboard`." | ||
| ); | ||
| } | ||
| // Build the UI from the editor repo | ||
| console.error("Building UI..."); | ||
| try { | ||
| execSync("npx vite build", { stdio: "inherit", cwd: path.join(rootDir, "ui") }); | ||
| } catch { | ||
| console.error("Failed to build UI. Continuing with existing build (if any)..."); | ||
| } | ||
| /** | ||
| * Decide whether the launcher should open a browser tab to the editor UI. | ||
| * Pure (no fs, no spawn) so the gating is unit-testable without a binary, | ||
| * mirroring `planPreflightAction` / `postLaunchInitBanner`. | ||
| * | ||
| * The browser open belongs ONLY on a genuine cold start — bringing the | ||
| * editor server up fresh against a not-yet-running port. Two cases must | ||
| * NOT re-pop a tab, because one is presumably already open: | ||
| * | ||
| * shouldRestart → false `--restart` (rebuild-self, restart-server routed | ||
| * through this wrapper, a manual --restart) tears | ||
| * down and respawns a server the user is already | ||
| * looking at. Re-opening spams tabs — the reported | ||
| * symptom. Mirrors the Rust `--no-open` restart | ||
| * spawn in restart_server.rs. | ||
| * wasRunning → false `ensureServer` attached to an already-running | ||
| * same-project server (returned null) — no fresh | ||
| * process, so no new tab. | ||
| * | ||
| * Only a fresh spawn on a cold start (neither restart nor attach) opens the | ||
| * UI, matching the Rust `no_open`-gated `open_url` in start.rs. | ||
| * | ||
| * freshInit → true A brand-new empty project that was just init'd | ||
| * this run. The entire greenfield onboarding flow | ||
| * is driven from inside the editor UI, and a | ||
| * project that didn't exist a moment ago has no | ||
| * pre-existing tab for the --restart tab-spam guard | ||
| * to protect. So a fresh init opens regardless of | ||
| * `shouldRestart` — UNLESS we merely attached to an | ||
| * already-running server (`wasRunning`), which | ||
| * implies a tab is already up. | ||
| */ | ||
| function shouldOpenBrowser({ shouldRestart, wasRunning, freshInit }) { | ||
| if (freshInit) return !wasRunning; | ||
| return !shouldRestart && !wasRunning; | ||
| } | ||
| const serverChild = shouldRestart | ||
| ? await restartServer(SERVER_PORT) | ||
| : await ensureServer(SERVER_PORT); | ||
| module.exports = { | ||
| hasSubcommand, | ||
| buildBinary, | ||
| shouldRebuildOnPassthrough, | ||
| staleBinaryBanner, | ||
| hasStartCommand, | ||
| planPreflightAction, | ||
| postLaunchInitBanner, | ||
| shouldOpenBrowser, | ||
| }; | ||
| const appPort = readProjectPort(); | ||
| const viteInternalPort = appPort + 1; | ||
| console.error(`Waiting for dev server on port ${viteInternalPort}...`); | ||
| const viteReady = await waitForPort(viteInternalPort, 15000, 300); | ||
| if (!viteReady) { | ||
| console.error(`Warning: Dev server not ready on port ${viteInternalPort}. Live Preview may not work initially.`); | ||
| if (require.main === module) { | ||
| // --- Passthrough mode: build binary, then forward to it --- | ||
| if (hasSubcommand()) { | ||
| // Always rebuild before forwarding — running fresh source is the whole | ||
| // point of the dev wrapper. `cargo build` is a sub-second no-op when | ||
| // nothing changed (no relink → the server-side stale-binary watcher never | ||
| // fires), and relinks only when editor source actually changed, which is | ||
| // exactly when the developer wants the new code live. `ensureBinary()` | ||
| // still builds once if no binary exists at all. | ||
| if (shouldRebuildOnPassthrough()) { | ||
| enforceFreshBinary(buildBinary(), { action: "run editor commands" }); | ||
| } | ||
| const binary = ensureBinary(); | ||
| const child = spawn(binary, args, { | ||
| stdio: "inherit", | ||
| cwd: process.cwd(), | ||
| env: process.env, | ||
| }); | ||
| child.on("exit", (code) => { | ||
| process.exit(code ?? 1); | ||
| }); | ||
| } else { | ||
| // --- Launcher mode: build everything, start server, open browser --- | ||
| const shouldRestart = args.includes("--restart"); | ||
| const url = `http://localhost:${SERVER_PORT}`; | ||
| console.error(`Editor UI: ${url}`); | ||
| console.error(`Live Preview: http://localhost:${appPort}`); | ||
| console.error(`Project: ${projectDir}`); | ||
| open(url); | ||
| const keepAlive = setInterval(() => {}, 60_000); | ||
| const launcherMain = async () => { | ||
| const projectDir = process.cwd(); | ||
| console.error(`Editor repo: ${rootDir}`); | ||
| console.error(`Target project: ${projectDir}`); | ||
| process.on("SIGINT", () => { | ||
| clearInterval(keepAlive); | ||
| if (serverChild) serverChild.kill("SIGINT"); | ||
| process.exit(0); | ||
| }); | ||
| // Greenfield projects are auto-initialized here so the dev launcher | ||
| // stays a one-shot "build everything, run the editor against this | ||
| // project" tool. Legacy projects bail with guidance; modern projects | ||
| // skip init. See `planPreflightAction` for the rationale on each | ||
| // branch. Subcommand pass-through (handled above) is unaffected — the | ||
| // binary classifies its own subcommands. | ||
| const preflight = planPreflightAction(classifyProject(projectDir)); | ||
| // Capture emptiness BEFORE init runs — the spawn below creates | ||
| // .codeyam/ (and install_editor_files writes .claude/ etc.), which | ||
| // would make every project look non-empty by the time the | ||
| // post-launch banner prints. Same constraint as the Rust comment in | ||
| // `init_greenfield_branch` (init.rs:82-85). Computed as a sibling | ||
| // local (not folded into `preflight`) because emptiness is | ||
| // orthogonal to the preflight kind. | ||
| const wasEmpty = isEmptyProjectDir(projectDir); | ||
| if (preflight.action === "bail") { | ||
| console.error(preflight.guidance); | ||
| process.exit(preflight.code); | ||
| } | ||
| if (preflight.action === "init") { | ||
| // Run init from projectDir before the config reads below so the | ||
| // freshly-written .codeyam/editor.json feeds port resolution. | ||
| // ensureBinary builds the binary first if this is a fresh checkout. | ||
| console.error(`Initializing codeyam-editor in ${projectDir}...`); | ||
| const initBinary = ensureBinary(); | ||
| const initResult = spawnSync(initBinary, ["init"], { | ||
| cwd: projectDir, | ||
| stdio: "inherit", | ||
| env: process.env, | ||
| }); | ||
| if (initResult.status !== 0) { | ||
| console.error( | ||
| `codeyam-editor init exited ${initResult.status ?? "by signal"}; aborting launch.`, | ||
| ); | ||
| process.exit(1); | ||
| } | ||
| } | ||
| // Editor server's control port is owned by the editor repo's | ||
| // merged config (rootDir), not the target project's. The target | ||
| // project's own `editor.local.json` only contributes to the | ||
| // app/dev-server port below. | ||
| const editorMerged = readMergedEditorConfig(rootDir); | ||
| const SERVER_PORT = resolveLauncherPort( | ||
| process.env.CODEYAM_EDITOR_PORT, | ||
| editorMerged.proxy && editorMerged.proxy.controlPort, | ||
| 14199, | ||
| ); | ||
| // Target project's dev-server port comes from its own | ||
| // editor.json + editor.local.json (developers iterating on the | ||
| // editor against a real client project rely on the override | ||
| // file here too). | ||
| const projectMerged = readMergedEditorConfig(projectDir); | ||
| const appPort = resolveLauncherPort(undefined, projectMerged.port, 3000); | ||
| // Always rebuild the Rust binary + UI before launching — same contract | ||
| // as the passthrough path. The build runs BEFORE the server (re)starts | ||
| // below, so the new server records its start time after the relink and | ||
| // the stale-binary watcher reads it as fresh (no immediate SIGUSR1). A | ||
| // no-op `cargo build` (nothing changed) doesn't relink, so an external | ||
| // client-project launch is never disrupted. `ensureBinary()` still | ||
| // builds once if no binary exists. | ||
| if (shouldRebuildOnPassthrough()) { | ||
| enforceFreshBinary(buildBinary(), { | ||
| action: "run editor commands", | ||
| continueVerb: "launching with", | ||
| }); | ||
| // Build the UI from the editor repo | ||
| console.error("Building UI..."); | ||
| try { | ||
| execSync("npx vite build", { stdio: "inherit", cwd: path.join(rootDir, "ui") }); | ||
| } catch { | ||
| console.error("Failed to build UI. Continuing with existing build (if any)..."); | ||
| } | ||
| } | ||
| const serverChild = shouldRestart | ||
| ? await restartServer(SERVER_PORT) | ||
| : await ensureServer(SERVER_PORT); | ||
| // Re-read the (possibly just-initialized) project config to decide | ||
| // whether to wait on a dev server at all. On a project where init | ||
| // couldn't detect a framework (or a user-configured "editor UI | ||
| // only" setup), `startCommand` is empty — the editor server itself | ||
| // prints "No startCommand configured. Running editor UI only." and | ||
| // never spawns a dev server, so waiting 15s for `appPort + 1` is a | ||
| // pure UX hang. The launcher silently skips the wait; the server's | ||
| // own log line is the canonical signal to the user. | ||
| const postInitMerged = readMergedEditorConfig(projectDir); | ||
| const hasCommand = hasStartCommand(postInitMerged); | ||
| if (hasCommand) { | ||
| const viteInternalPort = appPort + 1; | ||
| console.error(`Waiting for dev server on port ${viteInternalPort}...`); | ||
| const viteReady = await waitForPort(viteInternalPort, 15000, 300); | ||
| if (!viteReady) { | ||
| console.error(`Warning: Dev server not ready on port ${viteInternalPort}. Live Preview may not work initially.`); | ||
| } | ||
| } | ||
| const url = `http://localhost:${SERVER_PORT}`; | ||
| console.error(`Editor UI: ${url}`); | ||
| if (hasCommand) { | ||
| console.error(`Live Preview: http://localhost:${appPort}`); | ||
| } | ||
| console.error(`Project: ${projectDir}`); | ||
| // Open the browser only on a genuine cold start. On the --restart path | ||
| // (rebuild-self, restart-server, manual --restart) and when we attached | ||
| // to an already-running server, a tab is presumably already open — the | ||
| // `Editor UI: <url>` line above is the signal. `ensureServer` returns | ||
| // null when it attached rather than spawning, so that is `wasRunning`. | ||
| // Exception: a fresh greenfield init (`freshInit`) opens regardless of | ||
| // --restart, because a project that didn't exist a moment ago has no | ||
| // pre-existing tab to protect and the onboarding flow lives in the UI. | ||
| // Compute the open decision once and feed the SAME boolean into the | ||
| // banner so it can never promise a tab that didn't appear. | ||
| const freshInit = preflight.action === "init" && wasEmpty; | ||
| const openedBrowser = shouldOpenBrowser({ | ||
| shouldRestart, | ||
| wasRunning: serverChild === null, | ||
| freshInit, | ||
| }); | ||
| if (openedBrowser) { | ||
| open(url); | ||
| } | ||
| const banner = postLaunchInitBanner({ | ||
| preflightAction: preflight.action, | ||
| wasEmpty, | ||
| browserOpened: openedBrowser, | ||
| url, | ||
| }); | ||
| if (banner) { | ||
| console.error(banner); | ||
| } | ||
| const keepAlive = setInterval(() => {}, 60_000); | ||
| // Track launcher-initiated teardown so the unexpected-exit handler below | ||
| // can tell "we killed it on SIGINT" from "the server died on its own". | ||
| let tearingDown = false; | ||
| process.on("SIGINT", () => { | ||
| // Forward a deliberate Ctrl+C (interactive launcher) to stop the editor. | ||
| // A SIGINT to a headless / agent-driven launcher is stray and must NOT | ||
| // reap the now-detached server, so swallow it and keep supervising. | ||
| // Record the signal's process-group forensics either way. | ||
| const interactive = isInteractiveLauncher(); | ||
| logLauncherTeardownSignal("SIGINT", projectDir, { interactive, forwarded: interactive }); | ||
| if (!interactive) { | ||
| return; | ||
| } | ||
| tearingDown = true; | ||
| clearInterval(keepAlive); | ||
| killChildProcess(serverChild); | ||
| process.exit(0); | ||
| }); | ||
| // Defense-in-depth against orphaned launchers: if the spawned server | ||
| // child exits and we did NOT initiate it (SIGINT), clear keepAlive and | ||
| // exit rather than lingering as a node process holding a dead child | ||
| // handle. We deliberately do NOT respawn — an exited server should let | ||
| // the launcher exit cleanly so stale supervisors can't accumulate across | ||
| // repeated launches. The in-place SIGUSR1 re-exec keeps the SAME pid, so | ||
| // it does NOT emit an "exit" here and does not trip this path; only a | ||
| // genuinely dead server does. `serverChild` is null when we attached to | ||
| // an already-running server (nothing of ours to watch). | ||
| if (serverChild) { | ||
| serverChild.on("exit", (code) => { | ||
| if (tearingDown) { | ||
| return; | ||
| } | ||
| clearInterval(keepAlive); | ||
| process.exit(code ?? 1); | ||
| }); | ||
| } | ||
| }; | ||
| launcherMain(); | ||
| } | ||
| main(); | ||
| } |
+229
-12
@@ -16,14 +16,186 @@ #!/usr/bin/env node | ||
| const { execSync } = require("child_process"); | ||
| const fs = require("fs"); | ||
| const os = require("os"); | ||
| const path = require("path"); | ||
| const { ensureServer, restartServer, waitForPort, rootDir } = require("./utils"); | ||
| const { | ||
| ensureServer, | ||
| stopServer, | ||
| waitForPort, | ||
| rootDir, | ||
| killChildProcess, | ||
| isInteractiveLauncher, | ||
| logLauncherTeardownSignal, | ||
| readMergedEditorConfig, | ||
| resolveLauncherPort, | ||
| resolveWrappedAppPort, | ||
| classifyPortHolder, | ||
| formatForeignListenerError, | ||
| formatCrossProjectError, | ||
| staleBinaryBanner, | ||
| enforceFreshBinary, | ||
| } = require("./utils"); | ||
| const { open } = require("./open"); | ||
| const SERVER_PORT = 14199; | ||
| const VITE_PORT = 5173; | ||
| // The reverse proxy sits on VITE_PORT; the actual Vite dev server runs on | ||
| // VITE_PORT + 1. We need to wait for the real Vite server, not the proxy. | ||
| const VITE_INTERNAL_PORT = VITE_PORT + 1; | ||
| const shouldRestart = process.argv.includes("--restart"); | ||
| function buildBinary() { | ||
| console.error("Building Rust binary..."); | ||
| try { | ||
| execSync("cargo build", { stdio: "inherit", cwd: rootDir }); | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
| // Resolve <CARGO_HOME>/bin (falling back to $HOME/.cargo/bin). Mirrors | ||
| // `installed_binary_path()` in crates/control-api/src/pty_broker_startup.rs | ||
| // — the Rust broker auto-spawn execs the binary at this path, so we MUST | ||
| // keep it in sync with the freshly-built debug binary or `npm run editor` | ||
| // will start a server whose PTY-broker auto-spawn execs a stale install. | ||
| function cargoBinDir() { | ||
| if (process.env.CARGO_HOME) return path.join(process.env.CARGO_HOME, "bin"); | ||
| return path.join(os.homedir(), ".cargo", "bin"); | ||
| } | ||
| // Place a hardlink at `dstPath` pointing at the same inode as `srcPath`, | ||
| // replacing any existing file at `dstPath`. On EXDEV (cross-filesystem, | ||
| // rare; target/ on a separate mount from the cargo bin dir) falls back | ||
| // to an atomic copy-and-rename via `tmpPath` so we still succeed — at | ||
| // the cost of distinct inodes, which means the dev binary's | ||
| // `stale_cargo_bin_message` check will fire on every boot. | ||
| // | ||
| // Hardlink-by-default is what keeps the stale-binary advisory silent on | ||
| // `npm run editor`: the freshly-installed binary at <cargo_bin> shares | ||
| // an inode with target/debug/codeyam-editor, so the inode-identity | ||
| // check returns "same underlying file" and nothing is printed. | ||
| function linkOrCopyBinary(srcPath, dstPath, tmpPath) { | ||
| try { | ||
| fs.unlinkSync(dstPath); | ||
| } catch (e) { | ||
| if (e.code !== "ENOENT") throw e; | ||
| } | ||
| try { | ||
| fs.linkSync(srcPath, dstPath); | ||
| } catch (err) { | ||
| if (err.code !== "EXDEV") throw err; | ||
| fs.copyFileSync(srcPath, tmpPath); | ||
| fs.chmodSync(tmpPath, 0o755); | ||
| fs.renameSync(tmpPath, dstPath); | ||
| } | ||
| } | ||
| // Install target/debug/codeyam-editor to <cargo_bin>/codeyam-editor and | ||
| // the dedicated target/debug/codeyam-editor-pty-broker to | ||
| // <cargo_bin>/codeyam-editor-pty-broker, both atomically (write to a tmp | ||
| // in the same dir, then rename). | ||
| // | ||
| // The broker is now its OWN binary (its own inode), NOT a hardlink of the | ||
| // monolith — a dedicated executable file is what gives the long-lived | ||
| // broker daemon a stable, honest process name on every platform | ||
| // (especially macOS, where hardlink aliases share one vnode name cache | ||
| // and bleed across roles). The PTY broker auto-spawn | ||
| // (control-api/src/pty_broker_startup.rs) prefers this dedicated binary | ||
| // next to the installed monolith; a stale or missing copy is what made | ||
| // build tabs hang on "Reconnecting...", so both must be refreshed here. | ||
| function installBinary() { | ||
| const srcPath = path.join(rootDir, "target", "debug", "codeyam-editor"); | ||
| if (!fs.existsSync(srcPath)) { | ||
| console.error(`Skipping install: ${srcPath} not found.`); | ||
| return; | ||
| } | ||
| const binDir = cargoBinDir(); | ||
| const dstPath = path.join(binDir, "codeyam-editor"); | ||
| const tmpPath = path.join(binDir, `.codeyam-editor.tmp.${process.pid}`); | ||
| console.error(`Installing fresh binary to ${dstPath}...`); | ||
| try { | ||
| fs.mkdirSync(binDir, { recursive: true }); | ||
| linkOrCopyBinary(srcPath, dstPath, tmpPath); | ||
| } catch (err) { | ||
| console.error(`Failed to install binary: ${err.message}`); | ||
| try { fs.unlinkSync(tmpPath); } catch {} | ||
| return; | ||
| } | ||
| // Dedicated broker binary — install as its own file (own inode), not a | ||
| // hardlink. Best-effort: if cargo didn't build it (older workspace), | ||
| // the broker auto-spawn falls back to the monolith `editor pty-broker | ||
| // daemon` shim, so a missing broker binary is a warning, not fatal. | ||
| const brokerSrc = path.join(rootDir, "target", "debug", "codeyam-editor-pty-broker"); | ||
| const brokerDst = path.join(binDir, "codeyam-editor-pty-broker"); | ||
| const brokerTmp = path.join(binDir, `.codeyam-editor-pty-broker.tmp.${process.pid}`); | ||
| if (!fs.existsSync(brokerSrc)) { | ||
| console.error( | ||
| `Note: ${brokerSrc} not found — broker will fall back to the monolith shim. ` + | ||
| `Run \`cargo build -p codeyam-pty-broker\` to build it.` | ||
| ); | ||
| return; | ||
| } | ||
| console.error(`Installing dedicated broker binary to ${brokerDst}...`); | ||
| try { | ||
| linkOrCopyBinary(brokerSrc, brokerDst, brokerTmp); | ||
| } catch (err) { | ||
| console.error(`Failed to install broker binary: ${err.message}`); | ||
| try { fs.unlinkSync(brokerTmp); } catch {} | ||
| } | ||
| } | ||
| // Evict Rust build artifacts not touched in the last day so target/ doesn't | ||
| // grow unboundedly. Must run after the old server is stopped (it holds files | ||
| // in target/debug/ open) and before the new server starts. Non-fatal. | ||
| function sweepStaleArtifacts() { | ||
| console.error("Sweeping stale Rust build artifacts (idle >1 day)..."); | ||
| try { | ||
| execSync("cargo sweep --time 1", { stdio: "inherit", cwd: rootDir }); | ||
| } catch { | ||
| console.error("cargo-sweep failed (install with `cargo install cargo-sweep`). Continuing."); | ||
| } | ||
| } | ||
| /** | ||
| * npm wrapper entry point for the editor binary: resolves ports from | ||
| * the merged editor config, ensures the Rust backend is running, and | ||
| * opens the editor UI. | ||
| */ | ||
| async function main() { | ||
| // Read ports from the merged editor config (editor.json + per-developer | ||
| // editor.local.json). The Rust backend reads the same merged config on | ||
| // its own — we resolve here so the launcher's log lines, `waitForPort` | ||
| // calls, and `--port` arg agree with what the binary will actually | ||
| // bind. Env vars still win for scripted/CI invocations | ||
| // (CODEYAM_EDITOR_PORT, PORT), then the merged config, then a hard | ||
| // fallback for fresh checkouts with no override file. | ||
| const merged = readMergedEditorConfig(rootDir); | ||
| const SERVER_PORT = resolveLauncherPort( | ||
| process.env.CODEYAM_EDITOR_PORT, | ||
| merged.proxy && merged.proxy.controlPort, | ||
| 14199, | ||
| ); | ||
| // `resolveWrappedAppPort` mirrors `resolve_app_port` in | ||
| // `crates/codeyam-editor/src/commands/start.rs`: `env > apps[0].port > | ||
| // config.port`. Without this the launcher's log line and | ||
| // `waitForPort` probe targeted one port while the backend bound | ||
| // another, producing the misleading "Vite dev server not ready on | ||
| // port N" warning whenever apps[0] declared its own port. | ||
| const VITE_PORT = resolveWrappedAppPort(merged, process.env.PORT, 5173); | ||
| // The reverse proxy sits on VITE_PORT; the actual Vite dev server runs on | ||
| // VITE_PORT + 1. We need to wait for the real Vite server, not the proxy. | ||
| const VITE_INTERNAL_PORT = VITE_PORT + 1; | ||
| // Rebuild the Rust binary so code changes are picked up on restart. | ||
| // This script is only invoked from a source checkout (via `npm run editor`), | ||
| // so Cargo.toml and the crates tree are always present. | ||
| const buildOk = buildBinary(); | ||
| // A failed rebuild means the server would boot on a stale binary that no | ||
| // longer matches the source. Refuse by default; CODEYAM_ALLOW_STALE=1 | ||
| // forces the old behavior (continue with the last-good install). | ||
| enforceFreshBinary(buildOk, { action: "start the editor server" }); | ||
| // Refresh <cargo_bin>/codeyam-editor (and the pty-broker hardlink) so | ||
| // the broker auto-spawn execs a binary that knows the `pty-broker | ||
| // daemon` subcommand. Skip if the build failed — installing a stale | ||
| // debug binary over a working install is worse than leaving it. | ||
| if (buildOk) { | ||
| installBinary(); | ||
| } | ||
| // Build the UI so the backend can serve it as static files | ||
@@ -41,6 +213,29 @@ console.error("Building UI..."); | ||
| // Start the Rust backend — it serves ui/dist/ and launches the Vite dev | ||
| // server (configured via startCommand in editor.json) for Live Preview HMR | ||
| const serverChild = shouldRestart | ||
| ? await restartServer(SERVER_PORT) | ||
| : await ensureServer(SERVER_PORT); | ||
| // server (configured via startCommand in editor.json) for Live Preview HMR. | ||
| // On --restart: identity-check first, then stop the existing server, | ||
| // sweep stale artifacts while target/ is idle, then start fresh. | ||
| // | ||
| // Stopping someone else's editor server on `--restart` is the worst | ||
| // possible outcome of a port collision — only stop the listener if | ||
| // the identity probe confirms it's this project's server. | ||
| if (shouldRestart) { | ||
| const holder = await classifyPortHolder(SERVER_PORT); | ||
| if (holder.kind === "foreign") { | ||
| console.error(formatForeignListenerError(SERVER_PORT)); | ||
| process.exit(1); | ||
| } | ||
| if (holder.kind === "cross-project") { | ||
| console.error(formatCrossProjectError(SERVER_PORT, holder, "restart")); | ||
| process.exit(1); | ||
| } | ||
| if (holder.kind === "same-project") { | ||
| console.error(`Stopping existing server on port ${SERVER_PORT}...`); | ||
| await stopServer(SERVER_PORT); | ||
| } | ||
| // holder.kind === "free": nothing to stop, fall through to ensureServer. | ||
| } | ||
| if (shouldRestart) { | ||
| sweepStaleArtifacts(); | ||
| } | ||
| const serverChild = await ensureServer(SERVER_PORT); | ||
@@ -63,4 +258,14 @@ // Wait for the actual Vite dev server on the internal port (not the reverse | ||
| process.on("SIGINT", () => { | ||
| // Forward a deliberate Ctrl+C (interactive launcher) to stop the editor. | ||
| // A SIGINT to a headless / agent-driven launcher is stray — the editor is | ||
| // torn down via API shutdown / pidfile there, never an interactive Ctrl+C — | ||
| // so do NOT forward it: the detached server stays alive. Either way, record | ||
| // the signal's process-group forensics for root-cause tracing. | ||
| const interactive = isInteractiveLauncher(); | ||
| logLauncherTeardownSignal("SIGINT", rootDir, { interactive, forwarded: interactive }); | ||
| if (!interactive) { | ||
| return; | ||
| } | ||
| clearInterval(keepAlive); | ||
| if (serverChild) serverChild.kill("SIGINT"); | ||
| killChildProcess(serverChild); | ||
| process.exit(0); | ||
@@ -70,2 +275,14 @@ }); | ||
| main(); | ||
| module.exports = { | ||
| buildBinary, | ||
| staleBinaryBanner, | ||
| cargoBinDir, | ||
| installBinary, | ||
| linkOrCopyBinary, | ||
| main, | ||
| sweepStaleArtifacts, | ||
| }; | ||
| if (require.main === module) { | ||
| main(); | ||
| } |
+452
-282
| #!/usr/bin/env node | ||
| // Render environment (colorScheme, deviceScaleFactor, userAgent, locale, | ||
| // timezoneId, reduceMotion, forcedColors) is read from config when present | ||
| // and passed to browser.newContext(). This is what makes screenshots match | ||
| // the Live Preview iframe's host browser — see docs/rendering.md. | ||
| // | ||
| // iframeBackground is forwarded to buildIframeHarness so the capture paints | ||
| // the user's editor-shell background (or whatever the UI detected) behind | ||
| // the iframe instead of a hardcoded white. | ||
| const fs = require("fs"); | ||
| const path = require("path"); | ||
| const { execSync } = require("child_process"); | ||
| const { chromium } = require("playwright"); | ||
| const LOADING_MARKERS = [ | ||
| "Loading scenario...", | ||
| "Loading tests...", | ||
| "Loading scenarios...", | ||
| "disconnected", | ||
| ]; | ||
| // Substring Playwright has emitted across every 1.x release when the | ||
| // browser cache is empty. Match the substring (not the full string) | ||
| // because Playwright includes the offending path inline. | ||
| const PLAYWRIGHT_MISSING_BROWSER_PATTERN = "Executable doesn't exist"; | ||
| const PLAYWRIGHT_INSTALL_COMMAND = "npx playwright install chromium"; | ||
| function hasLoadingMarkers(text) { | ||
| return LOADING_MARKERS.some((marker) => text.includes(marker)); | ||
| // One-shot self-heal around `chromium.launch()`. If the first launch | ||
| // throws the "missing browser" error, run `npx playwright install | ||
| // chromium` synchronously (with `stdio: "inherit"` so the user sees | ||
| // progress) and retry the launch exactly once. If the install or the | ||
| // retry fails, rethrow the ORIGINAL Playwright error so the existing | ||
| // `Scenario check failed: <stderr>` path keeps showing the actionable | ||
| // message — looping would hide a real ops failure under a slow timeout. | ||
| async function launchChromiumWithSelfHeal({ | ||
| launch = () => chromium.launch(), | ||
| install = () => execSync(PLAYWRIGHT_INSTALL_COMMAND, { stdio: "inherit" }), | ||
| stderr = process.stderr, | ||
| } = {}) { | ||
| try { | ||
| return await launch(); | ||
| } catch (error) { | ||
| const isMissingBrowser = | ||
| error && | ||
| typeof error.message === "string" && | ||
| error.message.includes(PLAYWRIGHT_MISSING_BROWSER_PATTERN); | ||
| if (!isMissingBrowser) throw error; | ||
| stderr.write( | ||
| "Playwright's Chromium browser is missing — installing it now (one-time ~150 MB download). Subsequent runs will be instant.\n", | ||
| ); | ||
| try { | ||
| install(); | ||
| } catch (_installError) { | ||
| throw error; | ||
| } | ||
| try { | ||
| return await launch(); | ||
| } catch (_retryError) { | ||
| throw error; | ||
| } | ||
| } | ||
| } | ||
| function hasRenderableContent(state) { | ||
| return Boolean( | ||
| state && | ||
| (state.rootChildCount > 0 || | ||
| state.rootTextLength > 0 || | ||
| state.bodyTextLength > 0), | ||
| ); | ||
| } | ||
| const { | ||
| findErrorPattern, | ||
| buildErrorContextSnippet, | ||
| hasRenderableContent, | ||
| describeBlankReason, | ||
| } = require("./scenario-metrics"); | ||
| const ERROR_PATTERNS = [ | ||
| "not found in registry", | ||
| "Component not found", | ||
| "Scenario Error", | ||
| ]; | ||
| const { | ||
| createIssue, | ||
| pushIssue, | ||
| buildResult, | ||
| } = require("./scenario-issues"); | ||
| function hasErrorPatterns(text) { | ||
| return ERROR_PATTERNS.some((pattern) => text.includes(pattern)); | ||
| } | ||
| const { | ||
| attachHttpMocks, | ||
| } = require("./scenario-mocks"); | ||
| function createIssue(kind, message, extra = {}) { | ||
| return { | ||
| kind, | ||
| message, | ||
| url: extra.url ?? null, | ||
| status: extra.status ?? null, | ||
| }; | ||
| } | ||
| const { | ||
| assertAppPortReachable, | ||
| loadScenarioInIframe, | ||
| loadScenarioTopLevel, | ||
| waitForStablePage, | ||
| createNetworkTracker, | ||
| waitForNetworkQuiet, | ||
| collectContentState, | ||
| performInteraction, | ||
| } = require("./scenario-playwright"); | ||
| function pushIssue(issues, issue) { | ||
| const key = JSON.stringify(issue); | ||
| if (!issues.some((existing) => JSON.stringify(existing) === key)) { | ||
| issues.push(issue); | ||
| const { | ||
| getInitScript, | ||
| handleConsoleMessage, | ||
| handlePageError, | ||
| handleRequestFailed, | ||
| } = require("./scenario-handlers"); | ||
| const { | ||
| probeInteractivity, | ||
| } = require("./scenario-interactivity"); | ||
| // Read project-specific loading markers from `.codeyam/stack.json` | ||
| // (`capture.loadingMarkers`). The capture script runs with cwd = project dir | ||
| // (scenario_check.rs sets `.current_dir(project_dir)`), so this relative path | ||
| // resolves to the project's own config. An app's loading copy ("Loading…", | ||
| // "Please wait") is app-specific, so it lives in stack.json rather than being | ||
| // hardcoded into the shared harness; the codeyam-harness defaults in | ||
| // scenario-metrics.js always apply on top. Never throws — a missing or | ||
| // malformed stack.json just yields no extra markers. | ||
| function readStackLoadingMarkers() { | ||
| try { | ||
| const raw = fs.readFileSync(path.join(".codeyam", "stack.json"), "utf8"); | ||
| const stack = JSON.parse(raw); | ||
| const markers = stack && stack.capture && stack.capture.loadingMarkers; | ||
| return Array.isArray(markers) | ||
| ? markers.filter((m) => typeof m === "string" && m.length > 0) | ||
| : []; | ||
| } catch (_) { | ||
| return []; | ||
| } | ||
| } | ||
| function escapeHtmlAttribute(value) { | ||
| return String(value) | ||
| .replaceAll("&", "&") | ||
| .replaceAll('"', """); | ||
| // Cold-start retry pause. waitForStablePage settles as soon as the page is | ||
| // HTML-stable, which for a lazy/Suspense app is the empty `<div id="root">` | ||
| // shell — stable for the ~3s the dynamic chunk takes to load (longer when the | ||
| // scenario's mocks slow the boot). 500ms re-checked before the chunk resolved | ||
| // and reported a false blank; this pause must comfortably exceed that window | ||
| // while staying under the test runner's default per-case timeout. | ||
| const BLANK_RETRY_DELAY_MS = 3000; | ||
| // True when `url` targets a different origin than `appOrigin`. Used to decide | ||
| // whether the codeyam capture markers must be stripped before the request | ||
| // leaves (cross-origin) or may ride along (same-origin, the app's own dev | ||
| // server). A malformed URL counts as same-origin (false) so we never strip | ||
| // markers from a request we can't classify — the conservative default keeps | ||
| // same-origin behavior unchanged. `url` may be a string or a URL-like object. | ||
| function isCrossOriginRequest(url, appOrigin) { | ||
| try { | ||
| const href = typeof url === "string" ? url : url.href; | ||
| return new URL(href).origin !== appOrigin; | ||
| } catch (_) { | ||
| return false; | ||
| } | ||
| } | ||
| function buildIframeHarness(url) { | ||
| const escapedUrl = escapeHtmlAttribute(url); | ||
| return `<!doctype html> | ||
| <html> | ||
| <head> | ||
| <meta charset="utf-8"> | ||
| <style> | ||
| html, body { | ||
| margin: 0; | ||
| width: 100%; | ||
| height: 100%; | ||
| overflow: hidden; | ||
| background: #fff; | ||
| } | ||
| iframe { | ||
| display: block; | ||
| width: 100%; | ||
| height: 100%; | ||
| border: 0; | ||
| background: #fff; | ||
| } | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <iframe id="scenario-frame" title="Scenario Preview" src="${escapedUrl}"></iframe> | ||
| </body> | ||
| </html>`; | ||
| // Return a copy of `headers` with every name in `markerNames` removed. | ||
| // Names are matched case-insensitively against the (lowercased) header keys | ||
| // Playwright reports. Pure — never mutates its input — so unrelated headers | ||
| // (Accept, User-Agent, a scenario's own requestHeaders) survive untouched. | ||
| function stripMarkerHeaders(headers, markerNames) { | ||
| const out = { ...headers }; | ||
| for (const name of markerNames) { | ||
| delete out[name.toLowerCase()]; | ||
| } | ||
| return out; | ||
| } | ||
| function buildResult({ loaded, hasContent, issues, outputPath, url }) { | ||
| return { | ||
| ok: loaded && hasContent && issues.length === 0, | ||
| loaded, | ||
| hasContent, | ||
| url, | ||
| outputPath: outputPath ?? null, | ||
| issues, | ||
| // Apply the scenario's merged `browserState` to a Playwright context and | ||
| // stamp the codeyam capture markers on every request the context makes. | ||
| // | ||
| // Cookies need a concrete URL to bind to (Playwright requires either | ||
| // `url` or `domain`+`path`); we derive domain/path from the requested | ||
| // capture URL when the scenario didn't pin them. Request headers go | ||
| // through `setExtraHTTPHeaders` so every navigation and resource | ||
| // request in the context carries them. | ||
| // | ||
| // Every capture-originated request carries `X-Codeyam-Capture: 1` (and | ||
| // `X-Codeyam-Scenario: <slug>` when a scenario is active) so a dev-server | ||
| // log can tell the headless capture apart from the operator's own browser | ||
| // hitting the same route — they are otherwise indistinguishable. These are | ||
| // defaults: a scenario's own `requestHeaders` are merged on top and win, | ||
| // so a user can override or clear them. | ||
| async function applyBrowserState(context, config) { | ||
| const state = (config && config.browserState) || {}; | ||
| const cookies = state.cookies || {}; | ||
| const cookieEntries = Object.entries(cookies); | ||
| if (cookieEntries.length > 0) { | ||
| let host = "127.0.0.1"; | ||
| try { | ||
| host = new URL(config.url).hostname || host; | ||
| } catch (_) { | ||
| /* fall back to localhost if config.url is malformed */ | ||
| } | ||
| const playwrightCookies = cookieEntries.map(([name, raw]) => { | ||
| const descriptor = | ||
| raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {}; | ||
| const value = typeof raw === "string" ? raw : descriptor.value || ""; | ||
| return { | ||
| name, | ||
| value, | ||
| domain: descriptor.domain || host, | ||
| path: descriptor.path || "/", | ||
| sameSite: descriptor.sameSite || "Lax", | ||
| httpOnly: descriptor.httpOnly === true, | ||
| secure: descriptor.secure === true, | ||
| }; | ||
| }); | ||
| await context.addCookies(playwrightCookies); | ||
| } | ||
| const codeyamHeaders = { | ||
| "X-Codeyam-Capture": "1", | ||
| ...(config && config.scenarioId | ||
| ? { "X-Codeyam-Scenario": config.scenarioId } | ||
| : {}), | ||
| }; | ||
| } | ||
| // Scenario `requestHeaders` are merged last so a user value overrides | ||
| // the codeyam default (e.g. setting `X-Codeyam-Capture: "0"` as an | ||
| // escape hatch). `codeyamHeaders` always has at least the capture marker, | ||
| // so this fires on every capture — including ones with no browserState. | ||
| const headers = { ...codeyamHeaders, ...(state.requestHeaders || {}) }; | ||
| if (Object.keys(headers).length > 0) { | ||
| await context.setExtraHTTPHeaders(headers); | ||
| } | ||
| function normalizeMockCandidates(url) { | ||
| // Strip the codeyam capture markers (and any scenario request headers) from | ||
| // CROSS-ORIGIN requests. setExtraHTTPHeaders applies context-wide, so the | ||
| // custom `X-Codeyam-*` headers ride along on third-party subresource | ||
| // requests (Google Fonts, CDNs, external APIs) too — and a non-safelisted | ||
| // request header forces a CORS preflight those hosts reject | ||
| // (`Request header field x-codeyam-capture is not allowed`), which fails the | ||
| // whole capture. The markers are only meaningful to the app's OWN dev server | ||
| // (same-origin), so re-send cross-origin requests without them. Same-origin | ||
| // requests are never matched here, so dev-module/HMR loading is untouched. | ||
| let appOrigin = null; | ||
| try { | ||
| const parsed = new URL(url); | ||
| return [url, `${parsed.pathname}${parsed.search}`, parsed.pathname]; | ||
| } catch { | ||
| return [url]; | ||
| appOrigin = new URL(config.url).origin; | ||
| } catch (_) { | ||
| /* malformed capture URL — skip the cross-origin guard entirely */ | ||
| } | ||
| if (appOrigin && typeof context.route === "function") { | ||
| // Only the codeyam markers are stripped — a scenario's own requestHeaders | ||
| // are the author's deliberate choice and left intact. | ||
| const markerNames = Object.keys(codeyamHeaders); | ||
| await context.route( | ||
| (url) => isCrossOriginRequest(url, appOrigin), | ||
| async (route) => { | ||
| const reqHeaders = stripMarkerHeaders(route.request().headers(), markerNames); | ||
| await route.continue({ headers: reqHeaders }); | ||
| }, | ||
| ); | ||
| } | ||
| } | ||
| function findHttpMock(httpMocks, request) { | ||
| const method = request.method().toUpperCase(); | ||
| const candidates = normalizeMockCandidates(request.url()); | ||
| for (const candidate of candidates) { | ||
| const key = `${method} ${candidate}`; | ||
| if (httpMocks[key]) { | ||
| return httpMocks[key]; | ||
| } | ||
| // Emit a `redirect-mismatch` issue when the final iframe URL's path | ||
| // differs from the requested path. This converts the silent | ||
| // screenshot-of-/login failure (auth lost in the capture context) into | ||
| // a typed, actionable diagnostic the agent can route to. | ||
| function pushRedirectMismatchIssue(issues, requestedUrl, frame, response, config) { | ||
| let requestedPath; | ||
| try { | ||
| requestedPath = new URL(requestedUrl).pathname; | ||
| } catch (_) { | ||
| return; | ||
| } | ||
| return null; | ||
| const finalUrl = (frame && frame.url && frame.url()) || requestedUrl; | ||
| let finalPath; | ||
| try { | ||
| finalPath = new URL(finalUrl).pathname; | ||
| } catch (_) { | ||
| return; | ||
| } | ||
| if (requestedPath === finalPath) return; | ||
| const cookies = | ||
| (config && config.browserState && config.browserState.cookies) || {}; | ||
| const hasCookies = Object.keys(cookies).length > 0; | ||
| const hint = hasCookies | ||
| ? " — scenario carries browserState.cookies; auth likely lost in the capture context (run `codeyam-editor editor scenario-explain <slug>` to verify)" | ||
| : ""; | ||
| pushIssue( | ||
| issues, | ||
| createIssue( | ||
| "redirect-mismatch", | ||
| `Capture URL redirected from ${requestedPath} to ${finalPath}${hint}`, | ||
| { | ||
| url: finalUrl, | ||
| status: response && response.status ? response.status() : null, | ||
| }, | ||
| ), | ||
| ); | ||
| } | ||
| async function attachHttpMocks(page, httpMocks) { | ||
| if (!httpMocks || Object.keys(httpMocks).length === 0) return; | ||
| await page.route("**/*", async (route) => { | ||
| const mock = findHttpMock(httpMocks, route.request()); | ||
| if (!mock) { | ||
| await route.continue(); | ||
| return; | ||
| // Read-only page-state snapshot for `capture-state`: the full localStorage | ||
| // map, a bounded sample of visible text nodes (document order), and — when a | ||
| // selector is given — that element's text. Evaluated in-page against the | ||
| // settled frame so it reflects exactly what a real capture saw (the proxy | ||
| // already injected the scenario's seed into the served HTML). Every read is | ||
| // individually guarded so a sandboxed/cross-origin localStorage never throws | ||
| // the whole capture; the worst case is an empty section, not a failure. | ||
| async function dumpPageState(frame, selector) { | ||
| return frame.evaluate((sel) => { | ||
| const localStorage = {}; | ||
| try { | ||
| for (let i = 0; i < window.localStorage.length; i++) { | ||
| const key = window.localStorage.key(i); | ||
| if (key != null) localStorage[key] = window.localStorage.getItem(key); | ||
| } | ||
| } catch (_) { | ||
| /* localStorage may be unavailable (sandboxed/opaque origin) */ | ||
| } | ||
| const headers = { ...(mock.headers || {}) }; | ||
| let body; | ||
| if (mock.body !== undefined) { | ||
| body = | ||
| typeof mock.body === "string" ? mock.body : JSON.stringify(mock.body); | ||
| const hasContentType = Object.keys(headers).some( | ||
| (key) => key.toLowerCase() === "content-type", | ||
| const visibleText = []; | ||
| try { | ||
| // Reject text inside non-rendered tags (SCRIPT/STYLE/etc.) so an | ||
| // injected proxy script or inline CSS never masquerades as on-screen | ||
| // text — that noise is exactly what makes a state dump misleading. | ||
| const SKIP_TAGS = new Set([ | ||
| "SCRIPT", | ||
| "STYLE", | ||
| "NOSCRIPT", | ||
| "TEMPLATE", | ||
| "HEAD", | ||
| "TITLE", | ||
| ]); | ||
| const walker = document.createTreeWalker( | ||
| document.body, | ||
| NodeFilter.SHOW_TEXT, | ||
| { | ||
| acceptNode(node) { | ||
| const parent = node.parentElement; | ||
| if (parent && SKIP_TAGS.has(parent.tagName)) { | ||
| return NodeFilter.FILTER_REJECT; | ||
| } | ||
| return (node.textContent || "").trim() | ||
| ? NodeFilter.FILTER_ACCEPT | ||
| : NodeFilter.FILTER_REJECT; | ||
| }, | ||
| }, | ||
| ); | ||
| if (!hasContentType) { | ||
| headers["content-type"] = "application/json"; | ||
| let node; | ||
| while ((node = walker.nextNode()) && visibleText.length < 40) { | ||
| const text = (node.textContent || "").replace(/\s+/g, " ").trim(); | ||
| if (text) visibleText.push(text); | ||
| } | ||
| } catch (_) { | ||
| /* no body / detached document */ | ||
| } | ||
| await route.fulfill({ | ||
| status: mock.status || 200, | ||
| headers, | ||
| body, | ||
| }); | ||
| }); | ||
| // Disable the in-page fetch mock by returning an empty active-mocks.json. | ||
| // The HTML injects a script that synchronously loads this file and | ||
| // monkey-patches window.fetch, which would bypass Playwright's route | ||
| // interception. This route is registered AFTER **/* so it takes priority | ||
| // (Playwright uses LIFO ordering for route handlers). | ||
| await page.route("**/active-mocks.json", async (route) => { | ||
| await route.fulfill({ | ||
| status: 200, | ||
| headers: { "content-type": "application/json" }, | ||
| body: "[]", | ||
| }); | ||
| }); | ||
| } | ||
| async function collectContentState(target) { | ||
| return target.evaluate(() => { | ||
| const root = document.getElementById("root"); | ||
| return { | ||
| bodyTextLength: document.body.innerText.trim().length, | ||
| rootChildCount: root ? root.childElementCount : 0, | ||
| rootTextLength: root ? (root.textContent || "").trim().length : 0, | ||
| }; | ||
| }); | ||
| } | ||
| async function waitForStablePage(page, target, timeoutMs = 10000) { | ||
| const started = Date.now(); | ||
| let lastHtml = ""; | ||
| let stableCount = 0; | ||
| while (Date.now() - started < timeoutMs) { | ||
| await page.waitForTimeout(500); | ||
| const pageState = await target.evaluate(() => ({ | ||
| bodyText: document.body.innerText, | ||
| html: document.body.innerHTML, | ||
| })); | ||
| if ( | ||
| !hasLoadingMarkers(pageState.bodyText) && | ||
| pageState.html === lastHtml | ||
| ) { | ||
| stableCount += 1; | ||
| if (stableCount >= 2) return; | ||
| } else { | ||
| stableCount = 0; | ||
| let selectorText = null; | ||
| if (sel) { | ||
| try { | ||
| const el = document.querySelector(sel); | ||
| if (el) selectorText = (el.textContent || "").replace(/\s+/g, " ").trim(); | ||
| } catch (_) { | ||
| /* invalid selector — leave selectorText null */ | ||
| } | ||
| } | ||
| lastHtml = pageState.html; | ||
| } | ||
| return { localStorage, visibleText, selectorText }; | ||
| }, selector || null); | ||
| } | ||
| async function loadScenarioInIframe(page, url) { | ||
| const responsePromise = page | ||
| .waitForResponse( | ||
| (response) => | ||
| response.request().resourceType() === "document" && | ||
| response.url() === url, | ||
| { timeout: 30000 }, | ||
| ) | ||
| .catch(() => null); | ||
| await page.setContent(buildIframeHarness(url), { waitUntil: "domcontentloaded" }); | ||
| const frameHandle = await page.waitForSelector("#scenario-frame", { | ||
| state: "attached", | ||
| timeout: 30000, | ||
| }); | ||
| const frame = await frameHandle.contentFrame(); | ||
| if (!frame) { | ||
| throw new Error("Scenario iframe did not attach"); | ||
| } | ||
| await frame.waitForLoadState("load", { timeout: 30000 }); | ||
| const response = await responsePromise; | ||
| return { frame, response }; | ||
| } | ||
| async function runScenarioCheck(config) { | ||
| // `preflight` is injectable (defaulting to the real app-port reachability | ||
| // check) so unit tests that mock the browser can stay network-free. | ||
| async function runScenarioCheck(config, { preflight = assertAppPortReachable } = {}) { | ||
| const { url, outputPath, width, height, httpMocks = {} } = config; | ||
| const issues = []; | ||
| const browser = await chromium.launch(); | ||
| const context = await browser.newContext({ | ||
| const browser = await launchChromiumWithSelfHeal(); | ||
| const contextOptions = { | ||
| viewport: { width: width || 1440, height: height || 900 }, | ||
| }); | ||
| }; | ||
| if (config.colorScheme) contextOptions.colorScheme = config.colorScheme; | ||
| if (config.deviceScaleFactor) | ||
| contextOptions.deviceScaleFactor = config.deviceScaleFactor; | ||
| if (config.userAgent) contextOptions.userAgent = config.userAgent; | ||
| if (config.locale) contextOptions.locale = config.locale; | ||
| if (config.timezoneId) contextOptions.timezoneId = config.timezoneId; | ||
| if (config.reduceMotion) contextOptions.reducedMotion = config.reduceMotion; | ||
| if (config.forcedColors) contextOptions.forcedColors = config.forcedColors; | ||
| const context = await browser.newContext(contextOptions); | ||
| // Apply the scenario's merged `browserState` (cookies + request | ||
| // headers) to the capture context BEFORE the first navigation. | ||
| // Belt-and-suspenders with the proxy's `Set-Cookie` injection: the | ||
| // proxy handles upstream-bound forwards, this branch handles the | ||
| // capture context's own request headers so an auth-gated route does | ||
| // not redirect to `/login` when the proxy is bypassed. | ||
| await applyBrowserState(context, config); | ||
| // Context-level init script runs in ALL frames (including cross-origin iframes) | ||
| await context.addInitScript(() => { | ||
| window.__codeyamUnhandledRejections = []; | ||
| window.addEventListener("unhandledrejection", (event) => { | ||
| const reason = event.reason; | ||
| const message = | ||
| reason instanceof Error ? reason.message : String(reason); | ||
| window.__codeyamUnhandledRejections.push(message); | ||
| }); | ||
| await context.addInitScript(getInitScript()); | ||
| // Stub WebSocket during capture to prevent terminal reconnection spam. | ||
| // Returns an object that behaves like a closed WebSocket — the terminal | ||
| // will attempt to reconnect a few times then give up silently. | ||
| window.WebSocket = class StubWebSocket { | ||
| static CONNECTING = 0; | ||
| static OPEN = 1; | ||
| static CLOSING = 2; | ||
| static CLOSED = 3; | ||
| readyState = 3; | ||
| onopen = null; | ||
| onclose = null; | ||
| onerror = null; | ||
| onmessage = null; | ||
| send() {} | ||
| close() {} | ||
| addEventListener() {} | ||
| removeEventListener() {} | ||
| dispatchEvent() { return false; } | ||
| constructor() { | ||
| // Fire onerror then onclose on next tick so the terminal sees a failed connection | ||
| setTimeout(() => { | ||
| if (this.onerror) this.onerror(new Event("error")); | ||
| if (this.onclose) this.onclose(new CloseEvent("close")); | ||
| }, 0); | ||
| } | ||
| }; | ||
| }); | ||
| const page = await context.newPage(); | ||
| // Attach the network tracker BEFORE navigation so every request (the document | ||
| // and every client-side data fetch) is counted. Used after DOM stability to | ||
| // wait out an in-flight fetch that would otherwise be screenshotted as a | ||
| // loading skeleton. | ||
| const networkTracker = createNetworkTracker(page); | ||
| await attachHttpMocks(page, httpMocks); | ||
| page.on("pageerror", (error) => { | ||
| pushIssue( | ||
| issues, | ||
| createIssue("pageerror", error.message || String(error), { | ||
| url: page.url() || url, | ||
| }), | ||
| ); | ||
| pushIssue(issues, handlePageError(error)); | ||
| }); | ||
| page.on("console", (message) => { | ||
| if (message.type() !== "error") return; | ||
| const text = message.text(); | ||
| // Ignore known dev-server WebSocket/HMR errors from Vite proxy | ||
| if ( | ||
| text.includes("WebSocket connection to") || | ||
| text.includes("Unsupported Media Type") | ||
| ) { | ||
| return; | ||
| const issue = handleConsoleMessage(message); | ||
| if (issue) { | ||
| pushIssue(issues, issue); | ||
| } | ||
| pushIssue( | ||
| issues, | ||
| createIssue("console", text, { | ||
| url: page.url() || url, | ||
| }), | ||
| ); | ||
| }); | ||
| page.on("requestfailed", (request) => { | ||
| pushIssue( | ||
| issues, | ||
| createIssue( | ||
| "requestfailed", | ||
| request.failure()?.errorText || "Request failed", | ||
| { url: request.url() }, | ||
| ), | ||
| ); | ||
| const issue = handleRequestFailed(request); | ||
| if (issue) { | ||
| pushIssue(issues, issue); | ||
| } | ||
| }); | ||
@@ -319,3 +407,15 @@ | ||
| try { | ||
| const { frame, response } = await loadScenarioInIframe(page, url); | ||
| // Application/route captures navigate at the top level so the | ||
| // first-party session cookie is sent (auth-gated routes render the | ||
| // authenticated page instead of /login); component captures keep the | ||
| // iframe harness for its background/sizing control. The backend signals | ||
| // the choice via `config.navigation` ("topLevel"); absent (the default) | ||
| // means the iframe harness, so existing callers are unchanged. | ||
| const { frame, response } = | ||
| config.navigation === "topLevel" | ||
| ? await loadScenarioTopLevel(page, url, { preflight }) | ||
| : await loadScenarioInIframe(page, url, { | ||
| background: config.iframeBackground, | ||
| preflight, | ||
| }); | ||
| loaded = true; | ||
@@ -333,4 +433,19 @@ | ||
| await waitForStablePage(page, frame); | ||
| pushRedirectMismatchIssue(issues, url, frame, response, config); | ||
| // Project loading markers come from config when a caller injects them | ||
| // (unit tests), otherwise from stack.json — so a stable-but-loading app | ||
| // screen is not mistaken for settled content and captured mid-hydration. | ||
| const loadingMarkers = Array.isArray(config.loadingMarkers) | ||
| ? config.loadingMarkers | ||
| : readStackLoadingMarkers(); | ||
| await waitForStablePage(page, frame, 10000, loadingMarkers); | ||
| // DOM-stable does not mean done: a client-side data fetch can still be in | ||
| // flight (the loading skeleton cleared but its replacement content hasn't | ||
| // landed). Wait for the network to go quiet — bounded, so a streaming / | ||
| // long-poll endpoint that never idles caps out and captures anyway rather | ||
| // than hanging. | ||
| await waitForNetworkQuiet(networkTracker); | ||
| const rejectionMessages = await frame.evaluate( | ||
@@ -348,4 +463,15 @@ () => window.__codeyamUnhandledRejections || [], | ||
| const contentState = await collectContentState(frame); | ||
| const hasContent = hasRenderableContent(contentState); | ||
| // Cold-start retry: a single re-collect after BLANK_RETRY_DELAY_MS covers | ||
| // the React.lazy / Suspense-fallback race where waitForStablePage settles | ||
| // on the still-empty `<div id="root">` shell before the dynamic chunk | ||
| // resolves. One retry is enough — the pause is sized to outlast the | ||
| // chunk-load window; only a genuinely blank page falls through to the | ||
| // blank issue below. | ||
| let contentState = await collectContentState(frame); | ||
| let hasContent = hasRenderableContent(contentState); | ||
| if (!hasContent) { | ||
| await new Promise((r) => setTimeout(r, BLANK_RETRY_DELAY_MS)); | ||
| contentState = await collectContentState(frame); | ||
| hasContent = hasRenderableContent(contentState); | ||
| } | ||
@@ -355,5 +481,7 @@ if (!hasContent) { | ||
| issues, | ||
| createIssue("blank", "Page rendered no visible content", { | ||
| url: page.url() || url, | ||
| }), | ||
| createIssue( | ||
| "blank", | ||
| `Page rendered no visible content (${describeBlankReason(contentState)})`, | ||
| { url: page.url() || url }, | ||
| ), | ||
| ); | ||
@@ -363,12 +491,43 @@ } | ||
| // Check for known error states in the rendered content | ||
| const bodyText = await frame.evaluate(() => document.body.innerText || ""); | ||
| if (hasErrorPatterns(bodyText)) { | ||
| const bodyText = await frame.evaluate(() => document.body?.innerText || ""); | ||
| const matchedPattern = findErrorPattern(bodyText); | ||
| if (matchedPattern) { | ||
| const contextSnippet = buildErrorContextSnippet(bodyText, matchedPattern); | ||
| pushIssue( | ||
| issues, | ||
| createIssue("error-state", `Page contains error content: ${bodyText.slice(0, 200)}`, { | ||
| url: page.url() || url, | ||
| }), | ||
| createIssue( | ||
| "error-state", | ||
| `Page contains error content (matched "${matchedPattern}"): ${contextSnippet ?? bodyText.slice(0, 200)}`, | ||
| { | ||
| url: page.url() || url, | ||
| matchedPattern, | ||
| contextSnippet, | ||
| }, | ||
| ), | ||
| ); | ||
| } | ||
| // Hydration / interactivity gate: a page can render content and log no | ||
| // errors yet never have hydrated, leaving every control dead. Read-only | ||
| // (it inspects framework-attachment markers, never clicks), so it is safe | ||
| // to run before the screenshot. Stack-gated and fail-safe — see | ||
| // scenario-interactivity.js — so backend / static / unknown-framework | ||
| // captures are an automatic pass. | ||
| const hydrationIssue = await probeInteractivity(frame, { | ||
| url: page.url() || url, | ||
| }); | ||
| if (hydrationIssue) { | ||
| pushIssue(issues, hydrationIssue); | ||
| } | ||
| // Drive the requested interaction (if any) against the settled frame, | ||
| // then re-settle, so `preview-interact` captures the RESULT of a click / | ||
| // fill / press (expanded accordion, open modal) without editing app | ||
| // source. A no-match target throws here and is caught below as a failed | ||
| // capture with the candidate-labels hint — never a silent blank shot. | ||
| if (config.interaction) { | ||
| await performInteraction(frame, config.interaction); | ||
| await waitForStablePage(page, frame, 5000, loadingMarkers); | ||
| } | ||
| if (outputPath && loaded) { | ||
@@ -379,3 +538,3 @@ fs.mkdirSync(path.dirname(outputPath), { recursive: true }); | ||
| return buildResult({ | ||
| const result = buildResult({ | ||
| loaded, | ||
@@ -387,2 +546,11 @@ hasContent, | ||
| }); | ||
| // `capture-state` mode: attach the read-only page-state snapshot so the | ||
| // backend can report localStorage + rendered text. Off by default, so a | ||
| // normal error-check capture is byte-for-byte unchanged. | ||
| if (config.captureState) { | ||
| result.state = await dumpPageState(frame, config.stateSelector); | ||
| } | ||
| return result; | ||
| } catch (error) { | ||
@@ -406,2 +574,7 @@ pushIssue( | ||
| /** | ||
| * npm wrapper entry point for the scenario-check binary: parses the | ||
| * JSON config from argv, drives Playwright to capture the configured | ||
| * URL, and writes the resulting screenshot. | ||
| */ | ||
| async function main() { | ||
@@ -422,15 +595,12 @@ const config = JSON.parse(process.argv[2] || "{}"); | ||
| module.exports = { | ||
| attachHttpMocks, | ||
| buildResult, | ||
| buildIframeHarness, | ||
| createIssue, | ||
| findHttpMock, | ||
| hasErrorPatterns, | ||
| hasLoadingMarkers, | ||
| hasRenderableContent, | ||
| loadScenarioInIframe, | ||
| normalizeMockCandidates, | ||
| pushIssue, | ||
| runScenarioCheck, | ||
| waitForStablePage, | ||
| dumpPageState, | ||
| readStackLoadingMarkers, | ||
| applyBrowserState, | ||
| isCrossOriginRequest, | ||
| stripMarkerHeaders, | ||
| main, | ||
| launchChromiumWithSelfHeal, | ||
| PLAYWRIGHT_INSTALL_COMMAND, | ||
| PLAYWRIGHT_MISSING_BROWSER_PATTERN, | ||
| }; | ||
@@ -437,0 +607,0 @@ |
+738
-27
@@ -9,20 +9,111 @@ const { execSync, spawn, spawnSync } = require("child_process"); | ||
| /** Find the compiled binary — check npm-installed location first, then dev builds. */ | ||
| /** | ||
| * Map (process.platform, process.arch) → the platform sub-package that | ||
| * carries the matching native binaries. Each sub-package ships TWO | ||
| * binaries side-by-side in its `bin/` dir: the `codeyam-editor` monolith | ||
| * and the dedicated `codeyam-editor-pty-broker` daemon (its own | ||
| * executable file / inode, for a stable honest process name). They live | ||
| * in the same dir so the broker auto-spawn's sibling-of-current-exe | ||
| * resolution finds the broker next to the monolith with no copying. | ||
| * Kept as a small data table so adding a new platform (e.g. linux/arm64) | ||
| * is one entry, not a new branch. | ||
| */ | ||
| const PLATFORM_PACKAGES = { | ||
| "darwin-arm64": { pkg: "@codeyam-editor/codeyam-editor-darwin-arm64", binary: "codeyam-editor", broker: "codeyam-editor-pty-broker" }, | ||
| "darwin-x64": { pkg: "@codeyam-editor/codeyam-editor-darwin-x64", binary: "codeyam-editor", broker: "codeyam-editor-pty-broker" }, | ||
| "linux-x64": { pkg: "@codeyam-editor/codeyam-editor-linux-x64", binary: "codeyam-editor", broker: "codeyam-editor-pty-broker" }, | ||
| "win32-x64": { pkg: "@codeyam-editor/codeyam-editor-win32-x64", binary: "codeyam-editor.exe", broker: "codeyam-editor-pty-broker.exe" }, | ||
| }; | ||
| /** | ||
| * Platform-correct executable filename for the codeyam-editor binary. | ||
| * Windows requires the `.exe` extension; on Unix the binary has no extension. | ||
| * Accepts an explicit platform so the helper is testable across platforms. | ||
| */ | ||
| function binaryName(platform = process.platform) { | ||
| return platform === "win32" ? "codeyam-editor.exe" : "codeyam-editor"; | ||
| } | ||
| /** | ||
| * Platform-correct executable filename for the dedicated broker binary. | ||
| * Mirrors {@link binaryName} for the second shipped binary. | ||
| */ | ||
| function brokerBinaryName(platform = process.platform) { | ||
| return platform === "win32" ? "codeyam-editor-pty-broker.exe" : "codeyam-editor-pty-broker"; | ||
| } | ||
| /** Look up the platform sub-package metadata for a given platform/arch pair. */ | ||
| function platformPackageInfo(platform = process.platform, arch = process.arch) { | ||
| return PLATFORM_PACKAGES[`${platform}-${arch}`] || null; | ||
| } | ||
| /** | ||
| * Resolve the native binary path. | ||
| * | ||
| * Production install (npm install @codeyam-editor/codeyam-editor): | ||
| * npm installed the matching @codeyam-editor/codeyam-editor-<plat>-<arch> | ||
| * sub-package via optionalDependencies. require.resolve finds the binary | ||
| * inside that sibling package. | ||
| * | ||
| * Dev checkout (`cargo build` in the repo): | ||
| * No platform sub-package installed; fall through to target/debug or | ||
| * target/release. | ||
| * | ||
| * Returns null if no binary can be found at any of these locations — the | ||
| * caller (ensureBinary / cli.js) decides whether to error or to invoke | ||
| * `cargo build` based on whether a Cargo.toml is present. | ||
| */ | ||
| function findBinary() { | ||
| const candidates = [ | ||
| path.join(rootDir, "bin", "codeyam-editor"), // npm install (postinstall) | ||
| path.join(rootDir, "target", "debug", "codeyam-editor"), // local dev (debug) | ||
| path.join(rootDir, "target", "release", "codeyam-editor"), // local dev (release) | ||
| ]; | ||
| for (const candidate of candidates) { | ||
| if (fs.existsSync(candidate)) { | ||
| return candidate; | ||
| const info = platformPackageInfo(); | ||
| if (info) { | ||
| try { | ||
| const resolved = require.resolve(`${info.pkg}/bin/${info.binary}`, { | ||
| paths: [rootDir], | ||
| }); | ||
| if (fs.existsSync(resolved)) return resolved; | ||
| } catch (e) { | ||
| if (e.code !== "MODULE_NOT_FOUND") throw e; | ||
| } | ||
| } | ||
| const name = binaryName(); | ||
| // Target directories to search, in precedence order. A custom | ||
| // CARGO_TARGET_DIR (cloud VMs set CARGO_TARGET_DIR=/codeyam-build-target) | ||
| // wins over the default ./target — cargo builds ONLY into the configured | ||
| // dir, so ./target may be stale or absent. Honoring it here keeps | ||
| // `ensureBinary` from building successfully and then failing to locate the | ||
| // result. CARGO_TARGET_DIR may be absolute or relative to the workspace | ||
| // root; path.resolve handles both (an absolute value ignores rootDir). | ||
| const targetDirs = []; | ||
| const customTarget = process.env.CARGO_TARGET_DIR; | ||
| if (customTarget) { | ||
| targetDirs.push(path.resolve(rootDir, customTarget)); | ||
| } | ||
| targetDirs.push(path.join(rootDir, "target")); | ||
| const devCandidates = []; | ||
| for (const dir of targetDirs) { | ||
| devCandidates.push(path.join(dir, "debug", name)); | ||
| devCandidates.push(path.join(dir, "release", name)); | ||
| } | ||
| for (const candidate of devCandidates) { | ||
| if (fs.existsSync(candidate)) return candidate; | ||
| } | ||
| return null; | ||
| } | ||
| /** Return the binary path — build from source only if no pre-built binary exists. */ | ||
| /** | ||
| * Return the binary path, building from source if we're in a dev checkout. | ||
| * | ||
| * Production install: if findBinary returns null, the platform sub-package | ||
| * is missing — npm install failed silently (likely because the user's | ||
| * platform isn't in the build matrix). Emit a clear, actionable error | ||
| * naming the expected sub-package, and exit 1. **Do not** fall through to | ||
| * `cargo build` — there's no Cargo.toml in the npm install dir, and the | ||
| * resulting "could not find Cargo.toml" error is misleading to users. | ||
| * | ||
| * Dev checkout: `Cargo.toml` is present at rootDir → run `cargo build`, | ||
| * same as the legacy behavior. | ||
| */ | ||
| function ensureBinary() { | ||
@@ -32,3 +123,22 @@ const existing = findBinary(); | ||
| // No pre-built binary; try building from source (local dev workflow). | ||
| const isDevCheckout = fs.existsSync(path.join(rootDir, "Cargo.toml")); | ||
| if (!isDevCheckout) { | ||
| const info = platformPackageInfo(); | ||
| if (!info) { | ||
| console.error( | ||
| `codeyam-editor: unsupported platform ${process.platform}/${process.arch}.\n` + | ||
| `Supported: darwin/arm64, darwin/x64, linux/x64, win32/x64.` | ||
| ); | ||
| } else { | ||
| console.error( | ||
| `codeyam-editor: native binary missing.\n` + | ||
| `Expected at ${info.pkg}/bin/${info.binary} — npm should have installed ` + | ||
| `that sub-package as an optionalDependency.\n` + | ||
| `Try: npm install --force ${info.pkg}@$(npm view @codeyam-editor/codeyam-editor version)` | ||
| ); | ||
| } | ||
| process.exit(1); | ||
| } | ||
| try { | ||
@@ -38,4 +148,4 @@ execSync("cargo --version", { stdio: "ignore" }); | ||
| console.error( | ||
| "codeyam-editor binary not found. Try reinstalling:\n" + | ||
| " npm install @codeyam-editor/codeyam-editor\n" | ||
| "codeyam-editor binary not found and `cargo` is not on PATH. " + | ||
| "Install Rust from https://rustup.rs/ or reinstall @codeyam-editor/codeyam-editor." | ||
| ); | ||
@@ -58,5 +168,64 @@ process.exit(1); | ||
| } | ||
| verifyBrokerBinaryPresent(binary); | ||
| return binary; | ||
| } | ||
| /** | ||
| * Resolve the dedicated broker binary path. Mirrors {@link findBinary}: | ||
| * the published platform sub-package ships it next to the monolith, and | ||
| * a dev `cargo build` produces it under target/debug|release. Returns | ||
| * null if it can't be located — the Rust broker auto-spawn then falls | ||
| * back to the monolith `editor pty-broker daemon` shim, so a missing | ||
| * broker binary degrades gracefully rather than breaking the editor. | ||
| */ | ||
| function findBrokerBinary() { | ||
| const info = platformPackageInfo(); | ||
| if (info && info.broker) { | ||
| try { | ||
| const resolved = require.resolve(`${info.pkg}/bin/${info.broker}`, { | ||
| paths: [rootDir], | ||
| }); | ||
| if (fs.existsSync(resolved)) return resolved; | ||
| } catch (e) { | ||
| if (e.code !== "MODULE_NOT_FOUND") throw e; | ||
| } | ||
| } | ||
| const name = brokerBinaryName(); | ||
| const targetDirs = []; | ||
| const customTarget = process.env.CARGO_TARGET_DIR; | ||
| if (customTarget) { | ||
| targetDirs.push(path.resolve(rootDir, customTarget)); | ||
| } | ||
| targetDirs.push(path.join(rootDir, "target")); | ||
| for (const dir of targetDirs) { | ||
| for (const profile of ["debug", "release"]) { | ||
| const candidate = path.join(dir, profile, name); | ||
| if (fs.existsSync(candidate)) return candidate; | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
| /** | ||
| * Best-effort check that the dedicated broker binary sits next to the | ||
| * monolith (the layout the broker auto-spawn's sibling resolution | ||
| * expects). Logs a one-line note when it's missing — never throws or | ||
| * exits, because the Rust side degrades to the monolith shim. `monolith` | ||
| * is the resolved monolith binary path; the broker is expected as its | ||
| * sibling. | ||
| */ | ||
| function verifyBrokerBinaryPresent(monolith) { | ||
| const sibling = path.join(path.dirname(monolith), brokerBinaryName()); | ||
| if (fs.existsSync(sibling)) return; | ||
| const located = findBrokerBinary(); | ||
| if (located) return; | ||
| console.error( | ||
| "Note: dedicated broker binary (codeyam-editor-pty-broker) not found next to " + | ||
| `${monolith}. The PTY broker will fall back to the monolith \`editor pty-broker daemon\` ` + | ||
| "shim. Reinstall the platform sub-package, or run `cargo build -p codeyam-pty-broker`." | ||
| ); | ||
| } | ||
| /** Resolve the UI dist directory for the binary to serve. */ | ||
@@ -67,2 +236,184 @@ function uiDistDir() { | ||
| /** | ||
| * Loud banner printed when `cargo build` failed and a wrapper is about to fall | ||
| * back to a stale binary. Shared by both wrappers (editor.js server boot, | ||
| * editor-dev.js passthrough/launch) so the refusal text and the documented | ||
| * CODEYAM_ALLOW_STALE escape hatch stay identical. `action` names what would | ||
| * run on the stale binary, e.g. "run editor commands" / "start the editor | ||
| * server". | ||
| */ | ||
| function staleBinaryBanner(action = "run editor commands") { | ||
| return [ | ||
| "", | ||
| "============================================================", | ||
| " cargo build FAILED — the editor source does not compile.", | ||
| ` Refusing to ${action} on a STALE binary,`, | ||
| " which would run code that no longer matches the source.", | ||
| "", | ||
| " Fix the compile error above, then re-run.", | ||
| " To deliberately run the last-good binary anyway, set", | ||
| " CODEYAM_ALLOW_STALE=1.", | ||
| "============================================================", | ||
| "", | ||
| ].join("\n"); | ||
| } | ||
| /** | ||
| * Enforce a fresh binary before a wrapper runs it. A no-op when the rebuild | ||
| * succeeded (`buildOk` true). When it failed, print the stale-binary banner and | ||
| * `process.exit(1)` — UNLESS `CODEYAM_ALLOW_STALE=1`, in which case print a | ||
| * one-line override notice and return so the caller proceeds on the last-good | ||
| * binary. This is the single code path the passthrough, launch, and | ||
| * server-boot call sites share, so they cannot diverge on whether they honor | ||
| * the override. `continueVerb` tailors the override notice ("continuing with" | ||
| * vs "launching with"). | ||
| */ | ||
| function enforceFreshBinary(buildOk, { action, continueVerb = "continuing with" } = {}) { | ||
| if (buildOk) { | ||
| return; | ||
| } | ||
| console.error(staleBinaryBanner(action)); | ||
| if (process.env.CODEYAM_ALLOW_STALE !== "1") { | ||
| process.exit(1); | ||
| } | ||
| console.error( | ||
| `CODEYAM_ALLOW_STALE=1 set — ${continueVerb} the existing (stale) binary.`, | ||
| ); | ||
| } | ||
| function isPlainJsonObject(value) { | ||
| return value !== null && typeof value === "object" && !Array.isArray(value); | ||
| } | ||
| // Mirrors `deep_merge_json` in crates/types/src/config.rs:1284. | ||
| // Objects merge by key (`null` in overlay removes the key); arrays merge | ||
| // by index (extra overlay items appended, base items past overlay length | ||
| // retained); scalars replace. The JS and Rust sides MUST match — any | ||
| // divergence shows up as "the CLI sees port X but `npm run editor` | ||
| // opens port Y", which is exactly the bug this whole helper exists to | ||
| // prevent. | ||
| function deepMergeJson(base, overlay) { | ||
| if (isPlainJsonObject(base) && isPlainJsonObject(overlay)) { | ||
| for (const k of Object.keys(overlay)) { | ||
| const v = overlay[k]; | ||
| if (v === null) { | ||
| delete base[k]; | ||
| } else if (Object.prototype.hasOwnProperty.call(base, k)) { | ||
| base[k] = deepMergeJson(base[k], v); | ||
| } else { | ||
| base[k] = v; | ||
| } | ||
| } | ||
| return base; | ||
| } | ||
| if (Array.isArray(base) && Array.isArray(overlay)) { | ||
| for (let i = 0; i < overlay.length; i++) { | ||
| if (i < base.length) { | ||
| base[i] = deepMergeJson(base[i], overlay[i]); | ||
| } else { | ||
| base.push(overlay[i]); | ||
| } | ||
| } | ||
| return base; | ||
| } | ||
| return overlay; | ||
| } | ||
| // Mirrors the post-merge strip in `merged_config_value` | ||
| // (crates/types/src/config.rs:1272). Any top-level key starting with | ||
| // `_` is documentation-only (`_README`, `_examples`, …) and is removed | ||
| // before the merged config is used by the launcher or handed to the | ||
| // Rust binary. Mutates in place; returns the same object for chaining. | ||
| function stripUnderscoreTopLevelKeys(value) { | ||
| if (isPlainJsonObject(value)) { | ||
| for (const k of Object.keys(value)) { | ||
| if (k.startsWith("_")) delete value[k]; | ||
| } | ||
| } | ||
| return value; | ||
| } | ||
| /** | ||
| * Read `<projectDir>/.codeyam/editor.json` and deep-merge an optional | ||
| * sibling `editor.local.json` on top, returning the merged plain object. | ||
| * | ||
| * This is the JS-side equivalent of `merged_config_value` in | ||
| * crates/types/src/config.rs — same merge rules, same underscore-prefix | ||
| * strip. Launcher scripts call this *before* the Rust binary is built | ||
| * (they invoke `cargo build` themselves), so shelling out to | ||
| * `codeyam-editor editor config-show` is not an option. | ||
| * | ||
| * Returns `{}` if the base file is missing (a freshly cloned repo with | ||
| * no `.codeyam/` directory should not throw at require-time). Throws | ||
| * on malformed JSON, naming the offending file path so the user knows | ||
| * which file to fix. | ||
| */ | ||
| function readMergedEditorConfig(projectDir = rootDir) { | ||
| const basePath = path.join(projectDir, ".codeyam", "editor.json"); | ||
| if (!fs.existsSync(basePath)) return {}; | ||
| let merged; | ||
| try { | ||
| merged = JSON.parse(fs.readFileSync(basePath, "utf8")); | ||
| } catch (e) { | ||
| throw new Error(`Failed to parse ${basePath}: ${e.message}`); | ||
| } | ||
| if (!isPlainJsonObject(merged)) merged = {}; | ||
| const overlayPath = path.join(projectDir, ".codeyam", "editor.local.json"); | ||
| if (fs.existsSync(overlayPath)) { | ||
| let overlay; | ||
| try { | ||
| overlay = JSON.parse(fs.readFileSync(overlayPath, "utf8")); | ||
| } catch (e) { | ||
| throw new Error(`Failed to parse ${overlayPath}: ${e.message}`); | ||
| } | ||
| if (isPlainJsonObject(overlay)) { | ||
| deepMergeJson(merged, overlay); | ||
| } | ||
| } | ||
| return stripUnderscoreTopLevelKeys(merged); | ||
| } | ||
| /** | ||
| * Resolve a port for a launcher in priority order: | ||
| * explicit env var → merged-config value → hard fallback. | ||
| * | ||
| * Exported so each launcher's port resolution can be unit-tested | ||
| * without spawning a process. The fallback is the historical hard-coded | ||
| * value (14199 / 5173 / 3000 depending on which slot the caller is | ||
| * resolving) and exists so a fresh checkout with no override file and | ||
| * no env var still works. | ||
| */ | ||
| function resolveLauncherPort(envValue, mergedValue, fallback) { | ||
| if (envValue !== undefined && envValue !== null && envValue !== "") { | ||
| const parsed = parseInt(envValue, 10); | ||
| if (Number.isFinite(parsed)) return parsed; | ||
| } | ||
| if (typeof mergedValue === "number" && Number.isFinite(mergedValue)) { | ||
| return mergedValue; | ||
| } | ||
| return fallback; | ||
| } | ||
| /** | ||
| * Resolve the wrapped-app port (the port the launcher logs and probes). | ||
| * Mirrors `resolve_app_port` in | ||
| * `crates/codeyam-editor/src/commands/start.rs`: `env > apps[0].port > | ||
| * config.port`. Any divergence between this helper and the Rust resolver | ||
| * shows up as "the launcher waits on port X but the backend binds Y", | ||
| * which is the failure mode this whole helper exists to prevent. | ||
| * | ||
| * `merged` is the deep-merged editor.json + editor.local.json blob; pass | ||
| * `null`/`undefined` when no config is available so the launcher's | ||
| * env-and-fallback path still works on a project with no `.codeyam/`. | ||
| */ | ||
| function resolveWrappedAppPort(merged, envValue, fallback) { | ||
| const appsZeroPort = merged && merged.apps && merged.apps[0] && merged.apps[0].port; | ||
| const topLevelPort = merged && merged.port; | ||
| const mergedValue = typeof appsZeroPort === "number" ? appsZeroPort : topLevelPort; | ||
| return resolveLauncherPort(envValue, mergedValue, fallback); | ||
| } | ||
| /** Try to connect to a specific host:port. Returns true if something is listening. */ | ||
@@ -125,2 +476,6 @@ function tryConnect(port, host) { | ||
| // Default ports across stopServer / restartServer / ensureServer / | ||
| // requestServerShutdown are a backstop for any future caller that | ||
| // forgets to pass an explicit port. Every production call site now | ||
| // resolves the port from `readMergedEditorConfig` + `resolveLauncherPort`. | ||
| async function requestServerShutdown(port = 14199, timeoutMs = 1500) { | ||
@@ -143,5 +498,127 @@ const controller = new AbortController(); | ||
| /** | ||
| * GET /api/codeyam-server-identity from the listener on `port` with a | ||
| * sub-second timeout. Returns the parsed `{projectDir, pid, version, | ||
| * startedAt}` body on a 2xx response, or `null` on any failure | ||
| * (timeout, non-200, parse failure, connection refused). | ||
| * | ||
| * `null` carries two distinct meanings that the caller must | ||
| * disambiguate by also checking `isPortInUse`: | ||
| * - port is free → no listener at all | ||
| * - port is in use → something is listening, but it's not a | ||
| * codeyam-editor server (or it's an old build that predates this | ||
| * endpoint). | ||
| */ | ||
| async function fetchServerIdentity(port, timeoutMs = 500) { | ||
| const controller = new AbortController(); | ||
| const timeout = setTimeout(() => controller.abort(), timeoutMs); | ||
| try { | ||
| const resp = await fetch(`http://127.0.0.1:${port}/api/codeyam-server-identity`, { | ||
| method: "GET", | ||
| signal: controller.signal, | ||
| }); | ||
| if (!resp.ok) return null; | ||
| const body = await resp.json(); | ||
| if (!body || typeof body !== "object" || typeof body.projectDir !== "string") { | ||
| return null; | ||
| } | ||
| return body; | ||
| } catch { | ||
| return null; | ||
| } finally { | ||
| clearTimeout(timeout); | ||
| } | ||
| } | ||
| /** | ||
| * Resolve the canonical (symlink-followed) path for `dir`, falling back to | ||
| * `dir` if `fs.realpath` fails (e.g. the path no longer exists). Both | ||
| * sides of the identity check route through this so a `~/work/foo` | ||
| * symlink and a `/Volumes/dev/foo` direct path compare equal. | ||
| */ | ||
| async function canonicalisePath(dir) { | ||
| try { | ||
| return await fs.promises.realpath(dir); | ||
| } catch { | ||
| return dir; | ||
| } | ||
| } | ||
| /** | ||
| * Build the "port held by a non-codeyam-editor process" error string. | ||
| * Lives next to `classifyPortHolder` so the wording is tested without | ||
| * a running server — both launchers (`ensureServer` and `editor.js`'s | ||
| * `--restart`) print this exact message on a "foreign" classification. | ||
| */ | ||
| function formatForeignListenerError(port) { | ||
| return ( | ||
| `Port ${port} is in use, but no codeyam-editor server responded ` + | ||
| `at /api/codeyam-server-identity. Free the port or change ` + | ||
| `controlPort in .codeyam/editor.local.json.` | ||
| ); | ||
| } | ||
| /** | ||
| * Build the "another project's editor is on this port" error string. | ||
| * `verb` is the action the launcher would have taken ("attach" / | ||
| * "restart"); the wording is otherwise identical so both launcher | ||
| * paths share the same advice. | ||
| */ | ||
| function formatCrossProjectError(port, holder, verb) { | ||
| const pidNote = Number.isInteger(holder.identity && holder.identity.pid) | ||
| ? ` (pid ${holder.identity.pid})` | ||
| : ""; | ||
| return ( | ||
| `Refusing to ${verb} a codeyam-editor server for a different project.\n` + | ||
| ` This project: ${holder.wantDir}\n` + | ||
| ` Server on port ${port}${pidNote}: ${holder.haveDir}\n` + | ||
| `\n` + | ||
| `Change controlPort in .codeyam/editor.local.json, e.g.:\n` + | ||
| ` codeyam-editor editor config-override proxy.controlPort 14399` | ||
| ); | ||
| } | ||
| /** | ||
| * Classify whatever is listening on `port` relative to `projectDir`. | ||
| * Returns one of: | ||
| * - `{ kind: "free" }` — nothing on the port | ||
| * - `{ kind: "foreign" }` — port held by a non-editor process | ||
| * - `{ kind: "cross-project", wantDir, haveDir, identity }` | ||
| * — held by another project's editor | ||
| * - `{ kind: "same-project", identity }` — held by THIS project's editor | ||
| * | ||
| * Pure: every effect (network, fs) is bounded; no console output, no | ||
| * process.exit. The launcher decides what to do with each kind. Splitting | ||
| * the policy (what to print, when to exit) from the classification keeps | ||
| * the helper testable without mocking process.exit, and lets `ensureServer` | ||
| * and `editor.js`'s `--restart` block share the same logic with different | ||
| * wording. | ||
| */ | ||
| async function classifyPortHolder(port, projectDir = process.cwd()) { | ||
| if (!(await isPortInUse(port))) { | ||
| return { kind: "free" }; | ||
| } | ||
| const identity = await fetchServerIdentity(port); | ||
| if (identity === null) { | ||
| return { kind: "foreign" }; | ||
| } | ||
| const wantDir = await canonicalisePath(projectDir); | ||
| const haveDir = await canonicalisePath(identity.projectDir); | ||
| if (wantDir !== haveDir) { | ||
| return { kind: "cross-project", wantDir, haveDir, identity }; | ||
| } | ||
| return { kind: "same-project", identity }; | ||
| } | ||
| function tryKillPid(pid) { | ||
| try { | ||
| process.kill(pid, "SIGINT"); | ||
| // On Windows, `process.kill(pid, "SIGINT")` either errors or no-ops | ||
| // depending on Node version. Drop the signal so Node maps to a | ||
| // platform TerminateProcess. Less graceful, but the child does exit. | ||
| if (process.platform === "win32") { | ||
| process.kill(pid); | ||
| } else { | ||
| process.kill(pid, "SIGINT"); | ||
| } | ||
| return true; | ||
@@ -153,3 +630,128 @@ } catch { | ||
| /** | ||
| * True when the launcher is attached to an interactive terminal — i.e. a human | ||
| * could have typed Ctrl+C. Headless / agent-driven launchers (cloud containers, | ||
| * `npm run editor` under a supervisor) have no tty here. Used to decide whether a | ||
| * received SIGINT is a deliberate "stop the editor" (forward it) or a stray | ||
| * group / spurious signal that must NOT reap the now-detached server. | ||
| */ | ||
| function isInteractiveLauncher(stream = process.stdout) { | ||
| return Boolean(stream && stream.isTTY); | ||
| } | ||
| /** | ||
| * Spawn options for the editor server child. The server is spawned `detached: | ||
| * true` so it leads its OWN session + process group (setsid), decoupling its | ||
| * lifetime from the launcher's controlling terminal and process group — a stray | ||
| * tty Ctrl+C or a group-wide `kill(-pgid, SIGINT)` can no longer reach it. stdio | ||
| * is detached from the terminal for the same reason (an inherited tty can still | ||
| * deliver tty signals to a detached child): stdout/stderr go to `logFd` when one | ||
| * is supplied, else are ignored — the Rust server writes its own | ||
| * `.codeyam/logs/editor-server.log` regardless. | ||
| */ | ||
| function buildServerSpawnOptions({ cwd, env, logFd = null }) { | ||
| return { | ||
| stdio: logFd === null ? "ignore" : ["ignore", logFd, logFd], | ||
| cwd, | ||
| detached: true, | ||
| env, | ||
| }; | ||
| } | ||
| /** | ||
| * Open (creating dirs as needed) the launcher-managed server stdout/stderr log | ||
| * in append mode and return its fd, or null if it can't be opened. Kept separate | ||
| * from the Rust server's own structured `editor-server.log` so the two streams | ||
| * never interleave. Best-effort: a failure here just means the detached server's | ||
| * stdio is ignored, never that the launch itself fails. | ||
| */ | ||
| function openServerOutputLog(cwd) { | ||
| try { | ||
| const dir = path.join(cwd, ".codeyam", "logs"); | ||
| fs.mkdirSync(dir, { recursive: true }); | ||
| return fs.openSync(path.join(dir, "editor-server.out.log"), "a"); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| /** | ||
| * Format one launcher-teardown-signal record for | ||
| * `.codeyam/logs/launcher-signals.log`. Pure: every input is passed in, so the | ||
| * formatting is unit-testable without a real signal or `ps`. Captures the | ||
| * launcher's own pid/ppid/pgid, whether it was interactive (a tty Ctrl+C) vs | ||
| * headless (a stray/spurious signal), the decision taken (forwarded teardown vs | ||
| * kept the server alive), and a best-effort `ps` group snapshot so the SENDER of | ||
| * a spurious `kill()` can be identified on the next repro. | ||
| */ | ||
| function formatLauncherSignalRecord({ signal, pid, ppid, pgid, interactive, forwarded, ps, timestamp }) { | ||
| const lines = [ | ||
| `[${timestamp}] launcher-signal signal=${signal} pid=${pid} ppid=${ppid} pgid=${pgid} ` + | ||
| `interactive=${interactive} action=${forwarded ? "forwarded-teardown" : "ignored-kept-server-alive"}`, | ||
| ]; | ||
| if (ps && ps.trim() !== "") { | ||
| lines.push(ps.trimEnd()); | ||
| } | ||
| return lines.join("\n") + "\n"; | ||
| } | ||
| /** | ||
| * Append a launcher-teardown-signal forensic record. Called from each launcher's | ||
| * SIGINT handler BEFORE it decides whether to forward, so a server reaped by a | ||
| * stray group SIGINT (or a spurious `kill()` to a headless launcher) always | ||
| * leaves a trail naming the launcher's process group. Best-effort: never throws, | ||
| * so a logging failure can never block the teardown decision. | ||
| */ | ||
| function logLauncherTeardownSignal(signal, projectDir, { interactive, forwarded }) { | ||
| try { | ||
| const pid = process.pid; | ||
| const ppid = typeof process.ppid === "number" ? process.ppid : -1; | ||
| let pgid = -1; | ||
| let ps = ""; | ||
| if (process.platform !== "win32") { | ||
| const pgidOut = spawnSync("ps", ["-o", "pgid=", "-p", String(pid)], { encoding: "utf8" }); | ||
| if (!pgidOut.error) { | ||
| pgid = parseInt(pgidOut.stdout.trim(), 10); | ||
| } | ||
| if (Number.isInteger(pgid) && pgid > 0) { | ||
| const grp = spawnSync("ps", ["-o", "pid,ppid,pgid,tpgid,comm", "-g", String(pgid)], { encoding: "utf8" }); | ||
| if (!grp.error) ps = grp.stdout; | ||
| } | ||
| } | ||
| const dir = path.join(projectDir, ".codeyam", "logs"); | ||
| fs.mkdirSync(dir, { recursive: true }); | ||
| const record = formatLauncherSignalRecord({ | ||
| signal, | ||
| pid, | ||
| ppid, | ||
| pgid, | ||
| interactive: Boolean(interactive), | ||
| forwarded: Boolean(forwarded), | ||
| ps, | ||
| timestamp: new Date().toISOString(), | ||
| }); | ||
| fs.appendFileSync(path.join(dir, "launcher-signals.log"), record); | ||
| } catch { | ||
| /* best-effort forensics — never block teardown on a logging failure */ | ||
| } | ||
| } | ||
| /** | ||
| * Send the platform-correct termination signal to a spawned child process. | ||
| * Used by editor.js / editor-dev.js / container.js when the user hits Ctrl+C — | ||
| * `child.kill("SIGINT")` is unreliable on Windows. | ||
| */ | ||
| function killChildProcess(child) { | ||
| if (!child) return; | ||
| if (process.platform === "win32") { | ||
| child.kill(); | ||
| } else { | ||
| child.kill("SIGINT"); | ||
| } | ||
| } | ||
| function findListeningPids(port) { | ||
| // Process discovery on Windows is best-effort — `lsof` is not installed and | ||
| // a `netstat -ano` parser is a follow-up. The API-shutdown path and the | ||
| // PID-file path together stop the server cleanly in normal operation. | ||
| if (process.platform === "win32") return []; | ||
@@ -212,19 +814,73 @@ | ||
| /** Start the Rust backend server if not already running. Returns the child process (or null if already running). */ | ||
| /** | ||
| * Start the Rust backend server if not already running, refusing to attach | ||
| * if the existing listener belongs to a different project (or to a foreign | ||
| * process that doesn't speak the identity endpoint at all). Returns the | ||
| * spawned child on a fresh start, or `null` when we attached to an | ||
| * already-running same-project server. Exits the process non-zero on a | ||
| * mismatch — silently choosing a different port would just relocate the | ||
| * surprise. | ||
| */ | ||
| async function ensureServer(port = 14199) { | ||
| if (await isPortInUse(port)) { | ||
| console.error(`Server already running on port ${port}`); | ||
| return null; | ||
| const holder = await classifyPortHolder(port); | ||
| switch (holder.kind) { | ||
| case "foreign": | ||
| console.error(formatForeignListenerError(port)); | ||
| process.exit(1); | ||
| break; | ||
| case "cross-project": | ||
| console.error(formatCrossProjectError(port, holder, "attach")); | ||
| process.exit(1); | ||
| break; | ||
| case "same-project": | ||
| console.error(`Server already running on port ${port}`); | ||
| return null; | ||
| case "free": | ||
| default: | ||
| break; | ||
| } | ||
| const binary = ensureBinary(); | ||
| const child = spawn(binary, ["start", "--no-open", "--port", String(port)], { | ||
| stdio: "inherit", | ||
| cwd: process.cwd(), | ||
| detached: false, | ||
| env: { ...process.env, CODEYAM_EDITOR_UI_DIR: uiDistDir() }, | ||
| }); | ||
| // UI bundle is embedded in the Rust binary; CODEYAM_EDITOR_UI_DIR is | ||
| // now an explicit dev-override only. | ||
| // | ||
| // The server is spawned into its OWN session/process group (detached: true) | ||
| // with stdio detached from the launcher's controlling terminal, so a stray | ||
| // tty Ctrl+C or a group-wide SIGINT can no longer reap it — its lifetime is | ||
| // governed only by the launcher's deliberate teardown forward and the | ||
| // pidfile / API-shutdown paths. stdout/stderr are appended to | ||
| // `.codeyam/logs/editor-server.out.log` (the Rust server also writes its own | ||
| // editor-server.log). `unref()` keeps the detached child from pinning the | ||
| // launcher's event loop alive on its own. | ||
| const logFd = openServerOutputLog(process.cwd()); | ||
| const child = spawn( | ||
| binary, | ||
| ["start", "--no-open", "--port", String(port)], | ||
| buildServerSpawnOptions({ cwd: process.cwd(), env: process.env, logFd }), | ||
| ); | ||
| if (logFd !== null) { | ||
| try { | ||
| fs.closeSync(logFd); | ||
| } catch { | ||
| /* child holds its own dup of the fd; the launcher's copy is no longer needed */ | ||
| } | ||
| } | ||
| child.unref(); | ||
| const ready = await waitForPort(port); | ||
| if (!ready) { | ||
| const { outcome, exitedBeforeBind } = await awaitServerReadyOrChildExit(child, port); | ||
| if (outcome === "exited" || (outcome === "timeout" && exitedBeforeBind !== null)) { | ||
| const { code, signal } = exitedBeforeBind ?? { code: null, signal: null }; | ||
| const detail = signal != null ? `signal ${signal}` : `code ${code}`; | ||
| console.error( | ||
| `codeyam-editor server exited (${detail}) before binding port ${port}. ` + | ||
| "Likely a preflight bail; see .codeyam/logs/editor-server.out.log " + | ||
| "and .codeyam/logs/editor-server.log.", | ||
| ); | ||
| // Always propagate as a real failure — even on code 0 the caller | ||
| // needs to know the server isn't running. | ||
| process.exit(code != null && code !== 0 ? code : 1); | ||
| } | ||
| if (outcome === "timeout") { | ||
| console.error("Server failed to start within 10 seconds"); | ||
@@ -238,7 +894,41 @@ child.kill(); | ||
| /** | ||
| * Race a child process's exit against a port-becomes-listening poll. | ||
| * Returns `{ outcome, exitedBeforeBind }` where `outcome` is one of | ||
| * `"ready"` (port bound first), `"timeout"` (poll exhausted before the | ||
| * port bound and the child is still alive), or `"exited"` (child exited | ||
| * before the port bound). `exitedBeforeBind` is `{ code, signal }` if | ||
| * the child exited at any point during the race, else `null`. | ||
| * | ||
| * Exists as its own helper so the silent-clean-exit branch of | ||
| * `ensureServer` is unit-testable without spawning the real Rust | ||
| * binary. `waitForPortFn` defaults to `waitForPort`; tests pass a fake | ||
| * that resolves synchronously. | ||
| */ | ||
| async function awaitServerReadyOrChildExit(child, port, waitForPortFn = waitForPort) { | ||
| let exitedBeforeBind = null; | ||
| const exitPromise = new Promise((resolve) => { | ||
| child.once("exit", (code, signal) => { | ||
| exitedBeforeBind = { code, signal }; | ||
| resolve("exited"); | ||
| }); | ||
| }); | ||
| const portPromise = waitForPortFn(port).then((ok) => (ok ? "ready" : "timeout")); | ||
| const outcome = await Promise.race([portPromise, exitPromise]); | ||
| return { outcome, exitedBeforeBind }; | ||
| } | ||
| module.exports = { | ||
| rootDir, | ||
| serverStatePath, | ||
| binaryName, | ||
| brokerBinaryName, | ||
| platformPackageInfo, | ||
| PLATFORM_PACKAGES, | ||
| findBinary, | ||
| findBrokerBinary, | ||
| verifyBrokerBinaryPresent, | ||
| ensureBinary, | ||
| staleBinaryBanner, | ||
| enforceFreshBinary, | ||
| uiDistDir, | ||
@@ -250,5 +940,26 @@ tryConnect, | ||
| readServerState, | ||
| clearServerState, | ||
| requestServerShutdown, | ||
| fetchServerIdentity, | ||
| canonicalisePath, | ||
| classifyPortHolder, | ||
| formatForeignListenerError, | ||
| formatCrossProjectError, | ||
| tryKillPid, | ||
| findListeningPids, | ||
| stopServer, | ||
| restartServer, | ||
| ensureServer, | ||
| awaitServerReadyOrChildExit, | ||
| isInteractiveLauncher, | ||
| buildServerSpawnOptions, | ||
| openServerOutputLog, | ||
| formatLauncherSignalRecord, | ||
| logLauncherTeardownSignal, | ||
| killChildProcess, | ||
| deepMergeJson, | ||
| stripUnderscoreTopLevelKeys, | ||
| readMergedEditorConfig, | ||
| resolveLauncherPort, | ||
| resolveWrappedAppPort, | ||
| }; |
+7
-5
| { | ||
| "name": "@codeyam-editor/codeyam-editor", | ||
| "version": "0.1.0-staging.46bd02f", | ||
| "version": "0.1.0-staging.4d8da9a", | ||
| "description": "Language-agnostic managed execution sandbox for scenario-driven development", | ||
@@ -8,8 +8,11 @@ "bin": { | ||
| }, | ||
| "scripts": { | ||
| "postinstall": "node ./npm/postinstall.js" | ||
| }, | ||
| "dependencies": { | ||
| "playwright": "^1.58.2" | ||
| }, | ||
| "optionalDependencies": { | ||
| "@codeyam-editor/codeyam-editor-darwin-arm64": "0.1.0-staging.4d8da9a", | ||
| "@codeyam-editor/codeyam-editor-darwin-x64": "0.1.0-staging.4d8da9a", | ||
| "@codeyam-editor/codeyam-editor-linux-x64": "0.1.0-staging.4d8da9a", | ||
| "@codeyam-editor/codeyam-editor-win32-x64": "0.1.0-staging.4d8da9a" | ||
| }, | ||
| "keywords": [ | ||
@@ -28,5 +31,4 @@ "codeyam", | ||
| "npm/", | ||
| "bin/", | ||
| "ui/" | ||
| ] | ||
| } |
| #!/usr/bin/env node | ||
| /** | ||
| * Post-install script for @codeyam-editor/codeyam-editor. | ||
| * | ||
| * Downloads the pre-built platform binary and UI assets from GitHub Releases. | ||
| * Skipped when running in local development (cargo builds from source instead). | ||
| */ | ||
| const fs = require("fs"); | ||
| const path = require("path"); | ||
| const { execSync } = require("child_process"); | ||
| const rootDir = path.resolve(__dirname, ".."); | ||
| const PLATFORM_MAP = { | ||
| "darwin-arm64": "codeyam-editor-darwin-arm64", | ||
| "darwin-x64": "codeyam-editor-darwin-x64", | ||
| "linux-x64": "codeyam-editor-linux-x64", | ||
| }; | ||
| function getArtifactName() { | ||
| const key = `${process.platform}-${process.arch}`; | ||
| const name = PLATFORM_MAP[key]; | ||
| if (!name) { | ||
| console.error( | ||
| `codeyam-editor: unsupported platform ${process.platform} ${process.arch}` | ||
| ); | ||
| console.error("Supported: macOS (arm64, x64), Linux (x64)"); | ||
| process.exit(0); // Don't fail the install — user may be building from source | ||
| } | ||
| return name; | ||
| } | ||
| /** | ||
| * Determine the GitHub release tag from the installed package version. | ||
| * | ||
| * - "0.1.0-staging.abc1234" -> "staging" | ||
| * - "0.1.2" -> "latest" | ||
| */ | ||
| function getReleaseTag() { | ||
| const pkg = JSON.parse( | ||
| fs.readFileSync(path.join(rootDir, "package.json"), "utf8") | ||
| ); | ||
| const version = pkg.version || "0.1.0"; | ||
| if (version.includes("-staging")) return "staging"; | ||
| return "latest"; | ||
| } | ||
| function download() { | ||
| // Skip if a binary already exists (e.g. local dev with cargo build) | ||
| const binPath = path.join(rootDir, "bin", "codeyam-editor"); | ||
| if (fs.existsSync(binPath)) { | ||
| return; | ||
| } | ||
| // Skip if we're in a dev checkout (has Cargo.toml at root) | ||
| if (fs.existsSync(path.join(rootDir, "Cargo.toml"))) { | ||
| return; | ||
| } | ||
| const artifactName = getArtifactName(); | ||
| const tag = getReleaseTag(); | ||
| const url = `https://github.com/codeyam-ai/codeyam-editor/releases/download/${tag}/${artifactName}.tar.gz`; | ||
| console.error(`Downloading codeyam-editor binary (${artifactName})...`); | ||
| const binDir = path.join(rootDir, "bin"); | ||
| const uiDir = path.join(rootDir, "ui"); | ||
| fs.mkdirSync(binDir, { recursive: true }); | ||
| fs.mkdirSync(uiDir, { recursive: true }); | ||
| try { | ||
| // Download and extract in one step using curl + tar | ||
| execSync( | ||
| `curl -fsSL "${url}" | tar xz -C "${rootDir}"`, | ||
| { stdio: ["ignore", "inherit", "inherit"], timeout: 120000 } | ||
| ); | ||
| // Ensure binary is executable | ||
| if (fs.existsSync(binPath)) { | ||
| fs.chmodSync(binPath, 0o755); | ||
| } | ||
| console.error("codeyam-editor installed successfully!"); | ||
| } catch (err) { | ||
| console.error(`Failed to download codeyam-editor binary from ${url}`); | ||
| console.error( | ||
| "You can build from source instead: cargo build --release" | ||
| ); | ||
| // Don't fail the install — user may want to build from source | ||
| } | ||
| } | ||
| download(); |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Network access
Supply chain riskThis module accesses the network.
Found 2 instances
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 19 instances
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 2 instances
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Minified code
QualityThis package contains minified code. This may be harmless in some cases where minified code is included in packaged libraries, however packages on npm should not minify code.
Found 2 instances
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Install scripts
Supply chain riskInstall scripts are run when the package is installed or built. Malicious packages often use scripts that run automatically to execute payloads or fetch additional code.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 3 instances
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
1883374
6519.48%57
533.33%14660
1562.13%0
-100%5
400%14
600%53
307.69%150
1775%