Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@codeyam-editor/codeyam-editor

Package Overview
Dependencies
Maintainers
1
Versions
19
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@codeyam-editor/codeyam-editor - npm Package Compare versions

Comparing version
0.1.0-staging.afe66b2
to
0.1.0-staging.baa0196
+442
npm/cloud-branch-switch.js
#!/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);
});
}
"use strict";
/**
* Launcher-side mirror of `is_empty_project_dir` in
* `crates/codeyam-editor/src/commands/init.rs`. The Rust side decides
* which `/codeyam-…` skill the init binary points at; this JS mirror
* decides the same for the launcher's post-launch tail banner. Keep
* both in sync — the test `empty-project-dir.test.js` and Rust's
* `is_empty_project_dir_ignores_hidden_entries` /
* `is_empty_project_dir_detects_visible_entry` pin the contract.
*
* A "fresh" project directory has no non-hidden entries — only things
* like `.git/`, `.gitignore`, or other dotfiles. Hidden entries don't
* count as project content, so a dir holding only `.git/` and
* `.gitignore` (the "I just ran `git init`" case) still reads as empty.
* A read error returns false, matching the Rust `Err(_) => false` branch:
* a missing / unreadable dir defaults to the existing-project path rather
* than falsely scaffolding over nothing.
*/
const fs = require("node:fs");
function isEmptyProjectDir(projectDir) {
let entries;
try {
entries = fs.readdirSync(projectDir);
} catch {
return false;
}
return entries.every((name) => name.startsWith("."));
}
module.exports = { isEmptyProjectDir };
'use strict';
// Pure helpers for the fleet dashboard's "Authenticate idle VMs" batch flow
// (scripts/operator/fleet-dashboard/server.js requires this). The decision of
// WHICH VMs a fleet-auth pass touches — and how to summarize an in-flight
// batch — lives here so server.js's HTTP orchestration stays thin and the
// guardrail logic is unit-testable in isolation (npm/fleet-auth.test.js).
//
// Why per-VM real `claude /login` and not one shared credential: copying a
// `.credentials.json` between VMs 401s (Claude rotates/binds the OAuth access
// token to its originating VM), and the `CLAUDE_CODE_OAUTH_TOKEN` setup-token
// is limited-scope + non-durable (lost on container recreate). So every VM
// needs its own interactive login — this batches the human step across the
// fleet (start every idle VM's login in parallel, approve them as one list)
// instead of distributing a single token. See docs/cloud-editor-stability.md
// "Fleet auth".
// Classify one VM for the fleet-auth pass. Returns { eligible, reason }.
//
// The guardrail mirrors Destroy / Reconfigure / roll: NEVER touch an active
// session (a mid-build agent must not have its editor driven into a login
// PTY), and never start an auth pass on a VM that already has a running or
// queued job. Only a reachable, idle VM the editor reports as `needs-login` is
// a target. An `unknown` / absent auth state is deliberately NOT a target —
// the probe is indeterminate (a transient failure on an otherwise-healthy VM),
// and driving a login we can't confirm is needed risks disrupting a working
// VM. `v` is the dashboard's per-VM poll state (`state.vms[n]`); `job` is
// `state.jobs[n]` (may be undefined).
function classifyAuthVm(v, job) {
if (job && (job.state === 'running' || job.state === 'queued')) {
return { eligible: false, reason: 'busy' };
}
if (!v || !v.reachable) return { eligible: false, reason: 'unreachable' };
if (v.hasSession) return { eligible: false, reason: 'active-session' };
const state = v.providerAuth && v.providerAuth.state;
if (state === 'authenticated') return { eligible: false, reason: 'already-authenticated' };
if (state === 'needs-login') return { eligible: true, reason: 'needs-login' };
return { eligible: false, reason: 'auth-state-unknown' };
}
// Select every VM the fleet-auth pass should drive a login on, plus the
// skipped VMs each with the reason it was excluded (surfaced to the operator
// so "why didn't VM-7 get authenticated?" is answerable without ssh).
//
// roster: number[] (the live VMS array). vms / jobs: maps keyed by VM number.
// Returns { targets: number[], skipped: [{ n, reason }] }, both sorted by n.
function selectAuthTargets({ roster, vms, jobs }) {
const targets = [];
const skipped = [];
for (const n of [...(roster || [])].sort((a, b) => a - b)) {
const c = classifyAuthVm((vms || {})[n], (jobs || {})[n]);
if (c.eligible) targets.push(n);
else skipped.push({ n, reason: c.reason });
}
return { targets, skipped };
}
// Aggregate an in-flight batch (map of n → { phase }) into counts for the
// operator report. Phases mirror the editor's /api/auth/status states
// ('starting' | 'url-ready' | 'authenticated' | 'failed') plus the
// dashboard-side 'submitting' while a pasted code is being delivered. Pure
// over the values; `done` is true once no VM is still mid-flight, which is
// what lets the UI stop polling / show a final "N of M authenticated" line.
function summarizeAuthBatch(batchVms) {
const counts = { total: 0, starting: 0, urlReady: 0, submitting: 0, authenticated: 0, failed: 0 };
for (const n of Object.keys(batchVms || {})) {
const phase = batchVms[n] && batchVms[n].phase;
counts.total++;
if (phase === 'starting') counts.starting++;
else if (phase === 'url-ready') counts.urlReady++;
else if (phase === 'submitting') counts.submitting++;
else if (phase === 'authenticated') counts.authenticated++;
else if (phase === 'failed') counts.failed++;
}
// In-flight = still starting, or awaiting a pasted code, or submitting one.
// A `failed` VM is terminal until the operator restarts the batch, and an
// `authenticated` VM is done — so neither counts as in-flight.
counts.inFlight = counts.starting + counts.urlReady + counts.submitting;
counts.done = counts.total > 0 && counts.inFlight === 0;
return counts;
}
// Derive the fleet's current default Claude account (improve38): the account a
// newly launched VM should authenticate as. Defined as the account of the
// rostered VM whose auth was updated most recently, where "auth-update time" =
// max(dashboard-recorded login-completion `lastAuthAt[n]`, the editor-reported
// `providerAuth.lastRefreshAt`). Re-authenticating ANY existing VM thus
// re-points the default. Pure over the polled VM snapshot + the lastAuthAt map.
//
// Returns the winning VM's account object ({ email, organizationName,
// organizationType, subscriptionType }), or null when no rostered VM reports an
// account. Ties — and the no-timestamp case — resolve to the first such VM in
// `vms` order, so the result is deterministic.
function deriveDefaultAuthAccount(vms, lastAuthAt) {
const at = lastAuthAt || {};
let best = null;
for (const v of vms || []) {
const pa = v && v.providerAuth;
const acct = pa && pa.account;
if (!acct || !acct.email) continue;
const refreshMs = pa.lastRefreshAt ? Date.parse(pa.lastRefreshAt) : NaN;
const loginMs = at[v.n] ? Date.parse(at[v.n]) : NaN;
const score = Math.max(
Number.isFinite(refreshMs) ? refreshMs : -Infinity,
Number.isFinite(loginMs) ? loginMs : -Infinity,
);
if (!best || score > best.score) best = { account: acct, score };
}
return best ? best.account : null;
}
module.exports = { classifyAuthVm, selectAuthTargets, summarizeAuthBatch, deriveDefaultAuthAccount };
'use strict';
// Pure branch-resolution precedence for the fleet dashboard (improve39 A).
//
// The dashboard's "current fleet branch" used to go stale: server.js read a
// baked process.env.BRANCH (set once by the launchd plist at install time) and
// a hardcoded 'editor-improvements-30' literal, so a `git checkout` to a new
// fleet branch on the laptop was invisible until the operator re-ran
// install-launchd-agents.sh (which then risked taking the dashboard down — the
// (B) half of improve39). This module makes the resolution RULE a pure,
// unit-tested function (npm/fleet-branch.test.js); server.js owns the I/O — the
// execSync git read and the short-TTL memo on the ~3s /data poll path.
//
// Precedence: explicit env override -> live git HEAD -> fallback sentinel.
// An operator can still pin a branch with BRANCH=... in the plist/env, but with
// no pin the dashboard tracks the laptop checkout's live HEAD, so a branch bump
// is just a `git checkout` — no re-install.
// Returned when neither an explicit pin nor a live git read yields a branch — a
// clearly-not-a-branch marker, chosen over a stale literal like
// 'editor-improvements-30' that would silently point Reset/Update at a dead
// branch. Callers can surface it as "unknown" instead of acting on it.
const UNKNOWN_BRANCH = 'unknown-branch';
/** Normalize a candidate branch value to a non-empty trimmed string, or null.
* Anything non-string, empty, or whitespace-only counts as "absent" so a
* failed git read (null) or an unset env var ('') falls through to the next
* source. */
function cleanBranch(v) {
if (typeof v !== 'string') return null;
const t = v.trim();
return t === '' ? null : t;
}
/** Resolve the fleet's active editor branch by precedence:
*
* explicit env override -> live git HEAD -> fallback.
*
* `fallback` defaults to the UNKNOWN_BRANCH sentinel, so a total failure
* (no pin, no git) yields a marker rather than a stale branch literal.
* Pure; no I/O. */
function resolveFleetBranch({ envBranch, gitBranch, fallback } = {}) {
const pin = cleanBranch(envBranch);
if (pin) return pin;
const live = cleanBranch(gitBranch);
if (live) return live;
return cleanBranch(fallback) || UNKNOWN_BRANCH;
}
module.exports = { UNKNOWN_BRANCH, cleanBranch, resolveFleetBranch };
'use strict';
// Pure alarm-only classification for the Fleet Dashboard's broker-death line
// (scripts/operator/fleet-dashboard/server.js requires this). Given the rolling
// `brokerHistory` forensics and the live tri-state `brokerStatus`, decide
// whether the newest death is a LIVE problem worth alarming about. No I/O —
// server.js does the polling.
//
// Contract (improve40): the death line alarms ONLY when the broker is provably
// down right now. For every other status it returns null and the card omits the
// row entirely.
//
// Why alarm-only: the live broker-status dot (renderBrokerStatus) already shows
// current health. The death line's only job is to EXPLAIN a broker that is down
// now (with the recorded reason / uptime / count). A death record for a broker
// that is back up is superseded history — the old muted "↩ broker recovered"
// footnote was noise. And a death we can't confirm (editor unreachable →
// 'unknown') must not over-claim: "absence of evidence is never down" is the
// established principle in npm/fleet-broker-status.js. The earlier
// recovered/active/unconfirmed tri-state collapses to a single down-only alarm.
//
// `brokerStatus` here is the DEBOUNCED tri-state from
// npm/fleet-broker-debounce.js, so a single transient probe timeout can never
// reach this as 'down'.
function classifyBrokerDeath(brokerHistory, brokerStatus) {
// Alarm only when the broker is confirmed down now. Every other status (up,
// unreachable, missing) → no death line.
if (brokerStatus !== 'down') return null;
// Common "broker never died" case → card renders nothing, unchanged.
if (!Array.isArray(brokerHistory) || brokerHistory.length === 0) return null;
const newest = brokerHistory[brokerHistory.length - 1];
if (!newest) return null;
// Mirror renderBrokerDeath's existing reason fallback chain.
const reason = newest.reason || newest.signalName || ('exit ' + newest.exitCode);
return {
reason,
// Passed through verbatim (may be null/absent — the renderer guards both).
atUnixSecs: newest.atUnixSecs,
uptimeSeconds: newest.uptimeSeconds,
count: brokerHistory.length,
};
}
module.exports = { classifyBrokerDeath };
'use strict';
// Pure consecutive-failure debounce for the Fleet Dashboard's broker tri-state
// (scripts/operator/fleet-dashboard/server.js requires this). Given the raw
// single-poll status from npm/fleet-broker-status.js::deriveBrokerStatus and the
// per-VM debounce state from the previous poll, decide the status the card
// actually renders. No I/O — server.js does the polling and threads the state.
//
// The bug this fixes (observed live 2026-06-02, VM3): the dashboard polls each
// VM's broker every 3s, and the editor probes the broker with a single 200ms
// socket attempt. One transient timeout (IAP / SSH-tunnel congestion — the same
// class of hiccup behind the known false "Editor stopped") instantly flipped a
// healthy VM's card to 'down', which raised both the red broker-down dot and the
// 💀 death alarm, even though the broker was fine (the build tab opened and chat
// started immediately). The single probe is left as-is on the editor side; the
// right seam for absorbing a one-poll blip is here at the aggregation layer.
//
// Asymmetric by design: ONLY the flip to 'down' is debounced (it requires
// `threshold` consecutive 'down' polls). 'idle' / 'agent-live' / 'unknown' take
// effect immediately — we never want to DELAY clearing a false alarm, only
// delay raising one. And an unreachable editor ('unknown') breaks a pending
// down-streak rather than escalating it, mirroring fleet-broker-status.js's
// "absence of evidence is never down" rule.
//
// State shape carried across polls: { status, downStreak, lastConfirmed }.
// status — the debounced value the card renders this poll.
// downStreak — how many consecutive raw 'down' polls we've seen.
// lastConfirmed — the last raw 'idle'/'agent-live' we proved; held during a
// pending (sub-threshold) down-streak so the card stays calm.
// ~9s at the dashboard's 3s poll cadence: comfortably absorbs a transient probe
// timeout and the brief down-window of a routine restart-server (broker down for
// <3s between SIGTERM and respawn), while still surfacing a real outage quickly.
// Named so retuning is a one-line edit.
const DOWN_DEBOUNCE_THRESHOLD = 3;
function debounceBrokerStatus(prev, rawStatus, opts) {
const threshold = (opts && opts.threshold) || DOWN_DEBOUNCE_THRESHOLD;
const lastConfirmed = prev ? prev.lastConfirmed : undefined;
// Confirmed up: the daemon answered now. Render immediately, reset the streak,
// and remember this as the last-confirmed-up value.
if (rawStatus === 'idle' || rawStatus === 'agent-live') {
return { status: rawStatus, downStreak: 0, lastConfirmed: rawStatus };
}
// Editor unreachable: we proved nothing. Never down — reset any pending
// down-streak and render 'unknown' (the card shows nothing for it).
if (rawStatus !== 'down') {
return { status: 'unknown', downStreak: 0, lastConfirmed };
}
// Raw 'down': only a real outage if it persists. Count the streak.
const downStreak = (prev ? prev.downStreak : 0) + 1;
if (downStreak >= threshold) {
// Confirmed down — surface the alarm.
return { status: 'down', downStreak, lastConfirmed };
}
// Pending (sub-threshold) down: hold the last confirmed up status if we have
// one, else 'unknown'. The card stays calm during the blip.
return { status: lastConfirmed || 'unknown', downStreak, lastConfirmed };
}
module.exports = { debounceBrokerStatus, DOWN_DEBOUNCE_THRESHOLD };
'use strict';
// Pure tri-state derivation for the Fleet Dashboard's per-VM broker indicator
// (scripts/operator/fleet-dashboard/server.js requires this). Given the raw
// /api/session-info poll merge `v`, collapse it into the one label the card
// renders. No I/O — server.js does the polling.
//
// The bug this fixes (observed live 2026-06-01): the old indicator was
// `v.reachable ? !!v.agentBrokerLive : null`, where `agentBrokerLive` means
// "a build-mode agent session is live in the broker" — NOT "broker daemon up".
// So a perfectly IDLE VM (healthy broker daemon, no parked build agent) reported
// `agentBrokerLive=false`, indistinguishable from a VM whose broker was actually
// DOWN. An operator killed a working broker chasing that phantom. The editor now
// also reports `brokerDaemonReachable` (the daemon answered its socket) and a
// derived `brokerStatus`; this module layers the dashboard's own reachability on
// top so the four states stay distinct:
//
// 'unknown' — editor unreachable (can't probe the broker at all), OR an
// older VM whose editor predates `brokerDaemonReachable`. Never
// rendered as a problem: absence of evidence is not "down".
// 'down' — daemon unreachable → the real broker problem, the state that
// had NO signal before. A VM here cannot start an agent session.
// 'idle' — daemon up, no build agent → a normal idle VM, not an error.
// 'agent-live' — a live build-mode agent session in the broker's build slot.
const BROKER_STATUSES = ['unknown', 'down', 'idle', 'agent-live'];
function deriveBrokerStatus(v) {
// Editor unreachable: we polled nothing, so the broker state is unknowable.
// Reporting 'down' here is exactly the conflation that misled operators —
// an unreachable editor is not a dead broker.
if (!v || !v.reachable) return 'unknown';
// Prefer the editor's own tri-state when it reports one (newer VMs).
if (v.brokerStatus === 'down' || v.brokerStatus === 'idle' || v.brokerStatus === 'agent-live') {
return v.brokerStatus;
}
// Fallback derivation from the raw bits, for a VM reporting the bools but not
// the pre-derived `brokerStatus`. `agentBrokerLive` implies the daemon is up.
if (v.agentBrokerLive) return 'agent-live';
if (v.brokerDaemonReachable === true) return 'idle';
if (v.brokerDaemonReachable === false) return 'down';
// Older VM: reachable editor, but no broker-reachability field at all. Treat
// as 'unknown' (graceful fallback) — never falsely 'down'.
return 'unknown';
}
module.exports = { deriveBrokerStatus, BROKER_STATUSES };
'use strict';
// Pure-ish helpers for the fleet dashboard's pending-job sidecar feature
// (scripts/operator/fleet-dashboard/server.js requires this). Sidecar
// serialization, regex parsing of filenames, gcloud-describe error
// classification, and the reap decision matrix all live here so the
// `server.js` orchestration code can be left thin and these pieces can be
// exercised in isolation (npm/fleet-cleanup.test.js).
//
// File-touching helpers take the directory as a PARAMETER so tests pass a
// throwaway tmpdir; the dashboard passes its fixed `PENDING_JOBS_DIR` constant.
const fs = require('fs');
const path = require('path');
// Sidecar filename grammar: `<action>-<n>.json` where action ∈ {add, destroy}.
// Returns { action, n } on a valid match, null otherwise. Hoisted out of the
// inline regex so the parser is unit-testable on its own (junk filenames, tmp
// turds, missing numbers).
function parsePendingJobFilename(name) {
const m = String(name == null ? '' : name).match(/^(add|destroy)-(\d+)\.json$/);
if (!m) return null;
return { action: m[1], n: Number(m[2]) };
}
// Atomically write a sidecar marking VM-n's <action> in flight. Best-effort:
// any I/O failure is logged, never thrown (the dashboard mustn't crash because
// .codeyam/logs is missing or read-only).
function persistPendingJob(dir, n, action, meta) {
try {
fs.mkdirSync(dir, { recursive: true });
const f = path.join(dir, `${action}-${n}.json`);
const tmp = f + '.tmp';
fs.writeFileSync(tmp, JSON.stringify({ n, action, startedAt: new Date().toISOString(), ...(meta || {}) }));
fs.renameSync(tmp, f);
} catch (e) { console.error('persistPendingJob:', e && e.message); }
}
// Remove a sidecar; a missing file is the already-cleared path, not an error.
function clearPendingJob(dir, n, action) {
try { fs.unlinkSync(path.join(dir, `${action}-${n}.json`)); }
catch (_) { /* missing is fine */ }
}
// Enumerate sidecars under `dir`. Returns one entry per RECOGNISED file —
// junk filenames are dropped silently so an operator's debug touch under
// pending-jobs/ can't crash the reap. Unparseable JSON meta degrades to `{}`
// (the sidecar's existence is what matters; meta is best-effort context).
function listPendingJobs(dir) {
let entries;
try { entries = fs.readdirSync(dir); } catch { return []; }
const out = [];
for (const name of entries) {
const parsed = parsePendingJobFilename(name);
if (!parsed) continue;
let meta = {};
try { meta = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8')) || {}; }
catch { /* unparseable — treat as bare {action, n} */ }
out.push({ n: parsed.n, action: parsed.action, meta });
}
return out;
}
// Classify the outcome of a `gcloud compute instances describe` exec callback.
// - err is null/undefined → instance exists (`true`)
// - stderr/err.message names "NOT_FOUND" / "was not found" / "not found"
// → instance is gone (`false`)
// - anything else (auth glitch, network blip, timeout) → uncertain (`null`)
// The `null` case is what makes the reap fail-soft: an uncertain probe leaves
// the sidecar in place so the next dashboard boot retries instead of clearing
// state on a flaky gcloud.
function classifyDescribeError(err, stderr) {
if (!err) return true;
const out = String(stderr == null ? '' : stderr) + ' ' + String((err && err.message) || '');
if (/NOT_FOUND|was not found|not found/i.test(out)) return false;
return null;
}
// Decide what the startup reap should do for one sidecar. Pure: takes the
// decoded sidecar + GCP-existence verdict + (for adds) container-up verdict +
// (for up adds) project-wiring verdict, returns a tag the caller dispatches on.
// verdict when dispatch
// 'cleanup' destroy gone, OR add gone runDestroyCleanup / runAddCleanup
// 'reconcile-add' add up, container up, project wired (or unknown) ensure roster + recordLaunch, then clear
// 'half-provisioned' add up, container up, but project NOT wired surface needs-recovery card (finish-wire from cfg)
// 'recover-bootstrap' add instance up but container missing/down re-run cloud:up to finish the half-done bootstrap
// 'clear-only' destroy sidecar but instance still exists just clear the sidecar
// 'leave' gcloud uncertain (exists === null) leave for the next boot
// The (exists, containerUp) split distinguishes a finished add from a container-
// down half-provisioned one: a dashboard SIGKILL'd mid-`cloud:up` leaves a GCP
// instance with no docker/container, which the old (exists-only) matrix wrongly
// classified 'reconcile-add' — adding an unreachable VM to the roster. The
// `projectWired` axis closes the remaining gap (improve39 VM-7): an instance +
// container that ARE up but whose `/workspace` is unwired (no git remote, empty
// manifest) is NOT a finished add — it's half-provisioned and must surface a
// recoverable card, not be silently reconciled "done". Only a POSITIVE unwired
// signal (`projectWired === false`) downgrades to 'half-provisioned'; an
// unknown/unprobed wiring (`null`/`undefined`) stays 'reconcile-add' so a flaky
// probe can't strand a healthy VM. `containerUp`/`projectWired` are only consulted
// for an add whose instance exists; destroy and the instance-gone add path ignore
// them. Splitting the matrix out lets the table be exhaustively tested without any
// of the side-effects in server.js.
function reapVerdict({ action, exists, containerUp, projectWired }) {
if (exists === null) return 'leave';
if (action === 'destroy') return exists ? 'clear-only' : 'cleanup';
if (action === 'add') {
if (!exists) return 'cleanup';
if (!containerUp) return 'recover-bootstrap';
return projectWired === false ? 'half-provisioned' : 'reconcile-add';
}
return 'leave';
}
// Classify whether a VM's editor `/workspace` is actually wired to a client
// project, from a best-effort probe of two signals: the git remote URL and
// whether `package.json` carries a real `name`. Pure: server.js runs the SSH +
// `docker exec` probe and hands the raw stdout/err here so the decision is
// unit-testable without a live VM.
// - err present (ssh/docker blip, timeout) → null (unknown — do NOT
// strand a healthy VM on a flaky probe; reapVerdict treats null as wired)
// - a git remote OR a non-empty package.json name → true (wired)
// - neither (empty / no-git workspace) → false (half-provisioned —
// the VM-7 signature: `/workspace` with no remote and an empty manifest)
// The probe emits the two signals separated by the `---CY---` sentinel so this
// parser is independent of locale/line-ending noise in the ssh transport.
function classifyProjectWiring({ stdout, err } = {}) {
if (err) return null;
const parts = String(stdout == null ? '' : stdout).split('---CY---').map((s) => s.trim());
const remote = parts[0] || '';
const nameCount = Number((parts[1] || '0').trim()) || 0;
if (remote || nameCount > 0) return true;
return false;
}
// Durable exit marker for a detached `add` provision (improve40). The add's
// `cloud:up` shell is spawned in its OWN session (detached) so a dashboard
// SIGKILL — the 2GB launchd hard cap, exactly the kill that aborted the VM-7
// add mid-flight — no longer reaches it; the provision keeps running. When that
// detached shell finally exits it writes `add-<n>.exit` (its trailing exit code
// + an ISO timestamp) into PENDING_JOBS_DIR, alongside the `add-<n>.json`
// sidecar. The marker is the GROUND-TRUTH completion signal the next boot reads
// instead of inferring `in-flight → error` from the lost child: a marker means
// "the provision finished while the dashboard was away," its code says whether
// it finished OK. The name is derived here so the shell `trap` (server.js) and
// the JS reader build the exact same path.
function exitMarkerName(n) {
return `add-${n}.exit`;
}
// Parse an exit-marker file body into { exitCode, finishedAt }, or null when the
// body is missing/blank/malformed. Format is two lines — the numeric exit code
// then an ISO timestamp — written by the shell trap. A non-integer first line is
// treated as no-marker (null) so a half-written / corrupt marker falls back to
// the gcloud reconcile path rather than being read as a bogus exit code. Pure:
// takes the text so it's unit-testable without touching disk.
function parseExitMarker(text) {
if (text == null) return null;
const lines = String(text).split('\n').map((s) => s.trim()).filter(Boolean);
if (!lines.length) return null;
const code = Number(lines[0]);
if (!Number.isInteger(code)) return null;
return { exitCode: code, finishedAt: lines[1] || null };
}
// Read VM-n's exit marker from `dir`, or null when it's absent/unreadable/
// malformed (the still-running and never-written cases both surface as null).
function readExitMarker(dir, n) {
try { return parseExitMarker(fs.readFileSync(path.join(dir, exitMarkerName(n)), 'utf8')); }
catch { return null; }
}
// Remove a consumed exit marker; a missing file is the already-cleared path, not
// an error (mirrors clearPendingJob).
function clearExitMarker(dir, n) {
try { fs.unlinkSync(path.join(dir, exitMarkerName(n))); }
catch (_) { /* missing is fine */ }
}
// Decide how a snapshot-restored `add` should be re-attached after a dashboard
// restart, BEFORE any gcloud probe. Pure: takes the durable signals the boot
// path gathers — whether the exit marker exists, its recorded code, and whether
// the detached provision child is still alive (recorded pid, `kill -0`).
// verdict when dispatch
// 'done' marker present, exit code 0 roster + recordLaunch, clear sidecar/marker
// 'error' marker present, non-zero exit code surface the real failure, clear sidecar/marker
// 'running' no marker yet, child still alive RE-ATTACH: keep polling, do NOT mark error
// 'reconcile' child gone, no usable marker fall through to fc.reapVerdict (gcloud probe)
// This is the durable fix's brain: a provision that survived the kill (still
// running, or finished while we were away) is classified from GROUND TRUTH, so
// it is never blanket-converted `in-flight → error` the way the snapshot-only
// path did (the improve39 VM-7 false `add-failed`). Only when the child is gone
// AND left no parseable marker do we fall back to inferring state from gcloud —
// `reconcile` hands that exact (exists, containerUp, projectWired) decision to
// fc.reapVerdict. A marker whose first line isn't an integer is treated as
// absent (parseExitMarker → null) so a corrupt marker reconciles rather than
// asserting a bogus code. The matrix is exhaustively tested without a live VM.
function reattachVerdict({ markerPresent, exitCode, childAlive } = {}) {
if (markerPresent) {
// Guard the null/undefined coercion BEFORE Number() — `Number(null)` is 0,
// which would masquerade as a clean exit. A marker with no parseable code
// (half-written / corrupt) must reconcile, not assert success.
const code = exitCode == null ? NaN : Number(exitCode);
if (Number.isInteger(code)) return code === 0 ? 'done' : 'error';
return 'reconcile'; // marker present but no parseable code — let gcloud decide
}
if (childAlive) return 'running';
return 'reconcile';
}
// Anti-loop budget for the `recover-bootstrap` path. Pure: given the count of
// recoveries ALREADY persisted on the sidecar, return the next attempt number,
// whether the reap should proceed (re-run cloud:up) or treat the VM as
// exhausted (leave the sidecar + surface a needs-manual-recovery card), and the
// cap. Keeps the off-by-one (prior 3 → attempt 4 → stop) out of server.js so it
// can be unit-tested without the side-effecting re-run.
function recoverBootstrapDecision({ recoverAttempts, max = 3 }) {
const prior = Number(recoverAttempts) || 0;
const attempt = prior + 1;
return { attempt, proceed: attempt <= max, max };
}
// improve40 — select the orphaned `add` jobs that must be reconciled from the
// VM's REAL provision state even though their per-job sidecar is GONE. The
// durable re-attach path (sidecar + exit marker + recorded pid) cannot fire for
// these — a restart left only the snapshot record — so the snapshot job's
// stashed `config` is the sole cfg source and a live gcloud probe is the only
// ground truth. Pure: takes the restored jobs map + the set of `<action>-<n>`
// sidecar keys the sidecar reap already owns; returns the [{ n, cfg }] the boot
// path hands to reconcileViaGcloud. A job is selected when ALL hold:
// - it is an `add`,
// - it is flagged `restartOrphaned` (restoreFromSnapshot downgraded a
// running add → error because the dashboard restarted before it finished —
// this is the ONLY error state we re-probe; an add that failed IN-PROCESS
// carries a plain terminal `error` with no flag and stays surfaced), AND
// - NO `add-<n>` sidecar exists (a sidecar means the sidecar reap already
// owns it — never double-reconcile).
// `cfg` rides from the job's `config`; null when the snapshot never carried one
// (older snapshot) — the caller still probes, but a roster reconcile then can't
// recordLaunch, exactly mirroring the sidecar path's `meta.cfg`-absent case.
function orphanedAddsToReconcile({ jobs, sidecarKeys } = {}) {
const sidecars = sidecarKeys instanceof Set ? sidecarKeys : new Set(sidecarKeys || []);
const src = (jobs && typeof jobs === 'object') ? jobs : {};
const out = [];
for (const [k, j] of Object.entries(src)) {
if (!j || typeof j !== 'object') continue;
if (j.action !== 'add') continue;
if (!j.restartOrphaned) continue;
if (sidecars.has(`add-${k}`)) continue;
out.push({ n: Number(k), cfg: j.config || null });
}
return out;
}
// improve41 — the destroy-direction mirror of orphanedAddsToReconcile: decide
// what to do with a VM that is STILL in the roster but whose GCP instance state
// we have just re-probed. The bug this closes: a Destroy that races a dashboard
// restart can leave the instance deleted while the startup reap took the
// `clear-only` path (instance still present at reap time) and dropped the only
// sidecar — so nothing ever runs roster cleanup and the VM polls forever
// "unreachable." A sidecar-independent reconcile, keyed on REAL GCP existence
// rather than on a pending-job sidecar, catches it. Pure: server.js runs the
// gcloud describe (→ exists, via classifyDescribeError) and reads session /
// pending-add state, and hands the three facts here so the matrix is testable
// without a live VM.
// verdict when dispatch
// 'cleanup' instance gone AND the VM is idle runDestroyCleanup
// 'leave' exists uncertain (null), OR the VM is busy do nothing this pass
// 'keep' instance still exists do nothing (healthy)
// Fail-safe by construction: cleanup fires ONLY on a DEFINITIVE absence
// (exists === false) — a flaky/transient describe (null) is always 'leave', so a
// gcloud blip can never evict a healthy VM (mirroring reapVerdict's `leave`
// posture). "Busy" (a live session or an in-flight add to this number) also
// holds the entry for an operator rather than auto-removing — the shouldn't-
// happen-but-fail-safe case where a vanished instance still reports a session.
function reconcileRosteredVm({ exists, hasSession, hasPendingAdd } = {}) {
if (exists === null || exists === undefined) return "leave";
if (exists) return "keep";
if (hasSession || hasPendingAdd) return "leave";
return "cleanup";
}
module.exports = {
parsePendingJobFilename,
persistPendingJob,
clearPendingJob,
listPendingJobs,
classifyDescribeError,
reapVerdict,
reconcileRosteredVm,
orphanedAddsToReconcile,
classifyProjectWiring,
recoverBootstrapDecision,
exitMarkerName,
parseExitMarker,
readExitMarker,
clearExitMarker,
reattachVerdict,
};
'use strict';
// Pure predicate module for the Fleet Dashboard's per-button "will clicking
// this change the VM, or is it already in that state?" indicators
// (scripts/operator/fleet-dashboard/server.js requires this). Given an
// already-gathered FACTS object — no I/O, no git/docker exec; server.js does
// the impure fact-gathering — computeButtonEffects returns a per-button verdict
// map. Mirrors the pure-module-plus-colocated-vitest-test shape of
// npm/fleet-reset.js so the whole decision table is unit-testable without
// spawning a process.
//
// Each verdict is { effect, reason }:
// - 'changes' — clicking does something; `reason` says what.
// - 'noop' — already in that state; clicking changes nothing.
// - 'recommended' — soft signal (run-setup only): a reset/update landed after
// the last successful setup. NEVER a confident green no-op.
// - 'unknown' — not yet checked, VM unreachable, or a needed fact is
// missing. We never CLAIM a safe no-op we couldn't verify —
// silent failure here would be worse than no indicator.
//
// The predicate for each button is derived from what that button actually
// touches (its tooltip in public/index.html is the source of truth): the sync
// buttons key off a git commit comparison, the reset buttons off
// working-tree/session/`.codeyam` state, and rebuild off a build-commit
// comparison.
// Short sha for compact reason strings. Tolerates null / short input.
function short(sha) {
return sha ? String(sha).slice(0, 7) : '?';
}
// "origin/<branch>" — or a bare "origin" when the branch is unknown.
function originRef(branch) {
return branch ? `origin/${branch}` : 'origin';
}
// "N commit(s) behind origin/<branch>" with correct singular/plural.
function behindReason(behind, branch) {
return `${behind} commit${behind === 1 ? '' : 's'} behind ${originRef(branch)}`;
}
// Compose a short reason listing which signals make a reset non-trivial, so the
// operator sees WHAT a Reset would discard rather than a bare "changes". The
// `agent` (a live build agent in the broker) leads the list — it's the most
// consequential thing a reset's `pty-broker stop` would silently discard.
function resetReason({ agent, session, dirty, behind, codeyam }) {
const parts = [];
if (agent) parts.push('running build agent');
if (dirty) parts.push('uncommitted changes');
if (behind) parts.push('source behind origin');
if (session) parts.push('active session');
if (codeyam) parts.push('.codeyam state');
return parts.length ? `will discard: ${parts.join(', ')}` : 'will reset state';
}
// The action ids governed by an effect indicator — the same ids server.js /
// index.html use for the buttons. Reconfigure / Env / Destroy are deliberately
// absent (one-shots, not converge-to-a-state actions, so a no-op predicate is
// meaningless for them).
const ACTION_IDS = [
'update-client-source',
'update-editor-source',
'reset-editor-source',
'reset-client',
'rebuild-binary',
'update-reset-everything',
'run-setup',
];
// facts (a plain object gathered by server.js — no I/O):
// {
// reachable: boolean,
// hasSession: boolean,
// client: { headSha, originSha, behind, dirty } | null, // /workspace
// editor: { headSha, originSha, behind, dirty, buildCommit } | null,
// codeyamDirty: boolean,
// setupRanSince: boolean, // a setup completed after the last reset/update
// clientBranch: string | null, // for reason text only
// editorBranch: string | null, // for reason text only
// }
function computeButtonEffects(facts) {
const f = facts || {};
// Unreachable VM (or no facts at all): every governed button is unknown. We
// could not reach the editor to gather facts, so never render a green no-op.
if (!f.reachable) {
const out = {};
for (const id of ACTION_IDS) out[id] = { effect: 'unknown', reason: 'VM unreachable' };
return out;
}
const client = f.client || null;
const editor = f.editor || null;
const hasSession = !!f.hasSession;
const codeyamDirty = !!f.codeyamDirty;
// A live build agent in the broker. A reset's first step is `pty-broker stop`,
// which kills it regardless of whether a workflow session is registered — so
// this is discardable work even when hasSession is false. Explicit-true only:
// a null/absent value (older or unreachable VM) must NOT flip a genuine noop.
const brokerLive = f.brokerLive === true;
// (1) Update client — ff-advance /workspace to origin tip. Changes iff behind.
const updateClient = client == null
? { effect: 'unknown', reason: 'client state not checked' }
: client.behind > 0
? { effect: 'changes', reason: behindReason(client.behind, f.clientBranch) }
: { effect: 'noop', reason: 'up to date with ' + originRef(f.clientBranch) };
// (2) Update editor — advance the editor source the binary builds from.
const updateEditor = editor == null
? { effect: 'unknown', reason: 'editor state not checked' }
: editor.behind > 0
? { effect: 'changes', reason: behindReason(editor.behind, f.editorBranch) }
: { effect: 'noop', reason: 'up to date with ' + originRef(f.editorBranch) };
// (5) Rebuild binary — advances source then compiles. Changes iff the running
// binary's build commit differs from the editor origin tip. Unknown when we
// don't have both commits to compare.
const rebuild = (editor == null || !editor.buildCommit || !editor.originSha)
? { effect: 'unknown', reason: 'binary/source commit not checked' }
: editor.buildCommit !== editor.originSha
? { effect: 'changes', reason: `binary at ${short(editor.buildCommit)}, ${originRef(f.editorBranch)} at ${short(editor.originSha)}` }
: { effect: 'noop', reason: 'binary built from ' + originRef(f.editorBranch) + ' tip' };
// (3) Reset editor — discards uncommitted + session, advances source, wipes
// .codeyam. No-op ONLY when clean at origin tip with no session.
const resetEditor = editor == null
? { effect: 'unknown', reason: 'editor state not checked' }
: (brokerLive || hasSession || editor.dirty || editor.behind > 0 || codeyamDirty)
? { effect: 'changes', reason: resetReason({ agent: brokerLive, session: hasSession, dirty: editor.dirty, behind: editor.behind > 0, codeyam: codeyamDirty }) }
: { effect: 'noop', reason: 'already clean at origin tip, no session' };
// (3b) Reset client — hard-clean /workspace + .codeyam, drop the session.
// No source-advance, so `behind` is NOT part of this predicate.
const resetClient = client == null
? { effect: 'unknown', reason: 'client state not checked' }
: (brokerLive || client.dirty || codeyamDirty || hasSession)
? { effect: 'changes', reason: resetReason({ agent: brokerLive, session: hasSession, dirty: client.dirty, behind: false, codeyam: codeyamDirty }) }
: { effect: 'noop', reason: 'already clean, no session' };
// (4b) Update & Reset Everything — composite. Changes iff ANY component would
// change: rebuild-binary, reset-editor-source, or the client being
// behind/dirty. Reason summarizes which parts.
const everything = (() => {
if (editor == null || client == null) {
return { effect: 'unknown', reason: 'state not checked' };
}
const parts = [];
if (rebuild.effect === 'changes') parts.push('rebuild');
if (resetEditor.effect === 'changes') parts.push('reset editor');
if (client.behind > 0 || client.dirty) parts.push('reset client');
if (parts.length > 0) return { effect: 'changes', reason: parts.join(' + ') };
// No part would change. But if a component was itself unverifiable, don't
// claim a confident no-op — fall back to unknown.
if (rebuild.effect === 'unknown' || resetEditor.effect === 'unknown') {
return { effect: 'unknown', reason: 'state not checked' };
}
return { effect: 'noop', reason: 'everything already at origin tip, no session' };
})();
// (run-setup) Honest soft signal — there is no cheap upstream proof that
// `npm run setup` would change nothing (DB/migration state isn't
// introspected). `recommended` when a reset/update landed after the last
// successful setup; otherwise `unknown`. Never a confident green no-op.
const runSetup = f.setupRanSince
? { effect: 'unknown', reason: 'no setup-affecting change since last setup' }
: { effect: 'recommended', reason: 'a reset/update landed since the last setup' };
return {
'update-client-source': updateClient,
'update-editor-source': updateEditor,
'reset-editor-source': resetEditor,
'reset-client': resetClient,
'rebuild-binary': rebuild,
'update-reset-everything': everything,
'run-setup': runSetup,
};
}
module.exports = { computeButtonEffects, ACTION_IDS };
'use strict';
// Pure helpers for the fleet dashboard's per-VM environment-variable feature
// (scripts/operator/fleet-dashboard/server.js requires this). Parsing,
// validation, and formatting only — no I/O, no secret logging — so it stays
// unit-testable in isolation (npm/fleet-env.test.js), exactly like
// launch-config.js.
// A KEY must be a POSIX-ish env identifier: a letter or underscore followed by
// letters, digits, or underscores. dotenv / Next.js / Prisma all read this
// shape; rejecting anything else stops a fat-fingered line from writing a
// half-broken .env into the VM.
const KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
// Env-var name the fleet dashboard sets on the `cloud:up` child to name the
// subset of `process.env` keys that came from the operator-supplied launch
// form (`.codeyam/logs/vm-env/<n>.env`). cloud:up reads it at
// project-bootstrap time (`npm/cloud.js::resolveSetupForwardEnv`) and
// forwards exactly those keys into the VM's bootstrap shell — so the
// bootstrap-time `npm run setup` has DB env on the FIRST try, instead of
// the post-add `reapplyVmEnv` race that left `db:push` failing fail-soft.
// One source of truth, imported on both sides — drift between dashboard
// and cloud:up would silently disable the forwarding.
const FORWARD_ENV_KEYS_ENV = "CY_FORWARD_ENV_KEYS";
// Parse a `KEY=value`-per-line .env blob into a validated result.
// - Blank lines are dropped.
// - `#` comment lines are preserved (they carry no key).
// - Every other line must be `KEY=value`; only the FIRST `=` splits, so a
// value may itself contain `=` (e.g. a connection string) or be empty.
//
// Returns { ok, keys, content, error }:
// ok — true when every non-blank/non-comment line parsed
// keys — ordered declared keys, no values (safe to display / log)
// content — normalized text to write to .env (trailing newline), '' on error
// or when there are no keys
// error — message naming the 1-based line number on failure; it NEVER
// echoes the line's value, so a malformed secret can't leak
function parseEnvVars(text) {
const rawLines = String(text == null ? '' : text).split(/\r?\n/);
const fail = (error) => ({ ok: false, keys: [], content: '', error });
const keys = [];
const out = [];
for (let i = 0; i < rawLines.length; i += 1) {
const trimmed = rawLines[i].trim();
if (trimmed === '') continue; // drop blank lines
if (trimmed.startsWith('#')) { out.push(trimmed); continue; } // keep comments
const eq = trimmed.indexOf('=');
if (eq <= 0) return fail(`malformed env line ${i + 1} (expected KEY=value)`);
const key = trimmed.slice(0, eq).trim();
const value = trimmed.slice(eq + 1);
if (!KEY_RE.test(key)) {
return fail(`invalid env key on line ${i + 1} (must match [A-Za-z_][A-Za-z0-9_]*)`);
}
if (keys.includes(key)) return fail(`duplicate env key "${key}" on line ${i + 1}`);
keys.push(key);
out.push(`${key}=${value}`);
}
return { ok: true, keys, content: keys.length ? `${out.join('\n')}\n` : '', error: null };
}
// Keys only, for masked display / pre-fill — never returns values. Best-effort:
// returns [] for unparseable input rather than throwing.
function envKeys(text) {
const r = parseEnvVars(text);
return r.ok ? r.keys : [];
}
// True when the parsed env-text declares at least one of `triggerKeys`. Used by
// the fleet dashboard to decide whether to re-fire `npm run setup` after
// `/workspace/.env` lands in the container: bootstrap-time setup runs BEFORE
// env injection, so a project whose setup needs DB env (Prisma db:push, etc.)
// half-completes silently on first launch. Pure: takes the persisted env text
// + the trigger list and returns a bool — no I/O, the I/O caller passes the
// already-read text in. Unparseable input → no keys → false (matches envKeys).
function envHasSetupTrigger(text, triggerKeys) {
if (!Array.isArray(triggerKeys) || triggerKeys.length === 0) return false;
const declared = new Set(envKeys(text));
return triggerKeys.some((k) => declared.has(k));
}
// Parse a `KEY=value`-per-line .env blob into a `{KEY: value}` dict suitable
// for spreading into a child-process env. Returns null for unparseable input
// (matches envKeys's best-effort shape so callers can fall back without
// re-handling parse failures). Comment lines are dropped. The fleet dashboard
// uses this to read a VM's persisted env file (`.codeyam/logs/vm-env/<n>.env`)
// at `cloud:up`-launch time so the bootstrap-time `npm run setup` already has
// DB env on first try — instead of the post-add `reapplyVmEnv` race where
// setup fails fail-soft for lack of `DATABASE_URL`.
function envToDict(text) {
const r = parseEnvVars(text);
if (!r.ok) return null;
const dict = {};
for (const line of r.content.split('\n')) {
if (line === '' || line.startsWith('#')) continue;
const eq = line.indexOf('=');
if (eq <= 0) continue;
dict[line.slice(0, eq)] = line.slice(eq + 1);
}
return dict;
}
// Wrap a parsed launch-form env dict in the cloud:up bootstrap-child-env
// shape: spreads `envDict` so the keys land in the child's `process.env`, and
// sets `CY_FORWARD_ENV_KEYS` to the comma-separated key list so
// `npm/cloud.js::resolveSetupForwardEnv` can whitelist exactly those keys
// when forwarding into the VM's bootstrap shell. Returns null on empty input
// so the dashboard's `startJob` call can omit `env` entirely (preserving
// `process.env` inheritance for the no-launch-form-env path). A non-object
// (programmer error) also returns null rather than throwing.
function buildBootstrapChildEnv(envDict) {
if (!envDict || typeof envDict !== "object") return null;
const keys = Object.keys(envDict);
if (keys.length === 0) return null;
return { ...envDict, [FORWARD_ENV_KEYS_ENV]: keys.join(",") };
}
module.exports = {
parseEnvVars,
envKeys,
envHasSetupTrigger,
envToDict,
buildBootstrapChildEnv,
FORWARD_ENV_KEYS_ENV,
KEY_RE,
};
// Group an already-sorted/filtered VM list by client project for the Fleet
// Dashboard's grouped layout.
//
// Returns an ORDERED array of { project, vms } groups: real project groups
// first, sorted case-insensitively by name (locale compare); the
// unknown/starting-up bucket (project === null) pinned LAST. Within each group
// the incoming order of `vms` is preserved untouched, so the caller's active
// Sort mode continues to drive within-group order — grouping never re-sorts the
// cards, it only partitions them.
//
// `projectKey(v)` resolves the group key for a VM and defaults to the same
// identity `card()` renders in the dashboard:
// (v.cardIdentity && v.cardIdentity.project) || v.project || null
// It is injectable so unit tests can drive grouping without constructing full
// card payloads. A null/empty key routes the VM into the trailing unknown
// bucket so VMs mid-provision (blank identity) never silently disappear.
//
// Pure; no I/O.
function defaultProjectKey(v) {
return (v && v.cardIdentity && v.cardIdentity.project) || (v && v.project) || null;
}
function groupVmsByProject(vms, projectKey = defaultProjectKey) {
// First pass: stable bucketing. `Map` preserves insertion order, so the
// first VM of each project fixes that group's relative position before we
// re-order — but we re-sort named groups below regardless, so insertion
// order only matters for tie-stability within equal-name groups (none here).
const named = new Map();
let unknown = null;
for (const v of vms) {
const raw = projectKey(v);
const key = raw == null || raw === "" ? null : raw;
if (key === null) {
if (!unknown) unknown = { project: null, vms: [] };
unknown.vms.push(v);
continue;
}
let group = named.get(key);
if (!group) {
group = { project: key, vms: [] };
named.set(key, group);
}
group.vms.push(v);
}
const groups = Array.from(named.values());
groups.sort((a, b) =>
a.project.localeCompare(b.project, undefined, { sensitivity: "base" })
);
if (unknown) groups.push(unknown);
return groups;
}
module.exports = { groupVmsByProject, defaultProjectKey };
'use strict';
// npm/fleet-job-badge.js — pure verdict for the fleet dashboard's failed-job
// badge reconciliation (improve39). A VM's last terminal-error heavy job
// (add/error, reset-all error, reset-editor error, …) used to stick on the
// dashboard card indefinitely — even after the VM was recovered and is healthy
// again — because the card renders the last job's error with no check against
// the VM's live state, so a recovered VM keeps screaming a red error and
// misleads the operator. This decides whether such a badge should be RECONCILED
// (presented as "recovered" rather than a live error) because the VM is
// demonstrably fine now. The live signals are gathered in server.js (the
// untestable server shell); the verdict lives here so the cases are unit-tested
// without a live VM.
// Should the VM's last terminal-error job badge be reconciled against live
// health — i.e. presented as "recovered" rather than a live red error?
//
// job the carried job record ({ state, action, ... }) or null
// reachable the VM's editor answered this poll (true = healthy / responding)
// drifted a project-dir-drift signal is present (reachable-but-unwired /
// half-provisioned add — genuinely needs-recovery, owned by the
// companion reconcile-inflight-jobs plan; never falsely cleared here)
// offBranch the VM's live client branch differs from its target branch
// degraded the job completed but a wiring sub-step is flagged degraded
//
// Returns true only when the job is a terminal error AND the VM is demonstrably
// fine (reachable, not drifted, on-branch, not degraded). An unreachable /
// drifted / off-branch VM keeps its error surfaced — a real problem must stay
// visible.
function shouldReconcileFailedBadge({ job, reachable, drifted, offBranch, degraded } = {}) {
if (!job || job.state !== 'error') return false;
if (!reachable) return false;
if (drifted) return false;
if (offBranch) return false;
if (degraded) return false;
return true;
}
module.exports = { shouldReconcileFailedBadge };
'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';
// npm/fleet-pool-policy.js — the warm pool's cost-control policy (improve38).
//
// Idle pool VMs cost money, so the pool's target size is not a constant — it's
// a function of the clock. During working hours the keeper holds `baseSize`
// claim-ready VMs; off-hours (and on an idle timer) it scales the target down,
// by default to zero, so an overnight pool doesn't bill for VMs nobody will
// claim. A claim against the resulting empty pool falls back to on-demand
// `fleet-ready` (slower, but it works) rather than failing — so scaling to zero
// is safe, not a service outage.
//
// This is the pure decision half, kept out of the keeper's untestable SSH loop:
// given a config and a clock, what should the pool target be right now? The
// keeper reads the answer and provisions/evicts toward it.
// Resolve the pool config from an env-like object (process.env in production,
// a literal in tests), applying defaults. All knobs are operator-tunable so the
// same keeper serves a 0-VM hobby fleet and a 20-VM team pool with no code
// change.
// POOL_TARGET base (working-hours) pool size default 4
// POOL_OFFHOURS_TARGET pool size during off-hours default 0
// POOL_OFFHOURS_START hour [0-23] off-hours begin (inclusive) default 20
// POOL_OFFHOURS_END hour [0-23] off-hours end (exclusive) default 8
// A start==end disables the off-hours window entirely (always baseSize).
function resolveConfig(env) {
const e = env || {};
return {
baseSize: intOr(e.POOL_TARGET, 4),
offHoursSize: intOr(e.POOL_OFFHOURS_TARGET, 0),
offHoursStart: clampHour(e.POOL_OFFHOURS_START, 20),
offHoursEnd: clampHour(e.POOL_OFFHOURS_END, 8),
};
}
// Non-negative integer or the default. Sizes can't be negative, so a negative
// (or non-numeric) value is rejected to the default rather than silently used.
function intOr(v, dflt) {
const n = Number(v);
return Number.isInteger(n) && n >= 0 ? n : dflt;
}
// Parse an hour into [0, 23], clamping out-of-range values both directions
// (a negative → 0, a too-large → 23) so a typo'd config indexes a real hour
// instead of wedging the window. A non-numeric value falls back to `dflt`.
function clampHour(v, dflt) {
const n = Number(v);
if (!Number.isInteger(n)) return dflt;
if (n < 0) return 0;
if (n > 23) return 23;
return n;
}
// Is `hour` within the off-hours window [start, end)? The window wraps midnight
// when start > end (the common case: 20:00 → 08:00 spans the night). start==end
// means "no off-hours window" → always false.
function isOffHours(hour, start, end) {
if (start === end) return false;
if (start < end) return hour >= start && hour < end; // same-day window
return hour >= start || hour < end; // wraps midnight
}
// The pool target size for the given clock: offHoursSize inside the off-hours
// window, baseSize otherwise. `date` is a Date (defaults to now) — the local
// hour drives the decision, matching the operator's working day.
function desiredPoolSize(config, date) {
const cfg = config && typeof config.baseSize === 'number' ? config : resolveConfig(config);
const d = date || new Date();
return isOffHours(d.getHours(), cfg.offHoursStart, cfg.offHoursEnd)
? cfg.offHoursSize
: cfg.baseSize;
}
module.exports = {
resolveConfig,
isOffHours,
desiredPoolSize,
};
'use strict';
// npm/fleet-pool.js — the warm-VM-pool state layer (improve38).
//
// Standing up a VM on demand pays for an image pull, a branch rebrand and —
// the slow part — an interactive auth approval. The warm pool removes that
// latency from the request path: keep N pre-provisioned, pre-authed, READY
// idle VMs and CLAIM them in seconds (rebrand-only, no OAuth). This module is
// the state seam for that pool — it answers "which VMs are idle-and-claimable
// vs already-assigned" and performs the ATOMIC claim that guarantees two
// operators never grab the same VM.
//
// Why a SEPARATE file from the roster (`fleet-vms.json`): the roster is a
// shared contract — a plain JSON array of VM-number integers that the
// dashboard, vm-watchdog, cloudflared-monitor and the bash publishers all
// read. Widening it to carry per-VM status would break every reader (they
// `jq -r '.[]'` it as scalars). So pool membership lives alongside the roster
// in `.codeyam/logs/fleet-pool.json`, keyed by VM number, and the roster keeps
// meaning "these VM numbers exist". A VM can be in the roster without being in
// the pool (e.g. an operator's hand-driven VM); the pool only tracks the warm
// set.
//
// On-disk shape (tolerant: a missing or malformed file is the empty pool):
// { "vms": { "<n>": { status, branch, freshAt, claimedAt } } }
// status: "pool" idle, claim-ready
// "assigned" claimed by an operator, rebranded to `branch`
// branch: the branch the VM is on (base branch while pooled)
// freshAt: ISO timestamp of the last successful auth/health refresh —
// drives evict-before-claim of a member whose token lapsed
// claimedAt: ISO timestamp of the claim (null while pooled)
//
// The pure functions (normalize / select / refill / staleness) operate on a
// plain state object and return a new one — no I/O — so they unit-test without
// touching disk. readState/writeState/withLock add the atomic file layer, and
// main() is the CLI seam the bash orchestrators (fleet-claim.sh,
// fleet-pool-keeper.sh) call instead of re-implementing the logic in shell.
const fs = require('fs');
const path = require('path');
const { rootDir } = require('./utils');
const POOL_STATE_FILE = process.env.POOL_STATE_FILE
|| path.join(rootDir, '.codeyam', 'logs', 'fleet-pool.json');
const POOL = 'pool';
const ASSIGNED = 'assigned';
// ── Pure state helpers ──────────────────────────────────────────────────────
// Coerce arbitrary parsed JSON into the canonical `{ vms: { <n>: entry } }`
// shape. Drops entries whose key is not a positive integer or whose status is
// unrecognized, so a truncated write or a hand-edit never feeds a malformed
// entry into the claim path. Returned object is a fresh copy — callers mutate
// it freely.
function normalizeState(raw) {
const out = { vms: {} };
const vms = raw && typeof raw === 'object' ? raw.vms : null;
if (!vms || typeof vms !== 'object') return out;
for (const key of Object.keys(vms)) {
const n = Number(key);
if (!Number.isInteger(n) || n <= 0) continue;
const e = vms[key] || {};
const status = e.status === ASSIGNED ? ASSIGNED : e.status === POOL ? POOL : null;
if (!status) continue;
out.vms[n] = {
status,
branch: typeof e.branch === 'string' ? e.branch : null,
freshAt: typeof e.freshAt === 'string' ? e.freshAt : null,
claimedAt: typeof e.claimedAt === 'string' ? e.claimedAt : null,
};
}
return out;
}
// VM numbers in a given status, sorted ascending. Ascending order makes claim
// selection deterministic (lowest-numbered idle VM first) so tests and two
// concurrent callers agree on which VM a claim takes.
function membersByStatus(state, status) {
return Object.keys(state.vms)
.map(Number)
.filter((n) => state.vms[n].status === status)
.sort((a, b) => a - b);
}
function poolMembers(state) {
return membersByStatus(state, POOL);
}
function assignedMembers(state) {
return membersByStatus(state, ASSIGNED);
}
// True when a pool entry's auth/health is older than `ttlMs` (its token may
// have lapsed). An entry with no `freshAt` is treated as stale — "freshness
// not proven" fails closed, mirroring fleet-ready's auth gate. Only meaningful
// for pooled VMs; an assigned VM is the operator's concern, not the keeper's.
function isStale(entry, nowMs, ttlMs) {
if (!entry || entry.status !== POOL) return false;
if (!entry.freshAt) return true;
const t = Date.parse(entry.freshAt);
if (Number.isNaN(t)) return true;
return nowMs - t > ttlMs;
}
// Pool VM numbers whose freshness has lapsed — the evict-before-claim set the
// keeper drops (and replaces) so a claim never hands a caller a stale VM.
function staleMembers(state, nowMs, ttlMs) {
return poolMembers(state).filter((n) => isStale(state.vms[n], nowMs, ttlMs));
}
// How many NEW pool VMs the keeper must provision to reach `target` idle
// members. Assigned VMs do not count toward the pool — once claimed they're
// gone from the warm set until released — so a claim naturally triggers a
// refill. Never negative (an over-target pool is shrunk by policy, not here).
function refillCount(state, target) {
const have = poolMembers(state).length;
return Math.max(0, target - have);
}
// The heart of the claim: deterministically move up to `count` idle pool VMs to
// `assigned` on `branch`. Pure — returns { next, claimed, shortfall } without
// touching disk; the caller persists `next` under the lock. Lowest-numbered
// VMs are taken first. `shortfall` (requested minus granted) is the signal the
// orchestrator uses to fall back to on-demand provisioning when the pool can't
// satisfy the whole request — an empty pool yields claimed=[] , shortfall=count
// rather than an error.
function selectForClaim(state, count, branch, nowIso) {
const next = normalizeState(state);
const idle = poolMembers(next);
const take = idle.slice(0, Math.max(0, count));
for (const n of take) {
next.vms[n] = {
...next.vms[n],
status: ASSIGNED,
branch,
claimedAt: nowIso,
};
}
return { next, claimed: take, shortfall: Math.max(0, count - take.length) };
}
// Register (or overwrite) a VM in the pool state. The keeper calls this after
// provisioning a fresh READY VM (status=pool) and the claim path can re-add an
// assigned VM. `freshAt` defaults to now for a pool member so a just-verified
// VM isn't immediately stale.
function applyAdd(state, n, status, branch, nowIso) {
const next = normalizeState(state);
const st = status === ASSIGNED ? ASSIGNED : POOL;
next.vms[n] = {
status: st,
branch: branch || null,
freshAt: st === POOL ? nowIso : (next.vms[n] && next.vms[n].freshAt) || null,
claimedAt: st === ASSIGNED ? nowIso : null,
};
return next;
}
// Return an assigned VM to the idle pool (operator finished with it). A no-op
// for an absent VM. `freshAt` is reset to now: a just-released VM was live and
// authed, so it's claim-ready again without waiting for the keeper's refresh.
function applyRelease(state, n, nowIso) {
const next = normalizeState(state);
if (!next.vms[n]) return next;
next.vms[n] = {
...next.vms[n],
status: POOL,
freshAt: nowIso,
claimedAt: null,
};
return next;
}
// Drop a VM from the pool state entirely (the keeper evicts a stale/unhealthy
// member before replacing it). A no-op for an absent VM.
function applyEvict(state, n) {
const next = normalizeState(state);
delete next.vms[n];
return next;
}
// Stamp a pool member as freshly auth/health-verified. The keeper calls this
// after a successful credential refresh so the member's eviction clock resets.
function applyMarkFresh(state, n, nowIso) {
const next = normalizeState(state);
if (!next.vms[n]) return next;
next.vms[n] = { ...next.vms[n], freshAt: nowIso };
return next;
}
// ── Atomic file layer ───────────────────────────────────────────────────────
// Synchronous millisecond sleep without a busy CPU spin — used by withLock's
// retry. Atomics.wait blocks the (single) thread for `ms`; a fresh 4-byte
// SharedArrayBuffer that nothing else holds means the wait always times out
// rather than being woken early.
function sleepSync(ms) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
// Run `fn` while holding a cross-PROCESS mutex around the pool-state file, so a
// read-modify-write (the claim) is atomic even when two operators run
// `fleet-claim` at the same moment. The lock is a directory next to the state
// file: `mkdir` is atomic on POSIX, so exactly one process wins the create and
// the rest retry. A lock older than `staleMs` is assumed abandoned (a crashed
// claimer) and stolen, so a dead process can't wedge the pool forever.
function withLock(fn, { retries = 100, delayMs = 25, staleMs = 30000 } = {}) {
const lockDir = `${POOL_STATE_FILE}.lock`;
fs.mkdirSync(path.dirname(POOL_STATE_FILE), { recursive: true });
let held = false;
for (let i = 0; i < retries && !held; i += 1) {
try {
fs.mkdirSync(lockDir);
held = true;
} catch (err) {
if (err.code !== 'EEXIST') throw err;
// Steal an abandoned lock (claimer crashed mid-operation).
try {
const age = Date.now() - fs.statSync(lockDir).mtimeMs;
if (age > staleMs) {
fs.rmdirSync(lockDir);
continue; // retry the mkdir immediately
}
} catch { /* lock vanished between stat and now — just retry */ }
sleepSync(delayMs);
}
}
if (!held) throw new Error(`fleet-pool: could not acquire lock ${lockDir} after ${retries} tries`);
try {
return fn();
} finally {
try { fs.rmdirSync(lockDir); } catch { /* already gone */ }
}
}
// Read + normalize the pool state. A missing or malformed file is the empty
// pool `{ vms: {} }` (the documented tolerant contract), so a truncated write
// never throws into the keeper's reconcile loop.
function readState() {
try {
return normalizeState(JSON.parse(fs.readFileSync(POOL_STATE_FILE, 'utf8')));
} catch {
return { vms: {} };
}
}
// Persist `state` atomically: write a pid-tagged temp file then `rename` over
// the target so a concurrent reader never observes a half-written file (same
// discipline as fleet-roster.writeRoster). Returns the normalized state.
function writeState(state) {
const next = normalizeState(state);
fs.mkdirSync(path.dirname(POOL_STATE_FILE), { recursive: true });
const tmp = `${POOL_STATE_FILE}.${process.pid}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(next));
fs.renameSync(tmp, POOL_STATE_FILE);
return next;
}
module.exports = {
POOL_STATE_FILE,
POOL,
ASSIGNED,
normalizeState,
poolMembers,
assignedMembers,
isStale,
staleMembers,
refillCount,
selectForClaim,
applyAdd,
applyRelease,
applyEvict,
applyMarkFresh,
withLock,
readState,
writeState,
main,
};
// ── CLI seam ────────────────────────────────────────────────────────────────
// The bash orchestrators call these subcommands instead of re-deriving the
// logic in shell. Each mutating subcommand runs its read-modify-write inside
// withLock so concurrent callers serialize. Output is line-oriented and
// machine-parseable (space-separated VM numbers / a single integer) so the
// shell side stays a thin `$(node fleet-pool.js ...)` capture.
function parsePoolFlags(argv) {
const flags = {};
for (let i = 0; i < argv.length; i += 1) {
const a = argv[i];
if (a.startsWith('--')) {
const key = a.slice(2);
const val = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[(i += 1)] : 'true';
flags[key] = val;
}
}
return flags;
}
function main(argv) {
const [cmd, ...rest] = argv;
const flags = parsePoolFlags(rest);
const now = flags.now || new Date().toISOString();
switch (cmd) {
case 'list':
process.stdout.write(`${JSON.stringify(readState(), null, 2)}\n`);
return 0;
case 'pool-members':
process.stdout.write(`${poolMembers(readState()).join(' ')}\n`);
return 0;
case 'assigned-members':
process.stdout.write(`${assignedMembers(readState()).join(' ')}\n`);
return 0;
case 'refill-count': {
const target = Number(flags.target);
if (!Number.isInteger(target) || target < 0) {
process.stderr.write('fleet-pool: refill-count needs --target <non-negative int>\n');
return 2;
}
process.stdout.write(`${refillCount(readState(), target)}\n`);
return 0;
}
case 'stale': {
const ttl = Number(flags['ttl-seconds']);
if (!Number.isInteger(ttl) || ttl < 0) {
process.stderr.write('fleet-pool: stale needs --ttl-seconds <non-negative int>\n');
return 2;
}
const out = staleMembers(readState(), Date.parse(now), ttl * 1000);
process.stdout.write(`${out.join(' ')}\n`);
return 0;
}
case 'claim': {
const count = Number(flags.count);
const branch = flags.branch;
if (!Number.isInteger(count) || count <= 0 || !branch || branch === 'true') {
process.stderr.write('fleet-pool: claim needs --count <pos int> --branch <name>\n');
return 2;
}
const result = withLock(() => {
const { next, claimed, shortfall } = selectForClaim(readState(), count, branch, now);
writeState(next);
return { claimed, shortfall };
});
// VM numbers on stdout (the orchestrator rebrands each); shortfall on
// stderr so an empty/short pool is visible without polluting the capture.
process.stdout.write(`${result.claimed.join(' ')}\n`);
if (result.shortfall > 0) {
process.stderr.write(`fleet-pool: shortfall ${result.shortfall} (pool could not satisfy full claim)\n`);
}
return 0;
}
case 'add': {
const vm = Number(flags.vm);
if (!Number.isInteger(vm) || vm <= 0) {
process.stderr.write('fleet-pool: add needs --vm <pos int>\n');
return 2;
}
const status = flags.status === ASSIGNED ? ASSIGNED : POOL;
withLock(() => writeState(applyAdd(readState(), vm, status, flags.branch, now)));
return 0;
}
case 'release': {
const vm = Number(flags.vm);
if (!Number.isInteger(vm) || vm <= 0) {
process.stderr.write('fleet-pool: release needs --vm <pos int>\n');
return 2;
}
withLock(() => writeState(applyRelease(readState(), vm, now)));
return 0;
}
case 'evict': {
const vm = Number(flags.vm);
if (!Number.isInteger(vm) || vm <= 0) {
process.stderr.write('fleet-pool: evict needs --vm <pos int>\n');
return 2;
}
withLock(() => writeState(applyEvict(readState(), vm)));
return 0;
}
case 'mark-fresh': {
const vm = Number(flags.vm);
if (!Number.isInteger(vm) || vm <= 0) {
process.stderr.write('fleet-pool: mark-fresh needs --vm <pos int>\n');
return 2;
}
withLock(() => writeState(applyMarkFresh(readState(), vm, now)));
return 0;
}
default:
process.stderr.write(
'fleet-pool: usage: <list|pool-members|assigned-members|refill-count|stale|claim|add|release|evict|mark-fresh> [--flags]\n',
);
return 2;
}
}
if (require.main === module) {
process.exit(main(process.argv.slice(2)));
}
'use strict';
// Pure helpers for the fleet dashboard's commit-queue auto-release logic
// (scripts/operator/fleet-dashboard/server.js requires this). The decision
// of whether to auto-release a queue head lives here so server.js's
// orchestration stays thin and this piece can be unit-tested in isolation
// (npm/fleet-queue.test.js).
//
// Auto-release fires only on the deterministic "owning VM left the fleet"
// signal — a VM that is absent from the roster AND has no in-flight
// provisioning job. The ambiguous "in roster but unreachable" case is
// explicitly out of scope: a transient blip could trigger a spurious
// release. That case is surfaced as a badge for the operator to act on.
/** Decide whether the current queue head should be auto-released.
*
* Conditions that must ALL hold:
* 1. The head entry's `vm` field is non-null (the machine name matched the
* fleet's INSTANCE_PREFIX-…-<n> shape via vmFromMachine). An
* unattributable head (vm===null) is never auto-released.
* 2. That VM number is absent from the live `vms` roster array.
* 3. There is no in-flight or queued provisioning job for that number
* (guards the Add→addVm gap where a VM is being created but not yet
* rostered). Also guards known-orphan VM numbers.
*
* Returns { release: false } or { release: true, uuid, vm, reason }.
* Pure; no I/O.
*/
function decideAutoReleaseGoneHead({ queue, vms, jobs, orphans }) {
const head = queue && queue[0];
if (!head || head.vm == null) return { release: false };
const n = head.vm;
if (vms.includes(n)) return { release: false };
const job = jobs[n];
const provisioning = job && (job.state === 'queued' || job.state === 'running')
&& /add|cloud:up/.test(job.action || '');
if (provisioning || orphans[n]) return { release: false };
return { release: true, uuid: head.uuid, vm: n, reason: `owning VM-${n} left the fleet` };
}
module.exports = { decideAutoReleaseGoneHead };
'use strict';
// npm/fleet-reconcile.js — pure reconciliation helpers for the fleet dashboard's
// "unrostered VM" safety net (improve38). The dashboard lists running GCP
// instances and surfaces any that aren't in the roster (provisioned out-of-band
// — raw `gcloud`, or a `cloud:up` whose roster write failed). The gcloud I/O
// lives in server.js (the untestable server shell); the pure parse + set
// difference live here so they're unit-tested without invoking gcloud.
// Parse `gcloud compute instances list --format='value(name)'` output into the
// sorted, de-duped fleet VM numbers it names — instances whose name matches
// `<prefix><n>`. Non-matching lines (other instances, blank lines, gcloud/SSH
// warning noise) are ignored.
function parseRunningFleetVms(stdout, prefix) {
const re = new RegExp(`^${String(prefix).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\d+)$`);
const ns = String(stdout || '')
.split('\n')
.map((line) => {
const m = line.trim().match(re);
return m ? Number(m[1]) : null;
})
.filter((n) => n != null);
return [...new Set(ns)].sort((a, b) => a - b);
}
// Set difference: the `running` VM numbers that are NOT already accounted for in
// `shown` (the roster plus any provisioning/orphan numbers already rendered as a
// card). Returns a sorted, de-duped array — the numbers that need an Adopt card.
function computeUnrostered(running, shown) {
const accounted = new Set(shown);
return [...new Set(running)]
.filter((n) => !accounted.has(n))
.sort((a, b) => a - b);
}
module.exports = { parseRunningFleetVms, computeUnrostered };
'use strict';
// Pure bash-script builders for the fleet dashboard's RESET actions
// (scripts/operator/fleet-dashboard/server.js requires this). String
// construction only — no I/O, no gcloud/docker exec — so the reset *recipe*
// (what the remote bash actually does) stays unit-testable in isolation
// (npm/fleet-reset.test.js), exactly like launch-config.js / fleet-env.js.
// The dispatch (gcloud ssh → docker exec → startJob) stays in server.js as
// platform-glue; only the decision-bearing script shape lives here.
// The `.codeyam` deep-clean preserve→pause→wipe→restore sequence, shared by every
// reset that hard-cleans a /workspace. `git reset --hard` + `session-reset`
// leave the git-IGNORED runtime state behind (workflow step files,
// scenario-*.js, server-state, logs, caches), so we explicitly wipe and
// restore the tracked `.codeyam`. Two gitignored files MUST survive the wipe:
// - editor.local.json — previewOrigin + the agent API key (a blind wipe
// breaks Live Preview + auth).
// - server-state.json — the running pid + control port the restart's verify
// reads.
// Both are cp'd aside, `.codeyam` is wiped, restored from the branch (tracked,
// e.g. self-host) or re-`init`'d (greenfield, where `.codeyam` is gitignored),
// then the two files are copied back. Returned as step strings the caller
// joins into its own `&&`-chain (the surrounding `( …; true )` guards keep each
// best-effort cp from breaking the chain when a file is absent).
//
// First step: release any commit-queue entries this VM owns. The wipe
// regenerates `.codeyam/run/queue-origin-id`, which would otherwise orphan
// the prior session's entry (different origin_id, same machine — invisible
// to the two-key adoption path until the next enqueue's same-machine guard
// reaps it). Routing through `branch-queue release-mine` makes the
// queue-side cleanup intentional and visible in the queue log instead.
// Best-effort: a no-remote / queue-disabled project legitimately exits
// non-zero, and we should not block the reset.
//
// Before the wipe we QUIESCE ALL WRITERS: park the editor server
// (`stop-server --hold-for-restart`) AND stop the PTY broker
// (`pty-broker stop`). Parking only the editor server is insufficient — the
// broker is a SEPARATE process (and hooks / agent subprocesses can fire), so
// it keeps re-creating `.codeyam/logs/` and `.codeyam/run/` between when the
// wipe enumerates the dir and when it removes it. That race surfaced live
// (VM-1, 2026-06-01) as `rm: cannot remove '.codeyam': Directory not empty`,
// aborting the whole reset. Both quiesce calls are fail-soft `|| true` so a
// server/broker that's already dead (e.g. the reset is rerun after a partial
// failure) doesn't block the chain.
//
// The wipe itself is RETRY-RESILIENT instead of a single `rm -rf .codeyam`
// that aborts the &&-chain on one transient re-creation: a bounded loop of
// `find .codeyam -mindepth 1 -delete` empties the contents and tolerates a
// brief re-create, then proceeds even if `.codeyam` is still non-empty after
// best-effort (a soft retry, never a hard failure — `git checkout`/`init`
// repopulates over whatever remains). Emptying contents (not removing the dir)
// also sidesteps the `rmdir` race entirely.
//
// The destructive region (park → broker-stop → wipe → restore) is made
// FAILURE-SAFE with an EXIT trap installed before the park and disarmed after
// a successful restore. If ANY step in that region aborts the chain, the trap
// still restores tracked `.codeyam` (or re-inits), copies the two preserved
// infra files back, and runs `restart-server --force-in-container` before
// exiting non-zero — so a failed deep-clean self-recovers to a working
// (un-reset, but UP) VM instead of leaving it parked-and-down with a
// half-wiped `.codeyam`, and still reports the failure. The happy path is
// unchanged: the restore steps run, the trap is disarmed, and the caller's
// own trailing `restart-server` resurrects the parked server.
function codeyamDeepCleanSteps(E) {
// Restore primitives shared by the happy path and the trap recovery body.
const restoreCodeyam = `git checkout HEAD -- .codeyam 2>/dev/null || ${E} init`;
const restoreElj =
'test -f /tmp/cy-elj.bak && mkdir -p .codeyam && cp /tmp/cy-elj.bak .codeyam/editor.local.json';
const restoreSs =
'test -f /tmp/cy-ss.bak && mkdir -p .codeyam && cp /tmp/cy-ss.bak .codeyam/server-state.json';
// Recovery body: restore .codeyam + the two infra backups, then resurrect the
// parked server. Runs ONLY on the failure path (the happy path's own restore
// steps remove the backups; recovery here intentionally leaves them in place
// because on a mid-region abort those steps never ran).
const recover =
`${restoreCodeyam}; ( ${restoreElj}; true ); ( ${restoreSs}; true ); ` +
`${E} editor restart-server --force-in-container || true`;
return [
`( ${E} editor branch-queue release-mine --yes 2>/dev/null; true )`,
'( cp .codeyam/editor.local.json /tmp/cy-elj.bak 2>/dev/null; true )',
'( cp .codeyam/server-state.json /tmp/cy-ss.bak 2>/dev/null; true )',
// A brace group (NOT a subshell) so the function + trap persist into the
// current shell where the rest of the &&-chain runs.
`{ __cy_recover() { ${recover}; }; trap '__cy_recover; exit 1' EXIT; }`,
`(${E} editor stop-server --hold-for-restart --force-in-container || true)`,
`(${E} editor pty-broker stop || true)`,
'( for i in 1 2 3 4 5; do find .codeyam -mindepth 1 -delete 2>/dev/null; [ -z "$(ls -A .codeyam 2>/dev/null)" ] && break; sleep 0.2; done; true )',
`( ${restoreCodeyam} )`,
`( ${restoreElj} && rm -f /tmp/cy-elj.bak; true )`,
`( ${restoreSs} && rm -f /tmp/cy-ss.bak; true )`,
// Restore succeeded — disarm the failure-safe trap so normal completion
// (and the caller's trailing session-reset + restart) doesn't re-trigger it.
'trap - EXIT',
];
}
// Belt-and-suspenders broker-ensure, appended after the trailing
// `restart-server` in every reset recipe. The PRIMARY fix lives in the
// editor server's `ensure_broker_running`: it now `rm -f`s the stale
// socket and retries the spawn, so the reexec'd `start` (which
// restart-server triggers) reliably respawns the broker with no
// down-window. This step is the safety net for an OLDER in-container
// binary whose `restart-server` predates that fix — it mirrors the
// vm-watchdog's recovery so a reset still ends broker-up regardless of
// which binary is installed.
//
// Shape: status-gated and idempotent. `pty-broker status` exits non-zero
// only when no broker is reachable; ONLY then does the `||` branch fire
// (`rm -f` the stale socket — mirroring the watchdog — then `nohup` a
// detached daemon). When a broker is already alive the status succeeds
// and the respawn branch never runs, so a healthy broker is NEVER killed.
// Wrapped in `( …; true )` so it can never break the reset's &&-chain
// (a down broker that fails to respawn must not abort the rest of the
// reset). The socket glob + `pty-broker daemon` invocation match the
// watchdog (`scripts/operator/vm-watchdog.sh`) and the per-project
// `$TMPDIR`/`/tmp` socket layout (`socket_path_for`).
function codeyamBrokerEnsureStep(E) {
return (
`( ${E} editor pty-broker status >/dev/null 2>&1 || ` +
`( rm -f /tmp/codeyam-pty-broker-*.sock; ` +
`nohup ${E} editor pty-broker daemon </dev/null >/tmp/broker-spawn.log 2>&1 & disown ); ` +
'true )'
);
}
// Build the in-container bash for "Reset client" — hard-clean the CLIENT
// project (/workspace) on ANY VM shape. The decisions encoded here:
// - Reset to HEAD, NOT origin: `git reset --hard` (bare) discards uncommitted
// churn but PRESERVES committed work. Advancing to origin tip is the
// separate "Update client" action — this must never `git fetch`/reset to
// `origin/<branch>`.
// - `git reset --hard` leaves untracked files, so node_modules + a per-VM
// .env survive (no forced re-install / lost secrets).
// - Stop the broker FIRST so abandoned agent WIP can't be re-written to disk
// mid-reset; drop a stale LOCAL credential.helper (fragility #29) so the
// re-seed + the agent's git ops fall back to the working system helper.
// - Re-seed the git credential cache AFTER the restart so the agent can
// pull/push immediately; the re-seed is a self-contained subshell whose
// own `|| echo` never masks an earlier &&-chain failure.
// `E` is the in-container editor binary; `gitReseedBody` is the re-seed bash
// body (passed in so this stays a pure function of its inputs).
function resetClientScript({ E, gitReseedBody }) {
return [
'cd /workspace',
`command -v ${E} >/dev/null`,
'echo ">> [1/5] stopping agent session"',
`(${E} editor pty-broker stop || true)`,
'(git config --local --unset-all credential.helper || true)',
'echo ">> [2/5] discarding uncommitted changes (git reset --hard to HEAD; keeps committed work + untracked files)"',
'git reset --hard',
'echo ">> [3/5] deep-cleaning .codeyam (clears ignored runtime state; keeps editor.local.json + server-state.json)"',
...codeyamDeepCleanSteps(E),
`${E} editor session-reset`,
'echo ">> [4/5] restarting server to respawn the broker (no rebuild)"',
`(${E} editor restart-server --force-in-container || true)`,
// Belt-and-suspenders: ensure a broker is live even on an older binary
// whose restart-server doesn't yet respawn it. Idempotent / status-gated.
codeyamBrokerEnsureStep(E),
'echo ">> [5/5] re-seeding git credential cache"',
`( ( ${gitReseedBody} ) || echo "WARN git cache re-seed failed" )`,
].join(' && ');
}
// Build the in-container bash for "Reset editor" — the editor-state recipe:
// stop the agent, drop a stale local credential.helper, advance the VM's branch
// to origin tip (skipped on a greenfield local-only repo), deep-clean .codeyam
// (preserving editor.local.json + server-state.json via codeyamDeepCleanSteps),
// run session-reset, and restart the server. This is the SAME &&-chain
// previously inlined in actionResetEditorSource in scripts/operator/
// fleet-dashboard/server.js — extracted so the standalone Reset editor action
// AND the composite Update & Reset Everything action share one definition and
// it gets unit-test coverage like the other reset builders.
//
// Binary-existence gate FIRST so a broken binary aborts BEFORE the destructive
// `git reset --hard` / `rm -rf .codeyam`. The composite action relies on this
// same gate as its safety contract — a failed rebuild that leaves no binary
// will short-circuit the trailing recipe before any wipe happens.
function resetEditorStateScript({ E }) {
return [
'cd /workspace',
`command -v ${E} >/dev/null`,
// Tolerate a missing broker: `pty-broker stop` exits non-zero when none is
// running; the subshell + `|| true` keeps it non-fatal without swallowing
// the binary-existence gate above.
'echo ">> [1/4] stopping agent session + clearing workflow state"',
`(${E} editor pty-broker stop || true)`,
// Drop a stale LOCAL credential.helper (fragility #29) so `git fetch` falls
// back to the working system helper. No-op when none is set.
'(git config --local --unset-all credential.helper || true)',
// Advance the VM's own branch to origin (skip on a greenfield local-only repo
// with no remote). fetch + `reset --hard origin/<branch>`, NOT `pull --ff-only`
// (which dies "Cannot fast-forward to multiple branches" on some VM states).
'if git remote | grep -qx origin; then ' +
'BR=$(git branch --show-current); ' +
'echo ">> [2/4] advancing source on $BR to origin tip"; ' +
'git reset --hard && git fetch origin "$BR" && git reset --hard "origin/$BR"; ' +
'else echo ">> [2/4] no origin remote (greenfield project) — skipping source advance"; fi',
// Deep-clean .codeyam: `git reset --hard` + session-reset leave git-IGNORED /
// untracked state (workflow + design-step state, server-state, logs, caches).
// Preserve editor.local.json (previewOrigin + agent API key — a blind wipe
// breaks Live Preview + auth) and server-state.json (runtime pid + control
// port the restart's verify reads), wipe .codeyam, restore it from the branch
// (tracked, e.g. self-host) or re-init (greenfield), then put both files back.
'echo ">> [3/4] deep-cleaning .codeyam (clears ignored state; keeps editor.local.json + server-state.json)"',
...codeyamDeepCleanSteps(E),
`${E} editor session-reset`,
// No rebuild here (that's Rebuild binary). Restart the server in place to
// respawn the broker we stopped: restart-server SIGUSR1s pid 1 and the
// docker-exec returns cleanly, so `|| true` keeps a broker-down VM green.
'echo ">> [4/4] restarting server to respawn the broker (no rebuild)"',
`(${E} editor restart-server --force-in-container || true)`,
// Belt-and-suspenders: ensure a broker is live even on an older binary
// whose restart-server doesn't yet respawn it. Idempotent / status-gated.
codeyamBrokerEnsureStep(E),
].join(' && ');
}
module.exports = {
codeyamDeepCleanSteps,
codeyamBrokerEnsureStep,
resetClientScript,
resetEditorStateScript,
};
'use strict';
// npm/fleet-roster.js — the fleet roster (`.codeyam/logs/fleet-vms.json`) is a
// shared contract: the dashboard, the CLI provisioner (`cloud:up`), the
// `cloud:branch-switch` orchestrator, the vm-watchdog, and the bash tunnel
// publishers (`vm-urls.sh` / `cloudflared-monitor.sh`) all read it to learn
// which VMs exist. Before improve38 the *writer* lived only inside the dashboard
// process (`server.js::saveVmsState`), so a VM provisioned by the CLI never
// appeared until someone hand-edited the file. This module is the single
// read+write seam every Node consumer shares, so the writer is no longer locked
// inside the dashboard.
//
// File format (unchanged): a JSON array of VM-number integers. Readers tolerate
// a missing or malformed file by treating it as the empty roster.
//
// Env override: `VMS_STATE_FILE` relocates the file (honored by server.js and
// cloud-branch-switch.js already); resolved once at module load.
const fs = require('fs');
const path = require('path');
const { rootDir } = require('./utils');
const VMS_STATE_FILE = process.env.VMS_STATE_FILE
|| path.join(rootDir, '.codeyam', 'logs', 'fleet-vms.json');
// Canonical roster shape: a sorted, de-duped set of integers. Every read and
// write funnels through here so on-disk content stays normalized regardless of
// which consumer last wrote it (or hand-edited it).
function normalizeRoster(arr) {
return [...new Set((arr || []).filter(Number.isInteger))].sort((a, b) => a - b);
}
// Read the roster, returning a normalized integer array. A missing or malformed
// file is treated as the empty roster `[]` (the documented contract the previous
// `readRoster`/`loadVmsState` duplicates shared) so a truncated write or an
// absent file never throws into a consumer's poll loop.
function readRoster() {
try {
const data = JSON.parse(fs.readFileSync(VMS_STATE_FILE, 'utf8'));
if (Array.isArray(data)) return normalizeRoster(data);
} catch { /* missing or malformed — treated as empty */ }
return [];
}
// Persist `arr` as the roster, atomically: write a temp file then `rename` it
// over the target so a concurrent reader never observes a half-written file.
// The temp name carries the pid so two *processes* writing at once (CLI +
// dashboard) don't clobber each other's temp file before their renames land.
// Returns the normalized roster that was written.
function writeRoster(arr) {
const next = normalizeRoster(arr);
fs.mkdirSync(path.dirname(VMS_STATE_FILE), { recursive: true });
const tmp = `${VMS_STATE_FILE}.${process.pid}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(next));
fs.renameSync(tmp, VMS_STATE_FILE); // atomic replace
return next;
}
// Append `n` to the roster if absent (idempotent), returning the new roster.
// A double-roster — e.g. the CLI rosters on `cloud:up` and the dashboard's Add
// onSuccess rosters again — is a harmless no-op, which is exactly what closes
// the race where one writer's roster write could fail.
function addToRoster(n) {
return writeRoster([...readRoster(), n]);
}
// Remove `n` from the roster, returning the new roster. Removing an absent
// entry is a no-op.
function removeFromRoster(n) {
return writeRoster(readRoster().filter((x) => x !== n));
}
module.exports = {
VMS_STATE_FILE,
normalizeRoster,
readRoster,
writeRoster,
addToRoster,
removeFromRoster,
};
"use strict";
// Pure helpers for the fleet-dashboard's parametrized launch + cached history.
//
// Dependency-free and unit-tested (npm/launch-config.test.js) so the dashboard
// server (scripts/operator/fleet-dashboard/server.js) can require them without
// pulling logic into its poll/HTTP path. A "launch config" is the operator's
// choice of WHICH client project (repo/branch) + WHICH editor (variant/branch)
// runs on a VM; the history is the cache that makes the last choice the default
// and past launches one click to re-run.
const { CANONICAL_EDITOR_REPO, EDITOR_VARIANTS, repoBasename } = require("./cloud");
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: "",
});
/** 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";
return {
clientRepo: str(input.clientRepo),
clientBranch: str(input.clientBranch),
newProjectName: str(input.newProjectName),
editorVariant,
editorBranch: str(input.editorBranch),
};
}
/** Validate a normalized config. Returns { ok, msg } so the HTTP layer can
* surface the reason without a try/catch. */
function validateLaunchConfig(cfg) {
const hasRepo = !!(cfg && cfg.clientRepo);
const hasNew = !!(cfg && cfg.newProjectName);
if (hasRepo && hasNew) {
return { ok: false, msg: "specify either a client repo or a new-project name, not both" };
}
if (!hasRepo && !hasNew) {
return { ok: false, msg: "a client repo or a new-project name is required" };
}
if (hasRepo && !/^(https?:\/\/|git@)/.test(cfg.clientRepo)) {
return { ok: false, msg: "client repo must be an https:// or git@ URL" };
}
if (hasNew && !/^[A-Za-z0-9._-]+$/.test(cfg.newProjectName)) {
return { ok: false, msg: "new-project name may contain only letters, numbers, dot, dash, underscore" };
}
if (!EDITOR_VARIANTS.includes(cfg.editorVariant)) {
return { ok: false, msg: `unknown editor variant: ${cfg.editorVariant}` };
}
return { ok: true };
}
/** Gate mirror of cloud.js::assertVariantProvisionable — which editor variants
* can be provisioned onto a cloud VM. All three variants now provision: `dev`
* builds the editor image from source, while `staging`/`production` run the
* published npm package (gcp-bootstrap pulls/builds Dockerfile.npm keyed off
* the variant). An unknown variant is excluded so a typo never reaches a
* launch. Pure; no I/O. */
function isVariantProvisionable(variant) {
return EDITOR_VARIANTS.includes(variant);
}
/** Gate for the in-place BINARY-REBUILD operations (the dashboard's "Rebuild
* binary" / "Update & Reset Everything" actions, and reconfigure's trailing
* binary refresh). Historically distinct from `isVariantProvisionable` because a
* packaged variant's rebuild is an npm-reinstall (rebuildStrategy ===
* "npm-reinstall") and that host-script path was not wired — so this stayed
* dev-only and packaged rebuild buttons failed loud. With the npm-reinstall
* branch now wired into `rebuildBinaryHostScript` (it rebuilds the Dockerfile.npm
* image at the variant's dist-tag instead of source-building Dockerfile), every
* provisionable variant is also rebuildable, so this now mirrors
* `isVariantProvisionable` — only an unknown variant is rejected. Pure; no I/O. */
function isVariantRebuildable(variant) {
return isVariantProvisionable(variant);
}
/** Map an editor variant to the npm dist-tag its package was published under.
* CI publishes `codeyam.com` → "latest" (production) and `staging.codeyam.com`
* → "staging" (see .github/workflows/cicd.yml "Set release variant"). `dev` has
* no published package (it builds the image from source), so return null and let
* callers reject it. This is the single source of truth for the variant→dist-tag
* contract — the Dockerfile.npm build-arg, publish-editor-image.sh's `--from-npm`
* mode, and the future reconfigure path all derive from it so they can't drift.
* Pure; no I/O. */
function variantNpmDistTag(variant) {
if (variant === "production") return "latest";
if (variant === "staging") return "staging";
return null; // dev / unknown — no packaged dist-tag
}
/** Map a packaged editor variant to the GHCR image-tag suffix the packaged
* image is pushed under (`<registry>/codeyam-editor-npm:<suffix>`). Lives beside
* `variantNpmDistTag` so the registry naming convention and the dist-tag mapping
* stay one reviewable unit; `scripts/publish-editor-image.sh --from-npm` asserts
* the literal string this returns. `dev` has no packaged image (built from
* source), so return null. Pure; no I/O. */
function variantImageTag(variant) {
if (variant === "production") return "production-latest";
if (variant === "staging") return "staging-latest";
return null; // dev / unknown — no packaged image
}
/** Build the `cloud:up` flag array for a launch config + instance name. The
* editor source is the canonical codeyam-editor repo (dev variant builds the
* image from editorBranch); the client repo/branch is what lands in
* /workspace. Decoupled exactly as cloud.js consumes them. */
function cloudUpArgs(cfg, instanceName, agent = "claude") {
const args = ["--instance-name", instanceName];
// 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", agent, "--editor-repo", CANONICAL_EDITOR_REPO, "--editor-variant", cfg.editorVariant);
if (!cfg.newProjectName && cfg.clientBranch) args.push("--branch", cfg.clientBranch);
if (cfg.editorBranch) args.push("--editor-branch", cfg.editorBranch);
// The dashboard opens its own per-VM tunnel, so cloud:up must NOT open one:
// it lets cloud:up exit cleanly and avoids parallel batches colliding on the
// fixed local ports 14199/5173 (the cause of multi-add "false failures").
args.push("--no-tunnel");
return args;
}
/** Classify one container-up wait tick into a terminal/continue decision from
* what's been observed so far. This is the race-semantics seam fragility #33
* got wrong (`scripts/operator/fleet-dashboard/server.js::addVm`): the wait
* must poll container status on its own cadence and treat ANY single `Up` as
* terminal success.
*
* Rules:
* - everSeenUp ⇒ "up" any single `Up` is terminal —
* transient probe blips before it
* are irrelevant.
* - instanceGone ⇒ "failed" fast-bail: the VM is gone, no
* point waiting out the deadline.
* - deadlineSec>0 & elapsed>=it ⇒ "failed" genuine timeout, no `Up` ever seen
* (a non-positive deadline means no
* deadline set yet, so never fail).
* - otherwise ⇒ "waiting"
*
* Deliberately NOT an input: whether `cloud:up` is still alive (its `UP_PID`).
* Under `--no-tunnel` cloud:up exits cleanly the instant the container is up
* (see cloudUpArgs), so its death is the NORMAL success path and must never
* decide failure. The bash loop mirrors this — `kill -0 $UP_PID` is logged as
* informational only. Callers may pass `upPidAlive` for readable test intent;
* it is ignored by design. */
function classifyContainerWait({ everSeenUp = false, instanceGone = false, elapsedSec = 0, deadlineSec = 0 } = {}) {
if (everSeenUp) return "up";
if (instanceGone) return "failed";
if (deadlineSec > 0 && elapsedSec >= deadlineSec) return "failed";
return "waiting";
}
/** Decide whether a newly-reserved Add slot should start immediately or be
* queued, given the current in-flight count + the configured cap. Pure: pulls
* no globals, has no side effects.
*
* Returns "start" when there is headroom under maxConcurrency; "queue"
* otherwise. A non-positive or non-numeric maxConcurrency falls back to
* unbounded ("start" always) — the historical behavior before throttling
* existed, so a misconfigured env var degrades to "no throttle" instead of
* silently freezing all adds.
*
* The caller is responsible for the side effects: pushing to a pending
* queue + posting a `queued` job entry on "queue", or starting addVm on
* "start". Decoupling the decision from the I/O makes it unit-testable
* against arbitrary (cap, active) tuples — the test signal the OOM bug
* needed and the queue never had. */
function decideAddSlot({ maxConcurrency, activeCount = 0 } = {}) {
const cap = Number(maxConcurrency);
if (!Number.isFinite(cap) || cap <= 0) return "start";
return Number(activeCount) < cap ? "start" : "queue";
}
/** Closed set of "heavy" action names — those that invoke `npm run cloud:up`
* (over gcloud-ssh) and so contend for the shared spawn budget that prevents
* the dashboard's launchd-managed node process from OOMing under burst. The
* set is the single source of truth shared between server.js's throttle gate
* (tryStartOrQueue) and the per-action queued-status mapping below; adding a
* new heavy action means editing both this set and queuedStatusForAction.
* Other actions (set-env, run-setup, reset-*, update-*, destroy) are a single
* ssh round-trip with no long-running child and bypass the throttle entirely
* — they can never OOM the dashboard on burst. */
const HEAVY_ACTIONS = Object.freeze(["add", "rebuild", "reconfigure", "reset-all", "fleet-ready"]);
/** Map a heavy action to its per-action "queued" UI status string. Used by
* server.js's deriveStatus to render the throttled-but-not-yet-started card
* with a status that matches the eventual running status family
* (add → add-queued / adding, rebuild → rebuild-queued / rebuilding,
* reconfigure → reconfigure-queued / reconfiguring). Returns null for any
* action not in HEAVY_ACTIONS (so the caller can fall through to the
* generic running-state branch). Pure; no I/O. */
function queuedStatusForAction(action) {
switch (action) {
case "add": return "add-queued";
case "rebuild": return "rebuild-queued";
case "reconfigure": return "reconfigure-queued";
case "reset-all": return "reset-all-queued";
// fleet-ready runs cloud:up/redeploy children, and deriveStatus already
// renders a *running* fleet-ready job as "rebuilding"; its queued card
// mirrors that same family rather than introducing an unstyled
// fleet-ready-queued status the dashboard CSS has no class for.
case "fleet-ready": return "rebuild-queued";
default: return null;
}
}
/** Push a launch entry onto the newest-first history, capped to `cap`. No
* cross-VM dedup at write time — the full log is what powers per-VM config
* lookups (configForVm); the "recent launches" picker de-dups at read time
* (distinctConfigs). Returns a new array; does not mutate the input. */
function pushHistory(history, entry, cap = 30) {
const arr = Array.isArray(history) ? history.slice() : [];
arr.unshift(entry);
return arr.slice(0, Math.max(1, cap));
}
/** Distinct configs, newest-first, for the "recent launches" picker (so the
* same config launched on several VMs shows once — the most-recent
* occurrence). */
function distinctConfigs(history, limit = 12) {
const seen = new Set();
const out = [];
for (const e of Array.isArray(history) ? history : []) {
const k = JSON.stringify(e && e.config);
if (seen.has(k)) continue;
seen.add(k);
out.push(e);
if (out.length >= limit) break;
}
return out;
}
/** The default config to pre-fill the launch form: the most recent launch's
* config, or an empty config when there's no history. */
function defaultConfig(history) {
const h = Array.isArray(history) && history.length ? history[0] : null;
return h && h.config ? { ...EMPTY_CONFIG, ...h.config } : { ...EMPTY_CONFIG };
}
/** The most recent config used on a specific VM number (for the per-card
* Reconfigure form + the card's project/variant display). null when unknown
* (e.g. a VM launched before this feature existed). */
function configForVm(history, n) {
const h = (Array.isArray(history) ? history : []).find((e) => e && e.vm === n);
return h && h.config ? { ...EMPTY_CONFIG, ...h.config } : null;
}
/** Resolve the card's display identity, preferring LIVE truth (the editor's
* `/api/session-info` `clientProject` + `editorIdentity`) over STALE launch
* history. This is the fix for fragility #33's lying label: a CLI branch
* switch (`git checkout -B … && rebuild-self`) never updates launch history,
* so a history-sourced label keeps showing the old branch — but the editor's
* live self-report tracks the actual branch.
*
* `live` is the polled VM object ({ clientProject?, editorIdentity? }); an
* older VM whose editor predates this feature reports neither, so the result
* falls back to `historyCfg` (a launch-config shape) field-by-field and
* `source` flags the card as showing inferred-not-live data.
*
* Returns { project, clientBranch, editorLabel, source }:
* - project bare project name (no @branch) — live name wins, else the
* history repo basename / new-project name.
* - clientBranch live client branch wins, else historyCfg.clientBranch; null
* when neither (e.g. a non-git client project).
* - editorLabel from editorIdentity — the branch when kind === "branch",
* "npm v<version>" when kind === "npm"; else the history
* variant(@branch) fallback.
* - source "live" when editorIdentity drove editorLabel, else
* "history" — the affordance the card uses to mark inferred
* data. Pure; no I/O. */
function resolveCardIdentity(live, historyCfg) {
const cfg = historyCfg || {};
const lcp = live && live.clientProject;
const lei = live && live.editorIdentity;
const historyName = cfg.clientRepo ? repoBasename(cfg.clientRepo) : (cfg.newProjectName || "");
const project = (lcp && lcp.name) || historyName || null;
const clientBranch = (lcp && lcp.branch) || cfg.clientBranch || null;
let editorLabel;
let source;
if (lei && lei.kind === "branch" && lei.branch) {
editorLabel = lei.branch;
source = "live";
} else if (lei && lei.kind === "npm") {
editorLabel = "npm v" + (lei.version || "?");
source = "live";
} else {
const variant = cfg.editorVariant || "dev";
editorLabel = variant + (cfg.editorBranch ? "@" + cfg.editorBranch : "");
source = "history";
}
return { project, clientBranch, editorLabel, source };
}
/** Sticky-merge a VM poll's `clientProject` against the last-known-good value
* so a transient null poll (timeout / partial probe) does not clobber a
* populated identity. The live `clientProject.branch` is the fleet
* classifier's first-choice branch source (`plan_actions` in
* fleet-roll-to-branch.sh); dropping it to null on a missed cycle would
* regress classification to the stale launch-history branch. Mirrors how the
* other live fields tolerate a missed poll: a populated `prev` survives a
* null/absent fresh poll, and a populated fresh poll always wins. Pure; no I/O.
*
* @param prev the previous `state.vms[n].clientProject` (null/undefined ok)
* @param info the fresh poll result object (null when the probe failed)
* @returns the clientProject to store: fresh value when present, else the
* last-known-good `prev`, else null. */
function stickyClientProject(prev, info) {
const fresh = info && info.clientProject;
if (fresh) return fresh;
return prev || null;
}
/** Classify a VM's project KIND as `"self-host"` vs `"guest"` from the live
* identity reported in `/api/session-info` (`clientProject.repo`), with
* launch-history `clientRepo` as a fallback when live is absent. A guest is
* any VM whose repo basename differs from `CANONICAL_EDITOR_REPO`'s
* (`codeyam-editor`) — Margo today, future guests tomorrow.
*
* The dashboard surfaces the result with a small `🛡 guest` chip so the
* "leave-the-margo-one-alone"-style caveats become a visible guardrail at
* glance instead of a memo the operator has to remember. The kind is a
* *type*, not an alert — the status badge remains the primary signal.
*
* Defaults: both signals missing → `"self-host"` (the dominant fleet shape;
* a falsely visible "guest" badge on a real self-host VM would be more
* confusing than a missing one). Empty-string repos collapse to the same
* unknown-→-self-host default. Reuses `CANONICAL_EDITOR_REPO` so the
* self-host identity stays a single source of truth shared with
* `cloudUpArgs`. Pure; no I/O. */
function classifyProjectKind(live, historyCfg) {
const cfg = historyCfg || {};
const lcp = live && live.clientProject;
const liveRepo = lcp && typeof lcp.repo === "string" ? lcp.repo : "";
const repoUrl = liveRepo || (typeof cfg.clientRepo === "string" ? cfg.clientRepo : "");
if (!repoUrl) return "self-host";
const canonical = repoBasename(CANONICAL_EDITOR_REPO);
return repoBasename(repoUrl) === canonical ? "self-host" : "guest";
}
/** Resolve where a VM's codeyam-editor SOURCE lives, given its shape. This is
* the load-bearing per-VM-shape branch for the dashboard's "update editor
* source" + "rebuild binary" actions: the editor source — and how you rebuild
* from it — differs between a self-host VM (the client project IS
* codeyam-editor) and a non-self-host one (editing some other project).
*
* - Self-host: the editor source IS the agent's workspace, so it's `/workspace`
* INSIDE the container; the rebuild runs there in place (`rebuild-self`).
* - Non-self-host: the editor binary comes from the `codeyam-editor:local`
* image, built on the VM HOST. `scripts/gcp-bootstrap.sh` clones the editor
* repo to `$HOME/codeyam-editor` and builds the image from there, so that is
* the authoritative image source. A self-host VM ALSO carries a creds-bearing
* workspace clone at `$HOME/projects/codeyam-editor` (docs/cloud-editor-stability.md
* "VM-side paths"); the image-rebuild flow prefers it WHEN PRESENT (it can
* `git pull` private repos), else falls back to the canonical image clone.
* The caller checks `path` then `fallbackPath` on disk at runtime.
*
* Stack assumption: this encodes the cloud-VM editor-source layout (Docker
* image + ~/projects mount). A non-Docker editor host would need its own
* resolver branch. Pure; no I/O. */
function resolveEditorSource({ selfHost = false, home = "$HOME" } = {}) {
if (selfHost) {
return { selfHost: true, location: "container", path: "/workspace", fallbackPath: null };
}
return {
selfHost: false,
location: "host",
path: `${home}/projects/codeyam-editor`,
fallbackPath: `${home}/codeyam-editor`,
};
}
/** Emit the shell snippet that resolves the host editor-source clone ON THE
* REMOTE VM, assigning the chosen path to `$ESRC`. Prefer the credentialed
* projects clone (`src.path`); fall back to the image clone (`src.fallbackPath`)
* only when the primary has no `.git`.
*
* CRITICAL — the paths must EXPAND on the VM, not on the laptop. `resolveEditorSource`
* returns `$HOME`-relative CONSTANTS (the dashboard doesn't know the VM's home dir),
* so they go into DOUBLE quotes here, never `shq()`/single-quotes. Single-quoting a
* `$HOME`-bearing path defeats remote expansion: `cd "$ESRC"` then fails with
* `cd: $HOME/...: No such file or directory` on every non-self-host VM (the improve34
* bug). These paths are fixed constants, not user input — no injection surface, so
* `shq()` (which exists to neutralize user input) is the wrong tool. Pure; no I/O.
*
* The primary clone is kept only when it is actually USABLE as an editor source,
* not merely when a `.git` directory exists. A half-cloned / corrupt tree has a
* `.git` dir yet can't build — `git rev-parse` fails and there's no Dockerfile, so
* `docker build "$ESRC"` dies on `open Dockerfile: no such file or directory` (the
* improve39 VM-9 bug). The gate therefore requires a valid work tree AND a
* Dockerfile; otherwise it falls back to the canonical image clone
* `$HOME/codeyam-editor` (the authoritative source provisioning built the image
* from). This preserves the prefer-credentialed-clone behavior, but only when the
* credentialed clone actually works. */
function editorSourceShellResolve(src) {
const primary = `ESRC="${src.path}"`;
if (!src.fallbackPath) return primary;
return `${primary}; { git -C "$ESRC" rev-parse --is-inside-work-tree >/dev/null 2>&1 && [ -f "$ESRC/Dockerfile" ]; } || ESRC="${src.fallbackPath}"`;
}
/** Choose HOW to rebuild the editor binary on a VM, from its shape + the editor
* variant. The three mechanisms map 1:1 to the EDITOR_VARIANTS contract:
* - "rebuild-self" — self-host `dev`: compile in place inside the container
* (`rebuild-self --force-in-container`); /workspace is the
* editor source.
* - "image-rebuild" — non-self-host `dev`: the binary ships in the
* `codeyam-editor:local` image, so it needs a host-side
* `docker build` + container recreate.
* - "npm-reinstall" — a packaged variant (`staging`/`production`): the binary
* is the published editor npm package, so a rebuild is a
* reinstall, not a compile.
* Pure; no I/O. `rebuildBinaryHostScript` consumes this to pick its build line
* (the npm-reinstall branch rebuilds the Dockerfile.npm image at the variant's
* dist-tag); the self-host axis is detected at runtime
* on the VM (the `[ -f /workspace/Cargo.toml ]` signal is authoritative even
* for VMs the dashboard has no launch-history record for). */
function rebuildStrategy({ selfHost = false, editorVariant = "dev" } = {}) {
if (editorVariant !== "dev") return "npm-reinstall";
return selfHost ? "rebuild-self" : "image-rebuild";
}
/** Decide whether a new per-VM job may start, given the VM's currently-tracked
* job. `state.jobs[n]` is a SINGLE slot per VM, so a second `startJob` would
* OVERWRITE an in-flight job's tracker — the bug where `reapplyVmEnv`'s
* follow-on `set-env` clobbered a still-running Rebuild's card, so the operator
* saw a `set-env` completion instead of the rebuild they asked for, with no
* error surfaced. Any job already `running` blocks a new one:
* - `idle` — no in-flight job (or the slot's job already finished);
* the caller starts normally.
* - `same-action` — a job with the SAME action is already running; reject
* (the pre-existing duplicate-click guard).
* - `different-action`— a DIFFERENT action is running; reject rather than
* clobber its tracker. Operators sequence the two jobs
* manually (the rare legitimate case).
* Returns `{ decision: 'start' }` or
* `{ decision: 'reject', reason, runningAction }` so the caller builds the
* user-facing message (the two reject reasons word it differently). Pure; no
* I/O — `existing` is the job record, `requested` the action about to start. */
function decideStartJob({ existing, requested } = {}) {
if (!existing || existing.state !== "running") return { decision: "start" };
const runningAction = existing.action;
if (runningAction === requested) {
return { decision: "reject", reason: "same-action", runningAction };
}
return { decision: "reject", reason: "different-action", runningAction };
}
/** Decide where a freshly-added VM's credentials come from. The dashboard's
* default is a container→container copy from a live donor VM, but a full
* teardown empties the roster and leaves no donor — so Add must fall back to
* operator-configured sources. Precedence, highest first:
* - "donor" — a reachable donor exists; copy claude + git from it (the
* unchanged default). `donor` = the chosen VM number.
* - "host-stash" — no donor, but CLAUDE_CREDS_SRC points at a known-good
* .credentials.json to push into the new container.
* - "token" — no donor and no stash, but GITHUB_TOKEN is set (cloud:up
* seeds git creds at boot). claude auth is NOT seeded.
* - "none" — nothing available; wire + roster only, claude + git both
* unseeded.
* Modes "token" and "none" carry no claude auth, so the caller marks the VM
* add-degraded with a `claude /login` remediation. `reachableDonors` must be
* pre-filtered to VMs that actually answered (a destroyed-but-rostered orphan
* is not a donor — fragility #24). Pure; no I/O. */
function resolveCredsSource({ reachableDonors = [], claudeCredsSrc = "", githubToken = "" } = {}) {
const donor = Array.isArray(reachableDonors) && reachableDonors.length ? reachableDonors[0] : null;
if (donor != null) return { mode: "donor", donor };
if (claudeCredsSrc) return { mode: "host-stash", donor: null };
if (githubToken) return { mode: "token", donor: null };
return { mode: "none", donor: null };
}
// Single-quote a string for safe inclusion in a remote shell command. Mirrors
// the server.js helper so callers don't have to thread one through.
function _shq(s) { return `'${String(s).replace(/'/g, `'\\''`)}'`; }
/** Build the shell steps that seed a freshly-added VM's claude + git credentials,
* for the resolved `mode` (see `resolveCredsSource`). Returns an array of shell
* strings the caller newline-joins into the larger provisioning script — each
* step is independent; a `CY_DEGRADED <message>` from one does not block the
* next, and the dashboard's job-attribution path lifts those markers onto the
* card as `degradedReasons`.
*
* Donor mode (the default) copies, container→container from the donor VM:
* 1. host-file git credentials → `$HOME/.codeyam-cloud-git-credentials`
* 2. claude `.credentials.json` → `/root/.claude/.credentials.json`
* 3. claude `.claude.json` → `/root/.claude.json`
* Step 3 (onboarding state — `hasCompletedOnboarding` + sibling keys) is what
* keeps the first interactive `claude` TUI from popping the welcome "Select
* login method" menu; without it the non-interactive `claude --print` probe
* still passes (so `/api/config` reports `authenticated`), but the Build tab
* PTY stalls at the picker. Failure of any single step emits its own
* `CY_DEGRADED` marker — the credentials copy is the load-bearing one; the
* onboarding-state copy is recoverable via `claude /login`.
*
* host-stash / token / none modes don't have a donor `.claude.json` to copy —
* for now those paths still land degraded for the same reason; a separate plan
* can extend host-stash with a sibling onboarding-stash env knob.
*
* Pure; no I/O. */
function buildCredsSteps({ mode, refInst = "", inst, container, gcloud, zone, n, claudeCredsSrc = "" } = {}) {
const claudeSink = `docker exec -i ${container} bash -c "umask 077; base64 -d > /root/.claude/.credentials.json && chmod 600 /root/.claude/.credentials.json"`;
const claudeJsonSink = `docker exec -i ${container} bash -c "umask 077; base64 -d > /root/.claude.json && chmod 600 /root/.claude.json"`;
const loginHint = `run: docker exec -it ${container} claude /login on ${inst}`;
if (mode === "donor") {
return [
`echo "copying git creds from ${refInst}..."`,
`${gcloud} compute ssh ${refInst} --tunnel-through-iap --zone=${zone} --command='cat $HOME/.codeyam-cloud-git-credentials' 2>/dev/null | ${gcloud} compute ssh ${inst} --tunnel-through-iap --zone=${zone} --command='umask 077; cat > $HOME/.codeyam-cloud-git-credentials' 2>/dev/null || echo "CY_DEGRADED git creds copy failed on VM-${n} — re-copy creds from a live donor (the agent can't git pull/push until then)"`,
`echo "copying claude auth from ${refInst} (container->container)..."`,
`${gcloud} compute ssh ${refInst} --tunnel-through-iap --zone=${zone} --command='docker exec ${container} base64 /root/.claude/.credentials.json' 2>/dev/null | ${gcloud} compute ssh ${inst} --tunnel-through-iap --zone=${zone} --command=${_shq(claudeSink)} 2>/dev/null && echo "claude auth copied" || echo "CY_DEGRADED claude auth copy failed on VM-${n} — ${loginHint}"`,
`echo "copying claude.json (onboarding state) from ${refInst} (container->container)..."`,
`${gcloud} compute ssh ${refInst} --tunnel-through-iap --zone=${zone} --command='docker exec ${container} base64 /root/.claude.json' 2>/dev/null | ${gcloud} compute ssh ${inst} --tunnel-through-iap --zone=${zone} --command=${_shq(claudeJsonSink)} 2>/dev/null && echo "claude.json copied" || echo "CY_DEGRADED claude.json copy failed on VM-${n} — first interactive claude launch may show 'Select login method' menu; ${loginHint} once to complete onboarding"`,
];
}
if (mode === "host-stash") {
return [
`echo "no reachable donor — seeding claude auth from host stash..."`,
`base64 < ${_shq(claudeCredsSrc)} | ${gcloud} compute ssh ${inst} --tunnel-through-iap --zone=${zone} --command=${_shq(claudeSink)} 2>/dev/null && echo "claude auth seeded from host stash" || echo "WARN claude stash seed failed — ${loginHint}"`,
];
}
if (mode === "token") {
return [
`echo "no reachable donor and no CLAUDE_CREDS_SRC — git creds seeded by cloud:up; claude auth NOT seeded. ${loginHint}"`,
];
}
return [
`echo "no reachable donor and no configured creds source — wiring + rostering only; claude AND git auth NOT seeded. ${loginHint}, and set CLAUDE_CREDS_SRC/GITHUB_TOKEN in the dashboard env"`,
];
}
/** Build the two host-side shell snippets that PRESERVE the container's
* /root/.claude.json (claude onboarding state) across a non-self-host Rebuild's
* container recreate. The whole /root/.claude DIRECTORY is bind-mounted from the
* host ($HOME/.codeyam-cloud-claude → /root/.claude, see docker-compose.cloud.yml),
* so /root/.claude/.credentials.json survives `docker compose down && up`. But
* /root/.claude.json is a SIBLING in the container's EPHEMERAL fs — NOT in the
* bind mount — so a recreate resets it to the image baseline, dropping the
* operator's `hasCompletedOnboarding`. The first interactive `claude` TUI then
* pops the "Select login method" welcome menu and the Build tab is blocked,
* even though the non-interactive `claude --print` auth probe still passes
* (improve36). The good copy is still in the LIVE container right up until
* `docker compose down`, so we capture it to a host temp file, then restore it
* into the freshly-recreated container — no donor required.
*
* Returns { capture, restore }, each an array of shell lines for the on-host
* rebuild script (run via gcloud-ssh, direct `docker exec` — same execution
* context as gitReseedHostStep). `capture` runs BEFORE `docker compose down`;
* `restore` runs AFTER `up`, BEFORE the editor verify probe. The lines share a
* `bakVar` shell variable holding the temp-file path, so they MUST be spread
* into the SAME bash invocation. A failed restore emits a `CY_DEGRADED` marker
* (the dashboard lifts it onto the card as a degradedReason) with a
* `claude /login` remediation — the rebuild itself still succeeded, so the job
* lands `done`, not `error`. Pure; no I/O. */
function buildClaudeJsonPreserveSteps({ container, n = "", bakVar = "CY_CLAUDE_JSON_BAK" } = {}) {
const loginHint = `run: docker exec -it ${container} claude /login`;
const capture = [
`${bakVar}=$(mktemp)`,
`if docker exec ${container} cat /root/.claude.json > "$${bakVar}" 2>/dev/null && [ -s "$${bakVar}" ]; then echo "captured /root/.claude.json ($(wc -c < "$${bakVar}") bytes) to preserve onboarding state across recreate"; else echo "no /root/.claude.json to preserve (new container will use the image baseline)"; : > "$${bakVar}"; fi`,
];
const restore = [
`if [ -s "$${bakVar}" ]; then docker exec -i ${container} bash -c 'umask 077; cat > /root/.claude.json && chmod 600 /root/.claude.json' < "$${bakVar}" && echo "re-seeded /root/.claude.json (onboarding state preserved across recreate)" || echo "CY_DEGRADED claude.json re-seed failed on VM-${n} after Rebuild — first interactive claude launch may show 'Select login method'; ${loginHint} once to complete onboarding"; else echo "no preserved /root/.claude.json; ${loginHint} if the Build tab shows the welcome menu"; fi`,
`rm -f "$${bakVar}"`,
];
return { capture, restore };
}
/** Build the shell command that base64-pipes a VM's persisted env over ssh +
* docker-exec stdin into the container's /workspace/.env (umask 077, chmod
* 600), chowns it to the workspace owner so the bind-mounted host file is
* readable by `docker compose --force-recreate` (Rebuild / Reconfigure /
* Add — host-side, as the unprivileged SSH user, not root), and restarts
* the dev server. The docker exec runs as root inside the container, so
* without the chown the file lands `root:root 0600` on the host bind-mount
* and the next compose recreate fails `open .env: permission denied`
* (fragility — improve36). `chown --reference=/workspace` inherits whatever
* uid the image's entrypoint assigned to the workspace (currently 1003 =
* the host SSH user), so the fix is robust against future UID changes.
* Values stay out of argv/logs: they ride as base64 over the ssh + docker
* exec stdin, never as CLI arguments. Pure; no I/O. */
function buildEnvApplyCommand({ gcloud, instance, zone, envFile, container, editorBin } = {}) {
const sink = `docker exec -i ${container} bash -lc "umask 077; base64 -d > /workspace/.env && chmod 600 /workspace/.env && chown --reference=/workspace /workspace/.env && cd /workspace && ${editorBin} editor restart-dev-server"`;
return `base64 < ${_shq(envFile)} | ${gcloud} compute ssh ${instance} --tunnel-through-iap --zone=${zone} --command=${_shq(sink)}`;
}
module.exports = {
EMPTY_CONFIG,
normalizeLaunchConfig,
validateLaunchConfig,
isVariantProvisionable,
isVariantRebuildable,
variantNpmDistTag,
variantImageTag,
cloudUpArgs,
classifyContainerWait,
decideAddSlot,
HEAVY_ACTIONS,
queuedStatusForAction,
pushHistory,
distinctConfigs,
defaultConfig,
configForVm,
resolveCardIdentity,
stickyClientProject,
classifyProjectKind,
resolveCredsSource,
buildCredsSteps,
buildClaudeJsonPreserveSteps,
buildEnvApplyCommand,
resolveEditorSource,
editorSourceShellResolve,
rebuildStrategy,
decideStartJob,
};
"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 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,
};
'use strict';
// Deterministic fleet-ingress URL derivation.
//
// The fleet used to reach every VM (editor + Live Preview) and the operator
// dashboard through cloudflared *quick*-tunnels whose `*.trycloudflare.com`
// hostnames rotate, rate-limit, and flap — so URLs lived in a churny
// `cloudflared-urls.json` that a monitor had to keep rewriting and pushing
// around the fleet. This module replaces that churn with a pure function: a
// VM's URLs are *derived* from its number plus one owned domain, so they never
// rotate and nothing has to be recorded, probed, or respawned.
//
// Pure logic only — no I/O, no env reads beyond the explicit `env` argument
// callers pass in — so it stays unit-testable in isolation (npm/vm-urls.test.js),
// exactly like launch-config.js / fleet-env.js. The bash side
// (scripts/operator/vm-urls.sh, named-tunnel-config.sh) derives the SAME shapes;
// the test guards them against drift.
//
// Stack note: this is fleet-operator infrastructure (the cloud GCE fleet that
// runs codeyam-editor itself), not a per-app-stack assumption — the hostnames
// front whatever dev server a VM's project serves, on any port, via the
// named-tunnel ingress rules. A project that never joins the fleet never sets
// FLEET_DOMAIN and never derives a URL here; the legacy quick-tunnel path stays
// the unchanged fallback for that case (see callers' `domain ? … : legacy`).
// Env var naming the operator's owned Cloudflare (or equivalent) domain that the
// named tunnels are routed under, e.g. `fleet.example.com`. Single source of
// truth shared by cloud.js, the dashboard, and the bash scripts (which read the
// same env var) so all sides derive identical hostnames. Unset ⇒ no fleet
// domain configured ⇒ callers fall back to the legacy quick-tunnel path.
const FLEET_DOMAIN_ENV = 'FLEET_DOMAIN';
// Validate an owned-domain string: one or more dot-separated DNS labels
// (letters/digits/hyphen, not leading/trailing hyphen), e.g. `fleet.example.com`.
// A bad value would bake a malformed hostname into a tunnel-ingress config or a
// previewOrigin and silently wedge the iframe, so we reject it loudly at the
// derivation boundary instead.
const DOMAIN_RE = /^(?!-)[a-z0-9-]+(?<!-)(\.(?!-)[a-z0-9-]+(?<!-))+$/;
/** Resolve the configured fleet domain from an env-like object, or null when
* none is set. Trims surrounding whitespace and lowercases (DNS is
* case-insensitive; downstream string compares are not). Returns null for a
* missing OR blank value so a caller can branch on `domain ? named : legacy`
* without distinguishing the two. Throws on a present-but-malformed value —
* a typo'd domain must fail loud, not derive a dead hostname. */
function resolveFleetDomain(env = {}) {
const raw = env[FLEET_DOMAIN_ENV];
if (raw == null) return null;
const domain = String(raw).trim().toLowerCase();
if (domain.length === 0) return null;
if (!DOMAIN_RE.test(domain)) {
throw new Error(
`${FLEET_DOMAIN_ENV}="${raw}" is not a valid domain ` +
'(expected e.g. fleet.example.com)',
);
}
return domain;
}
/** Validate a fleet VM number: a positive integer. The hostname scheme keys
* every surface off this single number, so a non-integer / non-positive value
* would produce `vm-NaN.editor.<domain>` — reject it here. Returns the
* normalized integer. */
function normalizeVmNumber(n) {
const num = Number(n);
if (!Number.isInteger(num) || num <= 0) {
throw new Error(`VM number must be a positive integer, got: ${n}`);
}
return num;
}
/** The bare per-VM hostnames (no scheme), derived from the VM number + domain.
* `editor` fronts the in-VM editor control API; `preview` fronts the VM's dev
* server (the Live Preview). These are what the named-tunnel ingress rules and
* the DNS CNAME records are keyed on — see named-tunnel-config.sh. */
function vmHostnames(n, domain) {
const num = normalizeVmNumber(n);
if (!domain) throw new Error('vmHostnames requires a fleet domain');
return {
editor: `vm-${num}.editor.${domain}`,
preview: `vm-${num}.preview.${domain}`,
};
}
/** The per-VM public HTTPS URLs, derived from the VM number + domain. The
* deterministic replacement for `cloudflared-urls.json[n]` /
* `cloudflared-urls.json[n-preview]` — same shape the dashboard surfaced from
* the monitor's recordings, now with no recording, probing, or rotation. */
function vmUrls(n, domain) {
const host = vmHostnames(n, domain);
return {
editor: `https://${host.editor}`,
preview: `https://${host.preview}`,
};
}
/** The bare dashboard hostname (no scheme), `dashboard.<domain>`. The fixed host
* the operator dashboard's own named tunnel routes to — retiring the rotating
* `*.trycloudflare.com` / ngrok-free dashboard URL. */
function dashboardHostname(domain) {
if (!domain) throw new Error('dashboardHostname requires a fleet domain');
return `dashboard.${domain}`;
}
/** The dashboard's fixed public HTTPS URL, `https://dashboard.<domain>`. */
function dashboardUrl(domain) {
return `https://${dashboardHostname(domain)}`;
}
module.exports = {
FLEET_DOMAIN_ENV,
resolveFleetDomain,
normalizeVmNumber,
vmHostnames,
vmUrls,
dashboardHostname,
dashboardUrl,
};

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 c}from"./markdown-v0F0UEt3.js";import{b as o}from"./react-CSS0HapR.js";import{S as m}from"./ScenarioDataPanel-nuLXJzLf.js";import"./useEvents-DggWrtHe.js";import"./xterm--24IGk-x.js";import"./index-KDFAgt2e.js";function S({slug:t}){const[r,i]=o.useState(void 0);return o.useEffect(()=>{let n=!1;return fetch(`/api/scenarios/${encodeURIComponent(t)}`).then(e=>e.ok?e.json():null).then(e=>{if(n||!e)return;const a=typeof e=="object"&&e!==null&&"name"in e?String(e.name):void 0;i(a),a&&(document.title=`${a} · data`)}).catch(()=>{}),()=>{n=!0}},[t]),c.jsx(m,{slug:t,scenarioName:r,variant:"fullPage"})}export{S as ScenarioDataPanelFullPage};

Sorry, the diff of this file is too big to display

+5
-0

@@ -20,2 +20,7 @@ #!/usr/bin/env node

/**
* 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() {

@@ -22,0 +27,0 @@ // Same resolution order as the editor launcher: env var

+339
-22

@@ -17,5 +17,15 @@ #!/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");

@@ -31,8 +41,20 @@ const {

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";
}

@@ -55,3 +77,7 @@ const args = process.argv.slice(2);

/** 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() {

@@ -61,13 +87,189 @@ 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;
}
}
module.exports = { hasSubcommand, buildBinary };
/**
* 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;
}
/**
* 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() !== "";
}
/**
* 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" };
}
/**
* 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`."
);
}
/**
* 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;
}
module.exports = {
hasSubcommand,
buildBinary,
shouldRebuildOnPassthrough,
staleBinaryBanner,
hasStartCommand,
planPreflightAction,
postLaunchInitBanner,
shouldOpenBrowser,
};
if (require.main === module) {
// --- Passthrough mode: build binary, then forward to it ---
if (hasSubcommand()) {
buildBinary();
// Always rebuild before forwarding — running fresh source is the whole
// point of the dev wrapper. `cargo build` is a sub-second no-op when
// nothing changed (no relink → the server-side stale-binary watcher never
// fires), and relinks only when editor source actually changed, which is
// exactly when the developer wants the new code live. `ensureBinary()`
// still builds once if no binary exists at all.
if (shouldRebuildOnPassthrough()) {
enforceFreshBinary(buildBinary(), { action: "run editor commands" });
}
const binary = ensureBinary();

@@ -91,2 +293,40 @@ const child = spawn(binary, args, {

// 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

@@ -110,10 +350,22 @@ // merged config (rootDir), not the target project's. The target

buildBinary();
// Always rebuild the Rust binary + UI before launching — same contract
// as the passthrough path. The build runs BEFORE the server (re)starts
// below, so the new server records its start time after the relink and
// the stale-binary watcher reads it as fresh (no immediate SIGUSR1). A
// no-op `cargo build` (nothing changed) doesn't relink, so an external
// client-project launch is never disrupted. `ensureBinary()` still
// builds once if no binary exists.
if (shouldRebuildOnPassthrough()) {
enforceFreshBinary(buildBinary(), {
action: "run editor commands",
continueVerb: "launching with",
});
// Build the UI from the editor repo
console.error("Building UI...");
try {
execSync("npx vite build", { stdio: "inherit", cwd: path.join(rootDir, "ui") });
} catch {
console.error("Failed to build UI. Continuing with existing build (if any)...");
// 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)...");
}
}

@@ -125,7 +377,19 @@

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.`);
// 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.`);
}
}

@@ -135,8 +399,42 @@

console.error(`Editor UI: ${url}`);
console.error(`Live Preview: http://localhost:${appPort}`);
if (hasCommand) {
console.error(`Live Preview: http://localhost:${appPort}`);
}
console.error(`Project: ${projectDir}`);
open(url);
// 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", () => {
tearingDown = true;
clearInterval(keepAlive);

@@ -146,2 +444,21 @@ killChildProcess(serverChild);

});
// 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);
});
}
};

@@ -148,0 +465,0 @@

+84
-22

@@ -27,5 +27,8 @@ #!/usr/bin/env node

resolveLauncherPort,
resolveWrappedAppPort,
classifyPortHolder,
formatForeignListenerError,
formatCrossProjectError,
staleBinaryBanner,
enforceFreshBinary,
} = require("./utils");

@@ -42,3 +45,2 @@ const { open } = require("./open");

} catch {
console.error("Failed to build Rust binary. Continuing with existing build (if any)...");
return false;

@@ -58,13 +60,42 @@ }

// Install target/debug/codeyam-editor to <cargo_bin>/codeyam-editor
// atomically (write to tmp in same dir, then rename), and refresh the
// codeyam-editor-pty-broker hardlink so it points at the new inode.
// 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.
//
// The PTY broker auto-spawn (control-api/src/pty_broker_startup.rs) always
// resolves the *installed* path, NOT current_exe() — that was a deliberate
// fix to prevent a fork-bomb where test binaries spawned themselves. So a
// fresh `target/debug/codeyam-editor` is not enough; the install path must
// be refreshed too. Without this, build tabs hang on "Reconnecting..."
// because the broker daemon errors out (`unrecognized subcommand`) before
// the UDS socket opens.
// 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() {

@@ -78,3 +109,2 @@ const srcPath = path.join(rootDir, "target", "debug", "codeyam-editor");

const dstPath = path.join(binDir, "codeyam-editor");
const linkPath = path.join(binDir, "codeyam-editor-pty-broker");
const tmpPath = path.join(binDir, `.codeyam-editor.tmp.${process.pid}`);

@@ -84,15 +114,30 @@ console.error(`Installing fresh binary to ${dstPath}...`);

fs.mkdirSync(binDir, { recursive: true });
fs.copyFileSync(srcPath, tmpPath);
fs.chmodSync(tmpPath, 0o755);
fs.renameSync(tmpPath, dstPath);
try {
fs.unlinkSync(linkPath);
} catch (e) {
if (e.code !== "ENOENT") throw e;
}
fs.linkSync(dstPath, linkPath);
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 {}
}
}

@@ -112,2 +157,7 @@

/**
* 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() {

@@ -127,3 +177,9 @@ // Read ports from the merged editor config (editor.json + per-developer

);
const VITE_PORT = resolveLauncherPort(process.env.PORT, merged.port, 5173);
// `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

@@ -137,2 +193,6 @@ // VITE_PORT + 1. We need to wait for the real Vite server, not the proxy.

const buildOk = buildBinary();
// A failed rebuild means the server would boot on a stale binary that no
// longer matches the source. Refuse by default; CODEYAM_ALLOW_STALE=1
// forces the old behavior (continue with the last-good install).
enforceFreshBinary(buildOk, { action: "start the editor server" });
// Refresh <cargo_bin>/codeyam-editor (and the pty-broker hardlink) so

@@ -210,4 +270,6 @@ // the broker auto-spawn execs a binary that knows the `pty-broker

buildBinary,
staleBinaryBanner,
cargoBinDir,
installBinary,
linkOrCopyBinary,
main,

@@ -214,0 +276,0 @@ sweepStaleArtifacts,

@@ -14,4 +14,47 @@ #!/usr/bin/env node

const path = require("path");
const { execSync } = require("child_process");
const { chromium } = require("playwright");
// 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";
// One-shot self-heal around `chromium.launch()`. If the first launch
// throws the "missing browser" error, run `npx playwright install
// chromium` synchronously (with `stdio: "inherit"` so the user sees
// progress) and retry the launch exactly once. If the install or the
// retry fails, rethrow the ORIGINAL Playwright error so the existing
// `Scenario check failed: <stderr>` path keeps showing the actionable
// message — looping would hide a real ops failure under a slow timeout.
async function launchChromiumWithSelfHeal({
launch = () => chromium.launch(),
install = () => execSync(PLAYWRIGHT_INSTALL_COMMAND, { stdio: "inherit" }),
stderr = process.stderr,
} = {}) {
try {
return await launch();
} catch (error) {
const isMissingBrowser =
error &&
typeof error.message === "string" &&
error.message.includes(PLAYWRIGHT_MISSING_BROWSER_PATTERN);
if (!isMissingBrowser) throw error;
stderr.write(
"Playwright's Chromium browser is missing — installing it now (one-time ~150 MB download). Subsequent runs will be instant.\n",
);
try {
install();
} catch (_installError) {
throw error;
}
try {
return await launch();
} catch (_retryError) {
throw error;
}
}
}
const {

@@ -35,5 +78,10 @@ findErrorPattern,

const {
assertAppPortReachable,
loadScenarioInIframe,
loadScenarioTopLevel,
waitForStablePage,
createNetworkTracker,
waitForNetworkQuiet,
collectContentState,
performInteraction,
} = require("./scenario-playwright");

@@ -48,5 +96,64 @@

const BLANK_RETRY_DELAY_MS = 500;
const {
probeInteractivity,
} = require("./scenario-interactivity");
// Apply the scenario's merged `browserState` to a Playwright context.
// 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 [];
}
}
// Cold-start retry pause. waitForStablePage settles as soon as the page is
// HTML-stable, which for a lazy/Suspense app is the empty `<div id="root">`
// shell — stable for the ~3s the dynamic chunk takes to load (longer when the
// scenario's mocks slow the boot). 500ms re-checked before the chunk resolved
// and reported a false blank; this pause must comfortably exceed that window
// while staying under the test runner's default per-case timeout.
const BLANK_RETRY_DELAY_MS = 3000;
// True when `url` targets a different origin than `appOrigin`. Used to decide
// whether the codeyam capture markers must be stripped before the request
// leaves (cross-origin) or may ride along (same-origin, the app's own dev
// server). A malformed URL counts as same-origin (false) so we never strip
// markers from a request we can't classify — the conservative default keeps
// same-origin behavior unchanged. `url` may be a string or a URL-like object.
function isCrossOriginRequest(url, appOrigin) {
try {
const href = typeof url === "string" ? url : url.href;
return new URL(href).origin !== appOrigin;
} catch (_) {
return false;
}
}
// 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;
}
// Apply the scenario's merged `browserState` to a Playwright context and
// stamp the codeyam capture markers on every request the context makes.
//

@@ -58,5 +165,11 @@ // Cookies need a concrete URL to bind to (Playwright requires either

// 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;
if (!state) return;
const state = (config && config.browserState) || {};
const cookies = state.cookies || {};

@@ -87,6 +200,44 @@ const cookieEntries = Object.entries(cookies);

}
const headers = state.requestHeaders || {};
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 {
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 });
},
);
}
}

@@ -132,6 +283,8 @@

async function runScenarioCheck(config) {
// `preflight` is injectable (defaulting to the real app-port reachability
// check) so unit tests that mock the browser can stay network-free.
async function runScenarioCheck(config, { preflight = assertAppPortReachable } = {}) {
const { url, outputPath, width, height, httpMocks = {} } = config;
const issues = [];
const browser = await chromium.launch();
const browser = await launchChromiumWithSelfHeal();
const contextOptions = {

@@ -162,2 +315,7 @@ viewport: { width: width || 1440, height: height || 900 },

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);

@@ -186,5 +344,15 @@

try {
const { frame, response } = await loadScenarioInIframe(page, url, {
background: config.iframeBackground,
});
// Application/route captures navigate at the top level so the
// first-party session cookie is sent (auth-gated routes render the
// authenticated page instead of /login); component captures keep the
// iframe harness for its background/sizing control. The backend signals
// the choice via `config.navigation` ("topLevel"); absent (the default)
// means the iframe harness, so existing callers are unchanged.
const { frame, response } =
config.navigation === "topLevel"
? await loadScenarioTopLevel(page, url, { preflight })
: await loadScenarioInIframe(page, url, {
background: config.iframeBackground,
preflight,
});
loaded = true;

@@ -204,4 +372,17 @@

await waitForStablePage(page, frame);
// Project loading markers come from config when a caller injects them
// (unit tests), otherwise from stack.json — so a stable-but-loading app
// screen is not mistaken for settled content and captured mid-hydration.
const loadingMarkers = Array.isArray(config.loadingMarkers)
? config.loadingMarkers
: readStackLoadingMarkers();
await waitForStablePage(page, frame, 10000, loadingMarkers);
// DOM-stable does not mean done: a client-side data fetch can still be in
// flight (the loading skeleton cleared but its replacement content hasn't
// landed). Wait for the network to go quiet — bounded, so a streaming /
// long-poll endpoint that never idles caps out and captures anyway rather
// than hanging.
await waitForNetworkQuiet(networkTracker);
const rejectionMessages = await frame.evaluate(

@@ -219,7 +400,8 @@ () => window.__codeyamUnhandledRejections || [],

// Cold-start retry: a single re-collect after a short pause covers
// the React.lazy / Suspense-fallback race that flagged 4-of-59
// scenarios as blank against a cold Vite dev server on 2026-04-30.
// One retry is enough — the Suspense window is sub-second in
// practice, longer waits just slow down genuine blank-render bugs.
// 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);

@@ -245,3 +427,3 @@ let hasContent = hasRenderableContent(contentState);

// Check for known error states in the rendered content
const bodyText = await frame.evaluate(() => document.body.innerText || "");
const bodyText = await frame.evaluate(() => document.body?.innerText || "");
const matchedPattern = findErrorPattern(bodyText);

@@ -264,2 +446,25 @@ if (matchedPattern) {

// Hydration / interactivity gate: a page can render content and log no
// errors yet never have hydrated, leaving every control dead. Read-only
// (it inspects framework-attachment markers, never clicks), so it is safe
// to run before the screenshot. Stack-gated and fail-safe — see
// scenario-interactivity.js — so backend / static / unknown-framework
// captures are an automatic pass.
const hydrationIssue = await probeInteractivity(frame, {
url: page.url() || url,
});
if (hydrationIssue) {
pushIssue(issues, hydrationIssue);
}
// Drive the requested interaction (if any) against the settled frame,
// then re-settle, so `preview-interact` captures the RESULT of a click /
// fill / press (expanded accordion, open modal) without editing app
// source. A no-match target throws here and is caught below as a failed
// capture with the candidate-labels hint — never a silent blank shot.
if (config.interaction) {
await performInteraction(frame, config.interaction);
await waitForStablePage(page, frame, 5000, loadingMarkers);
}
if (outputPath && loaded) {

@@ -295,2 +500,7 @@ fs.mkdirSync(path.dirname(outputPath), { recursive: true });

/**
* 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() {

@@ -312,3 +522,10 @@ const config = JSON.parse(process.argv[2] || "{}");

runScenarioCheck,
readStackLoadingMarkers,
applyBrowserState,
isCrossOriginRequest,
stripMarkerHeaders,
main,
launchChromiumWithSelfHeal,
PLAYWRIGHT_INSTALL_COMMAND,
PLAYWRIGHT_MISSING_BROWSER_PATTERN,
};

@@ -315,0 +532,0 @@

@@ -8,4 +8,18 @@ const LOADING_MARKERS = [

function hasLoadingMarkers(text) {
return LOADING_MARKERS.some((marker) => text.includes(marker));
// `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()),
);
}

@@ -12,0 +26,0 @@

@@ -22,31 +22,66 @@ function normalizeMockCandidates(url) {

// 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;
await page.route("**/*", async (route) => {
const mock = findHttpMock(httpMocks, route.request());
if (!mock) {
await route.continue();
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";
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,
});
});
await route.fulfill({
status: mock.status || 200,
headers,
body,
});
},
);

@@ -56,4 +91,4 @@ // Disable the in-page fetch mock by returning an empty active-mocks.json.

// 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).
// 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) => {

@@ -71,3 +106,5 @@ await route.fulfill({

findHttpMock,
mockedTargets,
requestTargetsMock,
attachHttpMocks,
};

@@ -5,3 +5,89 @@ const {

} = 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) {

@@ -66,3 +152,3 @@ return String(value).replaceAll("&", "&amp;").replaceAll('"', "&quot;");

return {
bodyTextLength: document.body.innerText.trim().length,
bodyTextLength: document.body ? document.body.innerText.trim().length : 0,
rootChildCount: root ? root.childElementCount : 0,

@@ -104,2 +190,14 @@ rootTextLength: root ? (root.textContent || "").trim().length : 0,

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 };

@@ -127,6 +225,79 @@ }

async function waitForStablePage(page, target, timeoutMs = 10000) {
// 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;

@@ -136,8 +307,37 @@ while (Date.now() - started < timeoutMs) {

const pageState = await target.evaluate(() => ({
bodyText: document.body.innerText,
html: document.body.innerHTML,
}));
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,
};
});
if (!hasLoadingMarkers(pageState.bodyText) && pageState.html === lastHtml) {
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;

@@ -150,2 +350,6 @@ if (stableCount >= 2) {

await waitForImagesSettled(target, { overallTimeoutMs: remaining() });
logCaptureTiming("stable-page", {
outcome: "stabilized",
elapsedMs: Date.now() - started,
});
return;

@@ -159,5 +363,22 @@ }

}
// Hit the cap without stabilizing — record WHY: a persistent loading marker
// (app stuck) vs HTML still mutating each poll (HMR / animation / re-render).
logCaptureTiming("stable-page", {
outcome: "timed-out",
elapsedMs: Date.now() - started,
timeoutMs,
lastHadLoadingMarkers,
lastHtmlStillChanging: lastHtmlChanged,
});
}
async function loadScenarioInIframe(page, url, { background } = {}) {
// `preflight` is injectable so unit tests that drive a mock page can stay
// network-free; production callers use the default real reachability check.
async function loadScenarioInIframe(
page,
url,
{ background, preflight = assertAppPortReachable } = {},
) {
await preflight(url);
const navStarted = Date.now();
const responsePromise = page

@@ -187,6 +408,122 @@ .waitForResponse(

const response = await responsePromise;
logCaptureTiming("navigate-iframe", {
elapsedMs: Date.now() - navStarted,
status: response ? response.status() : null,
url,
});
return { frame, response };
}
// Load the scenario as a top-level navigation instead of embedding it in
// the iframe harness. A top-level document is a first-party context, so a
// `SameSite=Lax` session cookie is sent on the navigation — which is what
// auth-gated application routes need to render the authenticated page
// rather than redirecting to /login. The returned `frame` is the page's
// main frame so callers can treat it uniformly with the iframe path
// (`frame.url()`, `frame.evaluate(...)`, `waitForStablePage(page, frame)`).
async function loadScenarioTopLevel(
page,
url,
{ preflight = assertAppPortReachable } = {},
) {
await preflight(url);
const navStarted = Date.now();
const response = await page.goto(url, {
waitUntil: "load",
timeout: 30000,
});
logCaptureTiming("navigate-toplevel", {
elapsedMs: Date.now() - navStarted,
status: response ? response.status() : null,
url,
});
return { frame: page.mainFrame(), response };
}
// Collect up to 20 distinct visible labels of interactive elements on the
// page — buttons, links, role=button, form controls, <summary>, and anything
// with an onclick. Used to build an ACTIONABLE error when an interaction's
// target matches nothing: the agent reliably knows a label it rendered, so
// listing the real candidates turns a silent blank capture into a "did you
// mean one of these?" hint. Pure read (no clicks); falls back to value /
// aria-label / placeholder when an element has no text.
async function collectInteractiveLabels(frame) {
return frame.evaluate(() => {
const selector =
"button, a[href], [role=button], input, select, textarea, summary, [onclick]";
const nodes = Array.from(document.querySelectorAll(selector));
const labels = nodes
.map((node) => {
const text =
(node.innerText || node.textContent || "").trim() ||
(typeof node.value === "string" ? node.value.trim() : "") ||
(node.getAttribute && node.getAttribute("aria-label")) ||
(node.getAttribute && node.getAttribute("placeholder")) ||
"";
return String(text).trim();
})
.filter((label) => label.length > 0);
return Array.from(new Set(labels)).slice(0, 20);
});
}
// Drive a single user-style interaction against the settled frame before the
// screenshot, so an interactive state (expanded accordion, open modal, filled
// field) can be captured without editing app source.
//
// The target is matched by visible `text` (preferred — the agent reliably
// knows the label it rendered) or a CSS `selector`. `action` is click / fill /
// press; `value` carries the text for `fill` or the key for `press` (e.g.
// `Enter`). On a no-match target this THROWS with the list of candidate
// interactive labels — the capture script's outer catch turns that into a
// failed capture with an actionable message, never a silent blank screenshot.
async function performInteraction(frame, interaction, { timeoutMs = 5000 } = {}) {
const { action, selector, text, value } = interaction || {};
let locator;
let targetDesc;
if (typeof text === "string" && text.length > 0) {
locator = frame.getByText(text, { exact: false }).first();
targetDesc = `text "${text}"`;
} else if (typeof selector === "string" && selector.length > 0) {
locator = frame.locator(selector).first();
targetDesc = `selector "${selector}"`;
} else {
throw new Error(
"preview-interact: interaction requires a `text` or `selector` target",
);
}
const matchCount = await locator.count();
if (matchCount === 0) {
const candidates = await collectInteractiveLabels(frame);
const candidateList =
candidates.length > 0 ? candidates.join(", ") : "(none found on page)";
throw new Error(
`preview-interact: no element matched ${targetDesc}. ` +
`Candidate interactive labels: ${candidateList}`,
);
}
switch (action) {
case "click":
await locator.click({ timeout: timeoutMs });
break;
case "fill":
await locator.fill(value ?? "", { timeout: timeoutMs });
break;
case "press":
await locator.press(value || "Enter", { timeout: timeoutMs });
break;
default:
throw new Error(
`preview-interact: unknown action "${action}" (expected click | fill | press)`,
);
}
}
module.exports = {
logCaptureTiming,
resolveTcpTarget,
assertAppPortReachable,
escapeHtmlAttribute,

@@ -198,4 +535,9 @@ buildIframeHarness,

waitForAnimationsSettled,
createNetworkTracker,
waitForNetworkQuiet,
waitForStablePage,
loadScenarioInIframe,
loadScenarioTopLevel,
collectInteractiveLabels,
performInteraction,
};

@@ -11,10 +11,16 @@ const { execSync, spawn, spawnSync } = require("child_process");

* Map (process.platform, process.arch) → the platform sub-package that
* carries the matching native binary. Kept as a small data table so adding
* a new platform (e.g. linux/arm64) is one entry, not a new branch.
* 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" },
"darwin-x64": { pkg: "@codeyam-editor/codeyam-editor-darwin-x64", binary: "codeyam-editor" },
"linux-x64": { pkg: "@codeyam-editor/codeyam-editor-linux-x64", binary: "codeyam-editor" },
"win32-x64": { pkg: "@codeyam-editor/codeyam-editor-win32-x64", binary: "codeyam-editor.exe" },
"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" },
};

@@ -31,2 +37,10 @@

/**
* 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. */

@@ -67,6 +81,21 @@ function platformPackageInfo(platform = process.platform, arch = process.arch) {

const name = binaryName();
const devCandidates = [
path.join(rootDir, "target", "debug", name),
path.join(rootDir, "target", "release", name),
];
// 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) {

@@ -139,5 +168,64 @@ if (fs.existsSync(candidate)) return candidate;

}
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. */

@@ -148,2 +236,49 @@ function uiDistDir() {

/**
* Loud banner printed when `cargo build` failed and a wrapper is about to fall
* back to a stale binary. Shared by both wrappers (editor.js server boot,
* editor-dev.js passthrough/launch) so the refusal text and the documented
* CODEYAM_ALLOW_STALE escape hatch stay identical. `action` names what would
* run on the stale binary, e.g. "run editor commands" / "start the editor
* server".
*/
function staleBinaryBanner(action = "run editor commands") {
return [
"",
"============================================================",
" cargo build FAILED — the editor source does not compile.",
` Refusing to ${action} on a STALE binary,`,
" which would run code that no longer matches the source.",
"",
" Fix the compile error above, then re-run.",
" To deliberately run the last-good binary anyway, set",
" CODEYAM_ALLOW_STALE=1.",
"============================================================",
"",
].join("\n");
}
/**
* Enforce a fresh binary before a wrapper runs it. A no-op when the rebuild
* succeeded (`buildOk` true). When it failed, print the stale-binary banner and
* `process.exit(1)` — UNLESS `CODEYAM_ALLOW_STALE=1`, in which case print a
* one-line override notice and return so the caller proceeds on the last-good
* binary. This is the single code path the passthrough, launch, and
* server-boot call sites share, so they cannot diverge on whether they honor
* the override. `continueVerb` tailors the override notice ("continuing with"
* vs "launching with").
*/
function enforceFreshBinary(buildOk, { action, continueVerb = "continuing with" } = {}) {
if (buildOk) {
return;
}
console.error(staleBinaryBanner(action));
if (process.env.CODEYAM_ALLOW_STALE !== "1") {
process.exit(1);
}
console.error(
`CODEYAM_ALLOW_STALE=1 set — ${continueVerb} the existing (stale) binary.`,
);
}
function isPlainJsonObject(value) {

@@ -265,2 +400,21 @@ return value !== null && typeof value === "object" && !Array.isArray(value);

/**
* 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. */

@@ -588,4 +742,17 @@ function tryConnect(port, host) {

const ready = await waitForPort(port);
if (!ready) {
const { outcome, exitedBeforeBind } = await awaitServerReadyOrChildExit(child, port);
if (outcome === "exited" || (outcome === "timeout" && exitedBeforeBind !== null)) {
const { code, signal } = exitedBeforeBind ?? { code: null, signal: null };
const detail = signal != null ? `signal ${signal}` : `code ${code}`;
console.error(
`codeyam-editor server exited (${detail}) before binding port ${port}. ` +
"Likely a preflight bail; see output above.",
);
// Always propagate as a real failure — even on code 0 the caller
// needs to know the server isn't running.
process.exit(code != null && code !== 0 ? code : 1);
}
if (outcome === "timeout") {
console.error("Server failed to start within 10 seconds");

@@ -599,2 +766,28 @@ child.kill();

/**
* Race a child process's exit against a port-becomes-listening poll.
* Returns `{ outcome, exitedBeforeBind }` where `outcome` is one of
* `"ready"` (port bound first), `"timeout"` (poll exhausted before the
* port bound and the child is still alive), or `"exited"` (child exited
* before the port bound). `exitedBeforeBind` is `{ code, signal }` if
* the child exited at any point during the race, else `null`.
*
* Exists as its own helper so the silent-clean-exit branch of
* `ensureServer` is unit-testable without spawning the real Rust
* binary. `waitForPortFn` defaults to `waitForPort`; tests pass a fake
* that resolves synchronously.
*/
async function awaitServerReadyOrChildExit(child, port, waitForPortFn = waitForPort) {
let exitedBeforeBind = null;
const exitPromise = new Promise((resolve) => {
child.once("exit", (code, signal) => {
exitedBeforeBind = { code, signal };
resolve("exited");
});
});
const portPromise = waitForPortFn(port).then((ok) => (ok ? "ready" : "timeout"));
const outcome = await Promise.race([portPromise, exitPromise]);
return { outcome, exitedBeforeBind };
}
module.exports = {

@@ -604,6 +797,11 @@ rootDir,

binaryName,
brokerBinaryName,
platformPackageInfo,
PLATFORM_PACKAGES,
findBinary,
findBrokerBinary,
verifyBrokerBinaryPresent,
ensureBinary,
staleBinaryBanner,
enforceFreshBinary,
uiDistDir,

@@ -627,2 +825,3 @@ tryConnect,

ensureServer,
awaitServerReadyOrChildExit,
killChildProcess,

@@ -633,2 +832,3 @@ deepMergeJson,

resolveLauncherPort,
resolveWrappedAppPort,
};
{
"name": "@codeyam-editor/codeyam-editor",
"version": "0.1.0-staging.afe66b2",
"version": "0.1.0-staging.baa0196",
"description": "Language-agnostic managed execution sandbox for scenario-driven development",

@@ -12,6 +12,6 @@ "bin": {

"optionalDependencies": {
"@codeyam-editor/codeyam-editor-darwin-arm64": "0.1.0-staging.afe66b2",
"@codeyam-editor/codeyam-editor-darwin-x64": "0.1.0-staging.afe66b2",
"@codeyam-editor/codeyam-editor-linux-x64": "0.1.0-staging.afe66b2",
"@codeyam-editor/codeyam-editor-win32-x64": "0.1.0-staging.afe66b2"
"@codeyam-editor/codeyam-editor-darwin-arm64": "0.1.0-staging.baa0196",
"@codeyam-editor/codeyam-editor-darwin-x64": "0.1.0-staging.baa0196",
"@codeyam-editor/codeyam-editor-linux-x64": "0.1.0-staging.baa0196",
"@codeyam-editor/codeyam-editor-win32-x64": "0.1.0-staging.baa0196"
},

@@ -18,0 +18,0 @@ "keywords": [

@@ -8,6 +8,6 @@ <!DOCTYPE html>

<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<script type="module" crossorigin src="/assets/index-t0yzyvUz.js"></script>
<script type="module" crossorigin src="/assets/index-KDFAgt2e.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-Bn7_cmUp.css">
<link rel="stylesheet" crossorigin href="/assets/index-Bih6HJ_u.css">
</head>

@@ -14,0 +14,0 @@ <body>

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 c}from"./markdown-v0F0UEt3.js";import{b as o}from"./react-CSS0HapR.js";import{S as m}from"./ScenarioDataPanel-D8Gm4DpJ.js";import"./useEvents-DggWrtHe.js";import"./xterm--24IGk-x.js";import"./index-t0yzyvUz.js";function S({slug:t}){const[r,i]=o.useState(void 0);return o.useEffect(()=>{let n=!1;return fetch(`/api/scenarios/${encodeURIComponent(t)}`).then(e=>e.ok?e.json():null).then(e=>{if(n||!e)return;const a=typeof e=="object"&&e!==null&&"name"in e?String(e.name):void 0;i(a),a&&(document.title=`${a} · data`)}).catch(()=>{}),()=>{n=!0}},[t]),c.jsx(m,{slug:t,scenarioName:r,variant:"fullPage"})}export{S as ScenarioDataPanelFullPage};

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display