@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', | ||
| ]; | ||
| // The in-container mount of the persistent cargo build-target volume | ||
| // (`cargo-target:/codeyam-build-target`, set as CARGO_TARGET_DIR in | ||
| // docker-compose.cloud.template.yml). This is SEPARATE from PRUNABLE_CACHE_DIRS | ||
| // on purpose: the frontend-cache allowlist deliberately excludes Rust target/ | ||
| // (expensive to rebuild), so it can never reclaim this volume — yet on a | ||
| // self-host VM that builds codeyam-editor, this volume is exactly what fills the | ||
| // disk (VM-3 grew it to ~69GB and wedged). The reclamation below is narrowly | ||
| // keyed on this known mount path, NOT folded into the generic allowlist, so a | ||
| // client project's own Rust target/ is never touched. | ||
| // | ||
| // Stack assumption: only self-host VMs that run cargo write to this volume; | ||
| // guest/client VMs (Next.js, Vite, Tauri, Python, …) carry an empty or absent | ||
| // volume, so cargoTargetReclaimPlan returns an empty plan there (a guaranteed | ||
| // no-op — honoring the stack-agnosticism rule in CLAUDE.md). | ||
| const CARGO_TARGET_DIR = '/codeyam-build-target'; | ||
| // Sub-dirs of CARGO_TARGET_DIR that are ALWAYS safe to delete: pure coverage | ||
| // instrumentation, regenerated on every `llvm-cov` run. Reclaiming this costs | ||
| // the warm incremental build nothing (~13GB on VM-3). Pruned at WARN already. | ||
| const CARGO_TARGET_ALWAYS_PRUNE = ['llvm-cov-target']; | ||
| // Sub-trees reclaimed ONLY at CRITICAL and ONLY when no build is in flight: | ||
| // `debug/` is the incremental compile cache that keeps `rebuild-self` fast, so | ||
| // deleting it forces a ~14-min cold recompile. We pay that cost only when the | ||
| // disk is genuinely critical AND nothing is mid-build (else we'd sabotage an | ||
| // in-flight compile to free space — the failure mode this tiering avoids). | ||
| const CARGO_TARGET_CRITICAL_CLEAN = ['debug']; | ||
| // Pure decision function for tiered cargo-target reclamation. Given the current | ||
| // pressure `level`, whether a build is in flight, and whether this VM actually | ||
| // has the cargo-target volume, return which sub-dirs to prune and whether the | ||
| // heavy `debug` clean was deferred (so the caller can log it — no silent cap). | ||
| // | ||
| // Tiers (mirrored by the bash guard + watchdog): | ||
| // - no cargo-target (guest/client VM) → prune nothing (total no-op) | ||
| // - warn → ALWAYS_PRUNE only (llvm-cov) | ||
| // - critical & idle → ALWAYS_PRUNE + CRITICAL_CLEAN | ||
| // - critical & build in flight → ALWAYS_PRUNE only, defer heavy | ||
| // - ok / unknown level → fail-safe: prune nothing | ||
| function cargoTargetReclaimPlan({ level, buildInFlight, hasCargoTarget } = {}) { | ||
| if (!hasCargoTarget) return { prune: [], deferredHeavyClean: false }; | ||
| if (level === 'warn') { | ||
| return { prune: [...CARGO_TARGET_ALWAYS_PRUNE], deferredHeavyClean: false }; | ||
| } | ||
| if (level === 'critical') { | ||
| if (buildInFlight) { | ||
| return { prune: [...CARGO_TARGET_ALWAYS_PRUNE], deferredHeavyClean: true }; | ||
| } | ||
| return { | ||
| prune: [...CARGO_TARGET_ALWAYS_PRUNE, ...CARGO_TARGET_CRITICAL_CLEAN], | ||
| deferredHeavyClean: false, | ||
| }; | ||
| } | ||
| // 'ok', undefined, or any unknown level → do nothing (fail safe). | ||
| return { prune: [], deferredHeavyClean: false }; | ||
| } | ||
| // 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; | ||
| } | ||
| // Classify a disk-guard sidecar age (updatedAt vs current time). | ||
| // If the sidecar is older than 600s (10 min), classify it as stale. | ||
| function classifySidecarAge(updatedAt, currentEpoch) { | ||
| if (typeof updatedAt !== 'number' || typeof currentEpoch !== 'number') return 'stale'; | ||
| const age = currentEpoch - updatedAt; | ||
| if (age > 600) return 'stale'; | ||
| return 'healthy'; | ||
| } | ||
| // Map or classify a general disk state level including 'unknown-exec-failed' | ||
| function mapDiskStateLevel(usedPct, hasExecError) { | ||
| if (hasExecError) return 'unknown-exec-failed'; | ||
| return classifyDiskPressure(usedPct); | ||
| } | ||
| module.exports = { | ||
| DISK_WARN_PCT, | ||
| DISK_CRITICAL_PCT, | ||
| PRUNABLE_CACHE_DIRS, | ||
| CARGO_TARGET_DIR, | ||
| CARGO_TARGET_ALWAYS_PRUNE, | ||
| CARGO_TARGET_CRITICAL_CLEAN, | ||
| cargoTargetReclaimPlan, | ||
| classifyDiskPressure, | ||
| parseDfUsedPct, | ||
| classifySidecarAge, | ||
| mapDiskStateLevel, | ||
| }; |
| "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 decision helpers for the fleet dashboard's false-unreachable reboot | ||
| // safety (2026-06-25 incident). The dashboard's server.js execs gcloud and reads | ||
| // its in-memory state; the DECISIONS those I/O results drive live here so they | ||
| // are unit-testable in isolation (npm/fleet-auth-health.test.js), the same | ||
| // split npm/fleet-cleanup.js (reconcileRosteredVm) and npm/fleet-host.js | ||
| // (gcloudCommand) follow. | ||
| // | ||
| // Pure logic only — no I/O, no env reads, no clock. | ||
| // Classify operator gcloud auth health from the facts server.js gathers via the | ||
| // ${GCLOUD} chokepoint. Returns { degraded, reason }: degraded true means the | ||
| // operator's own auth is broken (which makes EVERY VM read unreachable), so the | ||
| // reset gate must suppress all reboots and the UI must surface a banner. | ||
| // account trimmed `gcloud config list account` output (may be empty) | ||
| // accountErrMsg non-null when the account-resolve exec itself errored | ||
| // tokenOk true/false when `auth print-access-token` was run; null when | ||
| // it was not (account-only phase) — a valid SA account with | ||
| // tokenOk null is "account looks fine, token not yet proven", | ||
| // which is NOT degraded, so the caller can proceed to the mint. | ||
| function classifyOperatorAuth({ account, accountErrMsg, tokenOk } = {}) { | ||
| const acct = typeof account === 'string' ? account.trim() : ''; | ||
| if (accountErrMsg || !acct) { | ||
| return { | ||
| degraded: true, | ||
| reason: `operator gcloud could not resolve an account (${accountErrMsg || 'empty output'})`, | ||
| }; | ||
| } | ||
| if (!/\.gserviceaccount\.com$/.test(acct)) { | ||
| return { | ||
| degraded: true, | ||
| reason: `operator gcloud is on a non-service-account '${acct}' — config drift; run scripts/operator/setup-service-account.sh`, | ||
| }; | ||
| } | ||
| if (tokenOk === false) { | ||
| return { | ||
| degraded: true, | ||
| reason: `operator gcloud cannot mint a token for ${acct} (expired/absent credentials); run scripts/operator/setup-service-account.sh`, | ||
| }; | ||
| } | ||
| return { degraded: false, reason: null }; | ||
| } | ||
| // True when at least `fleetWideThreshold` VMs are simultaneously past the | ||
| // reset-unreachable `threshold` — the signature of an operator-side fault | ||
| // (auth/tunnel/network on the dashboard host) rather than independent VM | ||
| // failures. A real VM failure is isolated; a simultaneous N-VM "outage" is the | ||
| // operator, and the reset gate uses this to refuse rebooting the whole fleet. | ||
| // `vms` is an array of per-VM state objects (missing/null entries are ignored). | ||
| function isFleetWideUnreachable(vms, threshold, fleetWideThreshold) { | ||
| let count = 0; | ||
| for (const vm of Array.isArray(vms) ? vms : []) { | ||
| if (vm && !vm.reachable && (vm.unreachableStreak || 0) >= threshold) count++; | ||
| } | ||
| return count >= fleetWideThreshold; | ||
| } | ||
| // Classify a one-shot GCP-side IAP editor probe (server.js execs the SSH+curl). | ||
| // true → editor answered 200 from GCP's vantage (VM healthy; the dark local | ||
| // port is a laptop tunnel fault → respawn, never reset); | ||
| // false → the VM is up but the editor does not answer (genuinely unreachable); | ||
| // null → the IAP probe itself failed (inconclusive — never escalate a reset). | ||
| // The HTTP code is read from the LAST three chars of stdout because gcloud/ssh | ||
| // can prepend banner noise before curl's `%{http_code}` output. | ||
| function classifyIapEditorProbe({ err, stdout } = {}) { | ||
| if (err) return null; | ||
| const code = String(stdout || '').trim().slice(-3); | ||
| return code === '200'; | ||
| } | ||
| module.exports = { | ||
| classifyOperatorAuth, | ||
| isFleetWideUnreachable, | ||
| classifyIapEditorProbe, | ||
| }; |
| '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; | ||
| } | ||
| // Return true if this one VM is eligible for an auth-start action now. | ||
| function isAuthStartable(v, job) { | ||
| return classifyAuthVm(v, job).eligible; | ||
| } | ||
| module.exports = { classifyAuthVm, selectAuthTargets, summarizeAuthBatch, deriveDefaultAuthAccount, isAuthStartable }; |
| '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, | ||
| // True when the editor reports the build workflow reached its terminal step | ||
| // and is parked awaiting cleanup (session_info_response.rs emits it at | ||
| // step >= total). Flows onto poll state via the `...info` spread in server.js | ||
| // exactly like `feature`/`awaitingUserInput`; defaulted false here so a legacy | ||
| // editor that predates the field degrades to today's behavior. Consumed by | ||
| // fleet-live-status.js to read a parked-but-done agent as calm 'session-done' | ||
| // instead of active-green 'building'. | ||
| needsCleanup: !!v.needsCleanup, | ||
| 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 || [], | ||
| // Derived presentation split of degradedReasons (vm: Dashboard 'Finishing | ||
| // setup' state): `setupSteps` are expected first-run wiring rendered as a | ||
| // calm blue checklist, `attentionReasons` are genuine problems kept in the | ||
| // amber ⚠ treatment. `degradedReasons` is left intact for the many places | ||
| // that still consume the flat list (filters, sort, reconcile); these two are | ||
| // purely additive. Both default to [] so a legacy/healthy VM renders neither | ||
| // block. See npm/fleet-setup-steps.js::classifyReasons. | ||
| setupSteps: Array.isArray(ctx.setupSteps) ? ctx.setupSteps : [], | ||
| attentionReasons: Array.isArray(ctx.attentionReasons) ? ctx.attentionReasons : [], | ||
| 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, | ||
| // Raw workflow timestamps (ISO) surfaced by /api/session-info and merged onto | ||
| // the poll state via the `...info` spread, exactly like feature/step/label. | ||
| // The card live-ticks the elapsed minutes off these between polls. null for a | ||
| // legacy editor that predates the fields, or a VM with no active step. | ||
| startedAt: v.startedAt || null, | ||
| featureStartedAt: v.featureStartedAt || null, | ||
| // The server-computed stuck / needs-you verdict (npm/fleet-stuck-status.js): | ||
| // { stepElapsedMs, level: 'ok'|'slow'|'stuck', needsYou, needsYouElapsedMs }. | ||
| // The authoritative level only changes on poll; the card colors the step | ||
| // tenure badge by it. null when buildData couldn't compute one. | ||
| attention: ctx.attention || null, | ||
| // Per-VM card readiness derived upstream (npm/fleet-vm-readiness.js): one of | ||
| // READINESS_STATES. The card colors the border and GATES the editor link on | ||
| // it — a non-'ready' VM never presents its public URL as live, because the | ||
| // LB would 503. Computed in buildData where the broker tri-state / job / LB | ||
| // mode are on hand, then carried here so the browser needs no extra request. | ||
| readiness: ctx.readiness || null, | ||
| // The raw LB backend-health map { editor, preview } the readiness derivation | ||
| // consumed, surfaced so the card can show a precise tooltip (which surface | ||
| // is unwired). null in legacy mode or before the first slow probe lands. | ||
| backendHealth: v.backendHealth || null, | ||
| // The per-VM IAP access whitelist (the principals explicitly granted | ||
| // resource-level access to THIS VM, from the fleet-vm-access sidecar). Merged | ||
| // onto the poll state at server.js, it drives the card's 🔐 access line and | ||
| // the Manage access modal's "this VM only" list. It was previously dropped | ||
| // here — the exact currentProvider-style envelope drift this module guards | ||
| // against — so the line and list rendered empty even when the sidecar held | ||
| // principals. Defaults to [] for a VM with no list. | ||
| vmAccess: Array.isArray(v.vmAccess) ? v.vmAccess : [], | ||
| }; | ||
| } | ||
| // 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', 'needsCleanup', 'feature', 'step', 'mode', 'label', 'status', 'orphan', | ||
| 'degradedReasons', 'setupSteps', 'attentionReasons', | ||
| 'isHead', 'headHolderUnreachable', 'queuePos', 'queueLen', | ||
| 'job', 'config', 'project', 'variant', 'cardIdentity', 'brokerHistory', | ||
| 'brokerDeath', 'providerAuth', 'effects', 'currentProvider', | ||
| 'readiness', 'backendHealth', 'vmAccess', | ||
| 'startedAt', 'featureStartedAt', 'attention', | ||
| ]; | ||
| 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 */ } | ||
| } | ||
| // True when an integer exit code is the shell's "killed by signal N" encoding, | ||
| // i.e. 128 + N for N in 1..37 — the realistic kills being 143 (SIGTERM, the | ||
| // `launchctl bootout` graceful teardown), 137 (SIGKILL), 130 (SIGINT) and 129 | ||
| // (SIGHUP). Pure boundary check: 128 itself is NOT a signal kill (it's `exit | ||
| // 128`, an ordinary failure code), so the range is the OPEN interval | ||
| // (128, 165]. Anything outside it — a small `exit 1` from the addVm script's own | ||
| // failure branches, 0, NaN, null — is not a signal kill. The split that matters: | ||
| // "did a signal kill the provision shell" (interrupted → reconcile) vs "did the | ||
| // provision logic itself decide it failed" (real error). Exported so the matrix | ||
| // is unit-testable directly. | ||
| function isSignalKillExit(code) { | ||
| return Number.isInteger(code) && code > 128 && code <= 165; | ||
| } | ||
| // 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 | ||
| // 'reconcile' marker present, SIGNAL-kill code (128<c≤165) interrupted, not failed — gcloud probe decides | ||
| // 'error' marker present, other 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. | ||
| // | ||
| // SIGNAL-kill codes (improve46): `setsid` shields the detached provision from a | ||
| // dashboard-process-GROUP SIGKILL (the 2GB launchd hard cap), but NOT from a | ||
| // `launchctl bootout`, which tears down the whole launchd job tree regardless of | ||
| // session/process-group. A graceful SIGTERM then reaches the "detached" child and | ||
| // its EXIT trap records 143 (128+SIGTERM) into the marker. That is an INTERRUPTED | ||
| // add, not a failed one — the manual re-add succeeds in seconds off the image the | ||
| // interrupted build already left on the VM. So a signal-kill code routes to | ||
| // 'reconcile' (let the gcloud probe read ground truth and roster / recover) rather | ||
| // than 'error' (a false add-failed that throws away a resumable build). A genuine | ||
| // provision failure exits with a small non-zero code (e.g. `exit 1`), which STILL | ||
| // classifies 'error'. 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)) { | ||
| if (code === 0) return 'done'; | ||
| // A signal killed the provision shell (e.g. bootout SIGTERM → 143): the add | ||
| // was interrupted, not failed — reconcile against real VM state instead of | ||
| // surfacing a false add-failed and discarding a resumable build. | ||
| if (isSignalKillExit(code)) return 'reconcile'; | ||
| return '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. | ||
| // 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) | ||
| // 'reset' existing idle instance unreachable too long trigger auto-reset | ||
| // 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. | ||
| // auto-reset escalation (vm: OOM Wedge Prevention): | ||
| // When the VM has been unreachable for at least RESET_UNREACHABLE_THRESHOLD | ||
| // (e.g. 200, which is roughly 10 minutes at a 3s poll rate), we issue a 'reset' | ||
| // verdict. This auto-reset is guarded and bounded by a cooldown window (in ms) | ||
| // and a max attempts limit to prevent infinite reboot loops. | ||
| // | ||
| // false-unreachable reboot safety (2026-06-25): "unreachable" as the dashboard | ||
| // sees it means the LOCALLY-FORWARDED port is dark — which is ALSO the signature | ||
| // of an operator-side fault (the laptop's gcloud auth expired, the ssh -L tunnel | ||
| // zombied, a laptop network blip) where the VM itself is perfectly healthy. | ||
| // Rebooting a healthy production VM off that signal interrupts a live session. | ||
| // So three operator-fault guards are layered BEFORE the cooldown/attempts budget | ||
| // (a reset is a last resort, never the response to a dark local port). Each is | ||
| // OPT-IN: it only changes the verdict when the caller supplies the fact, so | ||
| // legacy callers (and the OOM-wedge tests) that pass none keep the old behavior. | ||
| // operatorAuthDegraded our own gcloud auth is broken → EVERY VM reads | ||
| // unreachable; rebooting them all is exactly wrong → keep | ||
| // fleetWideUnreachable a simultaneous N-VM "outage" is the operator (auth/ | ||
| // tunnel/network), not N dead VMs → keep | ||
| // gcpReachable GCP-side confirmation: true ⇒ the VM answers from | ||
| // GCP's vantage, so the dark local port is a laptop | ||
| // tunnel fault → 'respawn' (never reset); null ⇒ the | ||
| // GCP-side probe was inconclusive → keep (don't escalate | ||
| // a destructive reset on absence of evidence); false ⇒ | ||
| // genuinely unreachable from GCP too → fall through to | ||
| // the cooldown/attempts budget and reset as before. | ||
| function reconcileRosteredVm({ exists, hasSession, hasPendingAdd, streak, resetHistory, threshold, cooldownMs, maxAttempts, operatorAuthDegraded, fleetWideUnreachable, gcpReachable } = {}) { | ||
| if (exists === null || exists === undefined) return "leave"; | ||
| if (!exists) { | ||
| if (hasSession || hasPendingAdd) return "leave"; | ||
| return "cleanup"; | ||
| } | ||
| // If the VM exists, check if it's dead-unreachable and eligible for auto-reset. | ||
| if (streak !== undefined && threshold !== undefined) { | ||
| if (streak >= threshold) { | ||
| if (hasSession || hasPendingAdd) { | ||
| // Do NOT auto-reset a VM that is busy (holding a live session or an in-flight add) | ||
| return "keep"; | ||
| } | ||
| // Operator-fault guards — layered before the cooldown/attempts budget so a | ||
| // laptop-side fault never even consumes a reset attempt. | ||
| if (operatorAuthDegraded) return "keep"; | ||
| if (fleetWideUnreachable) return "keep"; | ||
| if (gcpReachable === true) return "respawn"; | ||
| if (gcpReachable === null) return "keep"; | ||
| const history = resetHistory || { count: 0, lastResetAt: 0 }; | ||
| const attempts = history.count || 0; | ||
| const limit = maxAttempts !== undefined ? maxAttempts : 3; | ||
| if (attempts >= limit) { | ||
| // Budget exhausted (VM wedges repeatedly) -> park (needs-manual-recovery status) | ||
| return "keep"; | ||
| } | ||
| const lastReset = history.lastResetAt || 0; | ||
| const cooldown = cooldownMs !== undefined ? cooldownMs : 600000; // default 10 minutes | ||
| const now = Date.now(); | ||
| if (now - lastReset < cooldown) { | ||
| // Within cooldown window -> do not reset again yet | ||
| return "keep"; | ||
| } | ||
| return "reset"; | ||
| } | ||
| } | ||
| return "keep"; | ||
| } | ||
| module.exports = { | ||
| parsePendingJobFilename, | ||
| persistPendingJob, | ||
| clearPendingJob, | ||
| listPendingJobs, | ||
| classifyDescribeError, | ||
| reapVerdict, | ||
| reconcileRosteredVm, | ||
| orphanedAddsToReconcile, | ||
| classifyProjectWiring, | ||
| recoverBootstrapDecision, | ||
| exitMarkerName, | ||
| parseExitMarker, | ||
| readExitMarker, | ||
| clearExitMarker, | ||
| isSignalKillExit, | ||
| reattachVerdict, | ||
| reattachNextStep, | ||
| }; |
| 'use strict'; | ||
| // Pure decision + formatting helpers for the fleet dashboard's destroy/deroster | ||
| // false-unreachable safety (2026-06-26 incident, where a reauth-triggered | ||
| // false-unreachable cascade permanently deleted VM2/3/4). The dashboard's | ||
| // server.js gathers the live operator-fault facts (state.operatorAuthDegraded + | ||
| // fleetWideUnreachableNow) and runs the side effects (spawn the delete, log, | ||
| // stop+deroster); the DECISIONS those facts drive — "is this destroy blocked, | ||
| // and why" and "what does the attribution log line read" — live here so they | ||
| // are unit-testable in isolation, the same pure/IO split npm/fleet-auth-health.js | ||
| // (classifyOperatorAuth) and npm/fleet-cleanup.js (reconcileRosteredVm) follow. | ||
| // | ||
| // Pure logic only — no I/O, no env reads, no clock, no global state. | ||
| // Classify whether a destructive removal (hard destroy or --shrink) must be | ||
| // REFUSED because the operator's own side is faulted, from the two booleans | ||
| // server.js computes. Returns { blocked, reason }: blocked is true when the | ||
| // operator's gcloud auth is degraded OR a fleet-wide unreachable spike is in | ||
| // progress — the signature of a laptop-side fault (which makes EVERY VM read | ||
| // unreachable), NOT N genuinely-dead VMs. authDegraded takes precedence in the | ||
| // reason because it is the upstream cause (a broken operator auth is what | ||
| // produces the fleet-wide false-unreachable spike in the first place), so the | ||
| // message points at the thing to fix. | ||
| // authDegraded state.operatorAuthDegraded — operator gcloud auth is broken | ||
| // fleetWide fleetWideUnreachableNow() — N+ VMs unreachable at once | ||
| function classifyDestroyBlock({ authDegraded, fleetWide } = {}) { | ||
| if (authDegraded) { | ||
| return { blocked: true, reason: 'operator gcloud auth degraded' }; | ||
| } | ||
| if (fleetWide) { | ||
| return { blocked: true, reason: 'fleet-wide unreachable spike in progress (operator-side fault signature)' }; | ||
| } | ||
| return { blocked: false, reason: null }; | ||
| } | ||
| // Build the single attributable destroy-decision log line. Mirrors the | ||
| // performAutoReset "confirmed … not fleet-wide" template so every destroy — | ||
| // proceed OR refused, across every trigger source — is diagnosable from one | ||
| // grep. `remote` ({ ip, ua }) is the caller attribution whose absence made the | ||
| // 2026-06-26 delete impossible to trace back to its trigger; null/absent renders | ||
| // as `n/a` (e.g. the shrink / reap paths have no HTTP caller). All the flag | ||
| // fields are normalized to real booleans so a missing field never reads as | ||
| // `undefined` in the line. | ||
| function formatDestroyDecision(info = {}) { | ||
| const remote = info.remote ? `${info.remote.ip || '?'}/${info.remote.ua || '?'}` : 'n/a'; | ||
| return ( | ||
| `destroy-decision: VM-${info.n} source=${info.source} force=${!!info.force} ` + | ||
| `override=${!!info.overrideSafety} remote=${remote} hasSession=${!!info.hasSession} ` + | ||
| `authDegraded=${!!info.authDegraded} fleetWide=${!!info.fleetWide} → ${info.outcome}` | ||
| ); | ||
| } | ||
| module.exports = { | ||
| classifyDestroyBlock, | ||
| formatDestroyDecision, | ||
| }; |
| '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', | ||
| 'reset-client', | ||
| 'end-session', | ||
| 'rebuild-binary', | ||
| 'update-reset-everything', | ||
| ]; | ||
| // 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) }; | ||
| // (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' }; | ||
| // Reset editor (editor-state reset) — discards uncommitted + session, advances | ||
| // source, wipes .codeyam. No-op ONLY when clean at origin tip with no session. | ||
| // INTERNAL-ONLY: the standalone "Reset editor" button was removed; this | ||
| // predicate survives purely as an input to the "Reset all + rebuild" composite | ||
| // below (which still runs resetEditorStateScript), so it is NOT returned/keyed. | ||
| 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' }; | ||
| // (2) Reset client — hard-clean /workspace + .codeyam, drop the session, now | ||
| // ADVANCING to origin tip. Since the reset advances source, a VM behind origin | ||
| // would be changed by a re-run, so `behind` IS part of this predicate. | ||
| const resetClient = client == null | ||
| ? { effect: 'unknown', reason: 'client state not checked' } | ||
| : (brokerLive || client.dirty || client.behind > 0 || codeyamDirty || hasSession) | ||
| ? { effect: 'changes', reason: resetReason({ agent: brokerLive, session: hasSession, dirty: client.dirty, behind: client.behind > 0, codeyam: codeyamDirty }) } | ||
| : { effect: 'noop', reason: 'already clean at origin tip, no session' }; | ||
| // (2b) End session — kills the live agent + clears session state. The ONLY | ||
| // thing it touches is the running session, so it changes the VM iff a live | ||
| // agent or a registered session exists; otherwise it's a clean no-op. Unlike | ||
| // the resets it ignores dirty/behind/.codeyam — it neither advances source nor | ||
| // wipes state. | ||
| const endSession = (brokerLive || hasSession) | ||
| ? { effect: 'changes', reason: resetReason({ agent: brokerLive, session: hasSession }) } | ||
| : { effect: 'noop', reason: 'no active session' }; | ||
| // (4) Reset all + rebuild — composite. Changes iff ANY component would | ||
| // change: rebuild-binary, the editor-state reset (internal resetEditor | ||
| // predicate above), or the client being behind/dirty. Reason summarizes 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' }; | ||
| })(); | ||
| return { | ||
| 'update-client-source': updateClient, | ||
| 'reset-client': resetClient, | ||
| 'end-session': endSession, | ||
| 'rebuild-binary': rebuild, | ||
| 'update-reset-everything': everything, | ||
| }; | ||
| } | ||
| // Decide whether a Live Preview SSH forward needs to be re-wired (improve41) | ||
| function needsRewire(forwardAppPort, polledAppPort) { | ||
| if (typeof polledAppPort !== 'number' || isNaN(polledAppPort)) return false; | ||
| if (!forwardAppPort) return false; | ||
| return forwardAppPort !== polledAppPort; | ||
| } | ||
| module.exports = { computeButtonEffects, ACTION_IDS, needsRewire }; |
+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'; | ||
| // Pure operator-host placement resolution for the fleet dashboard | ||
| // (vm-dashboard-on-gce). | ||
| // | ||
| // The dashboard runs in two homes: the operator's laptop (legacy fallback) and a | ||
| // dedicated GCE "operator host" (the primary path). Three things differ by home — | ||
| // how gcloud authenticates, what address the dashboard binds, and how a branch | ||
| // pin is read from the env — and each used to be an inline ternary at the top of | ||
| // server.js's 200KB monolith, untestable. This module is the single home for | ||
| // those decisions, so server.js delegates the RULE and keeps only the I/O, the | ||
| // same split npm/fleet-branch.js (resolveFleetBranch) follows. | ||
| // | ||
| // Pure logic only — no I/O, no env reads beyond the explicit `env` argument a | ||
| // caller passes in — so it stays unit-testable in isolation (npm/fleet-host.test.js). | ||
| // Env var the systemd unit sets on the GCE operator host. Its presence (truthy) | ||
| // is what flips the dashboard from the laptop defaults to the on-GCE defaults. | ||
| const FLEET_ON_GCE_ENV = 'FLEET_ON_GCE'; | ||
| /** True when the dashboard is running on the GCE operator host, read from the | ||
| * `FLEET_ON_GCE` env. Accepts the conventional truthy spellings `1` / `true` / | ||
| * `yes` (case-insensitive, trimmed); everything else — unset, empty, `0`, | ||
| * `false`, garbage — is false, so a laptop run (which never sets it) stays on | ||
| * the loopback + named-configuration defaults. */ | ||
| function onGce(env = {}) { | ||
| return /^(1|true|yes)$/i.test(String(env[FLEET_ON_GCE_ENV] || '').trim()); | ||
| } | ||
| /** Resolve the gcloud invocation prefix the dashboard's `${GCLOUD}` chokepoint | ||
| * uses. On the GCE operator host the attached SA authenticates from instance | ||
| * metadata, so a plain `gcloud` is correct (no key file, no named config). Off | ||
| * GCE (laptop) it pins the non-expiring SA configuration with | ||
| * `--configuration=<config>` so background loops survive OAuth-token expiry. | ||
| * Returns `'gcloud'` on GCE; throws if a non-GCE call omits the config name, | ||
| * since silently emitting a bare `gcloud` there would run on the operator's | ||
| * personal account — the exact regression the chokepoint exists to prevent. */ | ||
| function gcloudCommand({ onGce: gce, config } = {}) { | ||
| if (gce) return 'gcloud'; | ||
| const c = typeof config === 'string' ? config.trim() : ''; | ||
| if (!c) { | ||
| throw new Error('gcloudCommand requires a config name when not on GCE'); | ||
| } | ||
| return `gcloud --configuration=${c}`; | ||
| } | ||
| /** Resolve the address the dashboard binds. An explicit `bind` (the DASH_BIND | ||
| * override) always wins. Otherwise the host serves the LB's zonal NEG so it must | ||
| * bind the instance's internal NIC (`0.0.0.0`, firewalled to the LB + IAP | ||
| * ranges); a laptop run binds loopback only (`127.0.0.1`). A blank/whitespace | ||
| * override counts as absent so a set-but-empty env doesn't bind to an empty | ||
| * string. */ | ||
| function dashboardBind({ onGce: gce, bind } = {}) { | ||
| const explicit = typeof bind === 'string' ? bind.trim() : ''; | ||
| if (explicit) return explicit; | ||
| return gce ? '0.0.0.0' : '127.0.0.1'; | ||
| } | ||
| /** Resolve the explicit branch pin from the env, or null when unpinned. The | ||
| * operator-host name is `FLEET_BRANCH`; `BRANCH` is the legacy laptop alias and | ||
| * is honored as a fallback. A blank/whitespace value is "no pin" (so the | ||
| * dashboard tracks live git HEAD via resolveFleetBranch). Returns the trimmed | ||
| * pin or null. */ | ||
| function branchPin(env = {}) { | ||
| const pin = String(env.FLEET_BRANCH || env.BRANCH || '').trim(); | ||
| return pin === '' ? null : pin; | ||
| } | ||
| module.exports = { | ||
| FLEET_ON_GCE_ENV, | ||
| onGce, | ||
| gcloudCommand, | ||
| dashboardBind, | ||
| branchPin, | ||
| }; |
| '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-client 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 running a VM `add` provision as its OWN transient launchd | ||
| // job (`com.codeyam.fleet-add-vm-<n>`) — improve46 Phase 3. The provision job | ||
| // is a SIBLING of `com.codeyam.fleet-dashboard` rather than a `setsid` child of | ||
| // it, so a `launchctl bootout` of the dashboard label (which tears down the | ||
| // whole dashboard launchd job tree regardless of session/process-group) has no | ||
| // authority over the provision. Phases 1 (signal-kill reclassification) and 2 | ||
| // (the reinstall in-flight-add guard) still compose with this — Phase 3 reduces | ||
| // how often a bootout reaches the provision at all. | ||
| // | ||
| // All the string-building and parsing lives here so server.js can stay thin and | ||
| // these pieces are unit-testable WITHOUT launchctl (npm/fleet-launchd.test.js) — | ||
| // the same decomposition `npm/fleet-cleanup.js` uses for the reap brain. | ||
| // | ||
| // Stack assumption: launchd is macOS-only. server.js gates the whole own-job | ||
| // path on platform + a `launchctl` probe and falls back to the existing `setsid` | ||
| // detached spawn on cloud containers / CI / Linux, where these helpers are never | ||
| // reached. They are pure either way. | ||
| // The launchd label for VM-n's transient add-provision job. ONE label per VM | ||
| // number so the re-add collision guard and the completion teardown each address | ||
| // exactly one job. Mirrors the `com.codeyam.fleet-*` label family used by the | ||
| // dashboard's other launchd agents (see scripts/operator/launchd-reinstall.sh). | ||
| function addVmLabel(n) { | ||
| return `com.codeyam.fleet-add-vm-${n}`; | ||
| } | ||
| // The file the transient job's stdout/stderr are redirected to (the plist's | ||
| // Standard{Out,Error}Path). The dashboard tails this for the in-progress card's | ||
| // live log when the launchd path is taken — the equivalent of the `setsid` | ||
| // path's stdout pipe, which a separate launchd job can't share. Kept DISTINCT | ||
| // from `/tmp/dash-add-vm-<n>.log` (the addVm script's own `cloud:up` redirect) | ||
| // so the provision shell's progress echoes ("provisioning…", "VM-n provisioned") | ||
| // aren't interleaved with the cloud:up build output. | ||
| function addVmJobLogPath(n) { | ||
| return `/tmp/dash-add-vm-${n}.provision.log`; | ||
| } | ||
| // XML-escape a string for safe inclusion in a plist `<string>` body. Plists are | ||
| // XML, so the five predefined entities must be escaped or a path/env value with | ||
| // `&`/`<`/`>`/quotes would corrupt the plist. Pure. | ||
| function xmlEscape(s) { | ||
| return String(s == null ? '' : s) | ||
| .replace(/&/g, '&') | ||
| .replace(/</g, '<') | ||
| .replace(/>/g, '>') | ||
| .replace(/"/g, '"') | ||
| .replace(/'/g, '''); | ||
| } | ||
| // Build a transient launchd plist (XML text) that runs `programArguments` ONCE | ||
| // under `label`. RunAtLoad=true so `launchctl bootstrap` fires it immediately; | ||
| // KeepAlive=false so launchd NEVER relaunches the provision after it exits (a | ||
| // one-shot job, not a daemon). stdout/stderr go to `logPath` so the dashboard | ||
| // can tail the live card. `environment` (the bootstrap form env) and | ||
| // `workingDirectory` are optional. Pure — caller writes the returned text to a | ||
| // temp file and bootstraps it. | ||
| function buildAddVmPlist({ label, programArguments, logPath, workingDirectory, environment } = {}) { | ||
| const args = (Array.isArray(programArguments) ? programArguments : []) | ||
| .map((a) => ` <string>${xmlEscape(a)}</string>`) | ||
| .join('\n'); | ||
| const lines = [ | ||
| '<?xml version="1.0" encoding="UTF-8"?>', | ||
| '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">', | ||
| '<plist version="1.0">', | ||
| '<dict>', | ||
| ' <key>Label</key>', | ||
| ` <string>${xmlEscape(label)}</string>`, | ||
| ' <key>ProgramArguments</key>', | ||
| ' <array>', | ||
| args, | ||
| ' </array>', | ||
| ' <key>RunAtLoad</key>', | ||
| ' <true/>', | ||
| ' <key>KeepAlive</key>', | ||
| ' <false/>', | ||
| ]; | ||
| if (logPath) { | ||
| lines.push(' <key>StandardOutPath</key>', ` <string>${xmlEscape(logPath)}</string>`); | ||
| lines.push(' <key>StandardErrorPath</key>', ` <string>${xmlEscape(logPath)}</string>`); | ||
| } | ||
| if (workingDirectory) { | ||
| lines.push(' <key>WorkingDirectory</key>', ` <string>${xmlEscape(workingDirectory)}</string>`); | ||
| } | ||
| const env = environment && typeof environment === 'object' ? environment : null; | ||
| if (env && Object.keys(env).length > 0) { | ||
| lines.push(' <key>EnvironmentVariables</key>', ' <dict>'); | ||
| for (const [k, v] of Object.entries(env)) { | ||
| lines.push(` <key>${xmlEscape(k)}</key>`, ` <string>${xmlEscape(v)}</string>`); | ||
| } | ||
| lines.push(' </dict>'); | ||
| } | ||
| lines.push('</dict>', '</plist>', ''); | ||
| return lines.join('\n'); | ||
| } | ||
| // Parse the integer pid from `launchctl print gui/$UID/<label>` output. While | ||
| // the job's process is alive the block contains a `pid = N` line; this resolves | ||
| // it so the dashboard records the REAL provision pid into the sidecar `meta.pid` | ||
| // (the field `reattachVerdict`'s `kill -0` re-attach reads) rather than the | ||
| // launchctl invocation's own pid. Returns the integer pid, or null when there's | ||
| // no live process line (job not started yet, already exited, or junk). `pid = 0` | ||
| // is treated as no-pid. Pure: takes the text so it's testable without launchctl. | ||
| function parseLaunchdPrintPid(text) { | ||
| const m = String(text == null ? '' : text).match(/^\s*pid\s*=\s*(\d+)\b/m); | ||
| if (!m) return null; | ||
| const pid = Number(m[1]); | ||
| return Number.isInteger(pid) && pid > 0 ? pid : null; | ||
| } | ||
| // Filter a captured transcript down to the live-card tail: drop the noisy | ||
| // gcloud/IAP/NumPy lines and keep the last `maxLines`. Pure — the same shaping | ||
| // startJob's `tailLog` applies to the setsid stdout pipe, extracted so the | ||
| // launchd path's logfile tail and the setsid path share ONE tested filter | ||
| // instead of duplicating the regex + slice. | ||
| function filterTailLog(text, maxLines = 14) { | ||
| return String(text == null ? '' : text).split('\n') | ||
| .filter((l) => !/WARNING|NumPy|google\.com\/iap/.test(l)) | ||
| .join('\n').trim().split('\n').slice(-maxLines).join('\n'); | ||
| } | ||
| // True when a `spawnSync` result represents a successful launchctl invocation: | ||
| // a defined result with a zero exit status and no spawn error (ENOENT etc.). | ||
| // Pure — lets the bootstrap/`load -w` success checks be one tested predicate. | ||
| function launchctlSpawnSucceeded(result) { | ||
| return !!result && result.status === 0 && !result.error; | ||
| } | ||
| // Decide what the launchd add-completion watcher should do on one tick, from the | ||
| // durable signals it gathers: the exit marker's code (or null when absent), | ||
| // whether the launchd process is still alive, and how many consecutive ticks it | ||
| // has been gone. Pure state machine — the I/O (reading the marker, `kill -0`, | ||
| // tailing the log) stays in the server.js watcher; this owns the decision so it | ||
| // is exhaustively testable without launchd. | ||
| // - marker present → done, that exit code (the normal path) | ||
| // - process gone for `graceTicks` → done, exit 1 (EXIT trap never wrote a | ||
| // marker — resolve the card as a failure | ||
| // rather than tailing forever) | ||
| // - process gone, under grace → keep waiting, bump goneTicks | ||
| // - process alive → keep waiting, reset goneTicks | ||
| function launchdWatchVerdict({ markerExitCode, pidAlive, goneTicks = 0, graceTicks = 3 } = {}) { | ||
| if (markerExitCode != null) return { done: true, exitCode: markerExitCode, goneTicks: 0 }; | ||
| if (!pidAlive) { | ||
| const next = goneTicks + 1; | ||
| if (next >= graceTicks) return { done: true, exitCode: 1, goneTicks: next }; | ||
| return { done: false, goneTicks: next }; | ||
| } | ||
| return { done: false, goneTicks: 0 }; | ||
| } | ||
| // Decide whether the own-launchd-job path should be taken for a spawn. Pure | ||
| // gate: it's taken only for a `detached` add (the sole caller that passes | ||
| // `detached`) AND on a host where `launchctl` is actually present. Everything | ||
| // else — a non-detached job, or a host with no launchctl (cloud container / CI / | ||
| // Linux) — falls back to the existing `setsid` detached spawn. Centralised here | ||
| // so the gate is one tested predicate, not an inline `&&` at the spawn site. | ||
| function shouldUseLaunchd({ detached, hasLaunchctl } = {}) { | ||
| return !!detached && !!hasLaunchctl; | ||
| } | ||
| module.exports = { | ||
| addVmLabel, | ||
| addVmJobLogPath, | ||
| xmlEscape, | ||
| buildAddVmPlist, | ||
| parseLaunchdPrintPid, | ||
| filterTailLog, | ||
| launchctlSpawnSucceeded, | ||
| launchdWatchVerdict, | ||
| shouldUseLaunchd, | ||
| }; |
| 'use strict'; | ||
| // Pure session/broker live-status derivation for the Fleet Dashboard's per-VM | ||
| // status LABEL (scripts/operator/fleet-dashboard/server.js requires this). This | ||
| // is the trailing decision of deriveStatus, after every heavy-job / orphan / | ||
| // failed-badge branch has been ruled out: given the reachable VM's session and | ||
| // broker signals, is it building, waiting, or genuinely idle? | ||
| // | ||
| // The bug this fixes (observed live 2026-06-20, VM-1): the label was derived | ||
| // SOLELY from `hasSession` — the editor server's own in-process build-session | ||
| // flag. But a Claude agent's lifetime is owned by the PTY broker, deliberately | ||
| // DECOUPLED from the editor-server lifetime (see docs/process-model.md). After a | ||
| // server restart, or for a broker-resumed/detached agent the server didn't spawn | ||
| // itself, `hasSession` is false while a fully-live agent keeps working in the | ||
| // broker. The card fell through to 'idle' even though the broker dot — driven by | ||
| // the same `brokerStatus` — already showed 'agent-live'. The label and the dot | ||
| // disagreed. | ||
| // | ||
| // The fix consults the already-debounced broker tri-state (from | ||
| // fleet-broker-status.js, threaded in by the caller) so a broker-held live agent | ||
| // reads as working, not idle. `awaitingUserInput` is honored independent of | ||
| // `hasSession`, so a broker-live agent blocked on input reads 'waiting'. | ||
| // | ||
| // The terminal-done refinement (observed live 2026-06-21): a broker-live agent | ||
| // whose WORKFLOW reached its terminal step is parked alive at its prompt but is | ||
| // NOT building — the editor masks the feature and reports `needsCleanup: true` | ||
| // (session_info_response.rs). Without this signal the card showed an active-green | ||
| // "Building" border on a finished, idle VM. We discriminate on `needsCleanup` | ||
| // (a positive terminal signal) rather than feature-emptiness (also briefly true | ||
| // at session START, which would mislabel a just-starting agent as done). This | ||
| // only ever DOWNGRADES a would-be-'building' verdict to 'session-done'; a | ||
| // mid-feature post-restart agent is not terminal (`needsCleanup` falsy) and stays | ||
| // 'building', preserving the 2026-06-20 decoupled-agent fix above. | ||
| // | ||
| // Returns 'waiting' | 'building' | 'session-done' | 'idle' for a reachable VM, or | ||
| // `null` when the VM is unreachable — the caller maps null to its own | ||
| // asleep/unreachable verdict (it owns the running-fleet snapshot this module | ||
| // deliberately does not touch) and otherwise passes the verdict through verbatim | ||
| // as the card's `status`, so 'session-done' must match the card's status class. | ||
| // Precedence is preserved: an unreachable VM returns null regardless of a stale | ||
| // 'agent-live', so the broker branch only ever upgrades a would-be-idle verdict. | ||
| function liveSessionStatus(v, brokerStatus) { | ||
| // Unreachable: we polled nothing reliable. Defer to the caller's | ||
| // asleep/unreachable mapping — a stale broker signal must not mask it. | ||
| if (!v.reachable) return null; | ||
| if (v.hasSession && v.awaitingUserInput) return 'waiting'; | ||
| if (v.hasSession) return 'building'; | ||
| // No editor-owned session, but a live agent may still be working in the PTY | ||
| // broker (decoupled lifetime — see docs/process-model.md). The broker | ||
| // tri-state is already debounced upstream; agent-live ⇒ working, not idle. | ||
| if (brokerStatus === 'agent-live') { | ||
| if (v.awaitingUserInput) return 'waiting'; | ||
| // The agent is parked alive in the broker's build slot, but its workflow | ||
| // reached the terminal step (editor reports needsCleanup) — finished and | ||
| // idle, not building. A mid-feature post-restart agent is NOT terminal and | ||
| // still reads 'building' (preserves the 2026-06-20 decoupled-agent fix). | ||
| if (v.needsCleanup) return 'session-done'; | ||
| return 'building'; | ||
| } | ||
| return 'idle'; | ||
| } | ||
| module.exports = { liveSessionStatus }; |
| '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' | ||
| ]; | ||
| /** | ||
| * Decides which data source a project's Queued Plans panel should read from. | ||
| * The dashboard's own project (codeyam-editor) reads plans fresh from origin | ||
| * via the laptop checkout; every other project keeps the VM probe. | ||
| * @param {Object} params | ||
| * @param {string} params.project The project name for the panel. | ||
| * @param {?string} params.selfHostProject The dashboard's own project name. | ||
| * @returns {'origin'|'vm'} The source to use. | ||
| */ | ||
| function planSourceForProject({ project, selfHostProject }) { | ||
| return selfHostProject && project === selfHostProject ? 'origin' : 'vm'; | ||
| } | ||
| /** | ||
| * Builds the static (non-ticking) part of a Queued Plans panel header label so | ||
| * the data source and its staleness risk are always visible. Origin-sourced | ||
| * panels name the branch + short sha; VM-sourced panels carry the "may lag | ||
| * origin" caveat. The relative age ("· 8s ago") is appended by the frontend's | ||
| * own ago ticker, so this returns the static prefix only. | ||
| * @param {Object} params | ||
| * @param {'origin'|'vm'} params.source Where the panel's data came from. | ||
| * @param {?string} params.ref The git ref (e.g. "origin/<branch>") for origin. | ||
| * @param {?string} params.sha The full commit sha for origin. | ||
| * @param {?number} params.fromVm The representative VM number for vm source. | ||
| * @param {?('stale-binary'|'fetch-failed'|'parse-error'|'unknown')} params.originError | ||
| * When the self-host origin path was *attempted* and failed, the classified | ||
| * reason it fell back to the VM probe. Absent/null for ordinary VM-sourced | ||
| * projects (which were never meant to read origin). | ||
| * @returns {string} The static header annotation. | ||
| */ | ||
| function planPanelSourceLabel({ source, ref, sha, fromVm, originError }) { | ||
| if (source === 'origin') { | ||
| const shortSha = sha ? String(sha).slice(0, 7) : '?'; | ||
| const where = ref || 'origin'; | ||
| return `${where} @ ${shortSha}`; | ||
| } | ||
| // VM-sourced: surface the residual lag risk the origin path eliminates. | ||
| const vm = (fromVm !== null && fromVm !== undefined) ? `VM-${fromVm}` : 'VM'; | ||
| if (originError) { | ||
| // The origin path was attempted and failed: this is a *degraded* panel, not | ||
| // a normal VM-sourced one. Name the failure class so it is self-diagnosing. | ||
| return `via ${vm} · origin unavailable: ${originErrorPhrase(originError)}`; | ||
| } | ||
| return `via ${vm} · may lag origin`; | ||
| } | ||
| /** | ||
| * Maps a classified origin-failure bucket to a short operator-facing phrase for | ||
| * the panel header. Mirrors the bucket set of classifyOriginError; an unknown | ||
| * bucket degrades to the generic "origin read failed" phrase. | ||
| * @param {string} originError The classified bucket. | ||
| * @returns {string} A short human-readable phrase. | ||
| */ | ||
| function originErrorPhrase(originError) { | ||
| switch (originError) { | ||
| case 'stale-binary': | ||
| return 'stale editor binary'; | ||
| case 'fetch-failed': | ||
| return 'git fetch failed'; | ||
| case 'parse-error': | ||
| return 'bad snapshot output'; | ||
| default: | ||
| return 'origin read failed'; | ||
| } | ||
| } | ||
| /** | ||
| * Classifies a caught origin-path error message into a small, stable set of | ||
| * buckets the operator can act on, rather than surfacing a noisy raw error | ||
| * (which can carry paths/branch names) in the panel header. Pure so it is | ||
| * unit-testable. | ||
| * - `unrecognized subcommand` → 'stale-binary' (CLI predates the command) | ||
| * - leading `git fetch origin` → 'fetch-failed' (couldn't reach origin) | ||
| * - no-JSON / malformed snapshot → 'parse-error' (plans-snapshot output) | ||
| * - anything else → 'unknown' | ||
| * @param {?string} message The caught error's message text. | ||
| * @returns {'stale-binary'|'fetch-failed'|'parse-error'|'unknown'} | ||
| */ | ||
| function classifyOriginError(message) { | ||
| const msg = typeof message === 'string' ? message : ''; | ||
| if (msg.includes('unrecognized subcommand')) return 'stale-binary'; | ||
| if (/^git fetch origin\b/.test(msg)) return 'fetch-failed'; | ||
| if (msg.includes('plans-snapshot produced no JSON') || | ||
| msg.includes('plans-snapshot JSON parse')) return 'parse-error'; | ||
| return 'unknown'; | ||
| } | ||
| /** | ||
| * True when an origin-fetch error means the branch no longer exists on the | ||
| * remote — it was merged and deleted, so `git fetch origin <branch>` fails with | ||
| * git's `couldn't find remote ref <branch>`. This is NOT a transient fetch | ||
| * failure (network / auth): the branch is *gone*, so re-fetching it every poll | ||
| * cycle is pure noise. The dashboard uses this to record the branch as gone, | ||
| * fall back to the VM probe quietly, and stop logging an error every cycle — | ||
| * the difference between a healthy fleet that *reads* broken in the log and one | ||
| * that reads calm. Pure so it is unit-testable. | ||
| * @param {?string} message The caught fetch-error message text. | ||
| * @returns {boolean} | ||
| */ | ||
| function isRemoteRefGone(message) { | ||
| const msg = typeof message === 'string' ? message : ''; | ||
| return /couldn't find remote ref/i.test(msg); | ||
| } | ||
| /** | ||
| * Parses the stdout of `editor plans-snapshot --format json` into the snapshot | ||
| * envelope the dashboard consumes. The pure half of snapshotPlansFromOrigin: | ||
| * extracts the JSON object (the CLI may prefix a dev-build banner), parses it, | ||
| * and normalizes missing arrays to []. Throws a descriptive error on no-JSON | ||
| * or malformed-JSON so the caller can fall back to the VM probe. | ||
| * @param {string} stdout The plans-snapshot CLI stdout. | ||
| * @returns {{ref: string, sha: string, plans: Object[], completedSlugs: string[]}} | ||
| */ | ||
| function parsePlansSnapshotStdout(stdout) { | ||
| const m = stdout && stdout.match(/\{[\s\S]*\}/); | ||
| if (!m) throw new Error('plans-snapshot produced no JSON'); | ||
| let data; | ||
| try { | ||
| data = JSON.parse(m[0]); | ||
| } catch (e) { | ||
| throw new Error(`plans-snapshot JSON parse: ${e.message}`); | ||
| } | ||
| return { | ||
| ref: data.ref, | ||
| sha: data.sha, | ||
| plans: data.plans || [], | ||
| completedSlugs: data.completedSlugs || [], | ||
| }; | ||
| } | ||
| module.exports = { | ||
| indexVmsBySessionId, | ||
| unsatisfiedDeps, | ||
| resolvePlanRows, | ||
| planSourceForProject, | ||
| planPanelSourceLabel, | ||
| classifyOriginError, | ||
| isRemoteRefGone, | ||
| parsePlansSnapshotStdout, | ||
| 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 classification + reason-string derivation for the dashboard's public-URL | ||
| // reachability probe (vm: Dashboard validates the public ingress URL, not just | ||
| // the tunnel). | ||
| // | ||
| // The fleet dashboard probes each VM's editor through the local SSH tunnel and, | ||
| // in Google-LB mode, the per-VM backend SERVICE health (gcloud get-health) — | ||
| // but never the public `<slug>.editor.<domain>` URL the card advertises. VM5 | ||
| // (2026-06-19) sat "healthy" on every one of those signals while the advertised | ||
| // URL returned 503 "no healthy upstream" for hours: a reused-slug Add aborted on | ||
| // a `Duplicate path matcher` collision before binding the host rule, so the | ||
| // hostname fell through to the default backend. A backend-health probe is | ||
| // structurally blind to a missing host rule; only an end-to-end GET of the | ||
| // advertised hostname catches it. | ||
| // | ||
| // This module is the PURE half — parsing + string derivation, no network — | ||
| // mirroring classifyBackendHealth's `absent|unknown|unhealthy|healthy` | ||
| // contract. The HTTPS GET I/O lives in server.js's gatherPublicUrlHealth, the | ||
| // same split gatherBackendHealth / classifyBackendHealth already follow. Unit- | ||
| // tested in npm/fleet-public-url.test.js. | ||
| // | ||
| // Stack note: this is fleet-operator infrastructure (the GCE fleet that runs | ||
| // codeyam-editor itself), GCP / HTTPS-LB-specific by design — the same posture | ||
| // as the scripts it backstops. A project that never joins the fleet never sets | ||
| // FLEET_DOMAIN and never reaches this probe. | ||
| // The Google HTTPS-LB "no backend answered" body signature: GFE emits this when | ||
| // a host rule routes nowhere (or to a backendless default). A single source so | ||
| // the classifier and any future log line agree on the string. | ||
| const NO_HEALTHY_UPSTREAM_RE = /no healthy upstream/i; | ||
| /** Classify a public-URL probe result into the ingress-wiring vocabulary, | ||
| * following classifyBackendHealth's contract — absence of evidence is never a | ||
| * problem state: | ||
| * | ||
| * 'unknown' — a network / TLS / timeout / DNS error (no statusCode): the | ||
| * probe couldn't reach a verdict. NEVER a degraded state, exactly | ||
| * like classifyBackendHealth's 'unknown'. Also covers an | ||
| * unexpected non-503 HTTP error (a 404/500 from the editor | ||
| * itself) — that means the host rule DID route, just not to a | ||
| * happy response, so it is neither 'unwired' nor a clean 'wired'. | ||
| * 'unwired' — the LB answered but no backend is bound to the advertised host: | ||
| * a 503, or a recognizable "no healthy upstream" default-backend | ||
| * body. THIS is the missing-host-rule signal a backend-health | ||
| * probe cannot see (the VM5 case). | ||
| * 'wired' — any 2xx / 3xx: the editor answered (a 200, or its login 302 | ||
| * redirect — the real VM5-healthy shape). The host rule routes. | ||
| */ | ||
| function classifyPublicUrlHealth({ statusCode, body, error } = {}) { | ||
| if (error || statusCode == null) return 'unknown'; | ||
| const code = Number(statusCode); | ||
| if (code === 503) return 'unwired'; | ||
| if (NO_HEALTHY_UPSTREAM_RE.test(String(body || ''))) return 'unwired'; | ||
| if (code >= 200 && code < 400) return 'wired'; | ||
| // Any other 4xx/5xx is the editor answering with an error of its own — the | ||
| // host rule routes, so it is NOT 'unwired'; but it is not a clean success | ||
| // either. Treat as 'unknown' (absence of the unwired signal) so we never raise | ||
| // a false ingress-unwired reason on it. | ||
| return 'unknown'; | ||
| } | ||
| /** The sticky `ingress-unwired` degraded-reason string — the single source for | ||
| * both the dashboard card reason and any log line. Embeds the advertised URL | ||
| * and the EXACT re-wire command an operator runs to fix it, so the amber card | ||
| * is self-documenting (the surface VM5 should have shown for hours instead of | ||
| * reading healthy). */ | ||
| function ingressUnwiredReason({ url, domain, slug, n } = {}) { | ||
| const target = url || `(vm-${n} editor URL)`; | ||
| const slugEnv = slug ? ` VM_SLUG=${slug}` : ''; | ||
| const rewire = `FLEET_DOMAIN=${domain}${slugEnv} bash scripts/gcp/vm-ingress.sh ${n} up`; | ||
| return `ingress-unwired: ${target} returns 503 (host rule missing). Re-wire: ${rewire}`; | ||
| } | ||
| /** Decide whether a just-landed public-URL verdict warrants an auto-rewire | ||
| * self-heal. PURE — the dashboard reads the live VM state (I/O at the edge) and | ||
| * passes the facts in; the gate logic itself is unit-testable without network | ||
| * or server state. Fires only for a GENUINE missing-host-rule case: | ||
| * - `verdict` is 'unwired' (the URL 503s), AND | ||
| * - `reachable` (the tunnel is up — a down VM is not a missing-host-rule | ||
| * case; re-wiring it accomplishes nothing), AND | ||
| * - `backendEditorHealth` is 'healthy' (the backend SERVICE is fine, so only | ||
| * the host rule is missing — the VM5 host-rule-drift case) OR 'absent' (the | ||
| * per-VM backend service was NEVER created — the interrupted-add VM-4 case, | ||
| * where a SIGTERM killed `backend-services create` mid-wiring) OR | ||
| * 'unhealthy' *with* `backendEditorBackendless` true (the BES EXISTS but has | ||
| * ZERO NEG backends attached — the concurrent-add editor-band race, where the | ||
| * backend slipped its NEG attachment but the BES object landed). All three | ||
| * are recoverable by a single `vm-ingress.sh up`, which is | ||
| * describe-before-create idempotent and creates/attaches only the missing | ||
| * pieces. A backendless BES classifies as 'unhealthy' (get-health returns | ||
| * empty, indistinguishable from a still-cold backend) yet will NEVER converge | ||
| * on its own — it has no endpoint to become healthy — so it MUST re-wire, not | ||
| * wait. 'unknown' (a transient gcloud/auth error — absence of evidence must | ||
| * never trigger a re-wire storm) and a GENUINELY-converging 'unhealthy' (a | ||
| * backend IS attached, health still flipping — `backendEditorBackendless` | ||
| * false: let it converge, don't churn the LB) stay EXCLUDED, AND | ||
| * - no re-wire is already `inFlight` (one per VM), AND | ||
| * - the debounce window has elapsed since `lastRewireAt`. | ||
| * The debounce is load-bearing: GCP host-rule changes take minutes to roll out | ||
| * to all GFEs (VM5's CORRECT rule 503'd for ~minutes before flipping), so a | ||
| * per-cycle re-fire would storm the LB while a good change is still | ||
| * propagating. A missing/0 `lastRewireAt` means "never re-wired" → the window | ||
| * is satisfied. */ | ||
| function shouldAutoRewireIngress({ | ||
| verdict, | ||
| reachable, | ||
| backendEditorHealth, | ||
| backendEditorBackendless, | ||
| inFlight, | ||
| lastRewireAt, | ||
| now, | ||
| debounceMs, | ||
| } = {}) { | ||
| if (verdict !== 'unwired') return false; | ||
| if (!reachable) return false; | ||
| // 'healthy' = host-rule drift (VM5); 'absent' = backend never created | ||
| // (interrupted-add VM-4); 'unhealthy' + backendless = BES exists with zero NEG | ||
| // backends (concurrent-add editor-band race) — it can't converge unaided, so it | ||
| // re-wires too. A genuinely-converging 'unhealthy' (backend attached, health | ||
| // flipping → backendless false) and 'unknown' stay excluded. | ||
| const rewirable = | ||
| backendEditorHealth === 'healthy' || | ||
| backendEditorHealth === 'absent' || | ||
| (backendEditorHealth === 'unhealthy' && backendEditorBackendless === true); | ||
| if (!rewirable) return false; | ||
| if (inFlight) return false; | ||
| return (Number(now) - (Number(lastRewireAt) || 0)) >= Number(debounceMs); | ||
| } | ||
| /** Classify a `gcloud compute backend-services describe --format=value(backends[].group)` | ||
| * result into the attachment vocabulary — the signal `get-health` cannot give, | ||
| * because an empty health result is ambiguous between "no backend attached" and | ||
| * "a backend attached but still health-pending": | ||
| * | ||
| * 'backendless' — the BES EXISTS but has ZERO NEG backends attached (the | ||
| * describe printed empty / whitespace-only output). This BES | ||
| * will NEVER converge on its own — there is no endpoint to | ||
| * become healthy — so it is re-wirable, feeding | ||
| * shouldAutoRewireIngress's `backendEditorBackendless` branch | ||
| * (the concurrent-add editor-band slip). | ||
| * 'attached' — the describe printed at least one backend group, OR it | ||
| * ERRORED. An error is treated as 'attached' (absence of | ||
| * evidence): a NotFound is the 'absent' case classifyBackendHealth | ||
| * already owns, and a transient gcloud/auth failure must NEVER | ||
| * manufacture a 'backendless' verdict that storms the LB with a | ||
| * re-wire. Pure (no I/O), so the empty-backends classification is | ||
| * unit-testable without gcloud — the I/O lives in server.js's | ||
| * gatherEditorBackendless, the same split classifyPublicUrlHealth | ||
| * follows. */ | ||
| function classifyBackendAttachment(err, stdout) { | ||
| if (err) return 'attached'; | ||
| return String(stdout == null ? '' : stdout).trim() === '' ? 'backendless' : 'attached'; | ||
| } | ||
| module.exports = { | ||
| classifyPublicUrlHealth, | ||
| ingressUnwiredReason, | ||
| shouldAutoRewireIngress, | ||
| classifyBackendAttachment, | ||
| }; |
| '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` }; | ||
| } | ||
| /** Compute the set-priority writes a reorder requires. | ||
| * | ||
| * A reorder POSTs a desired uuid order; the queue's priority convention is | ||
| * first=N … last=1 (higher priority sorts earlier). This maps the desired | ||
| * order onto target priorities, drops the entries already at their target | ||
| * priority (no-ops — no `set-priority` shell-out needed), and sorts the | ||
| * remaining writes intended-HEAD-first so the head lands first even if a | ||
| * later write fails. `current` is the authoritative queue ([{uuid,priority}]); | ||
| * `order` is the desired uuid sequence. | ||
| * | ||
| * Returns one of: | ||
| * { ok: false, reason: 'empty' } — no order supplied | ||
| * { ok: false, reason: 'stale' } — order doesn't match current set | ||
| * { ok: true, targets: [{uuid, prio}] } — writes to apply (possibly []) | ||
| * Pure; no I/O. | ||
| */ | ||
| function computeReorderTargets({ order, current }) { | ||
| const ord = (order || []).map((s) => String(s).trim()).filter(Boolean); | ||
| const cur = current || []; | ||
| const curUuids = cur.map((e) => e.uuid); | ||
| if (!ord.length) return { ok: false, reason: 'empty' }; | ||
| // Same membership, same length — a reorder, not an add/remove. Anything else | ||
| // means the queue changed underneath the operator since they last saw it. | ||
| if (ord.length !== curUuids.length || !ord.every((u) => curUuids.includes(u))) { | ||
| return { ok: false, reason: 'stale' }; | ||
| } | ||
| const N = ord.length; | ||
| const targets = ord | ||
| .map((uuid, i) => ({ uuid, prio: N - i })) // first=N … last=1 | ||
| .filter(({ uuid, prio }) => { | ||
| const e = cur.find((x) => x.uuid === uuid); | ||
| return !e || e.priority !== prio; // skip no-ops | ||
| }) | ||
| .sort((a, b) => b.prio - a.prio); // intended HEAD first | ||
| return { ok: true, targets }; | ||
| } | ||
| module.exports = { decideAutoReleaseGoneHead, computeReorderTargets }; |
| '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; | ||
| } | ||
| // Self-heal for the slug sidecar: given the slug map (`{ "<N>": "<slug>" }`) and | ||
| // the roster (the live VM numbers), return the sorted, de-duped VM numbers whose | ||
| // slug entry should be released because the VM is no longer rostered. A | ||
| // destroyed VM is removed from the roster, so any slug still keyed to a | ||
| // non-rostered number is stranded (e.g. a destroy path that failed to call | ||
| // `removeSlug`) and would otherwise block re-using that name forever. Pure: the | ||
| // caller reads the sidecar + roster (I/O at the edge) and applies `removeSlug` | ||
| // to each returned number. Tolerates a null/absent slug map and an empty roster. | ||
| function pruneOrphanSlugs({ slugs, roster } = {}) { | ||
| const rostered = new Set((roster || []).filter(Number.isInteger)); | ||
| const orphans = Object.keys(slugs || {}) | ||
| .map((k) => Number(k)) | ||
| .filter((n) => Number.isInteger(n) && n > 0 && !rostered.has(n)); | ||
| return [...new Set(orphans)].sort((a, b) => a - b); | ||
| } | ||
| module.exports = { parseRunningFleetVms, computeUnrostered, reconcileTerminalAddBadge, pruneOrphanSlugs }; |
| '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, advancing to origin tip. The decisions | ||
| // encoded here: | ||
| // - Advance to ORIGIN tip: fetch + `reset --hard origin/<branch>` (the same | ||
| // shape resetEditorStateScript uses), so the working tree ends clean AT the | ||
| // origin tip. This discards uncommitted churn AND unpushed commits — more | ||
| // destructive than the old reset-to-HEAD; the card's confirm dialog warns | ||
| // about the unpushed-commit loss. A greenfield local-only repo (no origin) | ||
| // falls back to a bare `git reset --hard` to HEAD. The non-destructive | ||
| // ff-to-tip-that-keeps-the-session path is the separate "Update client". | ||
| // - `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)', | ||
| 'if git remote | grep -qx origin; then ' + | ||
| 'BR=$(git branch --show-current); ' + | ||
| 'echo ">> [2/5] advancing client to origin tip (git fetch + reset --hard origin/$BR; discards uncommitted AND unpushed; keeps untracked files)"; ' + | ||
| 'git reset --hard && git fetch origin "$BR" && git reset --hard "origin/$BR"; ' + | ||
| 'else echo ">> [2/5] no origin remote (greenfield) — git reset --hard to HEAD"; git reset --hard; fi', | ||
| 'echo ">> [3/5] deep-cleaning .codeyam (clears ignored runtime state; keeps editor.local.json + server-state.json)"', | ||
| ...codeyamDeepCleanSteps(E), | ||
| `${E} editor session-reset`, | ||
| // Self-host: client === editor, so a client reset advances /workspace (the | ||
| // editor source) and MUST rebuild before restart or the binary↔source | ||
| // mismatch loops. No-op off self-host (the client /workspace is the client | ||
| // app, not the editor source). See selfHostRebuildStep. | ||
| selfHostRebuildStep(E), | ||
| 'echo ">> [4/5] restarting server to respawn the broker"', | ||
| `(${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" )`, | ||
| // Terminal contract guard: the reset PROMISES idle, so verify it and end any | ||
| // agent that slipped back after the restart/broker-ensure. No-op when the | ||
| // reset already reached idle. See verifyIdleOrEndSessionStep. | ||
| verifyIdleOrEndSessionStep({ E }), | ||
| // Verify the editor came back SERVING THE PROJECT on :14199 (not stranded in | ||
| // launcher mode); on a self-host strand emit CY_NEEDS_RECONFIGURE so the | ||
| // dashboard auto-falls-back to reconfigure. | ||
| verifyServingProjectOrFlagReconfigureStep(), | ||
| ].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. | ||
| // `rebuildOnSelfHost` (default true): rebuild the binary before the final | ||
| // restart when this is a self-host VM (see selfHostRebuildStep). The composite | ||
| // update-reset-everything passes `false` because its leading | ||
| // rebuildBinaryHostScript already rebuilt — a second cargo build would be a | ||
| // ~5-8 min no-op. | ||
| function resetEditorStateScript({ E, rebuildOnSelfHost = true } = {}) { | ||
| 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`, | ||
| // Self-host VMs build the binary from /workspace, so a reset that advanced | ||
| // source MUST rebuild before restarting or the binary↔source mismatch sends | ||
| // the editor into a stale-binary reexec loop. No-op off self-host (the | ||
| // binary is shipped). Skipped when the composite already rebuilt. | ||
| ...(rebuildOnSelfHost ? [selfHostRebuildStep(E)] : []), | ||
| // Restart the server in place to respawn the broker we stopped (now on a | ||
| // binary that matches the advanced source on self-host): 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"', | ||
| `(${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), | ||
| // Terminal contract guard: the reset PROMISES idle, so verify it and end any | ||
| // agent that slipped back after the restart/broker-ensure. No-op when the | ||
| // reset already reached idle. See verifyIdleOrEndSessionStep. | ||
| verifyIdleOrEndSessionStep({ E }), | ||
| // Verify the editor came back SERVING THE PROJECT on :14199 (not stranded in | ||
| // launcher mode on an ephemeral port); on a self-host strand emit | ||
| // CY_NEEDS_RECONFIGURE so the dashboard auto-falls-back to reconfigure. | ||
| verifyServingProjectOrFlagReconfigureStep(), | ||
| ].join(' && '); | ||
| } | ||
| // Build the in-container bash for "End session" — the MINIMAL "stop the agent" | ||
| // primitive. Unlike the reset family this does NOT advance source, deep-clean | ||
| // .codeyam, or rebuild: it kills the live agent process and clears the | ||
| // session-specific state files, taking the VM from a live `waiting`/`building` | ||
| // session back to a true `idle` in seconds. | ||
| // | ||
| // The recipe takes the VM to idle (`hasSession:false`) while leaving a BARE | ||
| // broker daemon ready for the next run: | ||
| // - `pty-broker stop` SIGTERMs the broker daemon, which kills the live agent. | ||
| // Fail-soft (`|| true`): a VM with no broker running legitimately exits | ||
| // non-zero (crates/codeyam-editor/src/commands/editor/pty_broker.rs), and a | ||
| // no-op end must not abort the chain. | ||
| // - `session-reset` deletes the session-specific state files (no resume | ||
| // target) so the still-running editor server's view drops to no-session. | ||
| // - codeyamBrokerEnsureStep respawns a BARE daemon (no agent attached) — only | ||
| // if none is reachable. Order matters: stop → reset → ensure, so the | ||
| // ensure can never re-bind stale session state. | ||
| // | ||
| // Why ensure a bare daemon instead of leaving the broker down: End session | ||
| // means "no agent session," NOT "no broker at all." The Build-tab terminal | ||
| // WebSocket handler does NOT respawn the broker on connect — if the daemon is | ||
| // down, `attach_or_spawn` fails with "PTY broker unreachable" | ||
| // (crates/control-api/src/terminal.rs). So leaving the broker down means the | ||
| // next Run / Build-tab open errors until the watchdog catches up. A bare daemon | ||
| // (broker alive, zero sessions) still reports `hasSession:false` → status | ||
| // `idle`, and lets the next `attach_or_spawn` attach instantly and spawn a | ||
| // fresh agent. The builder unit test asserts the chain ends with the daemon | ||
| // ensure and contains NO `restart-server`. | ||
| // | ||
| // CRITICAL: there is NO `restart-server` here. `restart-server` deliberately | ||
| // reattaches the surviving/respawned broker session and brings the agent back | ||
| // (crates/codeyam-editor/src/commands/editor/restart_server.rs), which would | ||
| // undo the end. codeyamBrokerEnsureStep, by contrast, never attaches an agent — | ||
| // it only spawns a bare daemon. | ||
| function endSessionScript({ E }) { | ||
| return [ | ||
| 'cd /workspace', | ||
| // Binary-existence gate FIRST, mirroring the reset builders. | ||
| `command -v ${E} >/dev/null`, | ||
| `(${E} editor pty-broker stop || true)`, | ||
| `${E} editor session-reset`, | ||
| // Respawn a BARE daemon (no agent) so the next Run / Build-tab open attaches | ||
| // instantly. Status-gated + idempotent, and NOT restart-server (no resume). | ||
| codeyamBrokerEnsureStep(E), | ||
| ].join(' && '); | ||
| } | ||
| // Build the TERMINAL "verify idle, end the agent if one slipped back" step, | ||
| // appended as the LAST step of every reset recipe. This is the contract fix for | ||
| // `vm: Resets Must Land at Idle`: the reset actions ("Reset client", "Reset | ||
| // editor", "Reset all + rebuild") all promise to drop the agent and return the | ||
| // VM to a fresh-provision state, but in practice a live Claude agent came back | ||
| // resumed at the Plan step after the reset's trailing restart-server + | ||
| // codeyamBrokerEnsureStep — so the dashboard card read `waiting` instead of | ||
| // `idle` (observed live on VM-4). | ||
| // | ||
| // Diagnosis note (the fix is MECHANISM-AGNOSTIC on purpose): the reset already | ||
| // STOPS the broker before the wipe (codeyamDeepCleanSteps) and runs | ||
| // session-reset, so the agent is killed mid-reset; it reappears only because of | ||
| // the trailing restart-server + broker-ensure — the same two steps | ||
| // endSessionScript deliberately OMITS to land at idle (see its idle-end | ||
| // invariant test). The EXACT re-attach path — restart-server's in-place SIGUSR1 | ||
| // reexec resuming a session, the broker-ensure respawn the server then attaches | ||
| // an agent to, or the editor server auto-resuming a workflow on startup — was | ||
| // NOT reproduced on a scratch cloud VM in the authoring environment, so rather | ||
| // than guess at one we make the reset TERMINALLY VERIFY idle regardless of which | ||
| // path fires: read /api/session-info from the now-running server and, if it | ||
| // still reports an active session (`hasSession: true`) OR a live build-slot | ||
| // broker agent (`agentBrokerLive: true`), end the session with the proven | ||
| // endSessionScript primitive (`pty-broker stop` + `session-reset`, with NO | ||
| // further restart). The EITHER condition exactly mirrors the editor UI's | ||
| // Build-dot clear condition in useBuildSessionLive (the dot clears only when | ||
| // `!hasSession && !agentBrokerLive`): a reset that left a live build-slot agent | ||
| // in the broker — the `fresh_branch_surfaces_agent_broker_live_true` server | ||
| // shape, `hasSession:false` + `agentBrokerLive:true` — previously slipped past a | ||
| // `hasSession`-only check, reading "idle" on the dashboard while the editor UI | ||
| // Build dot stayed lit. Gating on either signal closes that gap (and | ||
| // `pty-broker stop` is what drops `agentBrokerLive` to false). Robust even if a | ||
| // future change reintroduces a resume path, and a no-op on the happy path where | ||
| // the reset already reached idle (the bare broker daemon from | ||
| // codeyamBrokerEnsureStep stays up — which reads idle, both signals false). | ||
| // | ||
| // Stack assumption: in-container the editor server's control port is the | ||
| // preserved server-state.json `controlPort` (rewritten by restart-server), | ||
| // falling back to the standard 14199 — the same port the rebuild verify curls | ||
| // (scripts/operator/fleet-dashboard/server.js). server-state.json survives the | ||
| // deep-clean wipe (codeyamDeepCleanSteps cp's it aside and restores it). | ||
| // | ||
| // SETTLE-WINDOW POLL (vm: Resets Stay at Idle After restart-server Reattach): | ||
| // a single point-in-time probe loses a race. The reset family ends with | ||
| // `restart-server --force-in-container`, which is DESIGNED to reattach the | ||
| // surviving broker session and bring the agent back asynchronously | ||
| // (crates/codeyam-editor/src/commands/editor/restart_server.rs:29-33) — a beat | ||
| // AFTER this terminal guard's first curl already saw `hasSession:false` and | ||
| // declared idle. Observed live on VM-3/VM-6 (2026-06-22): the reset-client job | ||
| // logged `>> reset reached idle` then ~90s later `/data` reported | ||
| // `status=building, hasSession=true`. The one-shot check won the probe but lost | ||
| // the race. endSessionScript lands at idle reliably ONLY because it omits | ||
| // restart-server; the reset NEEDS restart-server to respawn the broker / advance | ||
| // the binary, so it must DEFEAT the reattach rather than avoid it. | ||
| // | ||
| // The fix keeps every property of the single-probe guard above (mechanism- | ||
| // agnostic `hasSession || agentBrokerLive` grep, the reused endSessionScript | ||
| // primitive with NO restart-server, the `( …; true )` non-fatal wrapper, the | ||
| // server-state.json controlPort resolution) and turns "checked once, too early" | ||
| // into "checked until stable": a bounded loop probes /api/session-info | ||
| // PROBE_COUNT times (~1s apart), and on ANY probe where a session or live broker | ||
| // agent reappears it ends it with endSessionScript (`pty-broker stop` + | ||
| // `session-reset`, NEVER another restart) and keeps probing — so a late reattach, | ||
| // or even a second one within the window, is each caught. The window outlasts | ||
| // restart-server's async reattach (a few seconds) without stretching the reset. | ||
| // On the happy path (already idle and stays idle) every probe reads idle, zero | ||
| // endSession calls fire, and it ends with the same `reset reached idle` line as | ||
| // before — a no-op, same as the old single probe. | ||
| // | ||
| // Wrapped in `( …; true )` so a curl/parse hiccup mid-window can never abort the | ||
| // reset's &&-chain, exactly like codeyamBrokerEnsureStep. | ||
| function verifyIdleOrEndSessionStep({ E, controlPort = 14199 }) { | ||
| const end = endSessionScript({ E }); | ||
| // Settle-window budget. Kept as named locals so the builder test can assert the | ||
| // loop is bounded and a tuning change is a one-line edit. PROBE_COUNT probes | ||
| // PROBE_SLEEP_SECS apart ⇒ ~7s window — long enough to outlast restart-server's | ||
| // async reattach, short enough not to stretch the reset job. | ||
| const PROBE_COUNT = 8; | ||
| const PROBE_SLEEP_SECS = 1; | ||
| const probe = `si=$(curl -sf "http://127.0.0.1:$PORT/api/session-info" 2>/dev/null || true)`; | ||
| const sessionLiveGate = `printf '%s' "$si" | grep -qE '"(hasSession|agentBrokerLive)"[[:space:]]*:[[:space:]]*true'`; | ||
| const reattachMsg = | ||
| '">> reset left a session or live broker agent attached — ending the session to land at idle"'; | ||
| return ( | ||
| `( PORT=$(grep -oE '"controlPort"[^0-9]*[0-9]+' .codeyam/server-state.json 2>/dev/null | grep -oE '[0-9]+$' | head -1); ` + | ||
| `PORT=\${PORT:-${controlPort}}; ` + | ||
| // Settle window: probe up to PROBE_COUNT times; end any session/agent that | ||
| // reattaches on each probe and keep polling, so a late (or double) reattach | ||
| // after restart-server is caught instead of slipping past a single probe. | ||
| `i=0; while [ "$i" -lt ${PROBE_COUNT} ]; do ` + | ||
| `${probe}; ` + | ||
| `if ${sessionLiveGate}; then echo ${reattachMsg}; ${end}; fi; ` + | ||
| `i=$((i+1)); [ "$i" -lt ${PROBE_COUNT} ] && sleep ${PROBE_SLEEP_SECS}; ` + | ||
| `done; ` + | ||
| // Confirming trailing probe — the window has now outlasted the async | ||
| // reattach, so this read is the authoritative idle verdict. End once more if | ||
| // a session somehow survived the final loop iteration; otherwise declare idle. | ||
| `${probe}; ` + | ||
| `if ${sessionLiveGate}; then echo ${reattachMsg}; ${end}; ` + | ||
| `else echo ">> reset reached idle (no session, no live broker agent)"; fi; true )` | ||
| ); | ||
| } | ||
| // REBUILD-BEFORE-RESTART on a self-host VM. The reset family advances source to | ||
| // origin tip; on a self-host VM (the client project IS codeyam-editor, so | ||
| // /workspace/Cargo.toml exists) the binary is BUILT from /workspace, so a reset | ||
| // that advances source without rebuilding deterministically produces a | ||
| // binary↔source mismatch — and the editor, deciding it's stale, enters a | ||
| // reexec/rebuild loop. So before the final restart we rebuild the binary in | ||
| // place (`rebuild-self`, the same mechanism rebuildBinaryHostScript uses). The | ||
| // self-host axis is detected at RUNTIME with the same `/workspace/Cargo.toml` | ||
| // probe rebuildBinaryHostScript uses, so the NON-self-host path — where the | ||
| // editor binary is shipped, not built from the client repo — keeps its fast | ||
| // "(no rebuild)" restart untouched. | ||
| // | ||
| // Stack assumption: only a self-host VM has /workspace/Cargo.toml AND builds its | ||
| // own binary; a normal client project's /workspace is the client's app, never | ||
| // the editor source, so the probe is false and this is a silent no-op there. | ||
| // | ||
| // Fail-soft: a `rebuild-self` failure `|| echo WARN`s rather than aborting the | ||
| // reset's &&-chain — restarting on the prior (un-advanced-binary) build is | ||
| // recoverable (the restart-server full-restart fallback makes the stale-binary | ||
| // loop non-fatal), and the trailing serving-probe still flags reconfigure if the | ||
| // editor doesn't come back. The whole step is wrapped so it can never break the | ||
| // chain. | ||
| function selfHostRebuildStep(E) { | ||
| return ( | ||
| '( if test -f /workspace/Cargo.toml; then ' + | ||
| 'echo ">> self-host VM — rebuilding binary before restart so it matches the advanced source (cargo ~5-8 min)"; ' + | ||
| `${E} editor rebuild-self --force-in-container || echo "WARN self-host rebuild-self failed — restart may hit the stale-binary reexec path"; ` + | ||
| 'fi; true )' | ||
| ); | ||
| } | ||
| // POST-RESET SERVING PROBE — verify the editor came back SERVING THE PROJECT on | ||
| // :14199, and on a self-host VM flag an auto-reconfigure if it didn't. The | ||
| // Project Launcher binds an EPHEMERAL port (never :14199 — see | ||
| // crates/codeyam-editor/src/commands/launcher/server.rs `TcpListener::bind(…, 0)`), | ||
| // so a launcher-mode strand — or a server that never came back — shows up | ||
| // identically: :14199 stops answering /api/health. (The serve-on-:14199 fix in | ||
| // start.rs::decide_launch prevents the strand at the source; this is the | ||
| // belt-and-suspenders that turns a residual strand into an automatic recovery | ||
| // instead of a hand-run reconfigure.) | ||
| // | ||
| // On a self-host VM that isn't serving :14199, emit the `CY_NEEDS_RECONFIGURE` | ||
| // marker the dashboard's job-completion handler reacts to by auto-running | ||
| // reconfigure (the proven container-recreate recovery). Off self-host we only | ||
| // WARN: reconfigure's container recreate is the wrong hammer for a shipped-binary | ||
| // client VM, so the operator decides. | ||
| // | ||
| // Reuses the server-state.json controlPort resolution (falling back to 14199) | ||
| // exactly like verifyIdleOrEndSessionStep, and is wrapped in `( …; true )` so a | ||
| // curl hiccup can never abort the reset's &&-chain. | ||
| function verifyServingProjectOrFlagReconfigureStep({ controlPort = 14199 } = {}) { | ||
| // ~30s window — long enough for restart-server's async respawn to bind the | ||
| // port, short enough not to stretch the reset job. | ||
| const PROBE_COUNT = 15; | ||
| const PROBE_SLEEP_SECS = 2; | ||
| // VERIFY-AFTER-REBUILD (VM-4 2026-06-25): a rebuild that produces a binary and | ||
| // answers /api/health on :14199 is NOT proof the fix took — the launcher, when | ||
| // it lands from the boot decision, binds the CONTROL port (so `npm run editor` | ||
| // finds it) and ALSO serves /api/health, so /api/health alone can't tell a | ||
| // serving editor from a launcher strand sitting ON :14199. /api/projects is | ||
| // launcher-ONLY (the editor serves /api/session-info, never /api/projects) and | ||
| // returns JSON, so a launcher signature ON the control port means the strand | ||
| // SURVIVED the rebuild — treat it as a FAILURE (emit the reconfigure marker), | ||
| // never as success. This makes "serves the PROJECT on :14199" the hard | ||
| // post-condition, not merely "something answers /api/health". | ||
| const launcherOnPort = | ||
| 'body=$(curl -sf --max-time 2 "http://127.0.0.1:$PORT/api/projects" 2>/dev/null); ' + | ||
| 'case "$body" in "["*|"{"*) true;; *) false;; esac'; | ||
| return ( | ||
| `( PORT=$(grep -oE '"controlPort"[^0-9]*[0-9]+' .codeyam/server-state.json 2>/dev/null | grep -oE '[0-9]+$' | head -1); ` + | ||
| `PORT=\${PORT:-${controlPort}}; ` + | ||
| `serving=0; launchered=0; i=0; while [ "$i" -lt ${PROBE_COUNT} ]; do ` + | ||
| 'if curl -sf "http://127.0.0.1:$PORT/api/health" >/dev/null 2>&1; then ' + | ||
| `if ${launcherOnPort}; then launchered=1; else serving=1; break; fi; fi; ` + | ||
| `i=$((i+1)); sleep ${PROBE_SLEEP_SECS}; done; ` + | ||
| 'if [ "$serving" = "1" ]; then echo ">> editor serving project on :$PORT"; ' + | ||
| 'elif test -f /workspace/Cargo.toml; then ' + | ||
| 'echo "CY_NEEDS_RECONFIGURE self-host editor not serving project on :$PORT after reset ($([ "$launchered" = "1" ] && echo "launcher-mode strand ON the control port — rebuild did not adopt the guard" || echo "launcher-mode strand or server down") ) — falling back to reconfigure"; ' + | ||
| 'else echo "WARN editor not serving project on :$PORT after reset — check the VM"; fi; true )' | ||
| ); | ||
| } | ||
| // Pure predicate the dashboard's job-completion handler uses to decide whether a | ||
| // finished reset must auto-fall-back to reconfigure: true iff the job log | ||
| // carries the `CY_NEEDS_RECONFIGURE` marker emitted by | ||
| // verifyServingProjectOrFlagReconfigureStep (self-host, didn't come back serving | ||
| // :14199). Kept here as a pure function of the log string so the decision is | ||
| // unit-tested without the gcloud/docker dispatch. | ||
| function needsReconfigureFallback(log) { | ||
| return typeof log === 'string' && log.includes('CY_NEEDS_RECONFIGURE'); | ||
| } | ||
| module.exports = { | ||
| codeyamDeepCleanSteps, | ||
| codeyamBrokerEnsureStep, | ||
| selfHostRebuildStep, | ||
| verifyServingProjectOrFlagReconfigureStep, | ||
| needsReconfigureFallback, | ||
| resetClientScript, | ||
| resetEditorStateScript, | ||
| endSessionScript, | ||
| verifyIdleOrEndSessionStep, | ||
| }; |
| '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, or null if the file is | ||
| // missing or malformed. | ||
| function readRoster() { | ||
| try { | ||
| const data = JSON.parse(fs.readFileSync(VMS_STATE_FILE, 'utf8')); | ||
| if (Array.isArray(data)) return normalizeRoster(data); | ||
| } catch { /* missing or malformed */ } | ||
| return null; | ||
| } | ||
| // 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 decision + bash-script composition for the dashboard's SELF-update action | ||
| // (scripts/operator/fleet-dashboard/server.js requires this). Mirrors the | ||
| // fleet-branch.js / fleet-reset.js pattern: string + decision logic only, no I/O | ||
| // (no git exec, no env detection, no `exec`), so the self-update *recipe* — what | ||
| // the detached bash actually does to pull the dashboard's own checkout to tip and | ||
| // which supervisor it restarts through — stays unit-testable in isolation | ||
| // (npm/fleet-self-update.test.js). server.js owns the I/O: current-branch read, | ||
| // `id -u`, FLEET_ON_GCE detection, and the detached `exec`. | ||
| // | ||
| // The dashboard's Node half is plain source (it `require`s server.js + | ||
| // npm/fleet-*.js from REPO_ROOT at boot, no compile step), so refreshing *it* is | ||
| // just "pull new source + restart the node process". Both supervisors auto-respawn | ||
| // on exit (systemd Restart=always, launchd KeepAlive), so the restart is a clean | ||
| // hand-off. But the dashboard also depends on the Rust `codeyam-editor` binary at | ||
| // runtime — `pollPlans` shells out to `codeyam-editor editor plans-snapshot` for the | ||
| // Queued-Plans origin view — so a self-update that advances editor source past a | ||
| // changed/new subcommand must rebuild the binary too, or the JS moves while a stale | ||
| // binary keeps erroring. buildSelfUpdateScript therefore rebuilds the binary, but | ||
| // only when editor source actually changed across the update and fail-soft so a | ||
| // build problem never blocks the restart. | ||
| const { cleanBranch } = require('./fleet-branch'); | ||
| // Single-quote a value for safe bash interpolation — identical to server.js's | ||
| // `shq`, duplicated here so this module stays a pure, dependency-light leaf (it | ||
| // must not require the untestable server shell). | ||
| function shq(s) { | ||
| return `'${String(s).replace(/'/g, `'\\''`)}'`; | ||
| } | ||
| // A branch name we're willing to hand to `git checkout` / `git reset --hard`. | ||
| // git's own ref rules are broader, but the dashboard only ever targets ordinary | ||
| // fleet branches, so we accept a conservative charset (letters, digits, and the | ||
| // `._/-` that real branch names use) and reject everything else. This is the | ||
| // belt to shq's suspenders: even though every interpolated value is quoted, an | ||
| // up-front reject turns an injection attempt / typo into a clear `{ok:false}` | ||
| // instead of a script that runs and fails deep in the detached child. | ||
| function isSafeBranch(b) { | ||
| return /^[A-Za-z0-9._/-]+$/.test(b) && !b.includes('..'); | ||
| } | ||
| // Decide whether a self-update request is actionable and on which branch. | ||
| // - `alreadyRunning` (a self-update already in flight) is refused first, so the | ||
| // in-flight guard is part of the tested decision rather than action branching. | ||
| // - A blank requested branch falls back to the dashboard's current branch | ||
| // (the modal prefills the current branch, but an operator can clear it). | ||
| // - An unsafe / empty resulting branch is rejected with a clear `msg`. | ||
| // Pure; no I/O. Returns `{ ok, branch, msg }`. Keeping the guard + resolution + | ||
| // validation here lets server.js's actionUpdateDashboard stay a thin I/O shell. | ||
| function decideSelfUpdate({ requestedBranch, currentBranch, alreadyRunning } = {}) { | ||
| if (alreadyRunning) { | ||
| return { ok: false, msg: 'dashboard update already running' }; | ||
| } | ||
| const branch = cleanBranch(requestedBranch) || cleanBranch(currentBranch); | ||
| if (!branch) { | ||
| return { ok: false, msg: 'self-update: no branch given and current branch is unknown' }; | ||
| } | ||
| if (!isSafeBranch(branch)) { | ||
| return { ok: false, msg: `self-update: invalid branch name '${branch}'` }; | ||
| } | ||
| return { ok: true, branch, msg: `updating dashboard to ${branch}` }; | ||
| } | ||
| // The supervisor-appropriate restart command. The dashboard restarting itself | ||
| // kills the very process serving the request, so this runs inside the detached | ||
| // child (see buildSelfUpdateScript). Selection: | ||
| // - GCE operator host (systemd) → `sudo systemctl restart codeyam-dashboard` | ||
| // (needs a passwordless sudoers drop-in; see the plan's provisioning note). | ||
| // - laptop (launchd) → `launchctl kickstart -k gui/<uid>/<label>` | ||
| // - bare-nohup fallback → `<repoRoot>/scripts/operator/fleet-dashboard/start.sh` | ||
| // The non-GCE path tries launchd first and falls back to start.sh in one `||` | ||
| // chain, so a laptop with no launchd agent (a bare `nohup node server.js`) still | ||
| // restarts. start.sh is idempotent (kills the port, re-execs node). | ||
| function restartCommand({ onGce, launchdUid, label, repoRoot }) { | ||
| if (onGce) return 'sudo systemctl restart codeyam-dashboard'; | ||
| const target = shq(`gui/${launchdUid}/${label}`); | ||
| const startSh = shq(`${repoRoot}/scripts/operator/fleet-dashboard/start.sh`); | ||
| return `launchctl kickstart -k ${target} || ${startSh}`; | ||
| } | ||
| // Compose the bash the detached child runs: pull the dashboard's own checkout to | ||
| // the tip of `branch`, conditionally rebuild the editor binary, then restart | ||
| // through the environment-appropriate supervisor. Hard-reset semantics | ||
| // (operator's choice): always land exactly on `origin/<branch>`, discarding any | ||
| // uncommitted/divergent state on the dashboard checkout. Bails loudly if the | ||
| // branch doesn't exist on origin (nothing is reset/restarted). | ||
| // | ||
| // Rebuild gating (between the reset and the restart): record the pre-reset HEAD, | ||
| // compare it to the post-reset HEAD restricted to the editor source paths, and | ||
| // rebuild only when something under `crates`/`Cargo.toml`/`Cargo.lock` actually | ||
| // moved — a JS-only update must not pay a multi-minute Rust compile. The rebuild | ||
| // is additionally gated on `codeyam-editor` being on PATH so a host without the | ||
| // toolchain skips cleanly, and is fail-soft (`|| echo …`) so a build failure can't | ||
| // abort the `set -e` script before the restart: a degraded-but-running dashboard | ||
| // beats a wedged one. The restart stays last and unconditional. | ||
| // | ||
| // Every interpolated value is shq-quoted. Pure; no I/O. | ||
| function buildSelfUpdateScript({ repoRoot, branch, onGce, launchdUid, label } = {}) { | ||
| const R = shq(repoRoot); | ||
| const B = shq(branch); | ||
| const originRef = shq(`origin/${branch}`); | ||
| const restart = restartCommand({ onGce, launchdUid, label, repoRoot }); | ||
| return [ | ||
| 'set -e', | ||
| `echo ">> self-update: fetching origin in ${repoRoot}"`, | ||
| `git -C ${R} fetch origin`, | ||
| // Verify origin/<branch> exists BEFORE touching the working tree, so a typo'd | ||
| // / unknown branch bails with nothing reset or restarted. | ||
| `git -C ${R} rev-parse --verify ${originRef} >/dev/null 2>&1 || ` + | ||
| `{ echo "self-update: origin/${branch} not found" >&2; exit 1; }`, | ||
| // Record the editor-source revision BEFORE the reset, so we can tell after | ||
| // whether the editor binary needs rebuilding. Read-only; touches nothing. | ||
| `OLD=$(git -C ${R} rev-parse HEAD)`, | ||
| `echo ">> self-update: checking out ${branch}"`, | ||
| // DWIM creates a local tracking branch when one doesn't exist yet; a no-op | ||
| // when already on it. | ||
| `git -C ${R} checkout ${B}`, | ||
| `echo ">> self-update: hard-reset to origin/${branch} (discards local changes)"`, | ||
| `git -C ${R} reset --hard ${originRef}`, | ||
| `NEW=$(git -C ${R} rev-parse HEAD)`, | ||
| // Rebuild the editor binary, but only when (a) editor source actually moved | ||
| // and (b) the binary is on PATH. The `git diff --quiet … -- crates …` exits 0 | ||
| // when nothing under the editor source paths changed → skip (the fast JS-only | ||
| // path). `command -v` skips a toolchain-less host. The rebuild itself is | ||
| // fail-soft so a build failure still falls through to the restart. Each guard | ||
| // sits in an `if`/`elif` condition, where a non-zero exit is allowed under | ||
| // `set -e`. | ||
| `if git -C ${R} diff --quiet "$OLD" "$NEW" -- crates Cargo.toml Cargo.lock; then ` + | ||
| `echo ">> self-update: skipping rebuild (no editor source change)"; ` + | ||
| `elif ! command -v codeyam-editor >/dev/null 2>&1; then ` + | ||
| `echo ">> self-update: skipping rebuild (codeyam-editor not on PATH)"; ` + | ||
| `else echo ">> self-update: editor source changed — rebuilding binary"; ` + | ||
| `codeyam-editor editor rebuild-self || ` + | ||
| `echo ">> self-update: rebuild-self FAILED — continuing to restart with the existing binary" >&2; ` + | ||
| `fi`, | ||
| `echo ">> self-update: restarting dashboard"`, | ||
| restart, | ||
| ].join('\n'); | ||
| } | ||
| module.exports = { shq, isSafeBranch, decideSelfUpdate, restartCommand, buildSelfUpdateScript }; |
| 'use strict'; | ||
| // npm/fleet-setup-steps.js — partition a VM's degradedReasons into expected | ||
| // first-run SETUP steps vs genuine ATTENTION problems for the fleet dashboard | ||
| // (vm: Dashboard 'Finishing setup' state for expected add steps). | ||
| // | ||
| // A clean greenfield Add lands with a wall of amber `⚠ Add degraded` badges | ||
| // because every unfinished post-provision wiring step is funneled into one flat | ||
| // `degradedReasons` string array and rendered identically. There is no | ||
| // distinction between *expected first-run setup that just hasn't finished yet* | ||
| // (Claude login not seeded, ingress host-rule not wired, project dir still the | ||
| // scaffold `workspace`, no start command on a brand-new project) and *something | ||
| // that actually broke* (broker death, half-provisioned orphan, off-branch | ||
| // drift). This module draws that line: it parses the existing reason-code | ||
| // prefixes and classifies each reason as a calm `setup` step or an `attention` | ||
| // problem, keeping the rules in a tested pure function the way fleet-job-badge.js | ||
| // / fleet-public-url.js do — server.js stays thin and the card colors finally | ||
| // tell the truth (blue = still wiring up, amber = look at this). | ||
| // | ||
| // Stack note: this is fleet-operator infrastructure (the GCE fleet that runs | ||
| // codeyam-editor itself), GCP-specific by design — the same posture as the | ||
| // scripts it backstops. The classification is pure string parsing, no network, | ||
| // so it unit-tests with no fixtures. | ||
| // The setup-step codes this module emits. A single source so server.js and the | ||
| // card UI agree on the vocabulary. | ||
| const SETUP_CODES = ['auth', 'ingress', 'project-dir', 'start-command']; | ||
| // The scaffold default project directory a brand-new (not-yet-created) VM runs | ||
| // under. project-dir-drift AWAY from this is a real problem (two real project | ||
| // names disagree); drift while still ON it is just "the greenfield project | ||
| // hasn't been created yet" — an expected setup step. | ||
| const SCAFFOLD_PROJECT_DIR = 'workspace'; | ||
| // Parse `project-dir-drift (expected: X, running: Y)` into its two names. | ||
| // Returns { expected, running } with null fields when the shape doesn't match. | ||
| function parseDriftReason(reason) { | ||
| const m = /expected:\s*([^,]+?)\s*,\s*running:\s*([^)]+?)\s*\)/.exec(reason); | ||
| return m ? { expected: m[1].trim(), running: m[2].trim() } : { expected: null, running: null }; | ||
| } | ||
| // The re-wire command the ingress-unwired reason embeds after "Re-wire: " (see | ||
| // fleet-public-url.js::ingressUnwiredReason). Surfaced as the setup step's | ||
| // inline action so the calm checklist row carries the exact command, not just a | ||
| // label. null when the reason carries no command tail. | ||
| function parseIngressAction(reason) { | ||
| const m = /Re-wire:\s*(.+)$/.exec(reason); | ||
| return m ? m[1].trim() : null; | ||
| } | ||
| // Partition degradedReasons into { setupSteps, attentionReasons }. | ||
| // | ||
| // setupSteps ordered [{ code, label, detail, action? }] — expected | ||
| // first-run wiring the card renders as a calm checklist. | ||
| // attentionReasons the original reason strings that signal a genuine | ||
| // problem — rendered amber with the existing ⚠ treatment. | ||
| // | ||
| // Unknown reason codes default to attentionReasons: an unrecognized signal is | ||
| // surfaced loudly, never silently hidden in the calm checklist. | ||
| function classifyReasons(degradedReasons = []) { | ||
| const setupSteps = []; | ||
| const attentionReasons = []; | ||
| const list = Array.isArray(degradedReasons) ? degradedReasons : []; | ||
| for (const raw of list) { | ||
| const reason = String(raw); | ||
| if (reason.startsWith('claude-auth-not-seeded')) { | ||
| setupSteps.push({ code: 'auth', label: 'Claude login', detail: reason }); | ||
| } else if (reason.startsWith('ingress-unwired')) { | ||
| setupSteps.push({ | ||
| code: 'ingress', | ||
| label: 'Public URL wiring', | ||
| detail: reason, | ||
| action: parseIngressAction(reason), | ||
| }); | ||
| } else if (reason.startsWith('project-dir-drift')) { | ||
| const { expected, running } = parseDriftReason(reason); | ||
| if (running === SCAFFOLD_PROJECT_DIR) { | ||
| setupSteps.push({ | ||
| code: 'project-dir', | ||
| label: expected ? `Create project ${expected}` : 'Create project', | ||
| detail: reason, | ||
| }); | ||
| } else { | ||
| // Drift between two REAL project names is a genuine problem, not setup. | ||
| attentionReasons.push(reason); | ||
| } | ||
| } else if (reason.startsWith('start-command')) { | ||
| setupSteps.push({ code: 'start-command', label: 'Start command', detail: reason }); | ||
| } else { | ||
| attentionReasons.push(reason); | ||
| } | ||
| } | ||
| return { setupSteps, attentionReasons }; | ||
| } | ||
| // True when a VM's reasons are ALL expected setup steps with zero genuine | ||
| // problems — the gate for the calm `finishing-setup` status. Requires at least | ||
| // one setup step (an empty reason list is not "finishing setup", it's just a | ||
| // healthy VM with nothing left to do) and zero attention reasons. | ||
| function hasOnlySetup(degradedReasons = []) { | ||
| const { setupSteps, attentionReasons } = classifyReasons(degradedReasons); | ||
| return setupSteps.length > 0 && attentionReasons.length === 0; | ||
| } | ||
| module.exports = { classifyReasons, hasOnlySetup, SETUP_CODES, SCAFFOLD_PROJECT_DIR }; |
| 'use strict'; | ||
| // Pure status helpers for the Sleep / Wake feature on the Fleet Dashboard | ||
| // (scripts/operator/fleet-dashboard/server.js requires this). Mirrors the | ||
| // pure-module-plus-colocated-vitest-test shape of npm/fleet-effect-status.js: | ||
| // server.js does the impure fact-gathering (the gcloud running-VM list, the | ||
| // per-VM reachability probe); these predicates turn those facts into the two | ||
| // new card statuses without any I/O, so the whole decision is unit-testable. | ||
| // Map a RUNNING stop/start job to its in-flight status badge. A Sleep click | ||
| // runs `gcloud compute instances stop` as a 'stop' job → 'stopping'; a Wake | ||
| // click runs `...start` as a 'start' job → 'starting'. Returns null for every | ||
| // other action so deriveStatus's existing per-action chain keeps owning it. | ||
| function sleepJobStatus(action) { | ||
| if (action === 'stop') return 'stopping'; | ||
| if (action === 'start') return 'starting'; | ||
| return null; | ||
| } | ||
| // Classify an UNREACHABLE rostered VM. The dashboard already polls GCP for the | ||
| // set of RUNNING fleet instances (runningFleetVms) on its slow 60s cycle, so we | ||
| // reuse it at zero extra API cost: a VM GCP still lists as running but whose | ||
| // control API we can't reach is genuinely 'unreachable' (booting, wedged, or | ||
| // the cloudflared tunnel is down); one absent from the running list has been | ||
| // stopped to pause billing — 'asleep'. A null/absent list is treated as "not | ||
| // running" so a cold dashboard with no poll yet defaults to the safe 'asleep' | ||
| // rather than masking a stopped VM as a red unreachable. | ||
| function unreachableStatus(n, runningFleetVms) { | ||
| return Array.isArray(runningFleetVms) && runningFleetVms.includes(n) | ||
| ? 'unreachable' | ||
| : 'asleep'; | ||
| } | ||
| module.exports = { sleepJobStatus, unreachableStatus }; |
| 'use strict'; | ||
| // Pure classification + self-heal predicates for the fleet dashboard's | ||
| // launcher-strand detection (vm: Fleet converges onto the launcher-strand fix | ||
| // and self-heals the strand regardless of trigger). | ||
| // | ||
| // A cloud VM can strand off its control port `:14199` in bare Project Launcher | ||
| // mode: the editor re-execs into the launcher on an EPHEMERAL port instead of | ||
| // serving its one /workspace project on the contract port, so the dashboard's | ||
| // local-forward probe gets connection-refused and reports a generic | ||
| // "unreachable" — indistinguishable from a real outage. Diagnosed on VM-4 | ||
| // (2026-06-25): an add-time binary that predated the `serve_cwd_directly` | ||
| // launcher-strand guard fell through `decide_launch` to the launcher because a | ||
| // stale `/tmp` test-fixture project padded `registry_count` to 2. | ||
| // | ||
| // The completed predecessor plan fixed the editor CODE PATH (serve the cwd | ||
| // project on :14199); this module is the dashboard-side SAFETY NET that detects | ||
| // the strand CONTINUOUSLY — by its trigger-independent SIGNAL SHAPE, not by what | ||
| // caused it — so a running fleet can't silently sit on the landmine. This is the | ||
| // PURE half (classification + decision), mirroring npm/fleet-public-url.js: the | ||
| // docker-exec / curl I/O lives in scripts/operator/fleet-dashboard/server.js's | ||
| // gatherLauncherStrand, the same gather-edge / pure-half split | ||
| // classifyPublicUrlHealth follows. Unit-tested in npm/fleet-strand.test.js. | ||
| // | ||
| // Stack note: this is fleet-operator infrastructure (the GCE fleet that runs | ||
| // codeyam-editor itself). A project that never joins the fleet never reaches it. | ||
| // The boot guard whose presence makes a binary safe from the launcher-mode | ||
| // strand. Single source so the classifier, the capability field the editor | ||
| // advertises, and any log line agree on the string. | ||
| const SERVE_CWD_GUARD = 'serve_cwd_directly'; | ||
| /** Classify a VM's in-container probe into the launcher-strand vocabulary. The | ||
| * DECISIVE, trigger-independent signal is observable in-VM regardless of what | ||
| * caused the strand (a stale add-time binary, a wiped open-project pointer, any | ||
| * future cause): | ||
| * | ||
| * 'serving' — `editorHealthOk`: the control port answers /api/health, | ||
| * so the editor is serving its project. Nothing to heal. | ||
| * 'launcher-strand'— container up, control port has NO listener, AND a | ||
| * codeyam-editor process is serving the Project Launcher on | ||
| * an ephemeral port. THIS is the strand: :14199 is dark but | ||
| * the editor is alive on the wrong port. | ||
| * 'down' — container up, control port dark, and NO launcher process | ||
| * either: a genuine outage (crashed editor / dead tunnel). | ||
| * Left to the existing unreachable handling — must NOT | ||
| * auto-rebuild, a rebuild is the wrong hammer for a crash. | ||
| * 'unknown' — anything else (container not up, probe failed): absence | ||
| * of evidence, never auto-acts (mirrors | ||
| * classifyPublicUrlHealth's 'unknown' contract). | ||
| * | ||
| * `editorHealthOk` wins first: once the control port answers, the VM is serving | ||
| * no matter what else a slow/racy probe reported. */ | ||
| function classifyLauncherStrand({ | ||
| controlPortListening, | ||
| containerUp, | ||
| launcherServing, | ||
| editorHealthOk, | ||
| } = {}) { | ||
| if (editorHealthOk) return 'serving'; | ||
| if (containerUp && !controlPortListening && launcherServing) return 'launcher-strand'; | ||
| if (containerUp && !controlPortListening && !launcherServing) return 'down'; | ||
| return 'unknown'; | ||
| } | ||
| /** Decide whether a just-landed strand verdict warrants an auto-recovery, the | ||
| * PURE gate the standing self-heal fires through — debounced exactly like | ||
| * shouldAutoRewireIngress so a recovery that doesn't immediately clear can't | ||
| * loop and a single transient poll during a legitimate restart can't act: | ||
| * - `verdict` is 'launcher-strand' (only the strand self-heals; 'down' is a | ||
| * genuine outage the unreachable path owns, 'serving'/'unknown' need nothing), | ||
| * - `strandStreak` has reached `minStreak` consecutive strand polls (a single | ||
| * transient dark-port poll during a restart must not trigger a heavy rebuild), | ||
| * - no recovery is `alreadyRecovering` (one heavy job per VM), AND | ||
| * - the `cooldownMs` window has elapsed since `lastRecoverAt` so a recovery | ||
| * still settling can't re-fire every slow cycle. | ||
| * `hasSession` deliberately does NOT gate: a stranded editor has no live project | ||
| * session to protect (same rationale as the post-reset force=true fallback). A | ||
| * missing/0 `lastRecoverAt` means "never recovered" → the cooldown is satisfied. */ | ||
| function shouldAutoRecoverStrand({ | ||
| verdict, | ||
| strandStreak, | ||
| alreadyRecovering, | ||
| lastRecoverAt, | ||
| now, | ||
| cooldownMs, | ||
| minStreak = 2, | ||
| } = {}) { | ||
| if (verdict !== 'launcher-strand') return false; | ||
| if (alreadyRecovering) return false; | ||
| if (Number(strandStreak) < Number(minStreak)) return false; | ||
| return (Number(now) - (Number(lastRecoverAt) || 0)) >= Number(cooldownMs); | ||
| } | ||
| /** Choose the recovery SHAPE for a launcher-strand by binary currency. A plain | ||
| * reconfigure KEEPS the editor image, so recreating the container with the SAME | ||
| * pre-guard binary would immediately re-strand — the wrong hammer when a stale | ||
| * binary caused the strand. Returns: | ||
| * 'rebuild' — advance source to origin tip → rebuild-self → restart (the | ||
| * update-reset-everything shape) when the binary is stale OR | ||
| * its currency is UNKNOWN. The dark-control-port strand can't | ||
| * advertise its capability live, and re-stranding on a kept | ||
| * image is the worse failure, so unknown defaults to rebuild. | ||
| * 'reconfigure' — image-kept container recreate, correct ONLY when the running | ||
| * binary is already CURRENT (e.g. a wiped-pointer strand on a | ||
| * fresh binary), where a rebuild would be a needless ~8-min build. | ||
| * `binaryCurrent === true` is the only input that yields 'reconfigure'; null / | ||
| * undefined / false all yield 'rebuild'. */ | ||
| function strandRecoveryAction({ binaryCurrent } = {}) { | ||
| return binaryCurrent === true ? 'reconfigure' : 'rebuild'; | ||
| } | ||
| /** Classify whether a STILL-SERVING VM's running binary is at risk of the | ||
| * launcher-strand on its next re-exec — the PROACTIVE convergence signal (step | ||
| * 5), distinct from the strand verdict above (which fires only once a VM is | ||
| * ALREADY dark). Whether a binary is guard-capable is answered most robustly by | ||
| * the editor advertising the capability (the `bootGuards` array on the | ||
| * health/session-info payload it already serves) rather than the dashboard | ||
| * guessing from commit ancestry: | ||
| * - `bootGuards` IS an array → authoritative: stale iff it lacks the guard. | ||
| * - `bootGuards` ABSENT (an older binary that predates the field — which are | ||
| * exactly the at-risk ones) → fall back to the build-commit comparison the | ||
| * dashboard already computes: flag stale only when the editor source is | ||
| * `editorBehind > 0` commits behind origin, so a current binary that merely | ||
| * predates the FIELD isn't falsely flagged while a genuinely-behind one is. | ||
| * Returns `{ stale, reason }`; `reason` is null when not stale. */ | ||
| function classifyBinaryGuard({ bootGuards, editorBehind, requiredGuard = SERVE_CWD_GUARD } = {}) { | ||
| if (Array.isArray(bootGuards)) { | ||
| if (bootGuards.includes(requiredGuard)) return { stale: false, reason: null }; | ||
| return { | ||
| stale: true, | ||
| reason: `running binary advertises bootGuards [${bootGuards.join(', ')}] without '${requiredGuard}'; rebuild to converge`, | ||
| }; | ||
| } | ||
| const behind = Number(editorBehind); | ||
| if (Number.isFinite(behind) && behind > 0) { | ||
| return { | ||
| stale: true, | ||
| reason: `running binary predates the boot-guard capability and editor source is ${behind} commit(s) behind origin; rebuild to converge`, | ||
| }; | ||
| } | ||
| return { stale: false, reason: null }; | ||
| } | ||
| module.exports = { | ||
| SERVE_CWD_GUARD, | ||
| classifyLauncherStrand, | ||
| shouldAutoRecoverStrand, | ||
| strandRecoveryAction, | ||
| classifyBinaryGuard, | ||
| }; |
| 'use strict'; | ||
| // Pure stuck / needs-you attention policy for the Fleet Dashboard's per-VM card | ||
| // (scripts/operator/fleet-dashboard/server.js requires this). It answers a | ||
| // single question the existing status label deliberately does NOT: given how | ||
| // long a VM has been parked on its CURRENT workflow step, is it progressing | ||
| // normally, slowing down, or likely STUCK — and if it is merely waiting on a | ||
| // human, has that wait dragged on long enough to flag "needs you"? | ||
| // | ||
| // Why the STEP timer, not the feature timer: the workflow cursor advances | ||
| // (resetting `started_at` in crates/types/src/step.rs) every time a signature | ||
| // command runs, so step-elapsed is the tightest "is it making progress?" proxy | ||
| // we have. A long feature is normal; a long single step is the smell. | ||
| // | ||
| // This is an ADDITIVE overlay — it never changes the building/waiting/idle | ||
| // status label (that stays in fleet-live-status.js). It only decorates a card | ||
| // with a soft 'slow' note, a hard 'stuck' alarm, or a distinct 'needs you' | ||
| // badge. The split mirrors the established dashboard pure-module pattern | ||
| // (fleet-live-status.js, fleet-broker-status.js, fleet-vm-readiness.js): | ||
| // server.js does the Date.now() I/O and passes the verdict to buildVmEnvelope; | ||
| // the browser renders it. | ||
| const DEFAULT_SLOW_MINUTES = 15; | ||
| const DEFAULT_STUCK_MINUTES = 30; | ||
| const DEFAULT_NEEDS_YOU_MINUTES = 10; | ||
| // Parse an env var that should hold a positive number of minutes, falling back | ||
| // to `fallback` when it is unset, empty, non-numeric, or non-positive. Keeping | ||
| // this tolerant means a fat-fingered override degrades to the documented | ||
| // default rather than producing a NaN threshold that no elapsed time can cross. | ||
| function minutesFromEnv(raw, fallback) { | ||
| if (raw == null || raw === '') return fallback; | ||
| const n = Number(raw); | ||
| if (!Number.isFinite(n) || n <= 0) return fallback; | ||
| return n; | ||
| } | ||
| // Resolve the three thresholds (in milliseconds) from a process-env-shaped | ||
| // object. Defaults live here so they are documented and unit-testable in one | ||
| // place: slow 15m, stuck 30m, needs-you 10m. Env overrides: | ||
| // FLEET_STUCK_STEP_SLOW_MINUTES, FLEET_STUCK_STEP_STUCK_MINUTES, | ||
| // FLEET_NEEDS_YOU_MINUTES. | ||
| function resolveThresholds(env) { | ||
| const e = env || {}; | ||
| return { | ||
| slowMs: minutesFromEnv(e.FLEET_STUCK_STEP_SLOW_MINUTES, DEFAULT_SLOW_MINUTES) * 60_000, | ||
| stuckMs: minutesFromEnv(e.FLEET_STUCK_STEP_STUCK_MINUTES, DEFAULT_STUCK_MINUTES) * 60_000, | ||
| needsYouMs: minutesFromEnv(e.FLEET_NEEDS_YOU_MINUTES, DEFAULT_NEEDS_YOU_MINUTES) * 60_000, | ||
| }; | ||
| } | ||
| // Compute elapsed ms from an ISO timestamp to nowMs, or null when the timestamp | ||
| // is missing / unparseable. A null elapsed is the graceful-degrade path for an | ||
| // older editor whose /api/session-info predates startedAt/featureStartedAt. | ||
| function elapsedMs(iso, nowMs) { | ||
| if (!iso) return null; | ||
| const started = Date.parse(iso); | ||
| if (!Number.isFinite(started)) return null; | ||
| const delta = nowMs - started; | ||
| return delta >= 0 ? delta : 0; | ||
| } | ||
| // Derive the attention verdict for one VM. | ||
| // | ||
| // deriveAttention({ status, stepStartedAt, awaitingInput, nowMs, thresholds }) | ||
| // -> { stepElapsedMs, level, needsYou, needsYouElapsedMs } | ||
| // | ||
| // Rules: | ||
| // - `level` ('ok' | 'slow' | 'stuck') is computed ONLY for an actively- | ||
| // building VM (status === 'building'). Thresholds are inclusive: | ||
| // >= stuckMs -> 'stuck', else >= slowMs -> 'slow', else 'ok'. | ||
| // - A VM awaiting input (status === 'waiting' or awaitingInput true) never | ||
| // reads 'slow'/'stuck' — it is legitimately blocked on a human. Instead | ||
| // `needsYou` flips true once its elapsed >= needsYouMs. | ||
| // - Missing/unparseable stepStartedAt degrades to stepElapsedMs:null, | ||
| // level:'ok', needsYou:false (older editor, no crash). | ||
| function deriveAttention(opts) { | ||
| const o = opts || {}; | ||
| const thresholds = o.thresholds || resolveThresholds({}); | ||
| const nowMs = o.nowMs; | ||
| const stepElapsed = elapsedMs(o.stepStartedAt, nowMs); | ||
| const awaiting = o.status === 'waiting' || !!o.awaitingInput; | ||
| let level = 'ok'; | ||
| let needsYou = false; | ||
| let needsYouElapsedMs = null; | ||
| if (awaiting) { | ||
| // Waiting on a human — never "stuck"; flag "needs you" past its threshold. | ||
| needsYouElapsedMs = stepElapsed; | ||
| needsYou = stepElapsed != null && stepElapsed >= thresholds.needsYouMs; | ||
| } else if (o.status === 'building' && stepElapsed != null) { | ||
| if (stepElapsed >= thresholds.stuckMs) level = 'stuck'; | ||
| else if (stepElapsed >= thresholds.slowMs) level = 'slow'; | ||
| } | ||
| return { | ||
| stepElapsedMs: stepElapsed, | ||
| level, | ||
| needsYou, | ||
| needsYouElapsedMs, | ||
| }; | ||
| } | ||
| module.exports = { | ||
| deriveAttention, | ||
| resolveThresholds, | ||
| DEFAULT_SLOW_MINUTES, | ||
| DEFAULT_STUCK_MINUTES, | ||
| DEFAULT_NEEDS_YOU_MINUTES, | ||
| }; |
| 'use strict'; | ||
| // npm/fleet-vm-access.js — the per-VM IAP access whitelist sidecar | ||
| // (`.codeyam/logs/fleet-vm-access.json`). It records which principals may reach | ||
| // a specific fleet VM's IAP-secured surfaces, keyed by VM number: | ||
| // `{ "<N>": ["user:a@x.com", "group:team@x.com", "domain:partner.com"], ... }`. | ||
| // | ||
| // Why a sidecar and not the roster: the roster (`npm/fleet-roster.js`) is a flat | ||
| // integer array with a hard "sorted, de-duped set of integers" invariant bash | ||
| // consumers depend on. Access is a per-VM list, a different shape, so it lives in | ||
| // a SEPARATE map fronted by a read+write seam modeled exactly on | ||
| // npm/fleet-vm-slugs.js (atomic temp-file + rename, tolerant of a missing or | ||
| // malformed file). The slug sidecar maps a VM number to one hostname label; this | ||
| // one maps a VM number to an access list — same persistence contract, sibling | ||
| // concern. | ||
| // | ||
| // IAP access is granted at the resource level on a VM's per-surface backend | ||
| // services (`vm-<N>-<surface>-bes`); this file is only the source-of-truth list, | ||
| // the gcloud binding is applied by scripts/gcp/iap-access.sh. The owner's | ||
| // project-wide grant (scripts/gcp/google-ingress.sh) is independent — these lists | ||
| // only ADD collaborators to specific VMs. | ||
| // | ||
| // Env override: `VM_ACCESS_FILE` relocates the file (mirrors fleet-vm-slugs.js's | ||
| // `VM_SLUGS_FILE`); resolved once at module load. | ||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
| const { rootDir } = require('./utils'); | ||
| const { normalizeVmNumber } = require('./vm-urls'); | ||
| const VM_ACCESS_FILE = process.env.VM_ACCESS_FILE | ||
| || path.join(rootDir, '.codeyam', 'logs', 'fleet-vm-access.json'); | ||
| // The IAM principal types IAP accepts on a backend-service binding. A bare email | ||
| // (no type prefix) defaults to `user:`. | ||
| const PRINCIPAL_TYPES = Object.freeze(['user', 'group', 'domain']); | ||
| // A DNS domain: at least two dot-separated labels, each a lowercase | ||
| // letters/digits/hyphen label (no leading/trailing hyphen). Used to validate a | ||
| // `domain:` principal and the domain half of a `user:`/`group:` email — a typo'd | ||
| // principal must fail loud before reaching gcloud. | ||
| const DOMAIN_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/; | ||
| /** Validate + normalize one IAM principal into the `<type>:<value>` form gcloud | ||
| * expects. Accepts `user:a@x.com`, `group:team@x.com`, `domain:x.com`; a bare | ||
| * `a@x.com` defaults to `user:`. Rejects (loudly) a malformed value so junk | ||
| * never reaches `gcloud iap web add-iam-policy-binding` — the same | ||
| * fail-at-the-boundary posture as normalizeVmSlug. Returns the lowercased, | ||
| * trimmed, type-prefixed principal. */ | ||
| function normalizePrincipal(raw) { | ||
| if (raw == null) throw new Error('access principal is required'); | ||
| const trimmed = String(raw).trim(); | ||
| if (trimmed === '') throw new Error('access principal must not be empty'); | ||
| const lower = trimmed.toLowerCase(); | ||
| const colon = lower.indexOf(':'); | ||
| const hasType = colon !== -1 && PRINCIPAL_TYPES.includes(lower.slice(0, colon)); | ||
| const type = hasType ? lower.slice(0, colon) : 'user'; | ||
| const value = hasType ? lower.slice(colon + 1) : lower; | ||
| if (value === '') { | ||
| throw new Error(`access principal "${raw}" has an empty value after "${type}:"`); | ||
| } | ||
| if (type === 'domain') { | ||
| if (value.includes('@') || !DOMAIN_RE.test(value)) { | ||
| throw new Error( | ||
| `access principal "${raw}" is not a valid domain (e.g. domain:example.com)`, | ||
| ); | ||
| } | ||
| } else { | ||
| const at = value.indexOf('@'); | ||
| if (at <= 0 || value.indexOf('@', at + 1) !== -1) { | ||
| throw new Error( | ||
| `access principal "${raw}" is not a valid ${type} email ` + | ||
| `(exactly one "@", e.g. ${type}:a@example.com)`, | ||
| ); | ||
| } | ||
| const domain = value.slice(at + 1); | ||
| if (!DOMAIN_RE.test(domain)) { | ||
| throw new Error(`access principal "${raw}" has an invalid email domain "${domain}"`); | ||
| } | ||
| } | ||
| return `${type}:${value}`; | ||
| } | ||
| /** Normalize a whole access list — an array of principals or a comma-separated | ||
| * string — into a deduped, validated array of `<type>:<value>` principals. | ||
| * Blank entries are skipped; a single malformed entry throws (the list is | ||
| * rejected as a whole rather than silently dropping the bad one). Order of first | ||
| * appearance is preserved. */ | ||
| function normalizeAccessList(input) { | ||
| const raw = Array.isArray(input) | ||
| ? input | ||
| : String(input == null ? '' : input).split(','); | ||
| const out = []; | ||
| const seen = new Set(); | ||
| for (const item of raw) { | ||
| const s = String(item).trim(); | ||
| if (s === '') continue; | ||
| const principal = normalizePrincipal(s); | ||
| if (!seen.has(principal)) { | ||
| seen.add(principal); | ||
| out.push(principal); | ||
| } | ||
| } | ||
| return out; | ||
| } | ||
| // Read the access sidecar, returning a `{ [vmNumber]: principal[] }` object, or | ||
| // null if the file is missing or malformed (treated as "no access lists"). Keys | ||
| // are the string form JSON emits; callers look them up by `String(n)`. | ||
| function readAccess() { | ||
| try { | ||
| const data = JSON.parse(fs.readFileSync(VM_ACCESS_FILE, 'utf8')); | ||
| if (data && typeof data === 'object' && !Array.isArray(data)) return data; | ||
| } catch { /* missing or malformed */ } | ||
| return null; | ||
| } | ||
| // Persist `map` as the sidecar, atomically: write a pid-stamped temp file then | ||
| // `rename` it over the target so a concurrent reader never observes a | ||
| // half-written file (mirrors fleet-vm-slugs.js::writeSlugs). Returns the written | ||
| // map. | ||
| function writeAccess(map) { | ||
| const next = map && typeof map === 'object' && !Array.isArray(map) ? map : {}; | ||
| fs.mkdirSync(path.dirname(VM_ACCESS_FILE), { recursive: true }); | ||
| const tmp = `${VM_ACCESS_FILE}.${process.pid}.tmp`; | ||
| fs.writeFileSync(tmp, JSON.stringify(next)); | ||
| fs.renameSync(tmp, VM_ACCESS_FILE); // atomic replace | ||
| return next; | ||
| } | ||
| /** The access list for VM `n` — the principals whitelisted onto its surfaces — | ||
| * or an empty array when it has none (or the file is absent). The number is | ||
| * validated so junk can't sneak through as a lookup key. */ | ||
| function accessFor(n) { | ||
| const num = normalizeVmNumber(n); | ||
| const map = readAccess(); | ||
| const list = map && map[String(num)]; | ||
| return Array.isArray(list) ? list.filter((p) => typeof p === 'string' && p) : []; | ||
| } | ||
| /** Replace VM `n`'s access list wholesale with `principals` (validated + deduped | ||
| * via normalizeAccessList). An empty result removes the VM's entry entirely so | ||
| * the sidecar never accumulates empty arrays. Returns the stored list. */ | ||
| function setAccess(n, principals) { | ||
| const num = normalizeVmNumber(n); | ||
| const list = normalizeAccessList(principals); | ||
| const map = { ...(readAccess() || {}) }; | ||
| if (list.length === 0) delete map[String(num)]; | ||
| else map[String(num)] = list; | ||
| writeAccess(map); | ||
| return list; | ||
| } | ||
| /** Add a single principal to VM `n`'s access list (validated + deduped). Adding | ||
| * one already present is an idempotent no-op-shaped write. Returns the new list. */ | ||
| function addAccess(n, principal) { | ||
| const num = normalizeVmNumber(n); | ||
| const p = normalizePrincipal(principal); | ||
| const next = accessFor(num); | ||
| if (!next.includes(p)) next.push(p); | ||
| const map = { ...(readAccess() || {}) }; | ||
| map[String(num)] = next; | ||
| writeAccess(map); | ||
| return next; | ||
| } | ||
| /** Remove a single principal from VM `n`'s access list. Removing an absent | ||
| * principal is a no-op; emptying the list removes the VM's entry. Returns the | ||
| * new list. */ | ||
| function removeAccess(n, principal) { | ||
| const num = normalizeVmNumber(n); | ||
| const p = normalizePrincipal(principal); | ||
| const next = accessFor(num).filter((x) => x !== p); | ||
| const map = { ...(readAccess() || {}) }; | ||
| if (next.length === 0) delete map[String(num)]; | ||
| else map[String(num)] = next; | ||
| writeAccess(map); | ||
| return next; | ||
| } | ||
| /** Parse the raw stdout of `gcloud projects get-iam-policy ... | ||
| * --format='value(bindings.members)'` into a deduped, trimmed array of | ||
| * principals. gcloud separates members within a binding by `;` and (when not | ||
| * flattened) bindings by newline, so we split on BOTH. Order of first | ||
| * appearance is preserved. | ||
| * | ||
| * This is deliberately NOT normalizePrincipal: it is a *read* of whatever IAM | ||
| * already holds, so it must surface principals verbatim (lowercased only for | ||
| * dedup-stability) — including `serviceAccount:` members that the strict | ||
| * write-path validator would reject. The one thing it drops, besides blanks, is | ||
| * gcloud's `deleted:` tombstone members (e.g. | ||
| * `deleted:user:x@x.com?uid=123`), which name principals that no longer exist | ||
| * and can't be acted on. */ | ||
| function parseProjectMembers(stdout) { | ||
| const out = []; | ||
| const seen = new Set(); | ||
| for (const raw of String(stdout == null ? '' : stdout).split(/[;\n]/)) { | ||
| const s = raw.trim(); | ||
| if (s === '') continue; | ||
| if (s.toLowerCase().startsWith('deleted:')) continue; | ||
| const principal = s.toLowerCase(); | ||
| if (!seen.has(principal)) { | ||
| seen.add(principal); | ||
| out.push(principal); | ||
| } | ||
| } | ||
| return out; | ||
| } | ||
| /** Drop VM `n`'s entire access list, returning the new map. Clearing an absent | ||
| * entry is a no-op. Called on Destroy so a number freed and later re-used | ||
| * doesn't inherit a stale whitelist. */ | ||
| function clearAccess(n) { | ||
| const num = normalizeVmNumber(n); | ||
| const map = { ...(readAccess() || {}) }; | ||
| delete map[String(num)]; | ||
| return writeAccess(map); | ||
| } | ||
| module.exports = { | ||
| VM_ACCESS_FILE, | ||
| PRINCIPAL_TYPES, | ||
| normalizePrincipal, | ||
| normalizeAccessList, | ||
| parseProjectMembers, | ||
| readAccess, | ||
| writeAccess, | ||
| accessFor, | ||
| setAccess, | ||
| addAccess, | ||
| removeAccess, | ||
| clearAccess, | ||
| }; |
| 'use strict'; | ||
| // Pure readiness derivation for the Fleet Dashboard's per-VM card | ||
| // (scripts/operator/fleet-dashboard/server.js requires this). Given the merged | ||
| // poll state `v` and the derived `ctx` the card builder already computes, | ||
| // collapse the VM into a single readiness label the card renders. No I/O — | ||
| // server.js owns the polling (including the slow-cadence LB backend-health | ||
| // probe that fills `v.backendHealth`). | ||
| // | ||
| // The bug this fixes: a VM card rendered as idle with a CLICKABLE editor URL the | ||
| // moment the VM was rostered + SSH-tunnel-reachable, even when its public LB URL | ||
| // (vm-<N>.editor.<domain>) would return Envoy "no healthy upstream" (503). The | ||
| // card reflected only SSH-tunnel reachability to :14199, never whether the load | ||
| // balancer actually routes to a HEALTHY backend. Two situations cause the 503, | ||
| // and the card had zero visibility into either: | ||
| // | ||
| // transient half-wire — the editor is serving but the LB health check hasn't | ||
| // marked the backend HEALTHY yet during cold start. It | ||
| // resolves on its own; the card should say 'starting'. | ||
| // permanent half-wire — the per-VM LB backend service was never created (e.g. | ||
| // the Add ran under a service account lacking | ||
| // compute.backendServices.create), so the URL 503s | ||
| // forever until re-wired. The card should say | ||
| // 'half-wired', visibly distinct from 'starting'. | ||
| // | ||
| // SSH reachability (v.reachable from probeVM over the tunnel) is necessary but | ||
| // not sufficient: the tunnel is up in BOTH 503 cases. Only the LB backend health | ||
| // (gcloud compute backend-services get-health, gathered into v.backendHealth) | ||
| // distinguishes ready / starting / half-wired. The editor's own /healthz can't | ||
| // see the half-wire, so it is not enough on its own. | ||
| // | ||
| // 'provisioning' — an Add/recover job is in flight; the VM is still being | ||
| // built, so judging its public-URL readiness is premature. | ||
| // 'unreachable' — the editor did not answer over the tunnel. Nothing | ||
| // downstream (broker, LB health) is meaningful; the link is | ||
| // gated exactly as today. | ||
| // 'half-wired' — LB mode, the editor backend service is ABSENT (never | ||
| // created). The permanent 503 case — the public URL will fail | ||
| // until re-wired. The state that had NO signal before. | ||
| // 'starting' — LB mode, the editor backend exists but is UNHEALTHY (LB has | ||
| // not yet marked it HEALTHY). The transient 503 case; flips to | ||
| // 'ready' on its own once the health check passes. | ||
| // 'broker-down' — reachable + LB-healthy (or legacy mode), but the broker | ||
| // daemon is down, so the VM cannot start an agent session. | ||
| // 'ready' — reachable, broker up, and (LB mode) the editor backend is | ||
| // HEALTHY. The only state whose editor link is live + green. | ||
| // | ||
| // Legacy mode (FLEET_DOMAIN unset ⇒ ctx.lbMode falsey, or no backendHealth | ||
| // supplied): there is no LB to health-check, so the backend-health branch is | ||
| // skipped and readiness degrades to today's reachability/broker behavior — no | ||
| // regression, no gcloud calls. | ||
| const READINESS_STATES = [ | ||
| 'provisioning', | ||
| 'unreachable', | ||
| 'half-wired', | ||
| 'starting', | ||
| 'broker-down', | ||
| 'ready', | ||
| ]; | ||
| // A VM mid-provision: an Add (incl. its recovering re-run) that is queued | ||
| // (number reserved, cloud:up not started) or running. Its public URL has no | ||
| // meaningful readiness yet, so this takes precedence over every other signal — | ||
| // a freshly-reserved VM must not flash 'half-wired' before its backend exists. | ||
| function isProvisioning(job) { | ||
| if (!job) return false; | ||
| if (job.action === 'add' && (job.state === 'running' || job.state === 'queued')) return true; | ||
| if (job.recovering && job.state === 'running') return true; | ||
| return false; | ||
| } | ||
| // `v` is the merged poll state (state.vms[n]); `ctx` carries the derived values | ||
| // the card builder already has on hand: | ||
| // ctx.job — the active job (or null), for the provisioning check. | ||
| // ctx.brokerStatus — the debounced broker tri-state ('down'|'idle'| | ||
| // 'agent-live'|'unknown') the card already derives. | ||
| // ctx.lbMode — true when FLEET_DOMAIN is set (the LB fronts the URLs). | ||
| // When false, the backend-health branch is skipped. | ||
| function deriveVmReadiness(v, ctx) { | ||
| const c = ctx || {}; | ||
| // 1. Still being built — don't judge a half-built VM as ready or half-wired. | ||
| if (isProvisioning(c.job)) return 'provisioning'; | ||
| // 2. Editor unreachable over the tunnel: we polled nothing, so broker / LB | ||
| // health are unknowable. Link gated, exactly as the pre-readiness card did. | ||
| if (!v || !v.reachable) return 'unreachable'; | ||
| // 3. LB mode only: the public URL's fate is the editor backend's LB health. | ||
| // Skipped entirely in legacy mode (no LB) or before the first probe lands | ||
| // (no backendHealth) — both fall through to the reachability/broker path. | ||
| const bh = v.backendHealth; | ||
| if (c.lbMode && bh) { | ||
| // Backend service never created ⇒ permanent 503 until re-wired. | ||
| if (bh.editor === 'absent') return 'half-wired'; | ||
| // Backend exists but not yet HEALTHY ⇒ transient 503 during cold start. | ||
| if (bh.editor === 'unhealthy') return 'starting'; | ||
| // 'healthy' falls through to ready; 'unknown' (get-health errored) is | ||
| // absence-of-evidence — never rendered as a problem, falls through too. | ||
| } | ||
| // 4. Reachable but the broker daemon is down: the VM can't start a session. | ||
| if (c.brokerStatus === 'down') return 'broker-down'; | ||
| // 5. Reachable, broker up, and (LB mode) the editor backend is HEALTHY — | ||
| // genuinely ready: green, clickable, public URL actually routes. | ||
| return 'ready'; | ||
| } | ||
| module.exports = { deriveVmReadiness, READINESS_STATES, isProvisioning }; |
| 'use strict'; | ||
| // npm/fleet-vm-slugs.js — the per-VM custom-slug sidecar | ||
| // (`.codeyam/logs/fleet-vm-slugs.json`). A slug is an optional human-readable | ||
| // override for a VM's leftmost hostname label, so its public URLs become | ||
| // `<slug>.editor.<domain>` instead of the number-derived `vm-<N>.editor.<domain>`. | ||
| // | ||
| // Why a sidecar and not the roster: the roster (`npm/fleet-roster.js`) is a flat | ||
| // integer array with a hard "sorted, de-duped set of integers" invariant that | ||
| // bash consumers (`vm-urls.sh`, the watchdog) depend on. Rather than break that | ||
| // shape, slugs live in a SEPARATE map `{ "<N>": "<slug>" }`, fronted by a | ||
| // read+write seam modeled exactly on fleet-roster.js (atomic temp-file + rename, | ||
| // tolerant of a missing/malformed file). | ||
| // | ||
| // The slug is purely a public-hostname override — internal GCP resource names | ||
| // (NEG / backend service / path matcher) stay keyed off the VM number, so a VM | ||
| // added without a slug keeps its `vm-<N>` URLs unchanged. | ||
| // | ||
| // Env override: `VM_SLUGS_FILE` relocates the file (mirrors fleet-roster.js's | ||
| // `VMS_STATE_FILE`); resolved once at module load. | ||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
| const { rootDir } = require('./utils'); | ||
| const { normalizeVmNumber, normalizeVmSlug } = require('./vm-urls'); | ||
| const VM_SLUGS_FILE = process.env.VM_SLUGS_FILE | ||
| || path.join(rootDir, '.codeyam', 'logs', 'fleet-vm-slugs.json'); | ||
| // Read the slug sidecar, returning a `{ [vmNumber]: slug }` object, or null if | ||
| // the file is missing or malformed (treated as "no slugs"). Keys are kept as the | ||
| // string form JSON emits; callers look them up by `String(n)`. | ||
| function readSlugs() { | ||
| try { | ||
| const data = JSON.parse(fs.readFileSync(VM_SLUGS_FILE, 'utf8')); | ||
| if (data && typeof data === 'object' && !Array.isArray(data)) return data; | ||
| } catch { /* missing or malformed */ } | ||
| return null; | ||
| } | ||
| // Persist `map` as the sidecar, 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 don't clobber each | ||
| // other's temp file before their renames land. Returns the written map. | ||
| function writeSlugs(map) { | ||
| const next = map && typeof map === 'object' && !Array.isArray(map) ? map : {}; | ||
| fs.mkdirSync(path.dirname(VM_SLUGS_FILE), { recursive: true }); | ||
| const tmp = `${VM_SLUGS_FILE}.${process.pid}.tmp`; | ||
| fs.writeFileSync(tmp, JSON.stringify(next)); | ||
| fs.renameSync(tmp, VM_SLUGS_FILE); // atomic replace | ||
| return next; | ||
| } | ||
| /** The slug mapped to VM `n`, or null when it has none (or the file is absent). | ||
| * This is the resolver every hostname derivation funnels through: | ||
| * `slugFor(n) || ('vm-' + n)`. */ | ||
| function slugFor(n) { | ||
| const num = normalizeVmNumber(n); | ||
| const map = readSlugs(); | ||
| const slug = map && map[String(num)]; | ||
| return typeof slug === 'string' && slug.length > 0 ? slug : null; | ||
| } | ||
| /** The VM number currently mapped to `slug`, or null when no VM owns it. Used to | ||
| * reject a duplicate slug BEFORE it is assigned (a slug must be unique across | ||
| * the fleet, else two VMs would claim the same hostname). `slug` is normalized | ||
| * first so the comparison is case-insensitive. */ | ||
| function vmForSlug(slug) { | ||
| const want = normalizeVmSlug(slug); | ||
| const map = readSlugs() || {}; | ||
| for (const [k, v] of Object.entries(map)) { | ||
| if (typeof v === 'string' && v.toLowerCase() === want) { | ||
| const num = Number(k); | ||
| if (Number.isInteger(num) && num > 0) return num; | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
| /** Assign `slug` to VM `n`, validating the slug (via `normalizeVmSlug`) and | ||
| * rejecting a slug already mapped to a DIFFERENT VM. Returns the written map. | ||
| * Re-assigning a VM its own existing slug is an idempotent no-op-shaped write. */ | ||
| function setSlug(n, slug) { | ||
| const num = normalizeVmNumber(n); | ||
| const norm = normalizeVmSlug(slug); | ||
| const owner = vmForSlug(norm); | ||
| if (owner != null && owner !== num) { | ||
| throw new Error( | ||
| `VM slug "${norm}" is already assigned to VM-${owner} — pick another name`, | ||
| ); | ||
| } | ||
| const map = { ...(readSlugs() || {}) }; | ||
| map[String(num)] = norm; | ||
| return writeSlugs(map); | ||
| } | ||
| /** Remove VM `n`'s slug mapping, returning the new map. Removing an absent entry | ||
| * is a no-op. Called on Destroy so a number freed and later re-used doesn't | ||
| * inherit a stale slug. */ | ||
| function removeSlug(n) { | ||
| const num = normalizeVmNumber(n); | ||
| const map = { ...(readSlugs() || {}) }; | ||
| delete map[String(num)]; | ||
| return writeSlugs(map); | ||
| } | ||
| module.exports = { | ||
| VM_SLUGS_FILE, | ||
| readSlugs, | ||
| writeSlugs, | ||
| slugFor, | ||
| vmForSlug, | ||
| setSlug, | ||
| removeSlug, | ||
| }; |
| 'use strict'; | ||
| // Deterministic Google Cloud HTTPS-LB resource-name derivation for the fleet. | ||
| // | ||
| // The fleet's public browser ingress moves from cloudflared named tunnels to a | ||
| // single Google external HTTPS Load Balancer (one URL map, host-based routing). | ||
| // Reaching a specific port on a specific VM needs a zonal NEG + backend service | ||
| // per (VM, surface); the URL map then routes each `vm-<N>.<surface>.<domain>` | ||
| // host to that backend. This module is the single source of truth for those | ||
| // Google resource NAMES so the bash provisioners (scripts/gcp/google-ingress.sh, | ||
| // scripts/gcp/vm-ingress.sh) and any node caller derive identical strings and a | ||
| // unit test guards them against drift — exactly the contract npm/vm-urls.js and | ||
| // npm/launch-config.js follow. | ||
| // | ||
| // Pure logic only — no I/O, no env reads beyond the explicit `env` argument a | ||
| // caller passes in, so it stays unit-testable in isolation | ||
| // (npm/google-ingress.test.js). The HOSTNAMES it routes to are reused verbatim | ||
| // from npm/vm-urls.js (vmHostnames / dashboardHostname): the Google migration is | ||
| // a serving-layer swap, not a URL-scheme change, so hostnames must never drift | ||
| // from the cert/DNS that vm-urls.js already defines. | ||
| // | ||
| // Stack note: this is fleet-operator infrastructure (the cloud GCE fleet that | ||
| // runs codeyam-editor itself), not a per-app-stack assumption. A project that | ||
| // never joins a Google fleet never sets FLEET_DOMAIN / FLEET_INGRESS=google and | ||
| // never derives a name here; the legacy cloudflared path stays the fallback. | ||
| const { | ||
| normalizeVmNumber, | ||
| vmHostnames, | ||
| dashboardHostname, | ||
| } = require('./vm-urls'); | ||
| // The two per-VM ingress surfaces. `editor` fronts the in-VM editor control API | ||
| // (a fixed port); `preview` fronts the VM's dev server (a per-project port). | ||
| // Keep this list as the single definition both name derivation and the host-rule | ||
| // builder iterate, so a third surface is one edit, not three. | ||
| const SURFACES = Object.freeze(['editor', 'preview']); | ||
| // The in-VM editor control API port. Fixed across the fleet (matches | ||
| // named-tunnel-config.sh's EDITOR_PORT default and cloud.js's `-L …:14199`), so | ||
| // the editor backend NEG always targets this port regardless of project. | ||
| const EDITOR_PORT = 14199; | ||
| /** The fixed in-VM editor control-API port the editor backend NEG targets. A | ||
| * function (not a bare constant export) so callers read it the same way they | ||
| * read the per-VM appPort — a uniform "resolve the port for this surface" shape. */ | ||
| function editorPort() { | ||
| return EDITOR_PORT; | ||
| } | ||
| /** Validate an ingress surface against SURFACES. The NEG / backend-service / | ||
| * host-rule derivations all key off it, so a typo'd surface would mint a | ||
| * resource that nothing routes to — reject it loudly at the boundary, mirroring | ||
| * how vm-urls.js rejects a bad VM number or domain. Returns the surface. */ | ||
| function normalizeSurface(surface) { | ||
| if (!SURFACES.includes(surface)) { | ||
| throw new Error( | ||
| `surface must be one of ${SURFACES.join('|')}, got: ${surface}`, | ||
| ); | ||
| } | ||
| return surface; | ||
| } | ||
| /** The zonal NEG name for a (VM, surface): `vm-<N>-<surface>-neg`. One network | ||
| * endpoint group per surface points at the VM's internal IP + that surface's | ||
| * port — the Google-native analog of a cloudflared ingress rule's local origin. */ | ||
| function negName(n, surface) { | ||
| const num = normalizeVmNumber(n); | ||
| return `vm-${num}-${normalizeSurface(surface)}-neg`; | ||
| } | ||
| /** The backend-service name for a (VM, surface): `vm-<N>-<surface>-bes`. One | ||
| * backend service per host rule wraps the matching NEG (with IAP + a health | ||
| * check); the URL map's host rule points at this name. */ | ||
| function backendServiceName(n, surface) { | ||
| const num = normalizeVmNumber(n); | ||
| return `vm-${num}-${normalizeSurface(surface)}-bes`; | ||
| } | ||
| /** The URL-map host rules for a VM: one `{ host, surface, backendService }` per | ||
| * surface, where `host` is the EXACT hostname vm-urls.js derives (so the LB | ||
| * routes the same hostnames the cert covers and DNS resolves) and | ||
| * `backendService` is this module's name for that surface. Reusing | ||
| * `vmHostnames` rather than re-deriving keeps the hostname scheme single-sourced. | ||
| * Returns the surfaces in SURFACES order so the provisioner's output is stable. | ||
| * | ||
| * An optional `slug` overrides the routed `host` with the VM's custom hostname | ||
| * (`<slug>.<surface>.<domain>`) while the NEG / `backendService` names stay | ||
| * keyed off the VM NUMBER — the slug changes only what the URL map matches, not | ||
| * the internal GCP resource names. */ | ||
| function urlMapHostRules(n, domain, slug) { | ||
| const hosts = vmHostnames(n, domain, slug); // also validates n, domain, slug | ||
| return SURFACES.map((surface) => ({ | ||
| host: hosts[surface], | ||
| surface, | ||
| backendService: backendServiceName(n, surface), | ||
| })); | ||
| } | ||
| /** Decide which gcloud URL-map operation reconciles a single `(host, | ||
| * pathMatcher)` against the map's CURRENT state, given `hostRules` — the | ||
| * flattened `[{ host, pathMatcher }]` projection of the URL map's host rules | ||
| * (one entry per host, since one host rule can list several hosts). Returns | ||
| * `{ op }`, one of: | ||
| * - `skip` — `host` already routes to `pathMatcher`; nothing to do. | ||
| * - `add-host-rule` — `pathMatcher` already exists (some other host points | ||
| * at it, e.g. the numeric `vmN.<surface>` host) but | ||
| * `host` is not attached: attach it. Fixes the | ||
| * `Duplicate path matcher` failure on a reused slug. | ||
| * - `repoint` — `host` exists but aims at a DIFFERENT matcher (a stale | ||
| * rule left by a destroyed VM): remove it, then attach | ||
| * `host` to the correct matcher. Fixes a reused slug | ||
| * silently skipped as "already configured" while it | ||
| * still routed to a dead backend. | ||
| * - `add-path-matcher`— neither `host` nor `pathMatcher` is present: create | ||
| * the matcher and attach the host (the original path). | ||
| * | ||
| * Pure and side-effect-free — no gcloud, no I/O — so vm-ingress.sh can read the | ||
| * URL-map state in bash, ask this helper for the op, and run the matching | ||
| * gcloud command, keeping the four-way decision unit-testable (the same | ||
| * bash↔node contract the name derivations above follow). Tolerates a | ||
| * missing/empty `hostRules`. */ | ||
| function hostRuleOperation({ hostRules, host, pathMatcher } = {}) { | ||
| if (!host) throw new Error('hostRuleOperation requires a host'); | ||
| if (!pathMatcher) throw new Error('hostRuleOperation requires a pathMatcher'); | ||
| const rules = Array.isArray(hostRules) ? hostRules : []; | ||
| const existing = rules.find((r) => r && r.host === host); | ||
| if (existing) { | ||
| return { op: existing.pathMatcher === pathMatcher ? 'skip' : 'repoint' }; | ||
| } | ||
| const matcherExists = rules.some((r) => r && r.pathMatcher === pathMatcher); | ||
| return { op: matcherExists ? 'add-host-rule' : 'add-path-matcher' }; | ||
| } | ||
| /** Decide whether a NEG must be attached as a backend to its backend service, | ||
| * given `backends` — the BES's CURRENT `backends[].group` self-links (what | ||
| * `gcloud compute backend-services describe --format='value(backends[].group)'` | ||
| * emits) — and `neg`, the bare NEG name this surface expects. Returns `{ op }`: | ||
| * - `skip` — some current backend already resolves to `neg`; nothing to do. | ||
| * - `attach` — `neg` is not yet a backend (an empty BES, or one wired to a | ||
| * different group): `add-backend` it. | ||
| * | ||
| * `backends[].group` is a full self-link | ||
| * (`.../networkEndpointGroups/vm-3-editor-neg`), so the match compares by | ||
| * basename — the bare NEG name — not the whole URL. Pure and side-effect-free | ||
| * (no gcloud, no I/O), so vm-ingress.sh reads the BES's backends in bash, asks | ||
| * this helper for the op, and `add-backend`s only when it returns `attach`: the | ||
| * same bash↔node contract `hostRuleOperation` follows. This is what makes `up` | ||
| * *converge* a BES that pre-exists with zero backends (the permanent-503 empty- | ||
| * backend bug) instead of skipping the attach as "already configured". Throws on | ||
| * a missing `neg` (mirrors `hostRuleOperation`'s guard); tolerates a | ||
| * missing/empty `backends`. */ | ||
| function backendNegAttachOperation({ backends, neg } = {}) { | ||
| if (!neg) throw new Error('backendNegAttachOperation requires a neg'); | ||
| const groups = Array.isArray(backends) ? backends : []; | ||
| const basename = (g) => String(g).split('/').pop(); | ||
| const attached = groups.some((g) => g && basename(g) === neg); | ||
| return { op: attached ? 'skip' : 'attach' }; | ||
| } | ||
| /** The dashboard's backend-service name: `dashboard-bes`. The dashboard is a | ||
| * single fleet-wide surface (not per-VM), so its name carries no VM number. The | ||
| * backend itself is only fully wired once the dashboard moves onto GCE | ||
| * (vm-dashboard-on-gce), but the name is defined here so the shared provisioner | ||
| * and that dependent plan agree. */ | ||
| function dashboardBackendServiceName() { | ||
| return 'dashboard-bes'; | ||
| } | ||
| /** The dashboard's zonal NEG name: `dashboard-neg`. One network endpoint group | ||
| * points at the operator host's internal IP + the dashboard port (8787) — the | ||
| * Google-native analog of the dashboard's old ngrok upstream. Single fleet-wide | ||
| * surface, so (like the backend-service name) it carries no VM number. Wired by | ||
| * scripts/gcp/dashboard-ingress.sh once the dashboard moves onto GCE. */ | ||
| function dashboardNegName() { | ||
| return 'dashboard-neg'; | ||
| } | ||
| /** The dashboard's dedicated health-check name: `dashboard-hc`. The per-VM | ||
| * surfaces share `fleet-ingress-hc` (port 14199), but the dashboard backend | ||
| * health-checks its own port (8787, path /healthz), so it needs its own check | ||
| * rather than reusing the editor-port one. */ | ||
| function dashboardHealthCheckName() { | ||
| return 'dashboard-hc'; | ||
| } | ||
| /** The dashboard's single URL-map host rule: `{ host, backendService }`, where | ||
| * `host` is `dashboard.<domain>` from vm-urls.js. Separate from | ||
| * `urlMapHostRules` because the dashboard is fleet-wide, not keyed off a VM | ||
| * number. */ | ||
| function dashboardHostRule(domain) { | ||
| return { | ||
| host: dashboardHostname(domain), // validates domain | ||
| backendService: dashboardBackendServiceName(), | ||
| }; | ||
| } | ||
| module.exports = { | ||
| SURFACES, | ||
| EDITOR_PORT, | ||
| editorPort, | ||
| normalizeSurface, | ||
| negName, | ||
| backendServiceName, | ||
| urlMapHostRules, | ||
| hostRuleOperation, | ||
| backendNegAttachOperation, | ||
| dashboardBackendServiceName, | ||
| dashboardNegName, | ||
| dashboardHealthCheckName, | ||
| dashboardHostRule, | ||
| }; |
| 'use strict'; | ||
| // Pure builders for the LOCAL shell invocations that wire the fleet's Google | ||
| // HTTPS Load Balancer ingress: the shared, VM-independent infra | ||
| // (scripts/gcp/google-ingress.sh) and one VM's per-surface backends | ||
| // (scripts/gcp/vm-ingress.sh). cloud.js calls these to decide WHEN to wire and | ||
| // to build the exact `{ command, args, env }` it spawns — keeping the decision | ||
| // and the argv/env shape unit-testable without ever spawning a process, exactly | ||
| // like npm/vm-urls.js and npm/google-ingress.js single-source their derivations. | ||
| // | ||
| // Pure logic only — no I/O, no env reads beyond the explicit `env`/`cfg` | ||
| // arguments callers pass in. The fleet domain governing ingress comes from the | ||
| // project's `.codeyam/cloud.json` (`fleetDomain`, persisted by cloud.js) with a | ||
| // `FLEET_DOMAIN` env fallback; when neither is set every builder reports the | ||
| // legacy quick-tunnel mode (resolveIngressDomain → null) and cloud.js leaves the | ||
| // Add/Destroy path byte-for-byte unchanged. | ||
| // | ||
| // Stack note: this is fleet-operator infrastructure (the cloud GCE fleet that | ||
| // runs codeyam-editor itself), GCP-specific by design — the same posture as the | ||
| // scripts it invokes. A project that never joins the fleet never sets | ||
| // fleetDomain and never reaches a builder here. | ||
| const { resolveFleetDomain } = require('./vm-urls'); | ||
| // Cloud DNS managed-zone name google-ingress.sh defaults to. Mirrored here so a | ||
| // cloud.json without an explicit `dnsZone` resolves to the same zone the script | ||
| // would, rather than passing an empty DNS_ZONE that shadows the script default. | ||
| const DEFAULT_DNS_ZONE = 'fleet-zone'; | ||
| /** Resolve the fleet domain that governs ingress wiring, or null when none is | ||
| * configured (legacy quick-tunnel mode). `cfg.fleetDomain` (persisted in | ||
| * cloud.json) wins; the `FLEET_DOMAIN` env value is the fallback. Both paths go | ||
| * through vm-urls.js::resolveFleetDomain, so a present-but-malformed value | ||
| * throws here (a typo'd domain must fail loud, never bake a dead host into the | ||
| * URL map) and a blank value reads as "unset". */ | ||
| function resolveIngressDomain(cfg = {}, env = {}) { | ||
| const fromCfg = cfg.fleetDomain; | ||
| if (fromCfg != null && String(fromCfg).trim() !== '') { | ||
| return resolveFleetDomain({ FLEET_DOMAIN: fromCfg }); | ||
| } | ||
| return resolveFleetDomain(env); | ||
| } | ||
| /** Resolve the Cloud DNS managed-zone name: `cfg.dnsZone`, then a `DNS_ZONE` | ||
| * env override, then the `fleet-zone` default google-ingress.sh itself uses. */ | ||
| function resolveDnsZone(cfg = {}, env = {}) { | ||
| const fromCfg = cfg.dnsZone; | ||
| if (fromCfg != null && String(fromCfg).trim() !== '') return String(fromCfg).trim(); | ||
| const fromEnv = env.DNS_ZONE; | ||
| if (fromEnv != null && String(fromEnv).trim() !== '') return String(fromEnv).trim(); | ||
| return DEFAULT_DNS_ZONE; | ||
| } | ||
| /** Derive the GCE region from a zone (`us-central1-a` → `us-central1`), or null | ||
| * when the zone is missing/unparseable so the caller omits REGION and lets | ||
| * google-ingress.sh apply its own default. A GCE zone is a region plus a | ||
| * trailing `-<letter>`. */ | ||
| function regionFromZone(zone) { | ||
| if (zone == null) return null; | ||
| const z = String(zone).trim(); | ||
| if (z === '') return null; | ||
| const m = /^(.+)-[a-z]$/.exec(z); | ||
| return m ? m[1] : null; | ||
| } | ||
| /** Build the local invocation for `scripts/gcp/vm-ingress.sh <N> <up|down>` — | ||
| * one VM's per-surface NEG → IAP backend → URL-map host rule. Returns | ||
| * `{ command, args, env }`; `env` carries only the keys the script reads beyond | ||
| * its own defaults (GCP_PROJECT, ZONE, FLEET_DOMAIN, APP_PORT, INSTANCE_PREFIX, | ||
| * VM_SLUG). `appPort` is the resolved per-project dev-server port the preview | ||
| * NEG targets; omit it (down, or unknown) to let the script's bootstrap default | ||
| * stand. An optional `slug` becomes `VM_SLUG`, which makes the script route the | ||
| * VM's custom `<slug>.<surface>.<domain>` host (the same slug must be passed on | ||
| * `down` so the matching host rule is removed); omit it to keep the numeric | ||
| * `vm-<N>` host. */ | ||
| function vmIngressInvocation({ | ||
| scriptPath, | ||
| vmNumber, | ||
| action, | ||
| domain, | ||
| project, | ||
| zone, | ||
| appPort, | ||
| instancePrefix, | ||
| slug, | ||
| } = {}) { | ||
| if (!scriptPath) throw new Error('vmIngressInvocation requires scriptPath'); | ||
| if (action !== 'up' && action !== 'down') { | ||
| throw new Error(`vm-ingress action must be 'up' or 'down', got: ${action}`); | ||
| } | ||
| const n = Number(vmNumber); | ||
| if (!Number.isInteger(n) || n <= 0) { | ||
| throw new Error(`vm-ingress requires a positive VM number, got: ${vmNumber}`); | ||
| } | ||
| if (!domain) throw new Error('vmIngressInvocation requires a fleet domain'); | ||
| const env = { FLEET_DOMAIN: domain }; | ||
| if (project) env.GCP_PROJECT = project; | ||
| if (zone) env.ZONE = zone; | ||
| if (appPort != null && String(appPort).trim() !== '') env.APP_PORT = String(appPort); | ||
| if (instancePrefix) env.INSTANCE_PREFIX = instancePrefix; | ||
| if (slug != null && String(slug).trim() !== '') env.VM_SLUG = String(slug).trim(); | ||
| return { command: 'bash', args: [scriptPath, String(n), action], env }; | ||
| } | ||
| /** Build the local invocation for `scripts/gcp/vm-ingress.sh <N> status` — the | ||
| * read-only probe that reports which of {NEG, backend service, host rule} exist | ||
| * per surface plus an overall `verdict=wired|partial|unwired`. Returns | ||
| * `{ command, args, env }`. Like `vmIngressInvocation` but the action is fixed | ||
| * to `status` and APP_PORT is omitted — status describes resources by NAME, and | ||
| * names key off the VM number, never the port. An optional `slug` rides through | ||
| * as VM_SLUG so the host-rule presence check matches the VM's custom | ||
| * `<slug>.<surface>.<domain>` host rather than the numeric `vm-<N>` one. */ | ||
| function vmIngressStatusInvocation({ | ||
| scriptPath, | ||
| vmNumber, | ||
| domain, | ||
| project, | ||
| zone, | ||
| slug, | ||
| } = {}) { | ||
| if (!scriptPath) throw new Error('vmIngressStatusInvocation requires scriptPath'); | ||
| const n = Number(vmNumber); | ||
| if (!Number.isInteger(n) || n <= 0) { | ||
| throw new Error(`vm-ingress status requires a positive VM number, got: ${vmNumber}`); | ||
| } | ||
| if (!domain) throw new Error('vmIngressStatusInvocation requires a fleet domain'); | ||
| const env = { FLEET_DOMAIN: domain }; | ||
| if (project) env.GCP_PROJECT = project; | ||
| if (zone) env.ZONE = zone; | ||
| if (slug != null && String(slug).trim() !== '') env.VM_SLUG = String(slug).trim(); | ||
| return { command: 'bash', args: [scriptPath, String(n), 'status'], env }; | ||
| } | ||
| /** Parse the `verdict=<wired|partial|unwired>` line vm-ingress.sh `status` | ||
| * prints, returning that verdict or null when the output carries none (e.g. the | ||
| * script errored before emitting it). Pure string parsing so the reconcile | ||
| * decision below stays unit-testable without spawning the script. */ | ||
| function parseIngressStatusVerdict(stdout) { | ||
| if (typeof stdout !== 'string') return null; | ||
| for (const line of stdout.split('\n')) { | ||
| const m = /^verdict=(wired|partial|unwired)\s*$/.exec(line.trim()); | ||
| if (m) return m[1]; | ||
| } | ||
| return null; | ||
| } | ||
| /** Pick which rostered VMs reconcile must re-wire from their status verdicts. | ||
| * `statuses` is `[{ vmNumber, verdict, ... }]`; a VM is selected iff its verdict | ||
| * is `unwired` or `partial` (it has missing LB resources). `wired` VMs are left | ||
| * untouched — no redundant gcloud churn — and an unknown/null verdict (the probe | ||
| * failed) is conservatively SKIPPED rather than blindly re-wired. Returns the | ||
| * selected entries in their input order, preserving any extra fields (slug) the | ||
| * caller threads through. Pure. */ | ||
| function selectIngressReconcileTargets(statuses) { | ||
| if (!Array.isArray(statuses)) return []; | ||
| return statuses.filter((s) => s && (s.verdict === 'unwired' || s.verdict === 'partial')); | ||
| } | ||
| /** Build the local invocation for `scripts/gcp/iap-access.sh <N> <grant|revoke>` | ||
| * — grant or revoke `roles/iap.httpsResourceAccessor` on one VM's per-surface | ||
| * backend services for a set of principals (the per-VM access whitelist). | ||
| * Returns `{ command, args, env }`; `env.VM_ACCESS` is the comma-joined, | ||
| * trimmed principal list the script reads (omitted when empty, so a revoke with | ||
| * nothing to remove is a clean no-op), plus GCP_PROJECT / ZONE when supplied. | ||
| * Mirrors `vmIngressInvocation` so the spawn shape stays unit-testable without | ||
| * ever invoking gcloud. The principal STRINGS are passed through verbatim — | ||
| * validation/normalization is the sidecar's job (npm/fleet-vm-access.js); this | ||
| * builder only shapes the argv/env. */ | ||
| function iapAccessInvocation({ | ||
| scriptPath, | ||
| vmNumber, | ||
| action, | ||
| principals, | ||
| project, | ||
| zone, | ||
| } = {}) { | ||
| if (!scriptPath) throw new Error('iapAccessInvocation requires scriptPath'); | ||
| if (action !== 'grant' && action !== 'revoke') { | ||
| throw new Error(`iap-access action must be 'grant' or 'revoke', got: ${action}`); | ||
| } | ||
| const n = Number(vmNumber); | ||
| if (!Number.isInteger(n) || n <= 0) { | ||
| throw new Error(`iap-access requires a positive VM number, got: ${vmNumber}`); | ||
| } | ||
| const list = Array.isArray(principals) | ||
| ? principals | ||
| : String(principals == null ? '' : principals).split(','); | ||
| const joined = list.map((p) => String(p).trim()).filter(Boolean).join(','); | ||
| const env = {}; | ||
| if (joined) env.VM_ACCESS = joined; | ||
| if (project) env.GCP_PROJECT = project; | ||
| if (zone) env.ZONE = zone; | ||
| return { command: 'bash', args: [scriptPath, String(n), action], env }; | ||
| } | ||
| /** Build the local invocation for `scripts/gcp/google-ingress.sh` — the shared, | ||
| * VM-independent LB infra (static IP, wildcard cert, Cloud DNS, URL map, IAP | ||
| * brand). Returns `{ command, args, env }`; `env` carries only the keys the | ||
| * script reads beyond its defaults (GCP_PROJECT, REGION, ZONE, FLEET_DOMAIN, | ||
| * DNS_ZONE). The script is idempotent, so re-invoking against existing infra is | ||
| * a safe no-op. */ | ||
| function sharedIngressInvocation({ | ||
| scriptPath, | ||
| domain, | ||
| project, | ||
| zone, | ||
| dnsZone, | ||
| region, | ||
| } = {}) { | ||
| if (!scriptPath) throw new Error('sharedIngressInvocation requires scriptPath'); | ||
| if (!domain) throw new Error('sharedIngressInvocation requires a fleet domain'); | ||
| const env = { FLEET_DOMAIN: domain }; | ||
| if (project) env.GCP_PROJECT = project; | ||
| if (region) env.REGION = region; | ||
| if (zone) env.ZONE = zone; | ||
| if (dnsZone) env.DNS_ZONE = dnsZone; | ||
| return { command: 'bash', args: [scriptPath], env }; | ||
| } | ||
| module.exports = { | ||
| DEFAULT_DNS_ZONE, | ||
| resolveIngressDomain, | ||
| resolveDnsZone, | ||
| regionFromZone, | ||
| vmIngressInvocation, | ||
| vmIngressStatusInvocation, | ||
| parseIngressStatusVerdict, | ||
| selectIngressReconcileTargets, | ||
| iapAccessInvocation, | ||
| sharedIngressInvocation, | ||
| }; |
| "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"); | ||
| const { normalizeVmSlug } = require("./vm-urls"); | ||
| const { normalizeAccessList } = require("./fleet-vm-access"); | ||
| /** 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", | ||
| // Optional human-readable hostname slug for a SINGLE VM. When set, cloudUpArgs | ||
| // forwards it as cloud:up's --vm-slug flag so the VM's public URLs become | ||
| // `<slug>.editor.<domain>`. Empty ⇒ the numeric vm-<N> scheme (unchanged). | ||
| vmSlug: "", | ||
| // Optional per-VM IAP access whitelist for a SINGLE VM: a comma-separated list | ||
| // of principals (user:/group:/domain:, a bare email defaults to user:) granted | ||
| // resource-level access to this VM's surfaces. cloudUpArgs forwards it as | ||
| // cloud:up's --vm-access flag. Empty ⇒ only the owner's project-wide grant. | ||
| vmAccess: "", | ||
| }); | ||
| /** Collapse a free-text access list (the add-form textarea, where the operator | ||
| * may separate principals by newlines or commas) into the canonical | ||
| * comma-separated string cloud:up's --vm-access expects. Pure + never throws — | ||
| * shape validation is validateLaunchConfig's job. */ | ||
| function normalizeAccessText(raw) { | ||
| if (typeof raw !== "string") return ""; | ||
| return raw | ||
| .split(/[\n,]+/) | ||
| .map((s) => s.trim()) | ||
| .filter(Boolean) | ||
| .join(","); | ||
| } | ||
| /** 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, | ||
| vmSlug: str(input.vmSlug), | ||
| vmAccess: normalizeAccessText(input.vmAccess), | ||
| }; | ||
| } | ||
| /** 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}` }; | ||
| } | ||
| // A custom slug, when supplied, must be a single DNS label (same rule cloud:up | ||
| // enforces) — surface a friendly reason here rather than letting the spawned | ||
| // job fail loud later. Empty ⇒ numeric vm-<N>, no check. | ||
| if (cfg.vmSlug) { | ||
| try { | ||
| normalizeVmSlug(cfg.vmSlug); | ||
| } catch (e) { | ||
| return { ok: false, msg: e.message }; | ||
| } | ||
| } | ||
| // A per-VM access whitelist, when supplied, must be a list of valid principals | ||
| // (same rule cloud:up enforces) — surface a friendly reason here rather than | ||
| // letting the spawned job fail loud later. Empty ⇒ no check. | ||
| if (cfg.vmAccess) { | ||
| try { | ||
| normalizeAccessList(cfg.vmAccess); | ||
| } catch (e) { | ||
| return { ok: false, msg: e.message }; | ||
| } | ||
| } | ||
| 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 | ||
| } | ||
| /** Return true if the value matches a known test-fixture placeholder (e.g., "gcp-proj"), | ||
| * is null, empty, or whitespace, signaling that it is not a valid GCP project name. | ||
| * Documented as the home for future fixture sentinels. Pure; no I/O. */ | ||
| function isPlaceholderProject(value) { | ||
| if (!value) return true; | ||
| if (typeof value !== "string") return true; | ||
| const trimmed = value.trim(); | ||
| if (trimmed === "") return true; | ||
| if (trimmed.toLowerCase() === "gcp-proj") return true; | ||
| return false; | ||
| } | ||
| /** 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 | ||
| // placeholder in cloud.json (validated via isPlaceholderProject) 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); | ||
| // Forward an optional custom hostname slug. Only single-VM Adds carry one (the | ||
| // dashboard strips it for batch count > 1 — N VMs can't share one hostname), so | ||
| // cloudUpArgs simply emits it when present and trusts the caller's batch guard. | ||
| if (cfg.vmSlug) args.push("--vm-slug", cfg.vmSlug); | ||
| // Forward an optional per-VM access whitelist. Like the slug, only single-VM | ||
| // Adds carry one (the dashboard strips it for batch count > 1 — the same guard | ||
| // actionAdd applies to vmSlug), so cloudUpArgs emits it when present and trusts | ||
| // the caller's batch guard. | ||
| if (cfg.vmAccess) args.push("--vm-access", cfg.vmAccess); | ||
| // 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; | ||
| } | ||
| /** Resolve a VM's app dev-server port stickily: prefer the freshly-polled | ||
| * numeric port, else retain the last-known-good `prevAppPort`, so a transient | ||
| * null poll (timeout / partial probe) never blanks a port the VM has already | ||
| * reported. Mirrors `stickyClientProject` — a populated `prev` survives a | ||
| * null/absent fresh poll, a genuinely-changed fresh poll always wins | ||
| * (stickiness must NOT pin a stale port: a real 3000→4321 move is honored), | ||
| * and `null` results ONLY when neither a polled nor a previous port exists — | ||
| * the true first-provision case, where the caller applies its 5173 bootstrap | ||
| * fallback. | ||
| * | ||
| * Without this, `appPort` was `(info && info.appPort) || null`, so one | ||
| * timed-out poll blanked the resolved port; the tunnel-open fallback then | ||
| * re-seeded the hardcoded 5173 Vite default and the next poll saw a 5173→3000 | ||
| * drift and re-wired — a churn loop that never converged. Pure; no I/O. | ||
| * | ||
| * @param prevAppPort the previous `state.vms[n].appPort` (null/undefined ok) | ||
| * @param info the fresh poll result object (null when the probe failed) | ||
| * @returns the app port to store: fresh numeric port when present, else the | ||
| * last-known-good `prevAppPort`, else null. */ | ||
| function stickyAppPort(prevAppPort, info) { | ||
| const fresh = info && info.appPort; | ||
| if (typeof fresh === 'number' && !Number.isNaN(fresh)) return fresh; | ||
| return typeof prevAppPort === 'number' && !Number.isNaN(prevAppPort) ? prevAppPort : 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 }; | ||
| } | ||
| /** True when the resolved creds `mode` (see `resolveCredsSource`) carries a real | ||
| * Claude-auth seed source, so the add path can seed it automatically and must | ||
| * NOT raise the `claude-auth-not-seeded` setup step: | ||
| * - "donor" — copies the donor's `.credentials.json` + `.claude.json`. | ||
| * - "host-stash" — pushes the operator's CLAUDE_CREDS_SRC `.credentials.json`. | ||
| * The token-only and none modes carry NO Claude credentials — a GITHUB_TOKEN | ||
| * seeds *git* auth at boot but not Claude, so those genuinely have nothing to | ||
| * seed Claude from and the add path raises `claude-auth-not-seeded` (rendered | ||
| * as the calm Plan-1 `auth` setup step with the one-click Login). Branching on | ||
| * this instead of unconditionally landing degraded is what keeps the reason from | ||
| * appearing when a donor/stash WAS available. Pure; no I/O. */ | ||
| function claudeAuthSeeded(mode) { | ||
| return mode === "donor" || mode === "host-stash"; | ||
| } | ||
| // 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)}`; | ||
| } | ||
| /** Build the `nohup … gcloud compute ssh … -N -L …` shell string that opens the | ||
| * EDITOR reachability forward (`editorPort` → VM:14199) as its OWN ssh process, | ||
| * logging to `/tmp/ssh-tunnel-vm-<n>.log`. The editor forward is the dashboard's | ||
| * reachability signal — the probe to `PORT_BASE+N` connects through it — so it | ||
| * must have a lifetime INDEPENDENT of the preview forward (whose port legitimately | ||
| * changes as the app's dev server is resolved). Splitting the two forwards into | ||
| * separate ssh processes is what makes a preview re-wire non-disruptive: a | ||
| * preview-port change restarts only the preview tunnel and never blips :14199, so | ||
| * the reachability probe can't flip the VM to a false "unreachable" (the | ||
| * VM-1/2/3 flapping observed 2026-06-22). Pairs with `previewTunnelCmd`; the two | ||
| * builders are the single source of truth for the attach (addVm) and re-wire call | ||
| * sites so neither forward's `-L` spec can drift back into the other's process. | ||
| * Pure; no I/O. */ | ||
| function editorTunnelCmd({ gcloud, inst, zone, editorPort, n } = {}) { | ||
| return `nohup ${gcloud} compute ssh ${inst} --tunnel-through-iap --zone=${zone} -- -N -L ${editorPort}:127.0.0.1:14199 > /tmp/ssh-tunnel-vm-${n}.log 2>&1 &`; | ||
| } | ||
| /** Build the `nohup … gcloud compute ssh … -N -L …` shell string that opens the | ||
| * LIVE-PREVIEW forward (`previewPort` → VM:<appPort>) as its OWN ssh process, | ||
| * logging to `/tmp/ssh-preview-vm-<n>.log`. The preview forward's target port | ||
| * changes whenever the app's dev-server port is (re)resolved, so it lives apart | ||
| * from the editor reachability forward (`editorTunnelCmd`): a preview re-wire | ||
| * pkills + respawns ONLY this process, leaving the editor :14199 forward — and | ||
| * the reachability probe riding it — untouched. The preview local port is unique | ||
| * per VM and never collides with the editor port, so the re-wire/destroy | ||
| * `pkill -f "<previewPort>:127.0.0.1:"` targets exactly this process. Pure; no | ||
| * I/O. */ | ||
| function previewTunnelCmd({ gcloud, inst, zone, previewPort, appPort, n } = {}) { | ||
| return `nohup ${gcloud} compute ssh ${inst} --tunnel-through-iap --zone=${zone} -- -N -L ${previewPort}:127.0.0.1:${appPort} > /tmp/ssh-preview-vm-${n}.log 2>&1 &`; | ||
| } | ||
| /** Build the shell snippet the Add script runs to AWAIT cloud:up's slow wiring | ||
| * tail instead of reaping it the instant the editor container reports Up. | ||
| * | ||
| * The bug this closes (vm-add-ingress-survives-bes-create): `wireVmIngressOnAdd` | ||
| * runs as a TAIL step of `cloud:up`, AFTER the container gate — the slow | ||
| * EXTERNAL_MANAGED `backend-services create` + NEG attach + url-map host-rule | ||
| * propagation (~2-3 min/step, plus the in-script readiness retries that ride | ||
| * ~6-8 min). The Add used to `kill $UP_PID` ~15s after the container was Up, on | ||
| * the false premise that `cloud:up --no-tunnel` had "already exited". It hadn't, | ||
| * so the reap SIGTERM'd `backend-services create` mid-flight and left the VM | ||
| * backendless at 503 until the dashboard self-heal swept it. This polls the | ||
| * backgrounded cloud:up pid (`pidVar`) until it exits on its own — wiring done, | ||
| * success OR loud-fail — and only force-reaps past `deadlineSec` so a genuinely | ||
| * wedged gcloud can't pin the Add open forever (the self-heal still converges a | ||
| * half-wired backend in that rare case). `pidVar` is referenced by name so it | ||
| * expands at run time. Pure; no I/O. */ | ||
| function awaitWiringCmd({ inst, deadlineSec = 900, pidVar = "UP_PID" } = {}) { | ||
| return [ | ||
| `echo "awaiting LB ingress wiring for ${inst} (cloud:up tail; up to ${deadlineSec}s)..."`, | ||
| `__cy_wiring_waited=0`, | ||
| `while kill -0 $${pidVar} 2>/dev/null; do`, | ||
| ` if [ "$__cy_wiring_waited" -ge ${deadlineSec} ]; then`, | ||
| ` echo "WARN LB ingress wiring still running after ${deadlineSec}s — reaping cloud:up (the dashboard self-heal will converge any half-wired backend)"`, | ||
| ` kill $${pidVar} 2>/dev/null; pkill -f "cloud.js up --instance-name ${inst}" 2>/dev/null; break`, | ||
| ` fi`, | ||
| ` sleep 5; __cy_wiring_waited=$((__cy_wiring_waited + 5))`, | ||
| `done`, | ||
| `wait $${pidVar} 2>/dev/null || true`, | ||
| ].join("\n"); | ||
| } | ||
| /** 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, | ||
| stickyAppPort, | ||
| classifyProjectKind, | ||
| resolveCredsSource, | ||
| claudeAuthSeeded, | ||
| buildCredsSteps, | ||
| buildClaudeJsonPreserveSteps, | ||
| buildEnvApplyCommand, | ||
| resolveEditorSource, | ||
| editorSourceShellResolve, | ||
| rebuildStrategy, | ||
| decideStartJob, | ||
| editorTunnelCmd, | ||
| previewTunnelCmd, | ||
| awaitWiringCmd, | ||
| getAuthRepoUrl, | ||
| interpretLsRemote, | ||
| isPlaceholderProject, | ||
| }; |
| "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"); | ||
| // Known substrings that mean the app refused to initialize because it was loaded | ||
| // from an *insecure context* — a bare dotted-quad IP rather than HTTPS or the | ||
| // hostname `localhost`. This is a whole CLASS of failure (Sveltia CMS's | ||
| // "only works with HTTPS or localhost", anything gating on `isSecureContext`, | ||
| // `crypto.subtle`, service workers, `Secure` cookies, WebAuthn), not one app. | ||
| // Matching reclassifies the error as an actionable `insecure-host` advisory | ||
| // instead of a generic console/page failure that surfaces as a bare | ||
| // `screenshot=null` with no explanation. Substrings are matched | ||
| // case-insensitively; keep the set small and well-known. | ||
| const INSECURE_CONTEXT_SIGNATURES = [ | ||
| "only works with https or localhost", | ||
| "issecurecontext", | ||
| "secure context", | ||
| "requires https", | ||
| ]; | ||
| // The single actionable message the `insecure-host` advisory carries. Names the | ||
| // class of failure and the fix (the preview origin is `localhost` by default; | ||
| // stubborn apps can opt into HTTPS) rather than echoing one app's raw error. | ||
| const INSECURE_HOST_ADVISORY_MESSAGE = | ||
| "The app refused to run because it was loaded from an insecure context " + | ||
| "(a bare IP). It requires a secure context — HTTPS or the hostname " + | ||
| "`localhost`. The preview origin is `localhost` by default; if this persists, " + | ||
| "the app may require HTTPS even on localhost — enable `proxy.httpsPreview` in " + | ||
| ".codeyam/editor.json."; | ||
| // Reclassify an error string as an `insecure-host` advisory when it matches a | ||
| // known insecure-context signature, else null. Pure; the original text is kept | ||
| // as the `contextSnippet` so the operator can still see what the app logged. | ||
| function insecureContextAdvisory(text) { | ||
| if (typeof text !== "string" || text.length === 0) return null; | ||
| const lower = text.toLowerCase(); | ||
| const matched = INSECURE_CONTEXT_SIGNATURES.some((sig) => lower.includes(sig)); | ||
| if (!matched) return null; | ||
| return createIssue("insecure-host", INSECURE_HOST_ADVISORY_MESSAGE, { | ||
| contextSnippet: text, | ||
| }); | ||
| } | ||
| // `allowWebSocket` keeps the real `WebSocket` for scenarios that script a | ||
| // `/ws/terminal` transcript (or a WS stream): those captures NEED the socket to | ||
| // connect so the server replays the scripted agent state into the frame. The | ||
| // stub below exists to silence the live terminal's reconnect loop on EVERY | ||
| // OTHER capture (a component that opens `/ws/terminal` with no scripted | ||
| // playback would otherwise screenshot the "Reconnecting…" overlay); for a | ||
| // scripted scenario the server holds the socket open after replay, so there is | ||
| // no reconnect spam to silence and stubbing only hides the very state we are | ||
| // trying to capture. The flag is computed by the capture orchestrator from the | ||
| // scenario's `mocks.transcripts` / `mocks.streams` — see | ||
| // `scenarioScriptsLiveSocket` in scenario-check.js. | ||
| function getInitScript(allowWebSocket = false) { | ||
| const webSocketStub = allowWebSocket | ||
| ? "" | ||
| : ` | ||
| // 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); | ||
| } | ||
| };`; | ||
| return ` | ||
| window.__codeyamUnhandledRejections = []; | ||
| window.addEventListener("unhandledrejection", (event) => { | ||
| const reason = event.reason; | ||
| const message = | ||
| reason instanceof Error ? reason.message : String(reason); | ||
| window.__codeyamUnhandledRejections.push(message); | ||
| }); | ||
| ${webSocketStub} | ||
| `; | ||
| } | ||
| function handleConsoleMessage(message) { | ||
| if (message.type() !== "error") return null; | ||
| const text = message.text(); | ||
| // An insecure-context refusal wins over the generic `console` classification: | ||
| // it is the targeted, actionable signal for the secure-context app class. | ||
| const advisory = insecureContextAdvisory(text); | ||
| if (advisory) return advisory; | ||
| // 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) { | ||
| const text = error.message || String(error); | ||
| // A secure-context guard often throws at boot rather than logging — surface | ||
| // the same actionable advisory for the pageerror path. | ||
| const advisory = insecureContextAdvisory(text); | ||
| if (advisory) return advisory; | ||
| return createIssue("pageerror", text); | ||
| } | ||
| 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, | ||
| insecureContextAdvisory, | ||
| INSECURE_CONTEXT_SIGNATURES, | ||
| INSECURE_HOST_ADVISORY_MESSAGE, | ||
| }; |
| 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; | ||
| // Reveal-suppression guard. A scroll-gated entrance animation starts its | ||
| // content at `opacity:0` and reveals it via an IntersectionObserver wired | ||
| // from the app shell. Captured in isolation (no shell), the observer never | ||
| // fires, so the text renders into the DOM — `bodyTextLength`/`rootTextLength` | ||
| // are non-zero — yet stays visually invisible and the screenshot is blank. | ||
| // `visibleTextLength` is the length of text actually PAINTED to the frame | ||
| // (computed-style opacity/visibility aware). When the DOM carries text but | ||
| // none of it is visible AND there is no visible media, the frame is | ||
| // near-blank: report no content so the blank gate rejects it instead of | ||
| // waving through an empty shell. `visibleTextLength` is optional — older | ||
| // capture configs (and stubbed test targets) omit it, in which case the | ||
| // original DOM-presence behavior below is preserved byte-for-byte. | ||
| const domTextLength = Math.max( | ||
| state.bodyTextLength || 0, | ||
| state.rootTextLength || 0, | ||
| ); | ||
| const hasVisibleMedia = | ||
| (state.loadedImageCount || 0) > 0 || (state.mediaBboxCount || 0) > 0; | ||
| if ( | ||
| typeof state.visibleTextLength === "number" && | ||
| domTextLength > 0 && | ||
| state.visibleTextLength === 0 && | ||
| !hasVisibleMedia | ||
| ) { | ||
| 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 = []; | ||
| // Distinguish "no text in the DOM at all" from "text rendered but nothing is | ||
| // visible" — the latter is the reveal-suppression symptom (opacity:0 | ||
| // entrance content whose observer never fired in isolation), which points | ||
| // the author at a different fix than a genuinely empty page. | ||
| if ( | ||
| typeof state.visibleTextLength === "number" && | ||
| state.bodyTextLength > 0 && | ||
| state.visibleTextLength === 0 | ||
| ) { | ||
| parts.push("text rendered but not visible (reveal-suppressed?)"); | ||
| } else 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", | ||
| ]; | ||
| // The DOM attribute codeyam's own ScenarioRenderer stamps on its error and | ||
| // seed-error fallback frames. Error detection anchors on this marker rather | ||
| // than scanning the whole page body for ERROR_PATTERNS: a legitimately-mocked | ||
| // scenario whose *content* quotes one of those harness phrases (a journal entry | ||
| // describing the error component, say) must not be flagged as a failed capture. | ||
| // Arbitrary client apps that never render a codeyam ScenarioRenderer never emit | ||
| // the marker, so their pages are treated as healthy regardless of body text — | ||
| // these patterns were always codeyam-harness-specific, never generic. | ||
| const SCENARIO_ERROR_MARKER = "data-codeyam-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}`; | ||
| } | ||
| // Classify a scenario-error fallback into a `{ matchedPattern, contextSnippet }` | ||
| // from the marker info collected off the page. `marker` is the | ||
| // `{ reason, text }` read from the `[data-codeyam-scenario-error]` element, or | ||
| // null when no such element is present. Returns null when there is no marked | ||
| // fallback — the page is healthy and no error-content failure is raised. | ||
| // | ||
| // When the marked fallback text contains a known ERROR_PATTERN (the render-error | ||
| // path renders `Component "X" not found in registry`), that pattern is named so | ||
| // the downstream Rust classifier can still extract the component; otherwise the | ||
| // marker's `reason` attribute classifies it (the seed-error fallback, whose | ||
| // "Seed Error" copy is intentionally not an ERROR_PATTERN). Because the text is | ||
| // scoped to the fallback element — not the whole body — content elsewhere on the | ||
| // page can quote these phrases freely without tripping detection. | ||
| function findScenarioError(marker) { | ||
| // The capture passes the `frame.evaluate` result of querying the marker | ||
| // element: either null (no fallback rendered) or a `{reason, text}` object. | ||
| // Anything else (a bare string, a primitive) is not a marked error — guard | ||
| // so a non-object never reads as a present marker and false-flags the page. | ||
| if (!marker || typeof marker !== "object") return null; | ||
| const text = marker.text || ""; | ||
| const reason = marker.reason || ""; | ||
| const matchedPattern = findErrorPattern(text) || reason || "scenario error"; | ||
| const contextSnippet = | ||
| buildErrorContextSnippet(text, matchedPattern) || | ||
| (text ? text.replace(/\s+/g, " ").trim().slice(0, 200) : null); | ||
| return { matchedPattern, contextSnippet }; | ||
| } | ||
| // Build the non-blocking "this page looks client-fetched" advisory from the two | ||
| // settle signals the capture already computes: `stableOutcome` from | ||
| // waitForStablePage (did the page settle, and was a loading marker still up at | ||
| // the cap?) and `networkOutcome` from waitForNetworkQuiet (did in-flight network | ||
| // go quiet, or did the bounded wait cap out?). Captures settle within a bounded | ||
| // window so a never-idle stream can't hang them — which means a slower | ||
| // client-side fetch lands AFTER the window and the screenshot catches the | ||
| // loading/initial state instead of the populated one. When either signal fires, | ||
| // name the cause and the two reliable fixes (server-render the data, or drive a | ||
| // props-driven isolated component scenario) so the agent reaches for those up | ||
| // front instead of rediscovering the constraint by trial and error. Returns the | ||
| // advisory string, or null when the page settled cleanly (the SSR / props-driven | ||
| // happy path — no advisory, so a healthy capture stays noise-free). | ||
| function buildSettleAdvisory(stableOutcome, networkOutcome) { | ||
| const causes = []; | ||
| if ( | ||
| stableOutcome && | ||
| stableOutcome.stabilized === false && | ||
| stableOutcome.hadLoadingMarkers === true | ||
| ) { | ||
| causes.push("a loading marker was still on the page"); | ||
| } | ||
| if (networkOutcome && networkOutcome.quiet === false) { | ||
| causes.push("network requests were still in flight"); | ||
| } | ||
| if (causes.length === 0) return null; | ||
| return ( | ||
| `When this page was captured, ${causes.join(" and ")} — it likely fetches ` + | ||
| `its data on the client. Captures settle within a bounded window, so data ` + | ||
| `that arrives via a client-side fetch after that window renders as the ` + | ||
| `loading/initial state, not the populated state. To capture a populated ` + | ||
| `state, server-render the data (SSR/RSC) or drive a props-driven isolated ` + | ||
| `component scenario.` | ||
| ); | ||
| } | ||
| module.exports = { | ||
| hasLoadingMarkers, | ||
| hasRenderableContent, | ||
| buildSettleAdvisory, | ||
| describeBlankReason, | ||
| shouldStopWaitingForImages, | ||
| hasErrorPatterns, | ||
| findErrorPattern, | ||
| findScenarioError, | ||
| buildErrorContextSnippet, | ||
| ERROR_PATTERNS, | ||
| SCENARIO_ERROR_MARKER, | ||
| 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: "[]", | ||
| }); | ||
| }); | ||
| } | ||
| // True when a console "Failed to load resource" error at `url` corresponds to | ||
| // a mock this scenario DECLARED with an error status (>= 400). An intentional | ||
| // error-state scenario (e.g. a History tab mocking `GET /api/history` -> 500) | ||
| // must not fail its own capture on the console noise its mock deliberately | ||
| // produces. Console errors carry no HTTP method, so any method's mock on a | ||
| // matching target counts. | ||
| function isDeclaredErrorMock(httpMocks, url) { | ||
| const candidates = normalizeMockCandidates(url); | ||
| for (const [key, mock] of Object.entries(httpMocks || {})) { | ||
| const spaceIdx = key.indexOf(" "); | ||
| if (spaceIdx === -1) continue; | ||
| const target = key.slice(spaceIdx + 1); | ||
| if (!candidates.includes(target)) continue; | ||
| if ((mock.status || 200) >= 400) return true; | ||
| } | ||
| return false; | ||
| } | ||
| module.exports = { | ||
| normalizeMockCandidates, | ||
| findHttpMock, | ||
| mockedTargets, | ||
| requestTargetsMock, | ||
| attachHttpMocks, | ||
| isDeclaredErrorMock, | ||
| }; |
| 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>`; | ||
| } | ||
| const path = require("path"); | ||
| // The reserved route the editor serves the secure-context iframe harness from. | ||
| // Mirrors `HARNESS_PATH` in crates/control-api/src/preview_proxy_route.rs — the | ||
| // canonical source of the harness markup now lives in that Rust handler; the | ||
| // `buildIframeHarness` template above survives only as the degraded fallback | ||
| // below for when the harness origin can't be resolved. | ||
| const HARNESS_PATH = "/__codeyam_harness"; | ||
| // Read `.codeyam/server-state.json` (cwd is the project dir — see the timing | ||
| // log note above) and return its parsed object, or null when it is absent. | ||
| function defaultReadServerState() { | ||
| const statePath = path.join(process.cwd(), ".codeyam", "server-state.json"); | ||
| if (!fs.existsSync(statePath)) return null; | ||
| return JSON.parse(fs.readFileSync(statePath, "utf8")); | ||
| } | ||
| // Resolve the `localhost` origin that serves the iframe-harness route. The | ||
| // harness is mounted on the editor's control-api listener, whose port is | ||
| // recorded in `.codeyam/server-state.json` as `controlPort` (the same file the | ||
| // top-level loader already reads for `appPort`). Returns | ||
| // `http://localhost:<controlPort>` — a secure context the nested iframe | ||
| // inherits — or null when the state file is missing/unreadable, in which case | ||
| // the caller falls back to the legacy in-page `setContent` harness. | ||
| // `readStateFile` is injectable so the resolver is unit-testable without disk. | ||
| function resolveHarnessOrigin({ readStateFile = defaultReadServerState } = {}) { | ||
| try { | ||
| const state = readStateFile(); | ||
| const port = state && state.controlPort; | ||
| if (typeof port === "number" && port > 0) { | ||
| return `http://localhost:${port}`; | ||
| } | ||
| } catch (_) { | ||
| /* missing/unreadable state — fall back to setContent */ | ||
| } | ||
| return null; | ||
| } | ||
| // Build the top-level harness URL for a scenario `url` and background. The | ||
| // editor-served harness document embeds `src` as the iframe URL, so the nested | ||
| // scenario inherits the harness's secure context. The background rides along as | ||
| // `bg`. Pure — `URLSearchParams` does the percent-encoding so a scenario URL | ||
| // with its own query string survives intact. Pure. | ||
| function buildHarnessUrl(harnessOrigin, url, background) { | ||
| const params = new URLSearchParams({ src: url }); | ||
| if (background != null && background !== "") { | ||
| params.set("bg", String(background)); | ||
| } | ||
| return `${harnessOrigin}${HARNESS_PATH}?${params.toString()}`; | ||
| } | ||
| 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, | ||
| }; | ||
| }); | ||
| } | ||
| // Trip scroll-gated entrance animations before capture. Many web/visual | ||
| // stacks reveal sections with an IntersectionObserver that flips an | ||
| // `opacity:0` element to its final state once it scrolls into view. Captured | ||
| // in isolation — where the app shell that wires those observers is absent — | ||
| // or on a tall page captured at a fixed viewport, the observers never fire and | ||
| // the content stays invisible, so the screenshot is blank. Scroll the document | ||
| // end-to-end in steps to trip every observer, then return to the top so the | ||
| // captured frame starts where the app does. Stack-agnostic: it drives the same | ||
| // scroll a real user would, touching no framework API. A frame whose document | ||
| // fits in (or is shorter than) the viewport still gets a nudge-and-restore so a | ||
| // single-screen page's scroll-triggered reveal fires too. Returns the measured | ||
| // document height (0 when there is nothing to scroll). | ||
| async function scrollThroughDocument(target) { | ||
| return target.evaluate(() => { | ||
| const doc = document.scrollingElement || document.documentElement; | ||
| const total = Math.max( | ||
| (doc && doc.scrollHeight) || 0, | ||
| document.body ? document.body.scrollHeight : 0, | ||
| ); | ||
| if (typeof window.scrollTo !== "function") return total; | ||
| const viewport = window.innerHeight || 0; | ||
| if (total <= viewport || total === 0) { | ||
| // Even a single-viewport page may gate a reveal on the first scroll | ||
| // event; a nudge-and-restore fires those observers without moving the | ||
| // captured frame off the top. | ||
| window.scrollTo(0, 1); | ||
| window.scrollTo(0, 0); | ||
| return total; | ||
| } | ||
| const stride = Math.max(1, Math.floor((total - viewport) / 8)); | ||
| for (let y = 0; y <= total; y += stride) { | ||
| window.scrollTo(0, y); | ||
| } | ||
| window.scrollTo(0, 0); | ||
| return total; | ||
| }); | ||
| } | ||
| // Sum the length of text that is actually PAINTED to the frame — text whose | ||
| // element ancestor chain is not hidden by `display:none` / `visibility:hidden` | ||
| // and is not collapsed to `opacity:0`, and which occupies a non-zero box. This | ||
| // is the signal the blank-frame gate uses to catch a reveal-suppressed capture: | ||
| // a frame whose DOM rendered text (so `bodyTextLength > 0`) but whose every | ||
| // section is still sitting at the `opacity:0` start of an entrance animation | ||
| // that never fired, leaving the screenshot blank. Counts text mid-transition | ||
| // (any opacity above exactly 0) as visible, so a reveal that has begun is not | ||
| // flagged. Returns `undefined` when the DOM-walking / computed-style APIs are | ||
| // unavailable (e.g. a stubbed test target) so callers fall back to the legacy | ||
| // DOM-presence behavior rather than treating an un-measurable frame as blank. | ||
| async function collectVisibleTextLength(target) { | ||
| return target.evaluate(() => { | ||
| if ( | ||
| typeof document.createTreeWalker !== "function" || | ||
| typeof window.getComputedStyle !== "function" || | ||
| typeof NodeFilter === "undefined" | ||
| ) { | ||
| return undefined; | ||
| } | ||
| const root = document.body || document.documentElement; | ||
| if (!root) return 0; | ||
| const isHidden = (el) => { | ||
| for ( | ||
| let node = el; | ||
| node && node.nodeType === 1; | ||
| node = node.parentElement | ||
| ) { | ||
| const style = window.getComputedStyle(node); | ||
| if (!style) continue; | ||
| if (style.display === "none" || style.visibility === "hidden") | ||
| return true; | ||
| if (parseFloat(style.opacity) === 0) return true; | ||
| } | ||
| return false; | ||
| }; | ||
| const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); | ||
| let total = 0; | ||
| let node; | ||
| while ((node = walker.nextNode())) { | ||
| const text = (node.nodeValue || "").trim(); | ||
| if (!text) continue; | ||
| const el = node.parentElement; | ||
| if (!el || isHidden(el)) continue; | ||
| const rect = el.getBoundingClientRect(); | ||
| if (rect.width <= 0 || rect.height <= 0) continue; | ||
| total += text.length; | ||
| } | ||
| return total; | ||
| }); | ||
| } | ||
| // Inject a capture-only stylesheet that snaps entrance animations to their | ||
| // FINAL frame: remove animation/transition timing and force the common | ||
| // "hidden until revealed" symptoms (`opacity:0`, an entrance `transform`) back | ||
| // to their resting values. This is the belt to `scrollThroughDocument`'s | ||
| // suspenders — it covers pure-CSS keyframe entrances that are mid-flight or | ||
| // stuck at an `opacity:0` start state even after the observers fired. It | ||
| // targets the generic CSS symptom, not any framework's reveal class, so it | ||
| // works for any stack. The caller gates this OFF when a scenario declares an | ||
| // interactive state, so an intentionally animated/collapsed interactive frame | ||
| // is never clobbered. Idempotent (a single injected style id) and best-effort. | ||
| // Returns true when the style is present after the call. | ||
| async function forceFinalVisualState(target) { | ||
| return target.evaluate(() => { | ||
| const STYLE_ID = "__codeyam_force_final_state"; | ||
| if ( | ||
| typeof document.getElementById === "function" && | ||
| document.getElementById(STYLE_ID) | ||
| ) { | ||
| return true; | ||
| } | ||
| if (typeof document.createElement !== "function") return false; | ||
| const style = document.createElement("style"); | ||
| style.id = STYLE_ID; | ||
| style.textContent = | ||
| "*, *::before, *::after {" + | ||
| "animation: none !important;" + | ||
| "transition: none !important;" + | ||
| "opacity: 1 !important;" + | ||
| "transform: none !important;" + | ||
| "}"; | ||
| const head = document.head || document.documentElement; | ||
| if (!head || typeof head.appendChild !== "function") return false; | ||
| head.appendChild(style); | ||
| return true; | ||
| }); | ||
| } | ||
| 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 { stabilized: true, hadLoadingMarkers: false }; | ||
| } | ||
| } 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). | ||
| // The returned `hadLoadingMarkers` is the signal the capture advisory keys | ||
| // off: a marker still on screen at the cap means the page never finished its | ||
| // (likely client-side) load, so the screenshot caught its loading state. | ||
| logCaptureTiming("stable-page", { | ||
| outcome: "timed-out", | ||
| elapsedMs: Date.now() - started, | ||
| timeoutMs, | ||
| lastHadLoadingMarkers, | ||
| lastHtmlStillChanging: lastHtmlChanged, | ||
| }); | ||
| return { stabilized: false, hadLoadingMarkers: lastHadLoadingMarkers }; | ||
| } | ||
| // `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, harnessOrigin } = {}, | ||
| ) { | ||
| await preflight(url); | ||
| // `undefined` (the default) means "resolve from server-state"; an explicit | ||
| // value (including `null`) is honored as-is so tests can force either path. | ||
| const resolvedHarnessOrigin = | ||
| harnessOrigin !== undefined ? harnessOrigin : resolveHarnessOrigin(); | ||
| const navStarted = Date.now(); | ||
| const responsePromise = page | ||
| .waitForResponse( | ||
| (response) => | ||
| response.request().resourceType() === "document" && | ||
| response.url() === url, | ||
| { timeout: 30000 }, | ||
| ) | ||
| .catch(() => null); | ||
| if (resolvedHarnessOrigin) { | ||
| // Navigate the page TOP-LEVEL to the harness document served from the | ||
| // editor's `localhost` origin, so the ancestor document is a secure context | ||
| // and the nested scenario iframe inherits it (the Sveltia-class fix). The | ||
| // inner iframe `src` is still `url`, so the document-response probe above is | ||
| // unchanged. | ||
| await page.goto(buildHarnessUrl(resolvedHarnessOrigin, url, background), { | ||
| waitUntil: "domcontentloaded", | ||
| }); | ||
| } else { | ||
| // Degraded fallback: no resolvable harness origin (server-state missing), so | ||
| // use the legacy in-page harness. The top-level document is then | ||
| // `about:blank` — NOT a secure context — so a secure-context-gated app may | ||
| // refuse to mount, but every non-secure-context scenario captures exactly as | ||
| // before. | ||
| 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(); | ||
| try { | ||
| 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 }; | ||
| } catch (error) { | ||
| if (error.message && error.message.toLowerCase().includes("timeout")) { | ||
| let parsed = null; | ||
| try { | ||
| parsed = new URL(url); | ||
| } catch (_) {} | ||
| if (parsed) { | ||
| let appPort = null; | ||
| try { | ||
| const path = require("path"); | ||
| const statePath = path.join(process.cwd(), ".codeyam", "server-state.json"); | ||
| if (fs.existsSync(statePath)) { | ||
| const state = JSON.parse(fs.readFileSync(statePath, "utf8")); | ||
| appPort = state.appPort; | ||
| } | ||
| } catch (_) {} | ||
| if (appPort) { | ||
| let appHealthy = false; | ||
| try { | ||
| await assertAppPortReachable(`http://127.0.0.1:${appPort}/`, { timeoutMs: 500 }); | ||
| appHealthy = true; | ||
| } catch (_) {} | ||
| if (appHealthy) { | ||
| throw new Error( | ||
| `proxy navigation timed out: the proxy at 127.0.0.1:${parsed.port} did not respond within 30000ms. app healthy on :${appPort}, proxy dead on :${parsed.port}.` | ||
| ); | ||
| } else { | ||
| throw new Error( | ||
| `proxy navigation timed out: the proxy at 127.0.0.1:${parsed.port} did not respond within 30000ms. app also unresponsive on :${appPort}.` | ||
| ); | ||
| } | ||
| } else { | ||
| throw new Error( | ||
| `proxy navigation timed out: the proxy at 127.0.0.1:${parsed.port} did not respond within 30000ms. proxy dead/hung?` | ||
| ); | ||
| } | ||
| } | ||
| } | ||
| throw error; | ||
| } | ||
| } | ||
| // 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}`, | ||
| ); | ||
| } | ||
| try { | ||
| 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; | ||
| case "hover": | ||
| // Reveals hover-only affordances (an action bar, a tooltip) — one of the | ||
| // most common ephemeral states a resting-render screenshot misses. | ||
| await locator.hover({ timeout: timeoutMs }); | ||
| break; | ||
| default: | ||
| throw new Error( | ||
| `preview-interact: unknown action "${action}" (expected click | fill | press | hover)`, | ||
| ); | ||
| } | ||
| } catch (error) { | ||
| const candidates = await collectInteractiveLabels(frame); | ||
| const candidateList = | ||
| candidates.length > 0 ? candidates.join(", ") : "(none found on page)"; | ||
| throw new Error( | ||
| `preview-interact: action "${action}" failed against ${targetDesc}: ${error.message || String(error)}. ` + | ||
| `Candidate interactive labels: ${candidateList}`, | ||
| ); | ||
| } | ||
| } | ||
| // Hold until a visible-text or selector predicate becomes true, bounded by a | ||
| // wall-clock timeout. Behavioral demos hinge on transient states (an overlay | ||
| // appears, then a new round renders); a flow step waits for that real signal | ||
| // instead of a fixed sleep — the same "hold to a real signal with a safety | ||
| // bound" rule the rest of the capture pipeline follows. THROWS on timeout | ||
| // naming the predicate and the bound, so the capture script's outer catch | ||
| // turns a never-appearing predicate into an actionable failure rather than an | ||
| // infinite hang. `text` is matched case-insensitively as a substring (the | ||
| // agent reliably knows the copy it rendered); `selector` is a CSS selector. | ||
| async function waitForPredicate(frame, predicate, { defaultTimeoutMs = 8000 } = {}) { | ||
| const { text, selector, timeoutMs } = predicate || {}; | ||
| const bound = | ||
| typeof timeoutMs === "number" && timeoutMs > 0 ? timeoutMs : defaultTimeoutMs; | ||
| let locator; | ||
| let desc; | ||
| if (typeof text === "string" && text.length > 0) { | ||
| locator = frame.getByText(text, { exact: false }).first(); | ||
| desc = `text "${text}"`; | ||
| } else if (typeof selector === "string" && selector.length > 0) { | ||
| locator = frame.locator(selector).first(); | ||
| desc = `selector "${selector}"`; | ||
| } else { | ||
| throw new Error("waitFor: predicate requires a `text` or `selector` target"); | ||
| } | ||
| try { | ||
| await locator.waitFor({ state: "visible", timeout: bound }); | ||
| } catch (_) { | ||
| throw new Error( | ||
| `waitFor: predicate ${desc} did not become visible within ${bound}ms`, | ||
| ); | ||
| } | ||
| } | ||
| // Drive an ordered sequence of interactions against the settled frame, settling | ||
| // the page between each so a later step sees the DOM the earlier one produced. | ||
| // This is the persisted-scenario path (`scenario.interactions`): unlike the | ||
| // single fire-and-forget `preview-interact`, the whole sequence is replayed on | ||
| // every capture and recapture. Any step that matches nothing throws (with the | ||
| // candidate-labels hint from `performInteraction`), and the caller turns that | ||
| // into a failed capture — never a silent resting-state screenshot for a | ||
| // sequence that didn't fully run. | ||
| // `settle` is injectable so unit tests that drive a mock frame stay | ||
| // network-free and fast; production callers use the default real | ||
| // `waitForStablePage` re-settle between steps. | ||
| async function performInteractionSequence( | ||
| page, | ||
| frame, | ||
| interactions, | ||
| { | ||
| timeoutMs = 5000, | ||
| settleMs = 5000, | ||
| loadingMarkers, | ||
| settle = waitForStablePage, | ||
| } = {}, | ||
| ) { | ||
| for (let i = 0; i < interactions.length; i += 1) { | ||
| try { | ||
| await performInteraction(frame, interactions[i], { timeoutMs }); | ||
| } catch (err) { | ||
| // Prefix the failing step's index so a miss in a multi-step sequence is | ||
| // locatable, matching the model-side `interactions[i]` validator. | ||
| throw new Error(`interactions[${i}]: ${err.message}`); | ||
| } | ||
| await settle(page, frame, settleMs, loadingMarkers); | ||
| } | ||
| } | ||
| module.exports = { | ||
| logCaptureTiming, | ||
| resolveTcpTarget, | ||
| assertAppPortReachable, | ||
| escapeHtmlAttribute, | ||
| buildIframeHarness, | ||
| HARNESS_PATH, | ||
| resolveHarnessOrigin, | ||
| buildHarnessUrl, | ||
| collectContentState, | ||
| scrollThroughDocument, | ||
| collectVisibleTextLength, | ||
| forceFinalVisualState, | ||
| collectImageStates, | ||
| waitForImagesSettled, | ||
| waitForAnimationsSettled, | ||
| createNetworkTracker, | ||
| waitForNetworkQuiet, | ||
| waitForStablePage, | ||
| loadScenarioInIframe, | ||
| loadScenarioTopLevel, | ||
| collectInteractiveLabels, | ||
| performInteraction, | ||
| waitForPredicate, | ||
| performInteractionSequence, | ||
| }; |
| 'use strict'; | ||
| /** | ||
| * Given a list of files with their hash values, decide if there is any script drift. | ||
| * @param {Object} options | ||
| * @param {Array<Object>} options.files | ||
| * Each file entry has: | ||
| * { | ||
| * path: string, | ||
| * localHash: string, | ||
| * remoteHash: string | null, // null indicates fetch/hash failure | ||
| * baseHash: string | null, | ||
| * dirty: boolean | ||
| * } | ||
| * | ||
| * Returns: | ||
| * { | ||
| * verdict: 'clean' | 'local-ahead' | 'stale' | 'diverged', | ||
| * staleFiles: string[] // List of paths that are stale/diverged | ||
| * } | ||
| */ | ||
| function classifyScriptDrift({ files }) { | ||
| if (!files || files.length === 0) { | ||
| return { verdict: 'clean', staleFiles: [] }; | ||
| } | ||
| let hasStale = false; | ||
| let hasDiverged = false; | ||
| let hasLocalAhead = false; | ||
| const staleFiles = []; | ||
| for (const f of files) { | ||
| // If remoteHash or baseHash is missing/null/undefined, we treat it as fetch/hash failure. | ||
| // In that case, we cannot safely declare the file stale/diverged, so we pass through. | ||
| if (f.remoteHash === null || f.baseHash === null || f.remoteHash === undefined || f.baseHash === undefined) { | ||
| continue; | ||
| } | ||
| const localHash = f.localHash; | ||
| const remoteHash = f.remoteHash; | ||
| const baseHash = f.baseHash; | ||
| const dirty = !!f.dirty; | ||
| // 1. Identical local and remote, and not dirty locally -> clean | ||
| if (localHash === remoteHash && !dirty) { | ||
| continue; | ||
| } | ||
| // 2. Remote hasn't changed from base, but local has changed (or is dirty) | ||
| if (remoteHash === baseHash) { | ||
| if (localHash !== baseHash || dirty) { | ||
| hasLocalAhead = true; | ||
| } | ||
| } | ||
| // 3. Local hasn't changed from base, but remote has changed from base | ||
| else if (localHash === baseHash && !dirty) { | ||
| hasStale = true; | ||
| staleFiles.push(f.path); | ||
| } | ||
| // 4. Both local and remote have changed from base and they differ from each other | ||
| else { | ||
| hasDiverged = true; | ||
| staleFiles.push(f.path); | ||
| } | ||
| } | ||
| if (hasDiverged) { | ||
| return { verdict: 'diverged', staleFiles }; | ||
| } | ||
| if (hasStale) { | ||
| return { verdict: 'stale', staleFiles }; | ||
| } | ||
| if (hasLocalAhead) { | ||
| return { verdict: 'local-ahead', staleFiles: [] }; | ||
| } | ||
| return { verdict: 'clean', staleFiles: [] }; | ||
| } | ||
| module.exports = { | ||
| classifyScriptDrift | ||
| }; |
+178
| '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; | ||
| } | ||
| // A single lowercase DNS label: 1–63 chars of letters/digits/hyphen, no leading | ||
| // or trailing hyphen, NO dots. A custom slug becomes the leftmost label of a | ||
| // `<slug>.<surface>.<domain>` host the wildcard cert (`*.editor.<domain>`) and | ||
| // the wildcard `A` records already cover, so it must be exactly one label — | ||
| // `my.slug` would escape the wildcard and resolve nowhere. | ||
| const SLUG_RE = /^(?!-)[a-z0-9-]{1,63}(?<!-)$/; | ||
| // Slugs that would collide with a fixed fleet surface. `dashboard.<domain>` is | ||
| // the operator dashboard's own host; `editor` / `preview` are the per-VM surface | ||
| // labels in `<slug>.editor.<domain>` — a slug of `editor` would mint the | ||
| // nonsensical `editor.editor.<domain>` and shadow the surface segment. | ||
| const RESERVED_SLUGS = Object.freeze(['dashboard', 'editor', 'preview']); | ||
| /** Validate + normalize a custom VM slug into a single lowercase DNS label. | ||
| * A bad slug would bake a dead host into the URL map or shadow another VM's | ||
| * routing, so we reject it loudly at the derivation boundary — the same posture | ||
| * as `resolveFleetDomain` / `normalizeVmNumber`. Rejects: anything that isn't a | ||
| * single label (dots, leading/trailing hyphen, >63 chars, empty); the reserved | ||
| * words `dashboard` / `editor` / `preview`; and the `vm-<N>` shape, which would | ||
| * collide with the numeric scheme. Returns the trimmed/lowercased slug. */ | ||
| function normalizeVmSlug(slug) { | ||
| if (slug == null) throw new Error('VM slug is required'); | ||
| const s = String(slug).trim().toLowerCase(); | ||
| if (!SLUG_RE.test(s)) { | ||
| throw new Error( | ||
| `VM slug "${slug}" is not a valid single DNS label ` + | ||
| '(lowercase letters, digits, hyphens; no dots; 1–63 chars; ' + | ||
| 'no leading/trailing hyphen)', | ||
| ); | ||
| } | ||
| if (RESERVED_SLUGS.includes(s)) { | ||
| throw new Error( | ||
| `VM slug "${s}" is reserved (${RESERVED_SLUGS.join(', ')})`, | ||
| ); | ||
| } | ||
| if (/^vm-\d+$/.test(s)) { | ||
| throw new Error( | ||
| `VM slug "${s}" collides with the numeric vm-<N> scheme — pick a name`, | ||
| ); | ||
| } | ||
| return s; | ||
| } | ||
| /** The leftmost hostname label for a VM: the validated `slug` when supplied, | ||
| * else the numeric `vm-<N>` default. Single-sources the "slug overrides the | ||
| * number, otherwise fall back" rule every hostname derivation shares. */ | ||
| function vmLabel(n, slug) { | ||
| if (slug != null && String(slug).trim() !== '') return normalizeVmSlug(slug); | ||
| return `vm-${normalizeVmNumber(n)}`; | ||
| } | ||
| /** 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. | ||
| * | ||
| * An optional `slug` overrides the leftmost `vm-<N>` label with a human-readable | ||
| * name (`<slug>.editor.<domain>`); with no slug the output is byte-for-byte the | ||
| * numeric scheme, so the change is a no-op for every existing caller. */ | ||
| function vmHostnames(n, domain, slug) { | ||
| const label = vmLabel(n, slug); // validates n (and slug, when present) | ||
| if (!domain) throw new Error('vmHostnames requires a fleet domain'); | ||
| return { | ||
| editor: `${label}.editor.${domain}`, | ||
| preview: `${label}.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. An | ||
| * optional `slug` rides through to `vmHostnames` so the URLs carry the custom | ||
| * hostname; absent, they are unchanged. */ | ||
| function vmUrls(n, domain, slug) { | ||
| const host = vmHostnames(n, domain, slug); | ||
| 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, | ||
| SLUG_RE, | ||
| RESERVED_SLUGS, | ||
| resolveFleetDomain, | ||
| normalizeVmNumber, | ||
| normalizeVmSlug, | ||
| vmLabel, | ||
| vmHostnames, | ||
| vmUrls, | ||
| dashboardHostname, | ||
| dashboardUrl, | ||
| }; |
| import{j as e}from"./markdown-v0F0UEt3.js";import{b as a}from"./react-CSS0HapR.js";import{u as O}from"./useEvents-DHkLJZR4.js";import{b as E}from"./index-C38H1nd8.js";function P({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…"})}const re=Object.freeze(Object.defineProperty({__proto__:null,ConnectionBadge:P},Symbol.toStringTag,{value:"Module"}));function N(){const[t,r]=a.useState(null),[o,s]=a.useState(!0),{events:l}=O(),n=a.useCallback(async()=>{try{const c=await(await fetch("/api/inspector/status")).json();r(c)}catch{}finally{s(!1)}},[]);return a.useEffect(()=>{n()},[n]),a.useEffect(()=>{const i=l[l.length-1];i&&i.type==="scenario_switch"&&n()},[l,n]),{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:o,refetch:n}}function v({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:r}){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:r})}const $={GET:"#D7FF63",POST:"#238636",PUT:"#d29922",DELETE:"#da3633",PATCH:"#bc8cff"};function k({method:t}){const r=$[t.toUpperCase()]??"#8b949e";return e.jsx("span",{style:{color:r,fontWeight:600,fontSize:11,minWidth:48,display:"inline-block"},children:t.toUpperCase()})}function C({color:t,children:r}){return e.jsx("div",{style:{padding:"12px 16px",borderRadius:6,border:`1px solid ${t}44`,background:`${t}11`,color:t},children:r})}const u={padding:"8px 12px",textAlign:"center",color:"#8b949e",fontWeight:500,fontSize:11,textTransform:"uppercase",letterSpacing:"0.5px"},h={padding:"6px 12px"},S={background:"#161b22",border:"1px solid #30363d",borderRadius:6,color:"#c9d1d9",padding:"6px 12px",fontSize:13,fontFamily:"inherit"},R={...S,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"},oe=Object.freeze(Object.defineProperty({__proto__:null,Badge:f,MethodBadge:k,SectionHeader:v,StatusBox:C,inputStyle:S,selectStyle:R,tdStyle:h,thStyle:u},Symbol.toStringTag,{value:"Module"}));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:u,children:"Method"}),e.jsx("th",{style:{...u,textAlign:"left"},children:"Pattern"}),e.jsx("th",{style:u,children:"Status"})]})}),e.jsx("tbody",{children:t.map((r,o)=>e.jsxs("tr",{style:{borderBottom:"1px solid #21262d"},children:[e.jsx("td",{style:{...h,textAlign:"center"},children:e.jsx(k,{method:r.method})}),e.jsx("td",{style:h,children:r.pattern}),e.jsx("td",{style:{...h,textAlign:"center",color:"#8b949e"},children:r.status})]},o))})]})}const se=Object.freeze(Object.defineProperty({__proto__:null,InspectorRouteTable:z},Symbol.toStringTag,{value:"Module"}));function A(){const{scenario:t,previewScenario:r,routes:o,routeCount:s,loading:l}=N();return e.jsxs("section",{children:[e.jsx(v,{title:"Active Scenario"}),l?e.jsx(C,{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:[s," mock route",s!==1?"s":""," loaded"]}),o.length>0&&e.jsx(z,{routes:o})]}):r?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:r.name}),e.jsxs("span",{style:{color:"#8b949e"},children:["(",r.slug,")"]})]}),e.jsx("div",{style:{color:"#8b949e",fontSize:12},children:"Component preview — no HTTP mocks active"})]}):e.jsx(C,{color:"#d29922",children:"No scenario active — all requests pass through to upstream"})]})}const ne=Object.freeze(Object.defineProperty({__proto__:null,InspectorScenarioSection:A},Symbol.toStringTag,{value:"Module"}));function F({textUrlFilter:t,onTextUrlFilterChange:r,methodFilter:o,onMethodFilterChange:s,sourceFilter:l,onSourceFilterChange:n}){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=>r(i.target.value),style:{...S,flex:1,minWidth:0}}),e.jsxs("select",{value:o,onChange:i=>s(i.target.value),style:R,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=>n(i.target.value),style:R,children:[e.jsx("option",{value:"ALL",children:"All Sources"}),e.jsx("option",{value:"MOCKED",children:"Mocked"}),e.jsx("option",{value:"PASSTHROUGH",children:"Passthrough"})]})]})}const ie=Object.freeze(Object.defineProperty({__proto__:null,InspectorTrafficFilterBar:F},Symbol.toStringTag,{value:"Module"})),G={color:"#8b949e",fontSize:11,fontWeight:600,textTransform:"uppercase",letterSpacing:"0.5px",marginBottom:4},M={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 _({label:t,children:r}){return e.jsxs("div",{style:{marginBottom:12},children:[e.jsx("div",{style:G,children:t}),r]})}function B({event:t}){let r=[];try{const n=new URL(t.url,"http://localhost");r=Array.from(n.searchParams.entries())}catch{}const o=t.requestHeaders&&Object.keys(t.requestHeaders).length>0,s=t.requestBody!=null,l=t.responseBody!=null;return e.jsxs("div",{style:{background:"#0d1117",padding:"12px 16px",borderTop:"1px solid #30363d"},children:[r.length>0&&e.jsx(_,{label:"Query Parameters",children:e.jsx("div",{style:{display:"grid",gridTemplateColumns:"auto 1fr",gap:"2px 12px",fontSize:12},children:r.map(([n,i],c)=>e.jsxs("div",{style:{display:"contents"},children:[e.jsx("span",{style:{color:"#D7FF63",fontFamily:"monospace"},children:n}),e.jsx("span",{style:{color:"#c9d1d9",fontFamily:"monospace"},children:i})]},c))})}),o&&e.jsx(_,{label:"Request Headers",children:e.jsx("pre",{style:M,children:Object.entries(t.requestHeaders).map(([n,i])=>`${n}: ${i}`).join(` | ||
| `)})}),s&&e.jsx(_,{label:"Request Body",children:e.jsx("pre",{style:M,children:typeof t.requestBody=="string"?t.requestBody:JSON.stringify(t.requestBody,null,2)})}),l&&e.jsx(_,{label:"Response Body",children:e.jsx("pre",{style:M,children:typeof t.responseBody=="string"?t.responseBody:JSON.stringify(t.responseBody,null,2)})}),r.length===0&&!o&&!s&&!l&&e.jsx("div",{style:{color:"#8b949e",fontSize:12},children:"No additional details available for this request"})]})}const le=Object.freeze(Object.defineProperty({__proto__:null,DetailSection:_,InspectorTrafficDetail:B},Symbol.toStringTag,{value:"Module"}));function L({event:t}){const[r,o]=a.useState(!1),s=new Date(t.timestampMs).toLocaleTimeString(),l=t.mocked;return e.jsxs(e.Fragment,{children:[e.jsxs("tr",{onClick:()=>o(!r),style:{borderBottom:"1px solid #21262d",background:l?"transparent":"rgba(210, 153, 34, 0.05)",cursor:"pointer"},children:[e.jsxs("td",{style:{...h,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:r?"rotate(90deg)":"rotate(0deg)"},children:"▶"}),s]}),e.jsx("td",{style:{...h,textAlign:"center"},children:e.jsx(k,{method:t.method})}),e.jsx("td",{style:{...h,maxWidth:400,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"},children:t.url}),e.jsx("td",{style:{...h,textAlign:"center",color:"#8b949e"},children:t.status??"---"}),e.jsxs("td",{style:{...h,textAlign:"center",color:"#8b949e"},children:[t.durationMs,"ms"]}),e.jsx("td",{style:{...h,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"})})]}),r&&e.jsx("tr",{children:e.jsx("td",{colSpan:6,style:{padding:0},children:e.jsx(B,{event:t})})})]})}const ae=Object.freeze(Object.defineProperty({__proto__:null,InspectorTrafficRow:L},Symbol.toStringTag,{value:"Module"}));function D({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:u,children:"Time"}),e.jsx("th",{style:u,children:"Method"}),e.jsx("th",{style:{...u,textAlign:"left"},children:"URL"}),e.jsx("th",{style:u,children:"Status"}),e.jsx("th",{style:u,children:"Duration"}),e.jsx("th",{style:u,children:"Source"})]})}),e.jsx("tbody",{children:t.map(r=>e.jsx(L,{event:r},r.id))})]})})}const ce=Object.freeze(Object.defineProperty({__proto__:null,InspectorTrafficTable:D},Symbol.toStringTag,{value:"Module"}));function K(t){if(t===void 0)return"checked";if(t==="checked")return"excluded"}function I({chips:t,chipStates:r,onChipStatesChange:o}){if(t.length===0)return null;const s=n=>{const i=new Map(r),c=K(i.get(n));c===void 0?i.delete(n):i.set(n,c),o(i)},l=()=>{o(new Map)};return e.jsxs("div",{style:{display:"flex",flexWrap:"wrap",gap:6,marginBottom:12},children:[r.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:n,count:i})=>{const c=r.get(n),d=c==="checked",g=c==="excluded";let m="#30363d",b="#8b949e",T="#30363d",y="";return d?(m="#D7FF6322",b="#D7FF63",T="#D7FF6355",y="✓ "):g&&(m="#da363322",b="#da3633",T="#da363355",y="✗ "),e.jsxs("button",{onClick:()=>s(n),style:{background:m,color:b,border:`1px solid ${T}`,borderRadius:4,padding:"2px 8px",fontSize:11,fontFamily:"monospace",cursor:"pointer",whiteSpace:"nowrap"},children:[y,n,e.jsxs("span",{style:{marginLeft:4,opacity:.6,fontFamily:"inherit"},children:["(",i,")"]})]},n)})]})}const de=Object.freeze(Object.defineProperty({__proto__:null,InspectorTrafficUrlChips:I},Symbol.toStringTag,{value:"Module"}));function H(){const{events:t}=O(),[r,o]=a.useState(""),[s,l]=a.useState(()=>new Map),[n,i]=a.useState("ALL"),[c,d]=a.useState("ALL"),g=t.filter(p=>p.type==="http_request"),m=a.useMemo(()=>{const p=new Map;for(const x of g){const j=E(x.url);p.set(j,(p.get(j)||0)+1)}return Array.from(p.entries()).sort((x,j)=>j[1]-x[1]).slice(0,10).map(([x,j])=>({path:x,count:j}))},[g]),b=a.useMemo(()=>Array.from(s.values()).some(p=>p==="checked"),[s]),y=g.filter(p=>{if(r&&!p.url.toLowerCase().includes(r.toLowerCase()))return!1;const x=E(p.url);return!(s.get(x)==="excluded"||b&&s.get(x)!=="checked"||n!=="ALL"&&p.method.toUpperCase()!==n||c==="MOCKED"&&!p.mocked||c==="PASSTHROUGH"&&p.mocked)}).slice(-50).reverse();return e.jsxs("section",{children:[e.jsx(v,{title:"Live Traffic"}),e.jsx(F,{textUrlFilter:r,onTextUrlFilterChange:o,methodFilter:n,onMethodFilterChange:i,sourceFilter:c,onSourceFilterChange:p=>d(p)}),e.jsx(I,{chips:m,chipStates:s,onChipStatesChange:l}),y.length===0?e.jsx(C,{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(D,{events:y})]})}const pe=Object.freeze(Object.defineProperty({__proto__:null,InspectorTrafficSection:H},Symbol.toStringTag,{value:"Module"}));function J(){const[t,r]=a.useState(null),[o,s]=a.useState(!1);return{explain:a.useCallback(async(n,i)=>{s(!0);try{const d=await(await fetch("/api/inspector/explain",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({method:n,url:i})})).json();r(d)}catch{}finally{s(!1)}},[]),result:t,loading:o}}function U({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((r,o)=>e.jsxs("div",{style:{display:"flex",alignItems:"center",gap:8,padding:"4px 8px",borderRadius:4,background:r.failure?"transparent":"rgba(35, 134, 54, 0.1)"},children:[e.jsx("span",{style:{color:r.failure?"#da3633":"#238636",fontWeight:600,width:16},children:r.failure?"x":"v"}),e.jsx(k,{method:r.method}),e.jsx("span",{children:r.pattern}),r.failure&&e.jsx("span",{style:{color:"#8b949e",fontSize:11},children:r.failure.reason==="methodMismatch"?`method: expected ${r.failure.expected}, got ${r.failure.actual}`:`path "${r.failure.path}" does not match "${r.failure.pattern}"`})]},o))})]})}const ue=Object.freeze(Object.defineProperty({__proto__:null,InspectorExplainerResult:U},Symbol.toStringTag,{value:"Module"}));function W(){const{explain:t,result:r,loading:o}=J(),[s,l]=a.useState("GET"),[n,i]=a.useState(""),c=d=>{d.preventDefault(),n.trim()&&t(s,n.trim())};return e.jsxs("section",{children:[e.jsx(v,{title:"Match Explainer"}),e.jsxs("form",{onSubmit:c,style:{display:"flex",gap:8,marginBottom:12},children:[e.jsx("select",{value:s,onChange:d=>l(d.target.value),style:{...S,width:100},children:["GET","POST","PUT","DELETE","PATCH"].map(d=>e.jsx("option",{value:d,children:d},d))}),e.jsx("input",{type:"text",value:n,onChange:d=>i(d.target.value),placeholder:"/api/users",style:{...S,flex:1}}),e.jsx("button",{type:"submit",disabled:o||!n.trim(),style:{background:"#238636",color:"#fff",border:"none",borderRadius:6,padding:"6px 16px",cursor:"pointer",fontSize:12,fontWeight:500,opacity:o||!n.trim()?.5:1},children:o?"Checking...":"Explain"})]}),r&&e.jsx(U,{result:r})]})}const he=Object.freeze(Object.defineProperty({__proto__:null,InspectorExplainerSection:W},Symbol.toStringTag,{value:"Module"})),w={"/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 V(t){if(w[t])return w[t];for(const[r,o]of Object.entries(w))if(r.endsWith("/*")){const s=r.slice(0,-1);if(t.startsWith(s))return o}}function Y(t){const r=new Set([...t,...Object.keys(w)]),o=[];for(const s of Array.from(r).sort()){if(s.endsWith("/*"))continue;const l=V(s);o.push({path:s,description:l??"Application endpoint"})}return o}function q(){const{events:t}=O(),r=a.useMemo(()=>{const o=[];for(const s of t)s.type==="http_request"&&o.push(E(s.url));return Y(o)},[t]);return r.length===0?null:e.jsxs("section",{children:[e.jsx(v,{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:r.map(({path:o,description:s})=>e.jsxs("tr",{style:{borderBottom:"1px solid #21262d"},children:[e.jsx("td",{style:{padding:"4px 12px",fontFamily:"monospace",color:"#D7FF63",fontSize:11},children:o}),e.jsx("td",{style:{padding:"4px 12px",color:"#8b949e"},children:s})]},o))})]})})]})}const xe=Object.freeze(Object.defineProperty({__proto__:null,InspectorUrlGlossary:q},Symbol.toStringTag,{value:"Module"}));function Q(){const{connected:t}=O();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(P,{connected:t})]}),e.jsx(A,{}),e.jsx(H,{}),e.jsx(W,{}),e.jsx(q,{})]})}const fe=Object.freeze(Object.defineProperty({__proto__:null,ContainerInspector:Q},Symbol.toStringTag,{value:"Module"}));export{f as B,Q as C,_ as D,L as I,k as M,v as S,xe as _,C as a,q as b,I as c,D as d,F as e,B as f,U as g,W as h,H as i,z as j,A as k,P as l,de as m,ce as n,pe as o,ae as p,ie as q,le as r,ne as s,u as t,se as u,oe as v,he as w,ue as x,fe as y,re as z}; |
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
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 l={color:"var(--text-secondary)",fontSize:"var(--text-sm, 14px)",lineHeight:1.6},s={...l,fontSize:"var(--text-xs, 12px)",lineHeight:1.5};function m({children:i,variant:d="default"}){const o=d==="compact",t=o?"0.375rem":"0.5rem";return r.jsx("div",{className:"markdown-content",style:o?s:l,children:r.jsx(a,{remarkPlugins:[c],components:{h1:({children:e})=>r.jsx("h1",{style:{fontSize:o?"1rem":"1.125rem",fontWeight:600,color:"var(--text-primary)",margin:`${t} 0`},children:e}),h2:({children:e})=>r.jsx("h2",{style:{fontSize:o?"0.875rem":"1rem",fontWeight:600,color:"var(--text-primary)",margin:`${t} 0`},children:e}),h3:({children:e})=>r.jsx("h3",{style:{fontSize:o?"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:n})=>(e==null?void 0:e.includes("language-"))?r.jsx("code",{className:e,style:{fontFamily:"'IBM Plex Mono', monospace",fontSize:"0.8125rem"},children:n}):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:n}),pre:({children:e})=>r.jsx("pre",{style:{background:"var(--bg-input)",borderRadius:"6px",padding:o?"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:n})=>r.jsx("a",{href:e,style:{color:"var(--accent-blue)",textDecoration:"underline",textUnderlineOffset:"2px"},target:"_blank",rel:"noopener noreferrer",children:n}),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:i})})}const p=Object.freeze(Object.defineProperty({__proto__:null,Markdown:m},Symbol.toStringTag,{value:"Module"}));export{m as M,p as _}; |
Sorry, the diff of this file is too big to display
| import{j as e}from"./markdown-v0F0UEt3.js";import{b as i}from"./react-CSS0HapR.js";function S(t){const r=t.filter(a=>a.isCurrent),s=t.filter(a=>!a.isCurrent);return[...r,...s]}function C({projects:t,loading:r=!1,error:s=null,busyPath:a=null,onOpen:m,onStop:d,onScan:u,scanning:x=!1,scanResult:v=null,scanError:h=null,onCreate:b,creating:o=!1,createError:f=null,bootstrapping:y=!1}){return e.jsx("div",{className:"flex min-h-screen flex-col items-center bg-[var(--bg-deep)] px-6 py-16 text-[var(--text-primary)]",children:e.jsxs("div",{className:"w-full max-w-2xl",children:[e.jsx(P,{}),b&&e.jsx(T,{onCreate:b,creating:o,createError:f}),u&&e.jsx(E,{onScan:u,scanning:x,scanResult:v,scanError:h}),y&&e.jsx(_,{}),r?e.jsx($,{}):s?e.jsx(B,{message:s}):t.length===0?e.jsx(A,{onScan:u,scanning:x}):e.jsx("ul",{className:"flex flex-col gap-3",children:S(t).map(g=>e.jsx(k,{project:g,busy:a===g.path,onOpen:m,onStop:d},g.path))})]})})}function k({project:t,busy:r,onOpen:s,onStop:a}){const{name:m,path:d,exists:u,running:x,controlPort:v,tests:h,thumbnailUrl:b,isCurrent:o}=t;return e.jsxs("li",{className:`flex items-center justify-between gap-4 rounded-lg border bg-[var(--bg-card)] px-4 py-3 ${o?"border-[var(--accent-active)]":"border-[var(--border-default)]"} ${u?"":"opacity-60"}`,children:[e.jsxs("div",{className:"flex min-w-0 items-center gap-3",children:[e.jsx(F,{src:u?b:null,name:m}),e.jsxs("div",{className:"min-w-0",children:[e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx("span",{className:"truncate font-medium text-[var(--text-primary)]",children:m}),o&&e.jsx(L,{}),x&&e.jsx(R,{controlPort:v})]}),e.jsx("div",{className:"truncate text-xs text-[var(--text-dim)]",title:d,children:d}),u?e.jsx(I,{tests:h}):e.jsx("div",{className:"mt-1 text-xs text-[var(--accent-orange)]",children:"Folder missing — moved or deleted"})]})]}),e.jsx(O,{path:d,exists:u,running:x,busy:r,isCurrent:o,onOpen:s,onStop:a})]})}function P(){return e.jsxs("header",{className:"mb-8",children:[e.jsx("h1",{className:"text-2xl font-semibold text-[var(--text-primary)]",children:"Your projects"}),e.jsx("p",{className:"mt-1 text-sm text-[var(--text-muted)]",children:"Pick a project to open in the editor, or stop one that's running."})]})}function T({onCreate:t,creating:r=!1,createError:s=null}){const[a,m]=i.useState(!1),[d,u]=i.useState(""),[x,v]=i.useState("");if(!(a||r||s!==null))return e.jsx("div",{className:"mb-3",children:e.jsx("button",{type:"button",onClick:()=>m(!0),className:"text-sm text-[var(--text-secondary)] underline-offset-2 transition hover:text-[var(--text-primary)] hover:underline",children:"+ New project…"})});const b=()=>{const o=d.trim();o.length>0&&!r&&t(o,x.trim())};return e.jsxs("div",{className:"mb-3 rounded-lg border border-[var(--border-default)] bg-[var(--bg-card)] px-4 py-4",children:[e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx("input",{type:"text",value:d,onChange:o=>u(o.target.value),onKeyDown:o=>{o.key==="Enter"&&b()},placeholder:"/path/to/new/project","aria-label":"Folder for the new project",className:"min-w-0 flex-1 rounded border border-[var(--border-default)] bg-[var(--bg-input)] px-3 py-1.5 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-dim)] focus:border-[var(--accent-active)] focus:outline-none"}),e.jsx("button",{type:"button",disabled:r||d.trim().length===0,onClick:b,className:"shrink-0 rounded bg-[var(--accent-active)] px-3 py-1.5 text-sm font-medium text-[var(--bg-deep)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40",children:r?"Creating…":"Create"})]}),e.jsx("input",{type:"text",value:x,onChange:o=>v(o.target.value),onKeyDown:o=>{o.key==="Enter"&&b()},placeholder:"Project name (optional)","aria-label":"Project name (optional)",className:"mt-2 w-full rounded border border-[var(--border-default)] bg-[var(--bg-input)] px-3 py-1.5 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-dim)] focus:border-[var(--accent-active)] focus:outline-none"}),s&&e.jsx("div",{className:"mt-2 text-xs text-[var(--accent-red)]",children:s})]})}function E({onScan:t,scanning:r=!1,scanResult:s=null,scanError:a=null}){const[m,d]=i.useState(!1),[u,x]=i.useState(""),[v,h]=i.useState(!0);if(!(m||r||s!==null||a!==null))return e.jsx("div",{className:"mb-6",children:e.jsx("button",{type:"button",onClick:()=>d(!0),className:"text-sm text-[var(--text-secondary)] underline-offset-2 transition hover:text-[var(--text-primary)] hover:underline",children:"+ Add projects from a folder…"})});const o=()=>{const f=u.trim();f.length>0&&!r&&t(f,v)};return e.jsxs("div",{className:"mb-6 rounded-lg border border-[var(--border-default)] bg-[var(--bg-card)] px-4 py-4",children:[e.jsxs("div",{className:"flex items-center gap-2",children:[e.jsx("input",{type:"text",value:u,onChange:f=>x(f.target.value),onKeyDown:f=>{f.key==="Enter"&&o()},placeholder:"/path/to/your/workspace","aria-label":"Folder to scan for projects",className:"min-w-0 flex-1 rounded border border-[var(--border-default)] bg-[var(--bg-input)] px-3 py-1.5 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-dim)] focus:border-[var(--accent-active)] focus:outline-none"}),e.jsx("button",{type:"button",disabled:r||u.trim().length===0,onClick:o,className:"shrink-0 rounded bg-[var(--accent-active)] px-3 py-1.5 text-sm font-medium text-[var(--bg-deep)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40",children:r?"Scanning…":"Scan"})]}),e.jsxs("label",{className:"mt-2 flex items-center gap-2 text-xs text-[var(--text-muted)]",children:[e.jsx("input",{type:"checkbox",checked:v,onChange:f=>h(f.target.checked)}),"Remember this folder and re-scan it next time"]}),a&&e.jsx("div",{className:"mt-2 text-xs text-[var(--accent-red)]",children:a}),s&&!a&&e.jsxs("div",{className:"mt-2 text-xs text-[var(--accent-green)]",children:["Found ",s.found," project",s.found===1?"":"s",", ",s.added," new"]})]})}function _(){return e.jsx("div",{className:"mb-6 rounded-lg border border-[var(--border-default)] bg-[var(--bg-card)] px-4 py-3 text-sm text-[var(--text-muted)]",children:"Looking for your projects…"})}function O({path:t,exists:r,running:s,busy:a,isCurrent:m=!1,onOpen:d,onStop:u}){const x=a?"Opening…":m?"Open this project":s?"Reopen":"Open";return e.jsxs("div",{className:"flex shrink-0 items-center gap-2",children:[s&&r&&e.jsx("button",{type:"button",disabled:a,onClick:()=>u(t),className:"rounded border border-[var(--border-default)] px-3 py-1.5 text-sm text-[var(--text-secondary)] transition hover:border-[var(--accent-red)] hover:text-[var(--accent-red)] disabled:cursor-not-allowed disabled:opacity-50",children:a?"Stopping…":"Stop"}),e.jsx("button",{type:"button",disabled:!r||a,onClick:()=>d(t),className:"rounded bg-[var(--accent-active)] px-3 py-1.5 text-sm font-medium text-[var(--bg-deep)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40",children:x})]})}function L(){return e.jsx("span",{className:"inline-flex items-center rounded-full bg-[var(--accent-active)]/15 px-2 py-0.5 text-xs font-medium text-[var(--accent-active)]",children:"Current"})}function F({src:t,name:r}){return e.jsx("div",{className:"h-12 w-16 shrink-0 overflow-hidden rounded border border-[var(--border-subtle)] bg-[var(--bg-surface)]",children:t?e.jsx("img",{src:t,alt:`${r} preview`,className:"h-full w-full object-cover"}):e.jsx("div",{className:"flex h-full w-full items-center justify-center text-[10px] text-[var(--text-dim)]",children:"no preview"})})}function I({tests:t}){return!t||t.total===0?e.jsx("div",{className:"mt-1 text-xs text-[var(--text-dim)]",children:"No tests yet"}):e.jsxs("div",{className:"mt-1 flex items-center gap-2 text-xs",children:[e.jsxs("span",{className:"text-[var(--text-muted)]",children:[t.total," tests"]}),t.passed>0&&e.jsxs("span",{className:"text-[var(--accent-green)]",children:[t.passed," passed"]}),t.failed>0&&e.jsxs("span",{className:"text-[var(--accent-red)]",children:[t.failed," failed"]})]})}function R({controlPort:t}){return e.jsxs("span",{className:"inline-flex items-center gap-1.5 rounded-full bg-[var(--accent-green)]/15 px-2 py-0.5 text-xs text-[var(--accent-green)]",children:[e.jsx("span",{className:"h-1.5 w-1.5 rounded-full bg-[var(--accent-green)]"}),"running",t?` · :${t}`:""]})}function $(){return e.jsx("div",{className:"rounded-lg border border-[var(--border-default)] bg-[var(--bg-card)] px-4 py-6 text-sm text-[var(--text-muted)]",children:"Loading projects…"})}function B({message:t}){return e.jsxs("div",{className:"rounded-lg border border-[var(--accent-red)]/40 bg-[var(--bg-card)] px-4 py-6 text-sm text-[var(--accent-red)]",children:["Couldn't load projects: ",t]})}function A({onScan:t,scanning:r=!1}={}){return e.jsx("div",{className:"rounded-lg border border-dashed border-[var(--border-default)] bg-[var(--bg-card)] px-4 py-10 text-center",children:e.jsx("p",{className:"text-sm text-[var(--text-muted)]",children:t?e.jsxs(e.Fragment,{children:["No projects yet."," ",r?"Scanning for projects…":"Add a folder above to scan for projects already on disk, or run"," ",!r&&e.jsx("code",{className:"rounded bg-[var(--bg-input)] px-1.5 py-0.5 text-xs text-[var(--text-secondary)]",children:"codeyam-editor init"}),!r&&" in a project folder."]}):e.jsxs(e.Fragment,{children:["No projects yet. Run"," ",e.jsx("code",{className:"rounded bg-[var(--bg-input)] px-1.5 py-0.5 text-xs text-[var(--text-secondary)]",children:"codeyam-editor init"})," ","in a project folder to get started."]})})})}const G=Object.freeze(Object.defineProperty({__proto__:null,CurrentBadge:L,ProjectBootstrapBanner:_,ProjectCreateForm:T,ProjectLauncher:C,ProjectLauncherEmpty:A,ProjectLauncherError:B,ProjectLauncherHeader:P,ProjectLauncherLoading:$,ProjectRow:k,ProjectRowActions:O,ProjectScanForm:E,RunningBadge:R,Thumbnail:F,sortCurrentFirst:S},Symbol.toStringTag,{value:"Module"})),U=3e3;function V(){const[t,r]=i.useState(null),[s,a]=i.useState(null),[m,d]=i.useState(null),[u,x]=i.useState(!1),[v,h]=i.useState(null),[b,o]=i.useState(null),[f,y]=i.useState(!1),[g,w]=i.useState(null),[H,J]=i.useState(!1),j=i.useCallback(async()=>{try{const c=await fetch("/api/projects");if(!c.ok)throw new Error(`HTTP ${c.status}`);const n=await c.json();r(Array.isArray(n.projects)?n.projects:[]),a(null)}catch(c){a(c instanceof Error?c.message:"Failed to load projects")}},[]),N=i.useCallback(async()=>{try{const c=await fetch("/api/projects/scan-status");if(!c.ok)return;const l=!!(await c.json()).running;J(p=>(p&&!l&&j(),l))}catch{}},[j]);i.useEffect(()=>{j(),N();const c=setInterval(()=>{document.visibilityState==="visible"&&(j(),N())},U),n=()=>{document.visibilityState==="visible"&&(j(),N())};return document.addEventListener("visibilitychange",n),()=>{clearInterval(c),document.removeEventListener("visibilitychange",n)}},[j,N]);const D=i.useCallback(async(c,n)=>{x(!0),o(null),h(null);try{const l=await fetch("/api/projects/scan",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({root:c,remember:n})}),p=await l.json().catch(()=>({}));if(!l.ok)throw new Error(typeof p.error=="string"?p.error:`HTTP ${l.status}`);r(Array.isArray(p.projects)?p.projects:[]),h({found:p.found??0,added:p.added??0})}catch(l){o(l instanceof Error?l.message:"Scan failed")}finally{x(!1)}},[]),K=i.useCallback(async(c,n)=>{y(!0),w(null);try{const l=await fetch("/api/projects/create",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(n?{path:c,name:n}:{path:c})}),p=await l.json().catch(()=>({}));if(!l.ok)throw new Error(typeof p.error=="string"?p.error:`HTTP ${l.status}`);if(typeof p.url=="string"){window.location.href=p.url;return}throw new Error("create did not return a url")}catch(l){w(l instanceof Error?l.message:"Failed to create project"),y(!1)}},[]),M=i.useCallback(async c=>{d(c);try{const n=await fetch("/api/projects/open",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({path:c})});if(!n.ok)throw new Error(`HTTP ${n.status}`);const{url:l}=await n.json();if(typeof l=="string"){window.location.href=l;return}throw new Error("open did not return a url")}catch(n){a(n instanceof Error?n.message:"Failed to open project"),d(null)}},[]),z=i.useCallback(async c=>{d(c);try{const n=await fetch("/api/projects/stop",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({path:c})});if(!n.ok)throw new Error(`HTTP ${n.status}`);await j()}catch(n){a(n instanceof Error?n.message:"Failed to stop project")}finally{d(null)}},[j]);return e.jsx(C,{projects:t??[],loading:t===null&&s===null,error:s,busyPath:m,onOpen:M,onStop:z,onScan:D,scanning:u,scanResult:v,scanError:b,onCreate:K,creating:f,createError:g,bootstrapping:H})}const Q=Object.freeze(Object.defineProperty({__proto__:null,ProjectLauncherScreen:V},Symbol.toStringTag,{value:"Module"}));export{L as C,V as P,R,F as T,Q as _,A as a,B as b,$ as c,P as d,_ as e,T as f,E as g,O as h,k as i,C as j,G as k}; |
| 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}; |
Sorry, the diff of this file is too big to display
| /** | ||
| * 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} |
| import{j as c}from"./markdown-v0F0UEt3.js";import{b as o}from"./react-CSS0HapR.js";import{S as l}from"./ScenarioDataPanel-DOgzpzFn.js";function m({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(l,{slug:t,scenarioName:r,variant:"fullPage"})}const p=Object.freeze(Object.defineProperty({__proto__:null,ScenarioDataPanelFullPage:m},Symbol.toStringTag,{value:"Module"}));export{m as S,p as _}; |
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 L}from"./Markdown-BoLHHFXH.js";function P({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(S,{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(k,{position:o.position,ifKey:o.ifKey,arms:o.arms,selectedSlug:r,onSelectSlug:t},`branch-${o.position}`))})}function S({position:n,slug:r,label:t,description:o,isSelected:s,onSelect:i,indent:a=0,branchHint:l}){return e.jsxs("button",{onClick:i,style:{display:"flex",alignItems:"flex-start",gap:8,padding:"7px 8px",paddingLeft:8+a,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,l&&e.jsx("span",{style:{marginLeft:6,fontSize:10,color:"var(--text-dim)",fontFamily:"'IBM Plex Mono', monospace"},children:l})]}),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 k({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(S,{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 X=Object.freeze(Object.defineProperty({__proto__:null,BranchRows:k,StepRow:S,StepsSimulatorStepList:P},Symbol.toStringTag,{value:"Module"})),O=["provider","feature","dimension","screenSizes","hasProject","isResuming","designSystem","stack","needsScaffold","projectDescription","startCommand","hasGlossary","hasUserPrompt","selectedPlan","needsInitialStartingSurface","port","controlPort"],A={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 z({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=>O.includes(o)).map(o=>e.jsx(w,{fieldKey:o,value:r[o],onChange:s=>t(o,s)},o))})}function w({fieldKey:n,value:r,onChange:t}){const o=A[n],s=typeof r=="boolean",i=typeof r=="number",a=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:l=>t(l.target.checked),style:{alignSelf:"flex-start"}}):i?e.jsx("input",{type:"number",value:r,onChange:l=>t(Number(l.target.value)),style:y()}):a?e.jsx("input",{type:"text",value:r.join(", "),onChange:l=>t(l.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:l=>t(l.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"}}const Z=Object.freeze(Object.defineProperty({__proto__:null,StepsSimulatorVariableControl:z,VariableField:w},Symbol.toStringTag,{value:"Module"}));function B({rendered:n,renderError:r,renderLoading:t,selectedSlug:o,selectedDescription:s,visibleVariables:i,vars:a,onVarChange:l}){return e.jsxs("div",{style:{flex:1,display:"flex",flexDirection:"column",overflow:"hidden"},children:[e.jsx(M,{rendered:n,selectedSlug:o,selectedDescription:s}),e.jsx(z,{visibleVariables:i,vars:a,onVarChange:l}),e.jsx(C,{rendered:n,renderError:r,renderLoading:t})]})}function M({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 C({rendered:n,renderError:r,renderLoading:t}){return e.jsx("div",{style:{flex:1,overflow:"auto",padding:"16px 20px"},children:r?e.jsx(b,{message:r,kind:"render"}):n?e.jsx("div",{style:{opacity:t?.6:1},children:e.jsx(L,{children:n.instructions})}):e.jsx("div",{style:{color:"var(--text-muted)",fontSize:13},children:t?"Rendering…":"Select a step."})})}const ee=Object.freeze(Object.defineProperty({__proto__:null,PanelBody:C,PanelHeader:M,StepsSimulatorInstructionsPanel:B},Symbol.toStringTag,{value:"Module"}));function W(){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),[a,l]=d.useState(null),[f,R]=d.useState(W),[I,g]=d.useState(null),[E,m]=d.useState(null),[D,v]=d.useState(!1),j=d.useCallback(async x=>{i(null),o(null),g(null),m(null),l(null);try{const u=await(await fetch(`/api/workflow?mode=${x}`)).json();if("error"in u){i(u.error);return}o(u);const p=N(u);l(p)}catch(c){i(c instanceof Error?c.message:String(c))}},[]);d.useEffect(()=>{j(n)},[n,j]);const _=d.useCallback(async(x,c,u)=>{v(!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{v(!1)}},[]);d.useEffect(()=>{a&&_(a,n,f)},[a,n,f,_]);const T=a?H(t,a):null,V=t&&a?U(t,a):[],F=t?[...V,...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:a,onModeChange:r,onSelectSlug:l}),e.jsx(B,{rendered:I,renderError:E,renderLoading:D,selectedSlug:a,selectedDescription:T,visibleVariables:F,vars:f,onVarChange:(x,c)=>R(u=>({...u,[x]:c}))})]})}function N(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 H(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(b,{message:t,kind:"load"}):r?e.jsx(P,{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 b({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 te=Object.freeze(Object.defineProperty({__proto__:null,ErrorBanner:b,StepsSimulator:$},Symbol.toStringTag,{value:"Module"}));export{k as B,M as P,S,w as V,Z as _,C as a,B as b,z as c,P as d,$ as e,X as f,ee as g,te as h}; |
| import{b as r}from"./react-CSS0HapR.js";function d(e){return e.scenarioType==="application"||e.scenarioType==="component"?e.scenarioType:e.componentName?"component":"application"}function _(e){return d(e)==="application"}function D(e){return d(e)==="component"}function L(e){return{overrideCounts:{mocks:0,seeds:0,inheritedMocks:0,inheritedSeeds:0},...e,scenarioType:d(e)}}const v=["http_request","db_query","process_output","scenario_switch","health_check","hook_execution","error"],h=new Set(v);function y(e){if(!e)return null;try{const n=JSON.parse(e);return typeof n!="object"||n===null||typeof n.type!="string"||!h.has(n.type)?null:n}catch{return null}}const T=new Set(["simulator-ios","simulator-android"]),w={"simulator-ios":"iOS Simulator","simulator-android":"Android Emulator"};function N(e){return T.has(e)}function k(e){return w[e]??"device window"}function C(e=typeof window<"u"?window:void 0){return e?e.self!==e.top:!1}function M({isInIframe:e,previewKind:n,previewMedium:u,selectedScenario:i}){return e||N(u)?!1:n!=="application"?!0:!i||!_(i)}const A=1e3,b=2e3,O=3e4;function P(e="/api/events"){const[n,u]=r.useState([]),[i,m]=r.useState(!1),t=r.useRef(null),a=r.useRef(null),l=r.useRef(0),f=r.useCallback(()=>{t.current&&(t.current.close(),t.current=null);const c=new EventSource(e);t.current=c,c.onopen=()=>{m(!0),l.current=0},c.onmessage=o=>{const s=y(o.data);s&&u(S=>{const p=[...S,s];return p.length>A?p.slice(-500):p})},c.onerror=()=>{m(!1),c.close(),t.current=null,l.current+=1;const o=l.current,s=Math.min(b*2**(o-1),O);(o===1||o%5===0)&&console.warn(`[events] SSE disconnected (attempt #${o}); reconnecting in ${s}ms`),a.current=setTimeout(f,s)}},[e]);r.useEffect(()=>{if(!C())return f(),()=>{t.current&&(t.current.close(),t.current=null),a.current&&(clearTimeout(a.current),a.current=null)}},[f]);const E=r.useCallback(()=>u([]),[]);return{events:n,connected:i,clear:E}}export{D as a,_ as b,N as c,k as d,C as i,L as n,d as r,M as s,P as u}; |
Sorry, the diff of this file is too big to display
| <svg fill="#171717" width="40" height="28" viewBox="0 0 40 28" xmlns="http://www.w3.org/2000/svg"> | ||
| <path d="M12.0391 8.23173H14.5737C15.0795 8.23173 15.5645 8.03081 15.9221 7.6732C16.2797 7.31559 16.4806 6.83057 16.4806 6.32484C16.4806 5.8191 16.2797 5.33408 15.9221 4.97647C15.5645 4.61886 15.0795 4.41797 14.5737 4.41797H12.0477C11.5419 4.41797 11.0569 4.61886 10.6993 4.97647C10.3417 5.33408 10.1408 5.8191 10.1408 6.32484C10.1408 6.57525 10.0915 6.82323 9.99564 7.05458C9.89981 7.28594 9.75934 7.49613 9.58227 7.6732C9.4052 7.85027 9.19498 7.99074 8.96363 8.08657C8.73227 8.1824 8.48432 8.23173 8.2339 8.23173H5.70784C5.20285 8.23173 4.71847 8.43203 4.36099 8.78872C4.0035 9.14541 3.80208 9.62936 3.80095 10.1344C3.80095 10.6394 3.60065 11.1237 3.24396 11.4812C2.88727 11.8387 2.40335 12.0401 1.89835 12.0412C1.3941 12.0435 0.911246 12.2454 0.55548 12.6027C0.199714 12.9601 -5.04736e-06 13.4439 0 13.9481C-1.26644e-06 14.452 0.199853 14.9353 0.55574 15.2919C0.911627 15.6486 1.39449 15.8496 1.89835 15.8507C2.40335 15.8518 2.88727 16.0533 3.24396 16.4107C3.60065 16.7682 3.80095 17.2526 3.80095 17.7576C3.80095 18.2633 4.00187 18.7484 4.35948 19.106C4.71709 19.4636 5.20211 19.6645 5.70784 19.6645H8.2339C8.7389 19.6645 9.22328 19.8648 9.58076 20.2215C9.93825 20.5782 10.1396 21.0621 10.1408 21.5671C10.1408 22.0728 10.3417 22.5579 10.6993 22.9155C11.0569 23.2731 11.5419 23.474 12.0477 23.474H14.5737C14.8239 23.474 15.0717 23.4246 15.3028 23.3287C15.5339 23.2328 15.7439 23.0923 15.9206 22.9152C16.0973 22.7381 16.2374 22.5278 16.3327 22.2965C16.4281 22.0652 16.4769 21.8173 16.4764 21.5671C16.4764 21.0625 16.2759 20.5786 15.9191 20.2217C15.5623 19.8649 15.0783 19.6645 14.5737 19.6645H12.0434C11.5384 19.6633 11.0545 19.4619 10.6978 19.1045C10.3411 18.747 10.1408 18.2626 10.1408 17.7576C10.1408 17.2519 9.93988 16.7668 9.58227 16.4092C9.22466 16.0516 8.73964 15.8507 8.2339 15.8507H5.67798C5.17923 15.8508 4.7004 15.655 4.34456 15.3056C3.98871 14.9561 3.7843 14.4809 3.77536 13.9822C3.77536 13.9822 3.77536 13.9822 3.77536 13.9481C3.77536 13.9481 3.77536 13.9225 3.77536 13.9097C3.7843 13.411 3.98871 12.9358 4.34456 12.5864C4.7004 12.2369 5.17923 12.0411 5.67798 12.0412H8.2339C8.73964 12.0412 9.22466 11.8403 9.58227 11.4827C9.93988 11.1251 10.1408 10.6401 10.1408 10.1344C10.1408 9.63049 10.3407 9.14718 10.6965 8.79049C11.0524 8.4338 11.5353 8.23286 12.0391 8.23173Z"></path> | ||
| <path d="M37.4462 4.49533C36.9404 4.49533 36.4554 4.69622 36.0978 5.05383C35.7402 5.41143 35.5393 5.89646 35.5393 6.40219V10.2117C35.5393 10.4621 35.49 10.7101 35.3942 10.9414C35.2983 11.1728 35.1579 11.383 34.9808 11.5601C34.8037 11.7371 34.5935 11.8776 34.3622 11.9734C34.1308 12.0692 33.8828 12.1186 33.6324 12.1186C33.1274 12.1197 32.6435 12.3211 32.2868 12.6786C31.9301 13.0361 31.7298 13.5205 31.7298 14.0255C31.7287 14.5305 31.5273 15.0144 31.1698 15.3711C30.8123 15.7278 30.3279 15.9281 29.8229 15.9281C29.3179 15.9281 28.8336 15.7278 28.4761 15.3711C28.1186 15.0144 27.9172 14.5305 27.9161 14.0255C27.9161 13.5205 27.7157 13.0361 27.359 12.6786C27.0024 12.3211 26.5184 12.1197 26.0134 12.1186C25.763 12.1186 25.5151 12.0692 25.2837 11.9734C25.0524 11.8776 24.8421 11.7371 24.6651 11.5601C24.488 11.383 24.3475 11.1728 24.2517 10.9414C24.1559 10.7101 24.1065 10.4621 24.1065 10.2117V6.415C24.1187 6.15729 24.0785 5.89977 23.9883 5.65805C23.8981 5.41633 23.7598 5.19543 23.5817 5.00873C23.4036 4.82203 23.1895 4.6734 22.9523 4.57185C22.7151 4.4703 22.4598 4.41797 22.2018 4.41797C21.9438 4.41797 21.6885 4.4703 21.4513 4.57185C21.2141 4.6734 21 4.82203 20.8219 5.00873C20.6439 5.19543 20.5055 5.41633 20.4153 5.65805C20.3251 5.89977 20.2849 6.15729 20.2971 6.415V10.2245C20.2971 10.7295 20.4974 11.2139 20.8541 11.5714C21.2108 11.9288 21.6947 12.1303 22.1997 12.1314C22.7054 12.1314 23.1904 12.3323 23.548 12.6899C23.9057 13.0475 24.1065 13.5325 24.1065 14.0383C24.1077 14.5433 24.3091 15.0272 24.6666 15.3839C25.0241 15.7406 25.5084 15.9409 26.0134 15.9409C26.5162 15.942 26.9982 16.1416 27.3545 16.4964C27.7109 16.8511 27.9127 17.3322 27.9161 17.835V21.6743C27.9161 22.1793 28.1164 22.6637 28.4731 23.0212C28.8297 23.3787 29.3137 23.5801 29.8187 23.5812C30.3244 23.5812 30.8094 23.3803 31.167 23.0227C31.5246 22.6651 31.7256 22.1801 31.7256 21.6743V17.8648C31.7256 17.3598 31.9259 16.8754 32.2825 16.5179C32.6392 16.1605 33.1232 15.9591 33.6281 15.9579C34.1331 15.9579 34.6175 15.7576 34.975 15.4009C35.3325 15.0443 35.5339 14.5603 35.535 14.0553C35.535 13.5496 35.7359 13.0646 36.0935 12.7069C36.4512 12.3493 36.9362 12.1484 37.4419 12.1484C37.9469 12.1473 38.4308 11.9459 38.7875 11.5884C39.1442 11.2309 39.3445 10.7465 39.3445 10.2415V6.415C39.3462 6.16423 39.2984 5.91558 39.2039 5.6833C39.1093 5.45103 38.9699 5.23969 38.7936 5.06138C38.6172 4.88306 38.4075 4.74129 38.1763 4.64415C37.9451 4.54702 37.697 4.49644 37.4462 4.49533Z"></path> | ||
| </svg> |
| <svg width="393" height="47" viewBox="0 0 393 47" fill="#0a0a0a" xmlns="http://www.w3.org/2000/svg"> | ||
| <path d="M29.1354 9.22952H35.2693C36.4932 9.22952 37.6669 8.74329 38.5324 7.87785C39.3978 7.01242 39.884 5.83864 39.884 4.61473C39.884 3.39082 39.3978 2.21704 38.5324 1.3516C37.6669 0.486167 36.4932 0 35.2693 0H29.156C27.9321 0 26.7584 0.486167 25.8929 1.3516C25.0275 2.21704 24.5413 3.39082 24.5413 4.61473C24.5413 5.22075 24.4219 5.82087 24.19 6.38076C23.9581 6.94065 23.6182 7.44933 23.1896 7.87785C22.7611 8.30637 22.2524 8.64632 21.6925 8.87823C21.1326 9.11014 20.5325 9.22952 19.9265 9.22952H13.8133C12.5912 9.22952 11.419 9.71426 10.5538 10.5775C9.6887 11.4407 9.20125 12.6119 9.19852 13.834C9.19852 15.0561 8.71377 16.2283 7.85057 17.0934C6.98737 17.9586 5.81624 18.446 4.59412 18.4487C3.3738 18.4542 2.20527 18.9428 1.34429 19.8076C0.483318 20.6725 -1.22149e-05 21.8432 2.31528e-10 23.0635C-3.06485e-06 24.2829 0.483655 25.4525 1.34492 26.3157C2.20619 27.1789 3.37474 27.6652 4.59412 27.6679C5.81624 27.6706 6.98737 28.1581 7.85057 29.0232C8.71377 29.8883 9.19852 31.0606 9.19852 32.2827C9.19852 33.5066 9.68475 34.6804 10.5502 35.5458C11.4156 36.4112 12.5894 36.8974 13.8133 36.8974H19.9265C21.1486 36.8974 22.3209 37.3822 23.186 38.2454C24.0511 39.1086 24.5385 40.2797 24.5413 41.5019C24.5413 42.7258 25.0275 43.8996 25.8929 44.765C26.7584 45.6304 27.9321 46.1166 29.156 46.1166H35.2693C35.8748 46.1166 36.4744 45.9972 37.0337 45.7651C37.593 45.5331 38.101 45.193 38.5287 44.7644C38.9564 44.3357 39.2954 43.8269 39.5261 43.2671C39.7569 42.7073 39.8751 42.1074 39.8737 41.5019C39.8737 40.2807 39.3886 39.1096 38.5251 38.2461C37.6616 37.3826 36.4904 36.8974 35.2693 36.8974H29.1457C27.9236 36.8947 26.7525 36.4073 25.8893 35.5422C25.0261 34.677 24.5412 33.5048 24.5413 32.2827C24.5413 31.0588 24.0551 29.885 23.1896 29.0196C22.3242 28.1541 21.1504 27.6679 19.9265 27.6679H13.741C12.534 27.6681 11.3752 27.1943 10.5141 26.3486C9.65292 25.5029 9.15823 24.3528 9.13658 23.146C9.13658 23.146 9.13658 23.1461 9.13658 23.0635C9.13658 23.0635 9.13658 23.0015 9.13658 22.9706C9.15823 21.7638 9.65292 20.6137 10.5141 19.768C11.3752 18.9223 12.534 18.4485 13.741 18.4487H19.9265C21.1504 18.4487 22.3242 17.9625 23.1896 17.0971C24.0551 16.2317 24.5413 15.0579 24.5413 13.834C24.5412 12.6146 25.025 11.445 25.8862 10.5818C26.7475 9.71855 27.916 9.23225 29.1354 9.22952Z" fill="white"></path> | ||
| <path d="M90.6271 0.187208C89.4032 0.187208 88.2295 0.673375 87.364 1.53881C86.4986 2.40425 86.0124 3.57803 86.0124 4.80194V14.0212C86.0124 14.6272 85.893 15.2273 85.6611 15.7872C85.4292 16.347 85.0893 16.8558 84.6608 17.2843C84.2322 17.7128 83.7235 18.0527 83.1637 18.2846C82.6038 18.5165 82.0036 18.6359 81.3976 18.6359C80.1755 18.6387 79.0044 19.126 78.1412 19.9912C77.278 20.8563 76.7932 22.0286 76.7932 23.2507C76.7905 24.4728 76.3031 25.644 75.4379 26.5072C74.5728 27.3704 73.4006 27.8551 72.1784 27.8551C70.9563 27.8551 69.7841 27.3704 68.919 26.5072C68.0538 25.644 67.5664 24.4728 67.5637 23.2507C67.5637 22.0286 67.0789 20.8563 66.2157 19.9912C65.3525 19.126 64.1814 18.6387 62.9593 18.6359C62.3532 18.6359 61.7532 18.5165 61.1933 18.2846C60.6334 18.0527 60.1246 17.7128 59.6961 17.2843C59.2676 16.8558 58.9277 16.347 58.6958 15.7872C58.4638 15.2273 58.3445 14.6272 58.3445 14.0212V4.83294C58.374 4.20925 58.2767 3.58604 58.0583 3.00106C57.84 2.41609 57.5052 1.8815 57.0743 1.42967C56.6433 0.977844 56.1251 0.618149 55.5512 0.3724C54.9772 0.12665 54.3593 0 53.7349 0C53.1105 0 52.4926 0.12665 51.9186 0.3724C51.3446 0.618149 50.8265 0.977844 50.3955 1.42967C49.9646 1.8815 49.6298 2.41609 49.4115 3.00106C49.1931 3.58604 49.0958 4.20925 49.1253 4.83294V14.0521C49.1253 15.2743 49.6101 16.4465 50.4733 17.3116C51.3365 18.1767 52.5076 18.6642 53.7297 18.6669C54.9536 18.6669 56.1274 19.1531 56.9929 20.0185C57.8583 20.884 58.3445 22.0577 58.3445 23.2817C58.3472 24.5038 58.8346 25.6749 59.6998 26.5381C60.5649 27.4013 61.7371 27.8861 62.9593 27.8861C64.176 27.8888 65.3425 28.3719 66.2048 29.2304C67.0671 30.0889 67.5555 31.2531 67.5637 32.4699V41.7614C67.5637 42.9835 68.0485 44.1557 68.9117 45.0208C69.7749 45.886 70.946 46.3734 72.1681 46.3761C73.392 46.3761 74.5658 45.8899 75.4312 45.0245C76.2967 44.1591 76.7829 42.9853 76.7829 41.7614V32.5422C76.7829 31.32 77.2676 30.1478 78.1308 29.2826C78.9941 28.4175 80.1652 27.9301 81.3873 27.9274C82.6094 27.9274 83.7817 27.4426 84.6468 26.5794C85.512 25.7162 85.9993 24.545 86.0021 23.3229C86.0021 22.099 86.4883 20.9252 87.3537 20.0598C88.2191 19.1944 89.3929 18.7082 90.6168 18.7082C91.8389 18.7055 93.0101 18.2181 93.8733 17.3529C94.7365 16.4878 95.2213 15.3155 95.2213 14.0934V4.83294C95.2253 4.22605 95.1097 3.6243 94.8809 3.06218C94.6521 2.50006 94.3147 1.98862 93.8879 1.55708C93.4612 1.12555 92.9535 0.782445 92.394 0.547383C91.8345 0.312321 91.234 0.18991 90.6271 0.187208Z" fill="white"></path> | ||
| <path d="M143.08 43.2244C139.48 43.2244 136.389 42.588 133.807 41.3152C131.225 40.0424 129.115 38.3877 127.479 36.3512C125.879 34.3147 124.697 32.0964 123.933 29.6962C123.17 27.2961 122.788 24.9868 122.788 22.7685V21.5684C122.788 19.0955 123.17 16.659 123.933 14.2588C124.733 11.8586 125.952 9.67668 127.588 7.71291C129.224 5.74914 131.297 4.18539 133.807 3.02168C136.352 1.8216 139.352 1.22156 142.807 1.22156C146.407 1.22156 149.535 1.87615 152.19 3.18533C154.881 4.45814 157.026 6.27645 158.626 8.64024C160.227 10.9677 161.19 13.6951 161.518 16.8226H153.717C153.426 15.0043 152.772 13.4769 151.753 12.2405C150.771 10.9677 149.517 10.0222 147.989 9.40393C146.462 8.74934 144.735 8.42205 142.807 8.42205C140.843 8.42205 139.098 8.76753 137.571 9.45848C136.043 10.1131 134.77 11.0586 133.752 12.295C132.734 13.5315 131.952 14.9861 131.406 16.659C130.897 18.3318 130.643 20.1865 130.643 22.223C130.643 24.1868 130.897 26.0051 131.406 27.6779C131.952 29.3507 132.752 30.8236 133.807 32.0964C134.861 33.3328 136.152 34.2965 137.68 34.9875C139.243 35.6785 141.043 36.0239 143.08 36.0239C146.062 36.0239 148.571 35.2966 150.608 33.842C152.681 32.351 153.935 30.2781 154.372 27.6234H162.172C161.809 30.4599 160.845 33.0601 159.281 35.4239C157.717 37.7877 155.572 39.6787 152.844 41.097C150.117 42.5153 146.862 43.2244 143.08 43.2244ZM182.346 43.2244C179.728 43.2244 177.418 42.8062 175.418 41.9698C173.418 41.1334 171.727 40.006 170.345 38.5877C168.963 37.1331 167.909 35.4966 167.181 33.6783C166.49 31.86 166.145 29.969 166.145 28.0052V26.8597C166.145 24.8232 166.509 22.8958 167.236 21.0775C168 19.2228 169.072 17.5863 170.454 16.168C171.873 14.7134 173.582 13.586 175.582 12.786C177.582 11.9496 179.837 11.5313 182.346 11.5313C184.855 11.5313 187.11 11.9496 189.11 12.786C191.11 13.586 192.801 14.7134 194.183 16.168C195.601 17.5863 196.674 19.2228 197.402 21.0775C198.129 22.8958 198.493 24.8232 198.493 26.8597V28.0052C198.493 29.969 198.129 31.86 197.402 33.6783C196.711 35.4966 195.674 37.1331 194.292 38.5877C192.91 40.006 191.219 41.1334 189.219 41.9698C187.219 42.8062 184.928 43.2244 182.346 43.2244ZM182.346 36.7331C184.201 36.7331 185.764 36.333 187.037 35.533C188.31 34.6966 189.274 33.5874 189.928 32.2055C190.583 30.7872 190.91 29.1871 190.91 27.4052C190.91 25.5869 190.565 23.9867 189.874 22.6048C189.219 21.1866 188.237 20.0774 186.928 19.2773C185.655 18.4409 184.128 18.0227 182.346 18.0227C180.564 18.0227 179.019 18.4409 177.709 19.2773C176.437 20.0774 175.455 21.1866 174.764 22.6048C174.073 23.9867 173.727 25.5869 173.727 27.4052C173.727 29.1871 174.055 30.7872 174.709 32.2055C175.4 33.5874 176.382 34.6966 177.655 35.533C178.928 36.333 180.491 36.7331 182.346 36.7331ZM217.405 43.1699C215.332 43.1699 213.423 42.788 211.678 42.0243C209.932 41.2607 208.423 40.1879 207.15 38.8059C205.877 37.424 204.895 35.8239 204.204 34.0056C203.514 32.1509 203.168 30.1508 203.168 28.0052V26.8597C203.168 24.7504 203.495 22.7685 204.15 20.9138C204.841 19.0591 205.786 17.4408 206.986 16.0589C208.223 14.677 209.696 13.6042 211.405 12.8405C213.151 12.0405 215.078 11.6405 217.187 11.6405C219.515 11.6405 221.551 12.1496 223.297 13.1678C225.079 14.1497 226.497 15.6407 227.552 17.6409C228.606 19.641 229.188 22.1503 229.297 25.1686L227.061 22.5503V2.36709H234.643V42.188H228.643V29.5871H229.952C229.843 32.6055 229.224 35.133 228.097 37.1695C226.97 39.1696 225.479 40.6788 223.624 41.697C221.806 42.6789 219.733 43.1699 217.405 43.1699ZM219.096 36.7876C220.587 36.7876 221.951 36.4603 223.188 35.8057C224.424 35.1148 225.406 34.1329 226.133 32.8601C226.897 31.5509 227.279 30.0235 227.279 28.2779V26.096C227.279 24.3504 226.897 22.8958 226.133 21.732C225.37 20.532 224.369 19.6228 223.133 19.0046C221.897 18.35 220.551 18.0227 219.096 18.0227C217.46 18.0227 216.005 18.4227 214.732 19.2228C213.496 19.9865 212.514 21.0775 211.787 22.4957C211.096 23.914 210.75 25.5687 210.75 27.4597C210.75 29.3507 211.114 31.0054 211.841 32.4237C212.569 33.8056 213.551 34.8784 214.787 35.6421C216.06 36.4058 217.496 36.7876 219.096 36.7876ZM256.529 43.2244C253.983 43.2244 251.747 42.788 249.82 41.9152C247.928 41.0425 246.347 39.8787 245.074 38.4241C243.837 36.9331 242.892 35.2784 242.237 33.4601C241.619 31.6418 241.31 29.7871 241.31 27.8961V26.8597C241.31 24.8959 241.619 23.0049 242.237 21.1866C242.892 19.3319 243.837 17.6954 245.074 16.2771C246.347 14.8225 247.91 13.677 249.765 12.8405C251.62 11.9677 253.765 11.5313 256.202 11.5313C259.402 11.5313 262.075 12.2405 264.22 13.6588C266.402 15.0407 268.039 16.8772 269.13 19.1682C270.221 21.4229 270.766 23.8595 270.766 26.4778V29.2053H244.528V24.5686H266.021L263.675 26.8597C263.675 24.9686 263.402 23.3503 262.857 22.0048C262.311 20.6592 261.475 19.6228 260.347 18.8955C259.257 18.1682 257.875 17.8045 256.202 17.8045C254.529 17.8045 253.111 18.1863 251.947 18.95C250.783 19.7137 249.892 20.8229 249.274 22.2775C248.692 23.6958 248.401 25.405 248.401 27.4052C248.401 29.2598 248.692 30.9145 249.274 32.3691C249.856 33.7874 250.747 34.9148 251.947 35.7512C253.147 36.5512 254.674 36.9513 256.529 36.9513C258.384 36.9513 259.893 36.5876 261.057 35.8603C262.22 35.0966 262.966 34.1693 263.293 33.0783H270.275C269.839 35.1148 269.003 36.8967 267.766 38.4241C266.53 39.9515 264.948 41.1334 263.02 41.9698C261.129 42.8062 258.966 43.2244 256.529 43.2244ZM285.337 26.9688L273.337 2.36709H281.301L290.465 21.6229L288.719 21.0775H293.902L292.047 21.6229L300.393 2.36709H307.921L296.847 26.9688H285.337ZM287.301 42.188V25.7687H294.884V42.188H287.301ZM328.768 42.188V33.4056H327.513V23.6413C327.513 21.9321 327.095 20.6592 326.258 19.8228C325.422 18.9864 324.131 18.5682 322.385 18.5682C321.476 18.5682 320.385 18.5864 319.112 18.6227C317.839 18.6591 316.548 18.7137 315.239 18.7864C313.966 18.8228 312.821 18.8773 311.803 18.95V12.5132C312.639 12.4405 313.585 12.3678 314.639 12.295C315.694 12.2223 316.767 12.1859 317.858 12.1859C318.985 12.1496 320.04 12.1314 321.022 12.1314C324.076 12.1314 326.604 12.5314 328.604 13.3315C330.64 14.1315 332.168 15.3862 333.186 17.0954C334.241 18.8046 334.768 21.0411 334.768 23.8049V42.188H328.768ZM319.221 42.9517C317.076 42.9517 315.185 42.5698 313.548 41.8061C311.948 41.0425 310.694 39.9515 309.784 38.5332C308.912 37.1149 308.475 35.4057 308.475 33.4056C308.475 31.2236 309.003 29.4417 310.057 28.0598C311.148 26.6778 312.657 25.6414 314.585 24.9504C316.548 24.2595 318.84 23.914 321.458 23.914H328.331V28.4416H321.349C319.603 28.4416 318.258 28.878 317.312 29.7508C316.403 30.5872 315.948 31.6782 315.948 33.0237C315.948 34.3693 316.403 35.4603 317.312 36.2967C318.258 37.1331 319.603 37.5513 321.349 37.5513C322.403 37.5513 323.367 37.3695 324.24 37.0058C325.149 36.6058 325.895 35.9512 326.476 35.042C327.095 34.0965 327.44 32.8237 327.513 31.2236L329.368 33.351C329.186 35.4239 328.677 37.1695 327.84 38.5877C327.04 40.006 325.913 41.097 324.458 41.8607C323.04 42.588 321.294 42.9517 319.221 42.9517ZM342.89 42.188V12.5678H348.89V25.2777H348.345C348.345 22.2957 348.727 19.8046 349.49 17.8045C350.254 15.768 351.381 14.2406 352.872 13.2224C354.4 12.1678 356.291 11.6405 358.545 11.6405H358.873C361.164 11.6405 363.055 12.1678 364.546 13.2224C366.073 14.2406 367.201 15.768 367.928 17.8045C368.692 19.8046 369.073 22.2957 369.073 25.2777H367.164C367.164 22.2957 367.546 19.8046 368.31 17.8045C369.11 15.768 370.255 14.2406 371.746 13.2224C373.274 12.1678 375.165 11.6405 377.419 11.6405H377.747C380.038 11.6405 381.947 12.1678 383.474 13.2224C385.002 14.2406 386.147 15.768 386.911 17.8045C387.711 19.8046 388.111 22.2957 388.111 25.2777V42.188H380.529V24.5686C380.529 22.7139 380.056 21.2411 379.11 20.1501C378.165 19.0228 376.819 18.4591 375.074 18.4591C373.328 18.4591 371.928 19.041 370.873 20.2047C369.819 21.332 369.292 22.8594 369.292 24.7868V42.188H361.709V24.5686C361.709 22.7139 361.236 21.2411 360.291 20.1501C359.345 19.0228 358 18.4591 356.254 18.4591C354.509 18.4591 353.109 19.041 352.054 20.2047C350.999 21.332 350.472 22.8594 350.472 24.7868V42.188H342.89Z" fill="white"></path> | ||
| </svg> |
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-C38H1nd8.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-CIXZkwfB.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 @@ |
+42
-5
@@ -11,8 +11,32 @@ #!/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() { | ||
| // A cloud container's stdin is not a TTY, so the cross-project takeover prompt | ||
| // in `ensureServer` resolves to "abort" (print-and-exit) here — never a silent | ||
| // kill of another project's editor in a headless context. | ||
| const serverChild = await ensureServer(PORT); | ||
@@ -26,4 +50,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 +66,6 @@ }); | ||
| main(); | ||
| module.exports = { main }; | ||
| if (require.main === module) { | ||
| main(); | ||
| } |
+499
-68
@@ -17,13 +17,46 @@ #!/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 os = require("os"); | ||
| 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 +66,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 +80,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 +90,466 @@ 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() }, | ||
| /** | ||
| * Build the React UI from the editor repo into ui/dist/. A failed build is | ||
| * non-fatal: log a warning and continue on the last-good bundle so a transient | ||
| * vite failure doesn't block launch. The cargo build embeds ui/dist/ at compile | ||
| * time via include_dir! (crates/control-api/src/embedded_ui.rs), so this MUST | ||
| * run before buildBinary() or the binary embeds the *previous* launch's bundle. | ||
| */ | ||
| function buildUI() { | ||
| 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)..."); | ||
| } | ||
| } | ||
| /** | ||
| * Build the launcher artifacts in embed-correct order: UI first (so the fresh | ||
| * ui/dist/ is on disk), then the Rust binary (which embeds that dist via | ||
| * include_dir!). build.rs declares `cargo:rerun-if-changed=ui/dist`, so a fresh | ||
| * bundle on disk before compilation forces cargo to recompile and embed it in | ||
| * the same launch — fixing the one-generation-stale UI bug. Only the launcher | ||
| * (no-subcommand) path calls this; the passthrough path rebuilds only the Rust | ||
| * binary and embeds no fresh UI, which is correct for non-launcher commands. | ||
| * Side-effecting steps are injectable so the ordering can be unit-tested. | ||
| */ | ||
| function buildLauncherArtifacts(deps = {}) { | ||
| const { | ||
| buildUI: doBuildUI = buildUI, | ||
| buildBinary: doBuildBinary = buildBinary, | ||
| enforceFreshBinary: doEnforceFreshBinary = enforceFreshBinary, | ||
| } = deps; | ||
| // UI build FIRST — the fresh bundle must be on disk before cargo embeds it. | ||
| doBuildUI(); | ||
| doEnforceFreshBinary(doBuildBinary(), { | ||
| action: "run editor commands", | ||
| continueVerb: "launching with", | ||
| }); | ||
| 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"); | ||
| } | ||
| 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; | ||
| } | ||
| /** | ||
| * Rebuild artifacts for the passthrough path (a known subcommand like | ||
| * `editor step`/`advance`): the Rust binary ONLY, no UI build. Non-launcher | ||
| * commands never serve a fresh UI, so embedding a rebuilt bundle would be | ||
| * wasted work — and pulling vite into every CLI invocation would slow the | ||
| * common `editor advance` round-trip. Side-effecting steps are injectable so | ||
| * the "no UI build on passthrough" contract is unit-testable. | ||
| */ | ||
| function buildPassthroughArtifacts(deps = {}) { | ||
| const { | ||
| buildBinary: doBuildBinary = buildBinary, | ||
| enforceFreshBinary: doEnforceFreshBinary = enforceFreshBinary, | ||
| } = deps; | ||
| doEnforceFreshBinary(doBuildBinary(), { action: "run editor commands" }); | ||
| } | ||
| /** | ||
| * 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; | ||
| } | ||
| /** | ||
| * Translate a passthrough child's exit into the code the dev wrapper should | ||
| * forward. A subcommand passthrough must report the inner editor binary's OWN | ||
| * outcome faithfully so every caller (gates, scripts, the agent) can trust the | ||
| * exit code: | ||
| * | ||
| * - A real exit code (including 0) is forwarded verbatim — a successful | ||
| * `analyze-imports` that printed valid JSON and exited 0 stays exit 0, | ||
| * never a spurious 1. | ||
| * - A null/undefined code means the child was terminated by a signal (e.g. | ||
| * SIGPIPE after it already produced its output, or SIGTERM at teardown). | ||
| * Report it distinctly via the conventional 128+signum so a clean run that | ||
| * was merely signalled at the end is not collapsed into a generic `1` that | ||
| * reads as a command failure. The previous `code ?? 1` erased that | ||
| * distinction, surfacing a successful inner command as exit 1. | ||
| * | ||
| * Pure (no process.exit) so the mapping is unit-testable. | ||
| */ | ||
| function passthroughExitCode(code, signal) { | ||
| if (code !== null && code !== undefined) { | ||
| return code; | ||
| } | ||
| const signum = signal ? os.constants.signals[signal] : undefined; | ||
| return signum ? 128 + signum : 1; | ||
| } | ||
| async function main() { | ||
| const projectDir = process.cwd(); | ||
| console.error(`Editor repo: ${rootDir}`); | ||
| console.error(`Target project: ${projectDir}`); | ||
| /** | ||
| * 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() !== ""; | ||
| } | ||
| buildBinary(); | ||
| /** | ||
| * 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" }; | ||
| } | ||
| // 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)..."); | ||
| } | ||
| /** | ||
| * 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`." | ||
| ); | ||
| } | ||
| const serverChild = shouldRestart | ||
| ? await restartServer(SERVER_PORT) | ||
| : await ensureServer(SERVER_PORT); | ||
| /** | ||
| * 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 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.`); | ||
| module.exports = { | ||
| hasSubcommand, | ||
| buildBinary, | ||
| buildUI, | ||
| buildLauncherArtifacts, | ||
| buildPassthroughArtifacts, | ||
| shouldRebuildOnPassthrough, | ||
| passthroughExitCode, | ||
| staleBinaryBanner, | ||
| hasStartCommand, | ||
| planPreflightAction, | ||
| postLaunchInitBanner, | ||
| shouldOpenBrowser, | ||
| }; | ||
| 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()) { | ||
| buildPassthroughArtifacts(); | ||
| } | ||
| const binary = ensureBinary(); | ||
| const child = spawn(binary, args, { | ||
| stdio: "inherit", | ||
| cwd: process.cwd(), | ||
| env: process.env, | ||
| }); | ||
| child.on("exit", (code, signal) => { | ||
| // Forward the inner editor binary's OWN outcome faithfully — exit 0 | ||
| // stays 0, a signal-terminated child reports 128+signum, not a generic | ||
| // 1 that would mask a successful command (see passthroughExitCode). | ||
| process.exit(passthroughExitCode(code, signal)); | ||
| }); | ||
| } 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()) { | ||
| // Embed-correct order: UI build → cargo build (embeds the fresh | ||
| // ui/dist via include_dir!). See buildLauncherArtifacts. | ||
| buildLauncherArtifacts(); | ||
| } | ||
| 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(); | ||
| } |
+287
-22
@@ -16,15 +16,43 @@ #!/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, | ||
| fetchServerIdentity, | ||
| formatForeignListenerError, | ||
| resolveCrossProjectCollision, | ||
| 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"); | ||
| async function main() { | ||
| // Build the UI so the backend can serve it as static files | ||
| function buildBinary() { | ||
| console.error("Building Rust binary..."); | ||
| try { | ||
| execSync("cargo build", { stdio: "inherit", cwd: rootDir }); | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
| // Build the React UI into ui/dist/. A failed build is non-fatal: we log a | ||
| // warning and continue on the last-good bundle (a transient vite failure must | ||
| // not block launch). The cargo build embeds ui/dist/ at compile time via | ||
| // include_dir! (crates/control-api/src/embedded_ui.rs), so this MUST run | ||
| // before buildBinary() or the binary embeds the *previous* launch's bundle. | ||
| function buildUI() { | ||
| console.error("Building UI..."); | ||
@@ -39,20 +67,233 @@ try { | ||
| } | ||
| } | ||
| // Build the launch artifacts in embed-correct order: UI first (so the fresh | ||
| // ui/dist/ is on disk), then the Rust binary (which embeds that dist via | ||
| // include_dir!), then install the freshly-compiled binary. build.rs declares | ||
| // `cargo:rerun-if-changed=ui/dist`, so a fresh bundle on disk before | ||
| // compilation forces cargo to recompile and embed it in the same launch — | ||
| // fixing the one-generation-stale UI bug. Side-effecting steps are injectable | ||
| // so the ordering can be unit-tested without shelling out to vite/cargo. | ||
| function buildArtifacts(deps = {}) { | ||
| const { | ||
| buildUI: doBuildUI = buildUI, | ||
| buildBinary: doBuildBinary = buildBinary, | ||
| enforceFreshBinary: doEnforceFreshBinary = enforceFreshBinary, | ||
| installBinary: doInstallBinary = installBinary, | ||
| } = deps; | ||
| // UI build FIRST — the fresh bundle must be on disk before cargo embeds it. | ||
| doBuildUI(); | ||
| // Rebuild the Rust binary so code changes (and the just-built UI) 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 = doBuildBinary(); | ||
| // 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). | ||
| doEnforceFreshBinary(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) { | ||
| doInstallBinary(); | ||
| } | ||
| return buildOk; | ||
| } | ||
| // 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; | ||
| // Build the launch artifacts in embed-correct order: UI build → cargo build | ||
| // (embeds the fresh ui/dist via include_dir!) → install. See buildArtifacts. | ||
| buildArtifacts(); | ||
| // 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") { | ||
| const outcome = await resolveCrossProjectCollision(SERVER_PORT, holder, "restart"); | ||
| if (outcome.action !== "took-over") { | ||
| // "abort" (non-TTY) or "declined": resolver already printed the message. | ||
| process.exit(1); | ||
| } | ||
| // took-over: stopServer freed the port; fall through to the fresh-boot path. | ||
| } | ||
| 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); | ||
| // Wait for the actual Vite dev server on the internal port (not the reverse | ||
| // proxy on VITE_PORT, which starts immediately with the Rust binary). | ||
| console.error("Waiting for Vite dev server..."); | ||
| const viteReady = await waitForPort(VITE_INTERNAL_PORT, 15000, 300); | ||
| if (!viteReady) { | ||
| console.error(`Warning: Vite dev server not ready on port ${VITE_INTERNAL_PORT}. Live Preview may not work initially.`); | ||
| // With 2+ projects registered the launch diverts to the Project Launcher | ||
| // (selector) on SERVER_PORT — there is no wrapped app, so the Vite wait and | ||
| // the "Live Preview" line don't apply. Probe the identity to detect it; | ||
| // best-effort, a missed detection only yields a harmless "Vite not ready" | ||
| // warning, never a failure. | ||
| const identity = await fetchServerIdentity(SERVER_PORT); | ||
| const launcherMode = Boolean(identity && identity.role === "launcher"); | ||
| if (!launcherMode) { | ||
| // Wait for the actual Vite dev server on the internal port (not the reverse | ||
| // proxy on VITE_PORT, which starts immediately with the Rust binary). | ||
| console.error("Waiting for Vite dev server..."); | ||
| const viteReady = await waitForPort(VITE_INTERNAL_PORT, 15000, 300); | ||
| if (!viteReady) { | ||
| console.error(`Warning: Vite dev server not ready on port ${VITE_INTERNAL_PORT}. Live Preview may not work initially.`); | ||
| } | ||
| } | ||
| const url = `http://localhost:${SERVER_PORT}`; | ||
| console.error(`Editor UI: ${url}`); | ||
| console.error(`Live Preview: http://localhost:${VITE_PORT}`); | ||
| console.error(launcherMode ? `Project selector: ${url}` : `Editor UI: ${url}`); | ||
| if (!launcherMode) { | ||
| console.error(`Live Preview: http://localhost:${VITE_PORT}`); | ||
| } | ||
| console.error(`Logs: ${path.join(rootDir, ".codeyam", "logs")}`); | ||
@@ -63,4 +304,14 @@ open(url); | ||
| 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 +321,16 @@ }); | ||
| main(); | ||
| module.exports = { | ||
| buildBinary, | ||
| buildUI, | ||
| buildArtifacts, | ||
| staleBinaryBanner, | ||
| cargoBinDir, | ||
| installBinary, | ||
| linkOrCopyBinary, | ||
| main, | ||
| sweepStaleArtifacts, | ||
| }; | ||
| if (require.main === module) { | ||
| main(); | ||
| } |
+925
-271
| #!/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"; | ||
| // Pin the headless capture browser's `localhost` resolution to the IPv4 | ||
| // loopback the editor's listeners bind. The browser-facing preview origin is | ||
| // now `localhost` (secure-context apps refuse a bare IP — see | ||
| // `BROWSER_FACING_HOST`), but on a dual-stack host `localhost` can resolve to | ||
| // `::1` first, which nothing answers on — stalling the capture or paying a | ||
| // happy-eyeballs fallback delay. Forcing the map removes that intermittency | ||
| // deterministically for capture. The operator's OWN preview browser is covered | ||
| // separately by the editor binding `::1` alongside `127.0.0.1` (see start.rs). | ||
| const CAPTURE_HOST_RESOLVER_RULES = "MAP localhost 127.0.0.1"; | ||
| const CAPTURE_LAUNCH_ARGS = [ | ||
| `--host-resolver-rules=${CAPTURE_HOST_RESOLVER_RULES}`, | ||
| ]; | ||
| 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({ args: CAPTURE_LAUNCH_ARGS }), | ||
| 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 { | ||
| findScenarioError, | ||
| SCENARIO_ERROR_MARKER, | ||
| hasRenderableContent, | ||
| buildSettleAdvisory, | ||
| 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, | ||
| isDeclaredErrorMock, | ||
| } = require("./scenario-mocks"); | ||
| const { | ||
| assertAppPortReachable, | ||
| loadScenarioInIframe, | ||
| loadScenarioTopLevel, | ||
| resolveHarnessOrigin, | ||
| waitForStablePage, | ||
| createNetworkTracker, | ||
| waitForNetworkQuiet, | ||
| collectContentState, | ||
| scrollThroughDocument, | ||
| collectVisibleTextLength, | ||
| forceFinalVisualState, | ||
| performInteraction, | ||
| waitForPredicate, | ||
| performInteractionSequence, | ||
| } = require("./scenario-playwright"); | ||
| 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 createIssue(kind, message, extra = {}) { | ||
| return { | ||
| kind, | ||
| message, | ||
| url: extra.url ?? null, | ||
| status: extra.status ?? null, | ||
| }; | ||
| // True when the scenario being captured scripts a `/ws/terminal` transcript or | ||
| // a WebSocket stream. Such captures need the REAL `WebSocket` so the server can | ||
| // replay the scripted agent state into the frame — `getInitScript` keeps its | ||
| // reconnect-silencing stub OFF for them (see the comment there). Read from the | ||
| // scenario file by slug (`config.scenarioId`), the same cwd-relative path | ||
| // `readStackLoadingMarkers` uses. Defaults to `false` (stub stays on, today's | ||
| // behavior) for the scenario-less CLI preview path or any unreadable/parse | ||
| // failure, so a missing file never accidentally un-stubs a live terminal. | ||
| function scenarioScriptsLiveSocket(config) { | ||
| const slug = config && config.scenarioId; | ||
| if (!slug) return false; | ||
| try { | ||
| const raw = fs.readFileSync( | ||
| path.join(".codeyam", "scenarios", `${slug}.json`), | ||
| "utf8", | ||
| ); | ||
| const mocks = (JSON.parse(raw) || {}).mocks || {}; | ||
| const transcripts = mocks.transcripts || {}; | ||
| const streams = mocks.streams || {}; | ||
| return Object.keys(transcripts).length > 0 || Object.keys(streams).length > 0; | ||
| } catch (_) { | ||
| return false; | ||
| } | ||
| } | ||
| function pushIssue(issues, issue) { | ||
| const key = JSON.stringify(issue); | ||
| if (!issues.some((existing) => JSON.stringify(existing) === key)) { | ||
| issues.push(issue); | ||
| async function getDOMFingerprint(frame) { | ||
| try { | ||
| return await frame.evaluate(() => { | ||
| const body = document.body; | ||
| if (!body) return ""; | ||
| const html = body.innerHTML; | ||
| let hash = 0; | ||
| for (let i = 0; i < html.length; i++) { | ||
| hash = (hash << 5) - hash + html.charCodeAt(i); | ||
| hash |= 0; | ||
| } | ||
| return `${html.length}-${hash}`; | ||
| }); | ||
| } catch (err) { | ||
| 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; | ||
| // Pause after scrolling the document to trip scroll-gated reveal observers, so | ||
| // the IntersectionObserver callbacks fire and any opacity/transform entrance | ||
| // transition begins before we measure visible content and screenshot. Driven | ||
| // through `page.waitForTimeout` so it is a real wait in Playwright but instant | ||
| // against the stubbed pages in unit tests. | ||
| const REVEAL_SETTLE_MS = 600; | ||
| // Measure the frame's visible (painted, non-opacity:0) text and fold it into | ||
| // the content state in place, so the blank gate can distinguish "text rendered | ||
| // but invisible" from a populated frame. Best-effort: a frame/target that | ||
| // cannot be measured (a stubbed test target returns a non-number, an evaluate | ||
| // throws) leaves the state untouched, so `hasRenderableContent` falls back to | ||
| // its DOM-presence behavior rather than treating an un-measurable frame as | ||
| // blank. | ||
| async function mergeVisibleTextLength(contentState, frame) { | ||
| if (!contentState) return; | ||
| let visible; | ||
| try { | ||
| visible = await collectVisibleTextLength(frame); | ||
| } catch (_) { | ||
| return; | ||
| } | ||
| if (typeof visible === "number") { | ||
| contentState.visibleTextLength = visible; | ||
| } | ||
| } | ||
| 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; | ||
| } | ||
| // 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; | ||
| } | ||
| } | ||
| 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>`; | ||
| // Decide whether a failed request should fail the capture. Only a failure | ||
| // targeting the captured page's OWN origin counts — a first-party route that | ||
| // 500s or a same-origin asset that 404s is a real capture problem. A | ||
| // cross-origin failure (the mocked app's dev port being down, an external | ||
| // CDN/API) is an external resource and is tolerated, so the editor-shell | ||
| // screenshot still succeeds. Mirrors the console handler's tolerance for | ||
| // declared error-mock URLs. When `appOrigin` is unknown (malformed capture | ||
| // URL) the failure stays fatal — the conservative default preserves today's | ||
| // behavior rather than silently swallowing every failure. Pure, so the | ||
| // decision is unit-testable without a live browser. | ||
| function isCaptureFatalRequestFailure(url, appOrigin) { | ||
| if (!appOrigin) return true; | ||
| return !isCrossOriginRequest(url, appOrigin); | ||
| } | ||
| function buildResult({ loaded, hasContent, issues, outputPath, url }) { | ||
| return { | ||
| ok: loaded && hasContent && issues.length === 0, | ||
| loaded, | ||
| hasContent, | ||
| url, | ||
| outputPath: outputPath ?? null, | ||
| issues, | ||
| }; | ||
| // 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 normalizeMockCandidates(url) { | ||
| // 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); | ||
| } | ||
| // 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 }); | ||
| }, | ||
| ); | ||
| } | ||
| // Seed the scenario's `browserState.localStorage` / `.sessionStorage` into | ||
| // the page before any app JS runs. Storage-gated UI (first-run banners, | ||
| // dismissed-prompt flags, persisted view state) is otherwise | ||
| // uncontrollable at capture time. Playwright serializes the function and | ||
| // its arg into the page context, so the function body must not close over | ||
| // outer variables. Only registered when the scenario actually carries | ||
| // storage, preserving the pre-storage capture context for everyone else. | ||
| const localStorageSeed = state.localStorage || {}; | ||
| const sessionStorageSeed = state.sessionStorage || {}; | ||
| if ( | ||
| Object.keys(localStorageSeed).length > 0 || | ||
| Object.keys(sessionStorageSeed).length > 0 | ||
| ) { | ||
| await context.addInitScript( | ||
| (storage) => { | ||
| try { | ||
| for (const [key, value] of Object.entries(storage.local)) { | ||
| window.localStorage.setItem(key, value); | ||
| } | ||
| for (const [key, value] of Object.entries(storage.session)) { | ||
| window.sessionStorage.setItem(key, value); | ||
| } | ||
| } catch (_) { | ||
| // Storage unavailable (sandboxed/opaque origin) — the seed is | ||
| // best-effort; never fail the capture over it. | ||
| } | ||
| }, | ||
| { local: localStorageSeed, session: sessionStorageSeed }, | ||
| ); | ||
| } | ||
| } | ||
| 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, | ||
| }); | ||
| }); | ||
| 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 */ | ||
| } | ||
| } | ||
| // 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: "[]", | ||
| }); | ||
| }); | ||
| return { localStorage, visibleText, selectorText }; | ||
| }, selector || null); | ||
| } | ||
| 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, | ||
| }; | ||
| }); | ||
| } | ||
| // Landed-state verification: assert the localStorage the capture INJECTED | ||
| // (`config.browserState.localStorage`, which already carries the seed-session | ||
| // overlay the editor merged in) actually reached the capture browser after the | ||
| // page settled. The core promise of a seeded scenario is remote control of the | ||
| // app — a seed that silently doesn't land produces an empty screenshot that | ||
| // looks like a successful capture of an empty app, which is exactly the | ||
| // failure this guards. Returns a loud `seed-not-landed` issue (which fails the | ||
| // capture, since `ok` requires zero issues) when a non-empty injected seed is | ||
| // missing/empty on read-back, or `null` when there was nothing to verify, the | ||
| // seed landed, or storage is unavailable (sandboxed/opaque origin — never fail | ||
| // the capture over the verifier itself). | ||
| async function verifySeededStorageLanded(frame, config) { | ||
| const expected = | ||
| (config && config.browserState && config.browserState.localStorage) || {}; | ||
| const expectedKeys = Object.keys(expected); | ||
| if (expectedKeys.length === 0) return null; // nothing was seeded into storage | ||
| async function waitForStablePage(page, target, timeoutMs = 10000) { | ||
| const started = Date.now(); | ||
| let lastHtml = ""; | ||
| let stableCount = 0; | ||
| let readback; | ||
| try { | ||
| readback = await frame.evaluate((keys) => { | ||
| try { | ||
| const allKeys = []; | ||
| for (let i = 0; i < window.localStorage.length; i++) { | ||
| const k = window.localStorage.key(i); | ||
| if (k != null) allKeys.push(k); | ||
| } | ||
| const present = {}; | ||
| for (const k of keys) { | ||
| const v = window.localStorage.getItem(k); | ||
| present[k] = v != null && v !== ""; | ||
| } | ||
| return { present, allKeys }; | ||
| } catch (_) { | ||
| // localStorage unavailable (sandboxed/opaque origin) — can't verify. | ||
| return null; | ||
| } | ||
| }, expectedKeys); | ||
| } catch (_) { | ||
| return null; // evaluate failed — never fail a capture over the verifier. | ||
| } | ||
| if (!readback) return null; // storage unavailable — best-effort, don't fail. | ||
| while (Date.now() - started < timeoutMs) { | ||
| await page.waitForTimeout(500); | ||
| const missing = expectedKeys.filter((k) => !readback.present[k]); | ||
| if (missing.length === 0) return null; // every seeded key landed — success. | ||
| 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; | ||
| } | ||
| lastHtml = pageState.html; | ||
| } | ||
| const actual = readback.allKeys.length ? readback.allKeys.join(", ") : "<none>"; | ||
| return createIssue( | ||
| "seed-not-landed", | ||
| `Seeded localStorage did not reach the capture browser: expected non-empty ` + | ||
| `keys [${expectedKeys.join(", ")}] but [${missing.join(", ")}] are ` + | ||
| `missing/empty after load (browser localStorage holds: [${actual}]). The ` + | ||
| `seed did NOT land, so this screenshot shows DEFAULT/EMPTY state — fix the ` + | ||
| `seed; do not delete the scenario. Likely causes: (1) the seed-session ` + | ||
| `overlay was stale or empty (re-run the seed adapter to refresh it), (2) the ` + | ||
| `adapter's stdout localStorage map failed to parse, or (3) the injection ` + | ||
| `path is down (the seeded origin differs from the captured page's origin).`, | ||
| { url: (frame && frame.url && frame.url()) || (config && config.url) }, | ||
| ); | ||
| } | ||
| async function loadScenarioInIframe(page, url) { | ||
| const responsePromise = page | ||
| .waitForResponse( | ||
| (response) => | ||
| response.request().resourceType() === "document" && | ||
| response.url() === url, | ||
| { timeout: 30000 }, | ||
| ) | ||
| .catch(() => null); | ||
| // Drive an ordered list of flow steps against ONE already-loaded browser | ||
| // session so a scripted multi-step demo (`editor preview-flow`) is captured as | ||
| // the real round-trip — click state and client transients persist across | ||
| // steps, which N independent fresh-load captures could never reproduce. Each | ||
| // step is one of: | ||
| // - navigate: re-load a route (resolved relative to the initial url) using | ||
| // the same loader strategy as the initial load, then re-settle. Returns | ||
| // the new content frame so subsequent steps target the navigated page. | ||
| // - click / fill / press: a `performInteraction` against the current frame, | ||
| // then re-settle. | ||
| // - waitFor: hold until a visible-text / selector predicate (bounded). | ||
| // - capture: write a numbered filmstrip frame to the step's `outputPath`. | ||
| // A failing step THROWS with its 1-based index and action, so the outer catch | ||
| // in `runScenarioCheck` reports exactly which step broke (and, for waitFor, | ||
| // the predicate that never appeared) instead of a silent blank capture. | ||
| // Returns the frame the flow ended on, so the caller's final screenshot and | ||
| // result URL reflect the last navigated route. | ||
| async function runFlowSteps(page, initialFrame, steps, ctx) { | ||
| const { url, loadingMarkers, navigation, iframeBackground, preflight, harnessOrigin } = | ||
| ctx; | ||
| let frame = initialFrame; | ||
| 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"); | ||
| for (let i = 0; i < steps.length; i++) { | ||
| const step = steps[i] || {}; | ||
| const n = i + 1; | ||
| try { | ||
| switch (step.action) { | ||
| case "navigate": { | ||
| const target = new URL(step.path, url).href; | ||
| const loadResult = | ||
| navigation === "topLevel" | ||
| ? await loadScenarioTopLevel(page, target, { preflight }) | ||
| : await loadScenarioInIframe(page, target, { | ||
| background: iframeBackground, | ||
| preflight, | ||
| harnessOrigin, | ||
| }); | ||
| frame = loadResult.frame; | ||
| await waitForStablePage(page, frame, 10000, loadingMarkers); | ||
| break; | ||
| } | ||
| case "click": | ||
| case "fill": | ||
| case "press": | ||
| await performInteraction(frame, step); | ||
| await waitForStablePage(page, frame, 5000, loadingMarkers); | ||
| break; | ||
| case "waitFor": | ||
| await waitForPredicate(frame, step); | ||
| break; | ||
| case "capture": | ||
| if (step.outputPath) { | ||
| fs.mkdirSync(path.dirname(step.outputPath), { recursive: true }); | ||
| await page.screenshot({ path: step.outputPath, fullPage: false }); | ||
| } | ||
| break; | ||
| default: | ||
| throw new Error( | ||
| `unknown step action "${step.action}" (expected navigate | click | fill | press | waitFor | capture)`, | ||
| ); | ||
| } | ||
| } catch (error) { | ||
| throw new Error( | ||
| `flow step ${n} (${step.action}) failed: ${error.message || String(error)}`, | ||
| ); | ||
| } | ||
| } | ||
| await frame.waitForLoadState("load", { timeout: 30000 }); | ||
| const response = await responsePromise; | ||
| return { frame, response }; | ||
| return frame; | ||
| } | ||
| 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. | ||
| // `harnessOrigin` is likewise injectable: it defaults to resolving the editor's | ||
| // harness origin from `.codeyam/server-state.json`, but a test passes an | ||
| // explicit value (e.g. `null` to force the legacy `setContent` harness) so the | ||
| // iframe-load path never silently depends on a server-state file in cwd. | ||
| // Resolved ONCE here and threaded into every iframe load below. | ||
| async function runScenarioCheck( | ||
| config, | ||
| { preflight = assertAppPortReachable, harnessOrigin } = {}, | ||
| ) { | ||
| const resolvedHarnessOrigin = | ||
| harnessOrigin !== undefined ? harnessOrigin : resolveHarnessOrigin(); | ||
| const { url, outputPath, width, height, httpMocks = {} } = config; | ||
| // Per-scenario capture-check allowances (see `CaptureAllowances` / | ||
| // `ScenarioDefinition` on the Rust side). Default false so the guards keep | ||
| // their strict behavior for every scenario that does not opt in. | ||
| const allowMinimalRender = !!(config && config.allowMinimalRender); | ||
| const expectedConsoleErrors = !!(config && config.expectedConsoleErrors); | ||
| let interactionEffect = null; | ||
| let interactionRetried = false; | ||
| const issues = []; | ||
| const browser = await chromium.launch(); | ||
| const context = await browser.newContext({ | ||
| // Origin of the captured page, used to classify failed requests: only a | ||
| // failure on the page's OWN origin should fail the capture (see | ||
| // `isCaptureFatalRequestFailure`). Derived from `config.url` exactly as | ||
| // `applyBrowserState` does; a malformed URL leaves it null, which keeps every | ||
| // request failure fatal (today's behavior). | ||
| let appOrigin = null; | ||
| try { | ||
| appOrigin = new URL(url).origin; | ||
| } catch (_) { | ||
| /* malformed capture URL — the guard no-ops and failures stay fatal */ | ||
| } | ||
| 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; | ||
| // When the opt-in HTTPS preview (`proxy.httpsPreview`) is on, the capture | ||
| // origin is `https://localhost:<port>` served by the reverse proxy's | ||
| // self-signed cert. Accept it for capture only — gated on the https origin so | ||
| // plain-HTTP captures keep full TLS-error fidelity. | ||
| if (typeof appOrigin === "string" && appOrigin.startsWith("https://")) { | ||
| contextOptions.ignoreHTTPSErrors = true; | ||
| } | ||
| const context = await browser.newContext(contextOptions); | ||
| // 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); | ||
| }); | ||
| // 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); | ||
| // 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); | ||
| } | ||
| }; | ||
| }); | ||
| // Context-level init script runs in ALL frames (including cross-origin iframes). | ||
| // Keep the real WebSocket for scenarios that script a `/ws/terminal` | ||
| // transcript / WS stream so the scripted agent state reaches the capture; | ||
| // every other capture gets the reconnect-silencing stub. | ||
| await context.addInitScript(getInitScript(scenarioScriptsLiveSocket(config))); | ||
| 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; | ||
| } | ||
| pushIssue( | ||
| issues, | ||
| createIssue("console", text, { | ||
| url: page.url() || url, | ||
| }), | ||
| ); | ||
| const issue = handleConsoleMessage(message); | ||
| if (!issue) return; | ||
| // Per-scenario allowance: a scenario that provokes console errors by design | ||
| // (a broken-image fallback whose `<img>` is meant to 404) opts in via | ||
| // `expectedConsoleErrors`. Load-bearing: without it the console-error guard | ||
| // fails the capture instead of screenshotting the intended fallback UI. A | ||
| // non-opted-in scenario still fails, so an unexpected console error is never | ||
| // silently tolerated. | ||
| if (expectedConsoleErrors) return; | ||
| // Console errors produced by the scenario's OWN declared error mocks | ||
| // (status >= 400) are the intended behavior of an error-state scenario, | ||
| // not a capture problem — skip them so "History - Load Error"-style | ||
| // scenarios can screenshot the failure UI they exist to demonstrate. | ||
| const sourceUrl = message.location && message.location().url; | ||
| if (sourceUrl && isDeclaredErrorMock(httpMocks, sourceUrl)) return; | ||
| // A console error originating from a cross-origin resource — the "Failed to | ||
| // load resource" Chromium logs alongside a cross-origin requestfailed (the | ||
| // down app dev port, an external CDN) — is an external-resource failure, not | ||
| // an editor-shell problem. Tolerate it for the same reason, and via the same | ||
| // predicate, as the requestfailed handler below. | ||
| if (sourceUrl && !isCaptureFatalRequestFailure(sourceUrl, appOrigin)) return; | ||
| pushIssue(issues, issue); | ||
| }); | ||
| page.on("requestfailed", (request) => { | ||
| pushIssue( | ||
| issues, | ||
| createIssue( | ||
| "requestfailed", | ||
| request.failure()?.errorText || "Request failed", | ||
| { url: request.url() }, | ||
| ), | ||
| ); | ||
| // Per-scenario allowance: the broken-image fallback scenario's `<img>` 404 | ||
| // is a SAME-origin request failure it exists to demonstrate, so | ||
| // `expectedConsoleErrors` tolerates it here too (the console guard above | ||
| // relaxes the paired "Failed to load resource" error). Load-bearing for the | ||
| // same reason; a non-opted-in same-origin failure still fails the capture. | ||
| if (expectedConsoleErrors) return; | ||
| // A cross-origin sub-resource failing must not fail an editor-shell | ||
| // screenshot — only the captured page's OWN origin counts. The most common | ||
| // case here is the live preview pane reaching the mocked project's app dev | ||
| // port (e.g. http://localhost:3000), which is not running in the | ||
| // self-hosting capture container. | ||
| if (!isCaptureFatalRequestFailure(request.url(), appOrigin)) return; | ||
| const issue = handleRequestFailed(request); | ||
| if (issue) { | ||
| pushIssue(issues, issue); | ||
| } | ||
| }); | ||
@@ -319,3 +743,20 @@ | ||
| 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 loadResult = | ||
| config.navigation === "topLevel" | ||
| ? await loadScenarioTopLevel(page, url, { preflight }) | ||
| : await loadScenarioInIframe(page, url, { | ||
| background: config.iframeBackground, | ||
| preflight, | ||
| harnessOrigin: resolvedHarnessOrigin, | ||
| }); | ||
| // `frame` is `let` so a `navigate` flow step (below) can re-point it at the | ||
| // freshly-loaded route's content frame; `response` is the initial load only. | ||
| let frame = loadResult.frame; | ||
| const response = loadResult.response; | ||
| loaded = true; | ||
@@ -333,4 +774,30 @@ | ||
| 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(); | ||
| const stableOutcome = 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. | ||
| const networkOutcome = await waitForNetworkQuiet(networkTracker); | ||
| // If the page never settled (a loading marker outlasted the wait) or the | ||
| // network never went quiet, the screenshot likely caught a client-fetched | ||
| // page mid-load. Compute the non-blocking advisory now, while both settle | ||
| // signals are in hand, and surface it on the capture-state report below. | ||
| const settleAdvisory = buildSettleAdvisory(stableOutcome, networkOutcome); | ||
| const rejectionMessages = await frame.evaluate( | ||
@@ -348,25 +815,182 @@ () => window.__codeyamUnhandledRejections || [], | ||
| const contentState = await collectContentState(frame); | ||
| const hasContent = hasRenderableContent(contentState); | ||
| // Reveal-suppression fix: scroll-gated entrance animations (content held at | ||
| // `opacity:0` until an IntersectionObserver fires on scroll) render blank in | ||
| // isolation, where the app shell that wires those observers is absent. | ||
| // Emulate reduced motion — so reduced-motion-aware stylesheets drop their | ||
| // entrance animations and render the final state — and scroll the document | ||
| // end-to-end to trip every observer, so the content reveals BEFORE we | ||
| // measure and screenshot it, with no per-project force-reveal script. Both | ||
| // are best-effort: a page/frame that cannot emulate or scroll (a | ||
| // backend/static capture, a stubbed test target) is a no-op. | ||
| if (typeof page.emulateMedia === "function") { | ||
| await page.emulateMedia({ reducedMotion: "reduce" }).catch(() => {}); | ||
| } | ||
| await scrollThroughDocument(frame).catch(() => {}); | ||
| if (typeof page.waitForTimeout === "function") { | ||
| await page.waitForTimeout(REVEAL_SETTLE_MS); | ||
| } | ||
| // 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); | ||
| await mergeVisibleTextLength(contentState, frame); | ||
| let hasContent = hasRenderableContent(contentState); | ||
| if (!hasContent) { | ||
| await new Promise((r) => setTimeout(r, BLANK_RETRY_DELAY_MS)); | ||
| contentState = await collectContentState(frame); | ||
| await mergeVisibleTextLength(contentState, frame); | ||
| hasContent = hasRenderableContent(contentState); | ||
| } | ||
| // Per-scenario allowance: a scenario whose intended UI is intrinsically | ||
| // minimal (an empty `<textarea>` the blank heuristic can't "see") opts in | ||
| // via `allowMinimalRender`. Accept the near-blank render as valid content so | ||
| // BOTH the blank issue below is skipped AND `buildResult`'s `ok` (which | ||
| // independently requires `hasContent`) can be true — otherwise the capture | ||
| // would still fail with an empty issue list. The cold-start retry already | ||
| // ran above, so a non-opted-in blank (a genuinely empty render) still falls | ||
| // through to the issue below. | ||
| if (!hasContent && allowMinimalRender) { | ||
| hasContent = true; | ||
| } | ||
| if (!hasContent) { | ||
| pushIssue( | ||
| 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 }, | ||
| ), | ||
| ); | ||
| } | ||
| // Check for known error states in the rendered content | ||
| const bodyText = await frame.evaluate(() => document.body.innerText || ""); | ||
| if (hasErrorPatterns(bodyText)) { | ||
| // Check for codeyam's scenario-renderer error fallback. We anchor on the | ||
| // DOM marker the ScenarioRenderer stamps on its error / seed-error frames | ||
| // rather than scanning the whole page body for ERROR_PATTERNS — a | ||
| // legitimately-mocked scenario whose content quotes one of those harness | ||
| // phrases must not be flagged. Apps with no codeyam ScenarioRenderer never | ||
| // emit the marker, so their pages are treated as healthy regardless of body | ||
| // text. The scan is scoped to the marked element's own text. | ||
| const errorMarker = await frame.evaluate((attr) => { | ||
| const el = document.querySelector(`[${attr}]`); | ||
| if (!el) return null; | ||
| return { | ||
| reason: el.getAttribute(attr) || "", | ||
| text: el.innerText || el.textContent || "", | ||
| }; | ||
| }, SCENARIO_ERROR_MARKER); | ||
| const scenarioError = findScenarioError(errorMarker); | ||
| if (scenarioError) { | ||
| const { matchedPattern, contextSnippet } = scenarioError; | ||
| 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 ?? ""}`, | ||
| { | ||
| 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); | ||
| } | ||
| // Assert the injected seed actually landed in the capture browser, at rest | ||
| // and BEFORE any interaction can legitimately mutate storage. A non-empty | ||
| // seed that didn't reach localStorage means the screenshot will show | ||
| // default/empty state — fail loudly instead of emitting a misleading frame. | ||
| const seedNotLandedIssue = await verifySeededStorageLanded(frame, config); | ||
| if (seedNotLandedIssue) { | ||
| pushIssue(issues, seedNotLandedIssue); | ||
| } | ||
| // Scripted multi-step flow (`editor preview-flow`): drive an ordered | ||
| // sequence of steps in THIS one browser session — so a behavioral | ||
| // round-trip (click → observe → click) is captured as the real flow, not N | ||
| // independent fresh-load snapshots. Each `capture` step writes a numbered | ||
| // filmstrip frame; the others advance the same page. Mutually exclusive | ||
| // with the single `interaction` path below. `frame` is reassigned so a | ||
| // `navigate` step's new content frame backs the rest of the flow and the | ||
| // final result URL. | ||
| if (Array.isArray(config.steps) && config.steps.length > 0) { | ||
| frame = await runFlowSteps(page, frame, config.steps, { | ||
| url, | ||
| loadingMarkers, | ||
| navigation: config.navigation, | ||
| iframeBackground: config.iframeBackground, | ||
| preflight, | ||
| harnessOrigin: resolvedHarnessOrigin, | ||
| }); | ||
| } else if (config.interaction) { | ||
| // Record fingerprint before interaction | ||
| const beforeFingerprint = await getDOMFingerprint(frame); | ||
| // 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. | ||
| await performInteraction(frame, config.interaction); | ||
| await waitForStablePage(page, frame, 5000, loadingMarkers); | ||
| // Record fingerprint after interaction | ||
| let afterFingerprint = await getDOMFingerprint(frame); | ||
| // If unchanged and it was a click, retry once after 500ms (covers the hydration race) | ||
| if (beforeFingerprint === afterFingerprint && config.interaction.action === "click") { | ||
| interactionRetried = true; | ||
| await new Promise((resolve) => setTimeout(resolve, 500)); | ||
| await performInteraction(frame, config.interaction); | ||
| await waitForStablePage(page, frame, 5000, loadingMarkers); | ||
| afterFingerprint = await getDOMFingerprint(frame); | ||
| } | ||
| interactionEffect = beforeFingerprint === afterFingerprint ? "none" : "changed"; | ||
| } | ||
| // Replay the scenario's PERSISTED interaction sequence (if any) in order, | ||
| // settling between steps, so a declared interactive state — an expanded | ||
| // section, a revealed hover bar, an open modal — is reproduced on every | ||
| // capture and recapture, not just in a one-off `preview-interact`. A step | ||
| // that matches nothing throws and is caught below as a failed capture, so a | ||
| // sequence that didn't fully run never persists a resting-state screenshot. | ||
| if (Array.isArray(config.interactions) && config.interactions.length > 0) { | ||
| await performInteractionSequence(page, frame, config.interactions, { | ||
| settleMs: 5000, | ||
| loadingMarkers, | ||
| }); | ||
| } | ||
| // Snap any remaining mid-flight CSS entrance animation to its final frame | ||
| // for the static shot — but only when the scenario has NOT declared an | ||
| // interactive state (a single `interaction`, a persisted `interactions` | ||
| // sequence, or a multi-step `steps` flow), so an intentionally | ||
| // animated/collapsed interactive frame is never clobbered by the force. | ||
| const hasDeclaredInteractiveState = | ||
| !!config.interaction || | ||
| (Array.isArray(config.interactions) && config.interactions.length > 0) || | ||
| (Array.isArray(config.steps) && config.steps.length > 0); | ||
| if (outputPath && loaded && !hasDeclaredInteractiveState) { | ||
| await forceFinalVisualState(frame).catch(() => {}); | ||
| } | ||
| if (outputPath && loaded) { | ||
@@ -377,3 +1001,3 @@ fs.mkdirSync(path.dirname(outputPath), { recursive: true }); | ||
| return buildResult({ | ||
| const result = buildResult({ | ||
| loaded, | ||
@@ -385,2 +1009,23 @@ hasContent, | ||
| }); | ||
| if (config.interaction) { | ||
| result.interactionEffect = interactionEffect; | ||
| result.interactionRetried = interactionRetried; | ||
| } | ||
| // `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); | ||
| // A populated state that renders blank is exactly when an agent reaches | ||
| // for capture-state, so bundle the client-fetch advisory with the dump it | ||
| // explains. Only when the page didn't settle cleanly — the SSR / | ||
| // props-driven happy path stays advisory-free. | ||
| if (settleAdvisory) { | ||
| result.state.advisories = [settleAdvisory]; | ||
| } | ||
| } | ||
| return result; | ||
| } catch (error) { | ||
@@ -404,2 +1049,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() { | ||
@@ -420,15 +1070,19 @@ const config = JSON.parse(process.argv[2] || "{}"); | ||
| module.exports = { | ||
| attachHttpMocks, | ||
| buildResult, | ||
| buildIframeHarness, | ||
| createIssue, | ||
| findHttpMock, | ||
| hasErrorPatterns, | ||
| hasLoadingMarkers, | ||
| hasRenderableContent, | ||
| loadScenarioInIframe, | ||
| normalizeMockCandidates, | ||
| pushIssue, | ||
| runScenarioCheck, | ||
| waitForStablePage, | ||
| mergeVisibleTextLength, | ||
| runFlowSteps, | ||
| dumpPageState, | ||
| verifySeededStorageLanded, | ||
| readStackLoadingMarkers, | ||
| scenarioScriptsLiveSocket, | ||
| applyBrowserState, | ||
| isCrossOriginRequest, | ||
| isCaptureFatalRequestFailure, | ||
| stripMarkerHeaders, | ||
| main, | ||
| launchChromiumWithSelfHeal, | ||
| CAPTURE_LAUNCH_ARGS, | ||
| CAPTURE_HOST_RESOLVER_RULES, | ||
| PLAYWRIGHT_INSTALL_COMMAND, | ||
| PLAYWRIGHT_MISSING_BROWSER_PATTERN, | ||
| }; | ||
@@ -435,0 +1089,0 @@ |
+1027
-37
@@ -5,2 +5,3 @@ const { execSync, spawn, spawnSync } = require("child_process"); | ||
| const fs = require("fs"); | ||
| const readline = require("readline"); | ||
@@ -10,20 +11,111 @@ const rootDir = path.resolve(__dirname, ".."); | ||
| /** 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() { | ||
@@ -33,3 +125,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 { | ||
@@ -39,4 +150,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." | ||
| ); | ||
@@ -59,5 +170,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. */ | ||
@@ -68,2 +238,203 @@ 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.`, | ||
| ); | ||
| } | ||
| // Default boot budget for the editor server. A real cold boot legitimately | ||
| // spends ~7–10s on plan-claims git migration + reap, a PTY-broker probe/respawn | ||
| // (worse when a stale orphan broker's socket is dead), and a branch-tip fetch. | ||
| // 30s is ~3× that — comfortable headroom without letting a genuinely hung | ||
| // server hang indefinitely. | ||
| const DEFAULT_START_TIMEOUT_MS = 30000; | ||
| /** | ||
| * Resolve the boot budget (ms) for `ensureServer`, honoring the | ||
| * `CODEYAM_EDITOR_START_TIMEOUT_MS` override. Mirrors the existing env-read | ||
| * style (`CODEYAM_ALLOW_STALE`): parse the raw value, accept only a positive | ||
| * integer, and fall back to the 30s default on missing/non-numeric/≤0 input. | ||
| * Exported so it's unit-testable without spawning the real binary. | ||
| */ | ||
| function resolveStartTimeoutMs(env = process.env) { | ||
| const parsed = Number.parseInt(env.CODEYAM_EDITOR_START_TIMEOUT_MS, 10); | ||
| return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_START_TIMEOUT_MS; | ||
| } | ||
| 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. */ | ||
@@ -126,2 +497,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) { | ||
@@ -144,5 +519,175 @@ const controller = new AbortController(); | ||
| /** | ||
| * GET /api/codeyam-server-identity from the listener on `port` with a | ||
| * sub-second timeout. Returns the parsed identity body on a 2xx response, | ||
| * or `null` on any failure (timeout, non-200, parse failure, connection | ||
| * refused). Two identity shapes are recognized: | ||
| * - a per-project editor: `{projectDir, pid, version, startedAt}` | ||
| * - the Project Launcher (selector): `{role: "launcher", pid, version}` | ||
| * — deliberately NO `projectDir`, since the selector isn't bound to one | ||
| * project. We must NOT reject it for the missing `projectDir`, or the | ||
| * wrapper would misclassify the live selector as a foreign squatter. | ||
| * | ||
| * `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") return null; | ||
| // The Project Launcher advertises a `launcher` role and no projectDir. | ||
| if (body.role === "launcher") return body; | ||
| if (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` | ||
| ); | ||
| } | ||
| /** | ||
| * Build the "server never bound the port" failure message, shown only after | ||
| * the boot budget AND one automatic retry have both been exhausted. Pure | ||
| * string builder (same shape as `formatForeignListenerError` / | ||
| * `formatCrossProjectError`): names what happened, the likely cause, where to | ||
| * look, and concrete next steps. Stack-agnostic — no app-type/framework | ||
| * assumptions. | ||
| */ | ||
| function formatServerStartTimeoutError(port, timeoutMs) { | ||
| const seconds = Math.round(timeoutMs / 1000); | ||
| return [ | ||
| `codeyam-editor server did not bind port ${port} within ${seconds}s, ` + | ||
| `even after one automatic retry.`, | ||
| "", | ||
| "Likely cause: a slow cold boot (plan-claims git migration, PTY-broker " + | ||
| "respawn) — often a stale codeyam-editor-pty-broker whose socket is gone.", | ||
| "", | ||
| "Or: with 2+ projects registered this launch diverts to the Project " + | ||
| "Launcher (selector). An old binary that still discards --port binds the " + | ||
| "selector on a DIFFERENT ephemeral port, so the wait for this one times " + | ||
| "out — check .codeyam/logs/editor-server.out.log for a `Project Launcher " + | ||
| "running at` line and rebuild the editor.", | ||
| "", | ||
| "Where to look:", | ||
| " .codeyam/logs/editor-server.log", | ||
| " .codeyam/logs/editor-server.out.log", | ||
| "", | ||
| "What to try:", | ||
| " - Re-run the launcher.", | ||
| " - If a stale codeyam-editor-pty-broker is lingering, kill it, then re-run.", | ||
| " - If boots are consistently slow, raise the budget, e.g. " + | ||
| "CODEYAM_EDITOR_START_TIMEOUT_MS=60000.", | ||
| ].join("\n"); | ||
| } | ||
| /** | ||
| * 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: "launcher", identity }` — held by the Project Launcher (selector) | ||
| * - `{ 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" }; | ||
| } | ||
| // The Project Launcher (selector) holds the port in multi-project mode. It is | ||
| // codeyam-editor, not a foreign process and not a per-project editor, so it | ||
| // gets its own kind — the caller just re-opens it rather than refusing. | ||
| if (identity.role === "launcher") { | ||
| return { kind: "launcher", identity }; | ||
| } | ||
| 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; | ||
@@ -154,12 +699,197 @@ } catch { | ||
| function findListeningPids(port) { | ||
| if (process.platform === "win32") return []; | ||
| /** | ||
| * 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); | ||
| } | ||
| const result = spawnSync("lsof", [`-tiTCP:${port}`, "-sTCP:LISTEN"], { | ||
| encoding: "utf8", | ||
| /** | ||
| * Ask a yes/no question at the terminal and resolve `true` only on an | ||
| * affirmative `y`/`yes` (case-insensitive). A bare Enter, EOF, `n`, or any | ||
| * other text resolves `false` — the safe default for a destructive prompt. | ||
| * | ||
| * Mirrors `promptForInstanceName` in `npm/cloud.js`: readline reads from stdin | ||
| * and writes its prompt to stderr by default, so the question never pollutes a | ||
| * piped stdout. `input`/`output` are injectable so the helper is unit-testable | ||
| * by feeding a fake stream without a real TTY. | ||
| */ | ||
| function promptYesNo(question, { input = process.stdin, output = process.stderr } = {}) { | ||
| return new Promise((resolve) => { | ||
| const rl = readline.createInterface({ input, output }); | ||
| rl.question(question, (ans) => { | ||
| rl.close(); | ||
| const normalized = String(ans).trim().toLowerCase(); | ||
| resolve(normalized === "y" || normalized === "yes"); | ||
| }); | ||
| }); | ||
| } | ||
| if (result.error) return []; | ||
| /** | ||
| * Decide what to do when `port` is held by a DIFFERENT project's editor server. | ||
| * | ||
| * Policy (shared by both launcher entry points so the wording and gating stay | ||
| * identical): | ||
| * - Non-interactive launcher (cloud container, agent-driven, piped/CI): print | ||
| * `formatCrossProjectError` and return `{ action: "abort" }` — never kill | ||
| * another project's server without a human at the keyboard, since the kill | ||
| * also tears down any Claude agent attached via the PTY broker. | ||
| * - Interactive launcher: print the full who/what/where, then ask whether to | ||
| * stop the other server and take over. On `y` → `stopServer(port)` and | ||
| * return `{ action: "took-over" }`. On anything else → `{ action: "declined" }`. | ||
| * | ||
| * Gates on `process.stdin.isTTY` (the INPUT stream — a human can only answer a | ||
| * prompt when stdin is a terminal), not stdout. The resolver prints the full | ||
| * `formatCrossProjectError` message in BOTH the abort and the interactive paths, | ||
| * so the caller only decides exit-vs-continue. | ||
| * | ||
| * Dependencies are injected (defaulting to the real implementations) so the | ||
| * resolver is unit-testable without a TTY or a live server — same style as | ||
| * `classifyPortHolder` / `resolveBootedServer`. | ||
| */ | ||
| async function resolveCrossProjectCollision( | ||
| port, | ||
| holder, | ||
| verb, | ||
| { | ||
| isInteractive = isInteractiveLauncher, | ||
| prompt = promptYesNo, | ||
| stop = stopServer, | ||
| } = {}, | ||
| ) { | ||
| console.error(formatCrossProjectError(port, holder, verb)); | ||
| if (!isInteractive(process.stdin)) { | ||
| return { action: "abort" }; | ||
| } | ||
| const pidNote = Number.isInteger(holder.identity && holder.identity.pid) | ||
| ? ` (pid ${holder.identity.pid})` | ||
| : ""; | ||
| const accepted = await prompt( | ||
| `\nKill that server${pidNote} and start this project's editor instead? [y/N] `, | ||
| ); | ||
| if (!accepted) { | ||
| return { action: "declined" }; | ||
| } | ||
| await stop(port); | ||
| return { action: "took-over" }; | ||
| } | ||
| return result.stdout | ||
| /** | ||
| * 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"); | ||
| } | ||
| } | ||
| /** Parse `lsof -t` stdout (one pid per line) into an array of integer pids. */ | ||
| function parseLsofPids(stdout) { | ||
| return (stdout || "") | ||
| .split(/\s+/) | ||
@@ -170,2 +900,50 @@ .map((line) => parseInt(line, 10)) | ||
| /** Run `lsof` for the listeners on a TCP port. Returns the raw spawnSync | ||
| * result (`{ error?, stdout }`) so callers can distinguish a spawn failure | ||
| * from a genuinely empty result. */ | ||
| function defaultRunLsof(port) { | ||
| return spawnSync("lsof", [`-tiTCP:${port}`, "-sTCP:LISTEN"], { | ||
| encoding: "utf8", | ||
| }); | ||
| } | ||
| /** Synchronous millisecond sleep — used to back off between lsof retries. */ | ||
| function sleepSync(ms) { | ||
| Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); | ||
| } | ||
| /** | ||
| * Find the pids listening on a TCP port via `lsof`. Returns [] on Windows | ||
| * (lsof absent) or when no process is listening. | ||
| * | ||
| * A transient *spawn* failure (e.g. fork `EAGAIN` under heavy concurrent load — | ||
| * the cause of the flaky multi-runner test failure) is RETRIED with a short | ||
| * backoff rather than swallowed into "no listeners found". This matters because | ||
| * `findListeningPids` feeds `stopServer`: a one-off lsof spawn error must not be | ||
| * mistaken for "the server isn't running" and leave it alive. A clean run that | ||
| * legitimately reports no listeners returns [] on the first attempt (no error, | ||
| * so no retry) — free-port behavior and latency are unchanged. | ||
| * | ||
| * `runLsof` and `sleep` are injectable so the retry path is unit-testable | ||
| * without depending on real system load. | ||
| */ | ||
| function findListeningPids(port, runLsof = defaultRunLsof, sleep = sleepSync) { | ||
| // 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 []; | ||
| const MAX_ATTEMPTS = 5; | ||
| const BACKOFF_MS = 50; | ||
| for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { | ||
| const result = runLsof(port); | ||
| // No spawn error → trust the result (empty stdout legitimately means "no | ||
| // listeners"; a non-empty stdout yields the pids). | ||
| if (!result.error) return parseLsofPids(result.stdout); | ||
| // Transient spawn failure — back off and retry, unless we're out of tries. | ||
| if (attempt < MAX_ATTEMPTS) sleep(BACKOFF_MS); | ||
| } | ||
| return []; | ||
| } | ||
| /** Stop the Rust backend server if it is running. */ | ||
@@ -214,32 +992,217 @@ async function stopServer(port = 14199) { | ||
| /** 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": { | ||
| const outcome = await resolveCrossProjectCollision(port, holder, "attach"); | ||
| if (outcome.action === "took-over") { | ||
| // `stopServer` already waited for the port to close, so fall through to | ||
| // the fresh-boot path below — `spawnAndAwait` binds the now-free port. | ||
| break; | ||
| } | ||
| // "abort" (non-TTY) or "declined" (user said no): the resolver already | ||
| // printed the cross-project message. On decline, remind how to keep both. | ||
| if (outcome.action === "declined") { | ||
| console.error( | ||
| "Leaving the existing server running. Change controlPort in " + | ||
| ".codeyam/editor.local.json to run both, e.g.:\n" + | ||
| " codeyam-editor editor config-override proxy.controlPort 14399", | ||
| ); | ||
| } | ||
| process.exit(1); | ||
| break; | ||
| } | ||
| case "launcher": | ||
| // The selector is already up on this port (multi-project mode). Treat it | ||
| // like an already-running same-project server: don't refuse, don't | ||
| // double-spawn — just let the caller re-open the port. | ||
| console.error(`Project selector already running on port ${port}`); | ||
| return null; | ||
| 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() }, | ||
| const startTimeoutMs = resolveStartTimeoutMs(); | ||
| // Spawn a fresh server child and race its boot against `timeoutMs`. Factored | ||
| // into a closure so the auto-retry path re-runs an identical spawn with a | ||
| // longer budget without duplicating the spawn options. | ||
| // | ||
| // 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 spawnAndAwait = async (timeoutMs) => { | ||
| 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 race = await awaitServerReadyOrChildExit(child, port, (p) => | ||
| waitForPort(p, timeoutMs), | ||
| ); | ||
| return { child, ...race }; | ||
| }; | ||
| // Orchestrate the boot (re-check-before-kill + one auto-retry) with the real | ||
| // effects injected, then map the discriminated result onto the process-exit | ||
| // policy. Keeping the orchestration in `resolveBootedServer` (pure control | ||
| // flow over injected effects) is what makes the re-check guard and the retry | ||
| // unit-testable without spawning the real binary or stubbing process.exit. | ||
| const result = await resolveBootedServer({ | ||
| port, | ||
| startTimeoutMs, | ||
| spawnAndAwait, | ||
| isPortInUseFn: isPortInUse, | ||
| }); | ||
| const ready = await waitForPort(port); | ||
| if (!ready) { | ||
| console.error("Server failed to start within 10 seconds"); | ||
| child.kill(); | ||
| process.exit(1); | ||
| switch (result.kind) { | ||
| case "ready": | ||
| return result.child; | ||
| case "early-exit": { | ||
| // A genuine early exit (preflight bail) is always a hard fail — even on | ||
| // code 0: the caller needs to know the server isn't running. | ||
| const { code, signal } = result.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.", | ||
| ); | ||
| process.exit(code != null && code !== 0 ? code : 1); | ||
| break; | ||
| } | ||
| case "timeout-exhausted": | ||
| default: | ||
| console.error(formatServerStartTimeoutError(port, result.retryTimeoutMs)); | ||
| process.exit(1); | ||
| } | ||
| } | ||
| return child; | ||
| /** | ||
| * Boot orchestration for `ensureServer`, isolated from the process-exit policy | ||
| * and from the spawn/port effects so the re-check-before-kill guard and the | ||
| * single auto-retry are unit-testable. Pure control flow over two injected | ||
| * effects: | ||
| * - `spawnAndAwait(timeoutMs)` → `{ child, outcome, exitedBeforeBind }` — | ||
| * one boot attempt racing the port against the child exit. | ||
| * - `isPortInUseFn(port)` → bool — the final re-check probe. | ||
| * | ||
| * Returns a discriminated result the caller maps to an action: | ||
| * - `{ kind: "ready", child }` — server bound; keep this child. | ||
| * - `{ kind: "early-exit", exitedBeforeBind }` — child exited before bind | ||
| * (preflight bail); a hard fail, never retried. | ||
| * - `{ kind: "timeout-exhausted", retryTimeoutMs }` — both the budget and a | ||
| * longer-budget retry missed; give up with actionable guidance. | ||
| * | ||
| * The destructive `child.kill()` only fires after a re-check confirms the port | ||
| * is NOT listening — a server that bound a hair after the deadline is kept, not | ||
| * torn down (the reported race). The retry runs at most once. | ||
| */ | ||
| async function resolveBootedServer({ port, startTimeoutMs, spawnAndAwait, isPortInUseFn }) { | ||
| // One boot attempt, with the re-check folded in: a post-deadline bind is | ||
| // reported as "ready" rather than "timeout" so the caller never kills it. | ||
| const attempt = async (timeoutMs) => { | ||
| const { child, outcome, exitedBeforeBind } = await spawnAndAwait(timeoutMs); | ||
| if (outcome === "ready") return { kind: "ready", child }; | ||
| if (outcome === "exited" || (outcome === "timeout" && exitedBeforeBind !== null)) { | ||
| return { kind: "early-exit", exitedBeforeBind }; | ||
| } | ||
| // Genuine timeout — re-check before reporting a miss. | ||
| if (await isPortInUseFn(port)) return { kind: "ready", child }; | ||
| return { kind: "timeout", child }; | ||
| }; | ||
| const first = await attempt(startTimeoutMs); | ||
| if (first.kind !== "timeout") return first; | ||
| // The boot was just slow (cold caches, broker respawn). Kill the stalled | ||
| // attempt and auto-retry ONCE with a longer budget — a single retry, not a | ||
| // loop, so a truly broken server isn't masked behind endless retries. | ||
| const retryTimeoutMs = startTimeoutMs * 2; | ||
| console.error( | ||
| `Server did not bind port ${port} within ${Math.round(startTimeoutMs / 1000)}s; ` + | ||
| `retrying once with a longer budget...`, | ||
| ); | ||
| first.child.kill(); | ||
| const second = await attempt(retryTimeoutMs); | ||
| if (second.kind !== "timeout") return second; | ||
| second.child.kill(); | ||
| return { kind: "timeout-exhausted", retryTimeoutMs }; | ||
| } | ||
| /** | ||
| * 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, | ||
@@ -251,5 +1214,32 @@ tryConnect, | ||
| readServerState, | ||
| clearServerState, | ||
| requestServerShutdown, | ||
| fetchServerIdentity, | ||
| canonicalisePath, | ||
| classifyPortHolder, | ||
| formatForeignListenerError, | ||
| formatCrossProjectError, | ||
| formatServerStartTimeoutError, | ||
| resolveStartTimeoutMs, | ||
| tryKillPid, | ||
| findListeningPids, | ||
| parseLsofPids, | ||
| stopServer, | ||
| restartServer, | ||
| ensureServer, | ||
| resolveBootedServer, | ||
| awaitServerReadyOrChildExit, | ||
| isInteractiveLauncher, | ||
| promptYesNo, | ||
| resolveCrossProjectCollision, | ||
| buildServerSpawnOptions, | ||
| openServerOutputLog, | ||
| formatLauncherSignalRecord, | ||
| logLauncherTeardownSignal, | ||
| killChildProcess, | ||
| deepMergeJson, | ||
| stripUnderscoreTopLevelKeys, | ||
| readMergedEditorConfig, | ||
| resolveLauncherPort, | ||
| resolveWrappedAppPort, | ||
| }; |
+7
-5
| { | ||
| "name": "@codeyam-editor/codeyam-editor", | ||
| "version": "0.1.0-staging.888bf79", | ||
| "version": "0.1.0-staging.a8ad44d", | ||
| "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.a8ad44d", | ||
| "@codeyam-editor/codeyam-editor-darwin-x64": "0.1.0-staging.a8ad44d", | ||
| "@codeyam-editor/codeyam-editor-linux-x64": "0.1.0-staging.a8ad44d", | ||
| "@codeyam-editor/codeyam-editor-win32-x64": "0.1.0-staging.a8ad44d" | ||
| }, | ||
| "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 22 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.
2784896
9688.05%78
766.67%22096
2405.22%0
-100%5
400%15
650%71
446.15%180
2150%