// rnx record — drives the engine's built-in canvas recorder over // the WS bridge. no playwright, no ffmpeg. encoding happens in the // running rnx page (webm via MediaRecorder, mp4 via WebCodecs, gif // via gifenc, raw png frames via a shared bitmap sampler). import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync, } from 'fs' import { homedir, tmpdir } from 'os' import { dirname, extname, join, resolve } from 'path' import { githubUploadIdentity, resolveCliAuth } from '../auth' import { getCliIdentityKey } from '../current-sim' import { openUrl } from '../open-url' import { ensureCliRecordingEntitlement } from '../recording-access' import { resolveRunProvenance } from '../run-registry' import { rnxExit } from '../run-rnx' import { checkSimHealth, createBridgeFromParsed, parseBridgeCliArgs, type WsBridge, } from '../ws-bridge' interface RecordOptions { port?: number verbose?: boolean } type OutputFormat = 'webm' | 'mp4' | 'gif' | 'png' // recording mode — 'video' is a local webm/mp4 download via the headless // recorder bridge (current CLI default). 'live' and 'combined' route // through the recording store so they capture pointer events (and, for // combined, video) and upload to /preview/. mirrors RecordingMode in // packages/sootsim-engine/src/recording/recordingStore.ts. type RecordMode = 'video' | 'live' | 'combined' // how long to wait for the live/combined upload to settle after stop. dev // bundles routinely include tens of MB of transformed JS plus captured // request bodies; 60s was too tight and left valid PR-preview recordings // uploading in the page after the CLI had already reported failure. const UPLOAD_TIMEOUT_MS = 240_000 // how long `record start --mode live/combined` waits for each setup bridge // eval (auth inject, store start) to round-trip. unlike the light `video` // start (a single __sootsimRecorder.start), the store-backed start must reach // an engine that — on a contended CI runner, right after the PR-preview agent // drove a populated app — is saturated rendering at a few fps and cannot answer // a bridge eval within the 15s the video path uses. that 15s cap made // `record start` time out, the agent retry it many times, and the leading idle // from the failed attempts blow the 15s first-interaction quality gate // (preview-quality.ts) so the take was rejected even once it finally recorded. // give the upload-bearing start the slack the atomic combined path // (runRecord, commandTimeoutMs 60s) already uses, plus headroom for a starved // engine. video start stays light at 15s. const STORE_RECORD_START_TIMEOUT_MS = 90_000 // per-step bridge-eval budget for local-file capture (gif / video). the // blanket 60s command timeout means a contended or wedged render host // produces a 60s generic "command timed out" hang with no hint which step // stalled (QA F19-5). scope each capture eval to its own recording duration // plus a generous encode/teardown allowance so a stalled host fails fast // and actionably. the bitmap-capture lock now self-releases on abandon // (bitmap-capture.ts), so recovery is simply to retry. const CAPTURE_STEP_ENCODE_BUDGET_MS = 30_000 async function evalCaptureStep( bridge: WsBridge, code: string, step: string, durationMs: number, ): Promise { const timeoutMs = Math.max(20_000, durationMs + CAPTURE_STEP_ENCODE_BUDGET_MS) try { return (await bridge.send({ type: 'evaluate', code }, { timeoutMs })) as T } catch (err) { const msg = err instanceof Error ? err.message : String(err) if (/^command timed out after \d+s$/.test(msg)) { console.error( ` ${step} stalled — the render host did not finish within ` + `${Math.round(timeoutMs / 1000)}s; it is likely contended or wedged.\n` + ` the capture lock auto-releases, so simply retry the recording.\n` + ` if it persists, recover the sim with \`rnx close --sim \`.`, ) rnxExit(1) } // a leaked/contended render-host streaming session surfaces as an // engine-side error delivered over the WS bridge. on the gif/video // start path this rejection previously had no upstream catch, so it // escaped as a raw unhandled stack trace AND the process self-exited // 0 (false success, no file written) — QA F20-1. handle it here: // actionable message, non-zero exit, no stack. if ( /render host not available|already streaming|already recording|lock already held/i.test( msg, ) ) { console.error( ` ${step} failed: ${msg}\n` + ` a prior recording was abandoned and its capture is still\n` + ` releasing. the lock self-heals in ~${Math.round( (durationMs + 20_000) / 1000, )}s — retry then, or recover now with\n` + ` \`rnx close --sim \` and reopen the sim.`, ) rnxExit(1) } // never let any other capture-eval rejection escape as an unhandled // promise (raw stack + false exit 0). surface it and exit non-zero. console.error(` ${step} failed: ${msg}`) rnxExit(1) } } // value-taking flags that may legitimately appear before the subcommand — // notably `--sim`, which bin.ts prepends to the command args. their values // must not be mistaken for the subcommand. const RECORD_VALUE_FLAGS = new Set([ '--sim', '--port', '-p', '--mode', '--duration', '--fps', '--format', '--output', '-o', '--frames', '--max-width', '--origin', '--owner', '--repo', ]) const RECORD_SUBCOMMANDS = new Set([ 'start', 'stop', 'cancel', 'status', 'prelude-start', 'upload', ]) // the record subcommand (start/stop/cancel/status) is the first non-flag // positional — NOT necessarily args[0]. bin.ts prepends `--sim ` when a // global --sim target is set, so a naive args[0] check silently fell through // to the atomic-record path and started a spurious recording (F41). function findRecordSubcommand(args: string[]): { name: string; index: number } | null { for (let i = 0; i < args.length; i++) { const a = args[i] if (a.startsWith('-')) { if (RECORD_VALUE_FLAGS.has(a)) i++ // skip the flag's value continue } return RECORD_SUBCOMMANDS.has(a) ? { name: a, index: i } : null } return null } type RepoScope = { owner: string; repo: string } | null // git provenance for PR-preview shares, read from the github-actions env // (run.sh exports these). repo-agnostic: every PR-preview run carries the // standard GITHUB_* vars, with CONTRAST_* overrides matching resolveCliAuth. // merged into the upload identity so the org/recent feed shows the same // branch / PR title + "sha · author" for previews as for branch-builds. function readRepoScope(args: string[]): RepoScope { const owner = valueOf(args, '--owner')?.trim() const repo = valueOf(args, '--repo')?.trim() if ((owner && !repo) || (!owner && repo)) { console.error(' --owner and --repo must be provided together') rnxExit(1) } return owner && repo ? { owner, repo } : null } export async function runRecord(args: string[], opts: RecordOptions) { // note: `--help` / `-h` never reach here — bin.ts intercepts them and // renders the registry-driven help page (packages/rnx-skills meta.ts). const sub = findRecordSubcommand(args) if (sub) { // drop the subcommand token; the rest (incl. any --sim/--port) flows on // to the subcommand handlers, which parse bridge flags themselves. const rest = [...args.slice(0, sub.index), ...args.slice(sub.index + 1)] if (sub.name === 'start') { await recordStart(rest, opts) return } if (sub.name === 'prelude-start') { await recordPreludeStart(rest, opts) return } if (sub.name === 'upload') { const { runUpload } = await import('./upload') await runUpload(rest, opts) return } if (sub.name === 'stop') { await recordStop(rest, opts) return } if (sub.name === 'cancel') { await recordCancel(rest, opts) return } await recordStatus(rest, opts) return } const parsed = parseBridgeCliArgs(args, { port: opts.port, stripBooleanFlags: ['--no-shell', '--shell-only', '--open', '--lockstep'], stripValueFlags: [ '--mode', '--duration', '--fps', '--format', '--output', '--frames', '--max-width', '--origin', '--owner', '--repo', ], }) const mode = parseMode(valueOf(args, '--mode')) const layers: 'tenant' | 'shell' | undefined = args.includes('--shell-only') ? 'shell' : args.includes('--no-shell') ? 'tenant' : undefined const formatArg = valueOf(args, '--format') as OutputFormat | undefined if (formatArg && !['webm', 'mp4', 'gif', 'png'].includes(formatArg)) { console.error(` invalid --format "${formatArg}" — expected webm | mp4 | gif | png`) rnxExit(1) } const outputArg = valueOf(args, '--output') const durationSec = Number(valueOf(args, '--duration') ?? '10') if (!Number.isFinite(durationSec) || durationSec <= 0) { console.error( ` invalid --duration "${valueOf(args, '--duration')}" — expected a positive number of seconds`, ) rnxExit(1) } // upper bound: a fat-fingered `--duration 99999` (≈27.7h) otherwise // started a runaway recording with no warning (QA F23-3). 600s mirrors // the server's MAX_FLOW_VIDEO_DURATION_MS — live/combined uploads past // it are rejected anyway, and no legitimate atomic capture runs longer // (the bitmap watchdog tears down well before that). float seconds stay // allowed (rounded to ms below) — unlike --frames, a duration is a time, // not a count, so sub-second precision is meaningful. const MAX_DURATION_SEC = 600 if (durationSec > MAX_DURATION_SEC) { console.error( ` invalid --duration "${valueOf(args, '--duration')}" — exceeds the ${MAX_DURATION_SEC}s (10m) maximum`, ) rnxExit(1) } const fps = Number(valueOf(args, '--fps') ?? '30') const framesArg = valueOf(args, '--frames') // --frames is a count, not a duration — reject 0 / negatives / fractions // up front instead of silently "saving 0 frames" (F11-3), mirroring the // --duration guard above. if (framesArg !== undefined) { const n = Number(framesArg) if (!Number.isInteger(n) || n <= 0) { console.error( ` invalid --frames "${framesArg}" — expected a positive integer count`, ) rnxExit(1) } } const openAfter = args.includes('--open') const maxWidth = valueOf(args, '--max-width') ? Number(valueOf(args, '--max-width')) : undefined if (args.includes('--lockstep')) { // atomic mode times the recording in wall seconds, but lockstep virtual // time runs slower than the wall clock, so `--duration` would lie about // the captured span. drive lockstep through start/stop instead. console.error( ' --lockstep works with `record start` / `record stop` (stateful mode) — atomic --duration is wall-clock and cannot bound a virtual-time capture', ) rnxExit(1) } const durationMs = Math.max(100, Math.round(durationSec * 1000)) // --origin explicitly pins the billing/upload origin. it wins over the // saved desktop-session origin so a key minted against a local :3000 // stack verifies there instead of prod (the e2e blocker — F12). const originOverride = valueOf(args, '--origin') const repoScope = readRepoScope(args) // live / combined route through the recording store (events + optional // video, upload to /preview/) instead of the headless recorder. if (mode === 'live' || mode === 'combined') { // --output only applies to local-file capture (video/gif/png). live and // combined upload to /preview/ and write no local file — say so // rather than silently swallowing the flag. if (outputArg) { console.log( ` note: --output is ignored for --mode ${mode} — the recording uploads to /preview/`, ) } // --no-shell / --shell-only select which layers the captured video // shows. live mode records events only (no video), so the flag has // nothing to act on there — say so rather than dropping it silently // (F12-1). if (layers && mode === 'live') { console.log( ` note: --${layers === 'shell' ? 'shell-only' : 'no-shell'} is ignored for --mode live — live recordings capture events only, no video`, ) } // only the upload-bearing modes touch the cloud, so gate the recording // entitlement here, not on local-file video/gif/png capture (F19). // allowGitHubAuth mirrors preview flow recording: the github pr-preview // runner authenticates with an installation token, not RNX_API_KEY // (zero-secrets design). the bypass only fires when auth.kind === // 'github' (ci/preview); local paid users have session/ // api-key auth and still go through the billing entitlement check. await ensureCliRecordingEntitlement('record', { originOverride, allowGitHubAuth: true, }) const bridge = createBridgeFromParsed({ ...parsed, commandTimeoutMs: 60_000 }) try { await assertStoreBridgesAvailable(bridge) await assertGuestBundleLoaded(bridge, mode) if (mode === 'combined') await assertTabVisibleForCapture(bridge) await injectPreviewSessionAuth(bridge, originOverride, repoScope) const started = await evalStoreStart( bridge, mode, mode === 'combined' ? layers : undefined, ) if (!started.ok) { console.error( ` start failed: recording store refused to start (${mode})` + (started.error ? `: ${started.error}` : ''), ) rnxExit(1) } console.log(` recording ${mode} for ${durationSec}s`) await new Promise((r) => setTimeout(r, durationMs)) await evalStoreStop(bridge) const result = await pollStoreUpload(bridge) handleStoreUploadResult(result, openAfter, mode) } finally { bridge.close() } return } const framesCount = framesArg ? Number(framesArg) : null const format: OutputFormat = resolveFormat(formatArg, outputArg, framesCount) // --open launches the uploaded /preview/ page — local-file capture // (video/gif/png) produces no URL, so say it's ignored rather than // swallowing the flag silently (F12-4). if (openAfter) { console.log( ` note: --open is ignored for local-file capture — it applies to --mode live/combined`, ) } const bridge = createBridgeFromParsed({ ...parsed, commandTimeoutMs: 60_000 }) try { await assertRecorderAvailable(bridge) await assertTabVisibleForCapture(bridge) if (format === 'png') { const count = framesCount ?? 10 const outDir = resolveRecordingOutput(outputArg, `rnx-frames-${stamp()}`) // --frames writes one png PER frame, so --output names a directory, not // a file. a caller passing `--output shot.png` gets a directory literally // named `shot.png` — flag the likely mistake rather than silently doing it. if (outputArg && extname(outputArg)) { console.log( ` note: --frames writes multiple pngs, so --output "${outputArg}" is a directory — frame-NNN.png files land inside it`, ) } mkdirSync(outDir, { recursive: true }) // clear stale frame-NNN.png from a prior run into the same dir — a // shorter `--frames` count would otherwise leave higher-numbered frames // behind, so the dir mixes two captures (F43). only frame-*.png is // touched; any other files the caller put there are left alone. for (const name of readdirSync(outDir)) { if (/^frame-\d+\.png$/.test(name)) rmSync(join(outDir, name), { force: true }) } console.log(` sampling ${count} frames over ${durationSec}s → ${outDir}`) const start: { ok: boolean; requestId?: number; error?: string } = await bridge.send({ type: 'evaluate', code: `window.__sootsimRecorder.startFrameCapture({ count: ${count}, durationMs: ${durationMs}${ layers ? `, layers: ${JSON.stringify(layers)}` : '' } })`, }) if (!start.ok || !start.requestId) { console.error(` frame capture start failed: ${start.error ?? 'unknown error'}`) rnxExit(1) } await new Promise((r) => setTimeout(r, durationMs)) const deadline = Date.now() + Math.max(5_000, durationMs) let frames: Array<{ data: string; size: number }> | null = null for (;;) { const result: { ok: boolean done: boolean frames?: Array<{ data: string; size: number }> error?: string } | null = await bridge.send({ type: 'evaluate', code: `window.__sootsimRecorder.getFrameCaptureResult(${start.requestId})`, }) if (!result) { console.error(' frame capture result missing') rnxExit(1) } if (result.done) { if (!result.ok) { console.error(` frame capture failed: ${result.error ?? 'unknown error'}`) rnxExit(1) } frames = result.frames ?? [] break } if (Date.now() >= deadline) { console.error(' frame capture timed out') rnxExit(1) } await new Promise((r) => setTimeout(r, 100)) } frames.forEach((f, i) => { const p = `${outDir}/frame-${String(i + 1).padStart(3, '0')}.png` writeFileSync(p, Buffer.from(f.data, 'base64')) }) console.log(` saved ${frames.length} frames`) return } if (format === 'gif') { const count = framesCount ?? Math.max(10, Math.round((durationSec * fps) / 3)) const out = resolveRecordingOutput(outputArg, `rnx-${stamp()}.gif`) mkdirSync(dirname(out), { recursive: true }) console.log(` encoding gif: ${count} frames over ${durationSec}s → ${out}`) const result: { data: string; size: number } | null = await evalCaptureStep( bridge, `window.__sootsimRecorder.captureGif({ frames: ${count}, durationMs: ${durationMs}${ maxWidth ? `, maxWidth: ${maxWidth}` : '' }${layers ? `, layers: ${JSON.stringify(layers)}` : ''} })`, 'gif encode', durationMs, ) if (!result) { console.error(' gif capture returned no frames') rnxExit(1) } writeFileSync(out, Buffer.from(result.data, 'base64')) console.log(` saved: ${out} (${formatBytes(result.size)})`) return } // video path: webm or mp4 const out = resolveRecordingOutput(outputArg, `rnx-${stamp()}.${format}`) mkdirSync(dirname(out), { recursive: true }) const startOpts: { format: string fps: number layers?: string durationMs: number } = { format, fps, // thread the known duration so an abandoned recording's bitmap lock // self-heals in ~duration+20s instead of the 8-min ceiling (F20-1). durationMs, } if (layers) startOpts.layers = layers const startResult: { ok: boolean; error?: string; format?: string } = await evalCaptureStep( bridge, `window.__sootsimRecorder.start(${JSON.stringify(startOpts)})`, `${format} start`, durationMs, ) if (!startResult.ok) { console.error(` start failed: ${startResult.error ?? 'unknown error'}`) // a stale render-host streaming session is the usual cause — say how // to recover instead of leaving the user staring at a bare error. if ( /render host not available|already streaming|already recording|lock already held/i.test( startResult.error ?? '', ) ) { console.error( ' a prior recording was abandoned and its capture is still\n' + ' releasing. retry in ~20s, or recover now with\n' + ' `rnx close --sim ` then reopen the sim.', ) } rnxExit(1) } console.log(` recording ${format} for ${durationSec}s → ${out}`) await new Promise((r) => setTimeout(r, durationMs)) const stopResult: { ok: boolean error?: string size?: number mime?: string durationMs?: number frameCount?: number } = await evalCaptureStep( bridge, `window.__sootsimRecorder.stop()`, `${format} encode/flush`, durationMs, ) if (!stopResult.ok) { console.error(` stop failed: ${stopResult.error ?? 'unknown error'}`) rnxExit(1) } if (!stopResult.size) { console.error(' recorder returned an empty blob — nothing written') rnxExit(1) } await downloadBlob(bridge, out) console.log(` saved: ${out} (${formatBytes(stopResult.size)})`) if (stopResult.durationMs && stopResult.frameCount !== undefined) { console.log( ` source frames: ${stopResult.frameCount} over ${(stopResult.durationMs / 1000).toFixed(2)}s ` + `(${(stopResult.frameCount / (stopResult.durationMs / 1000)).toFixed(1)}fps)`, ) } } finally { bridge.close() } } // readiness probe with a short timeout. a sim can be registered with the // bridge yet never answer an `evaluate` — it loaded no app, or its page/worker // died. without the short cap the probe stalls the full recording timeout // (60s) before failing; cap it at 6s and fail fast with an actionable message. const SIM_PROBE_TIMEOUT_MS = 6_000 async function probeSim(bridge: WsBridge, code: string): Promise { try { return await bridge.send( { type: 'evaluate', code }, { timeoutMs: SIM_PROBE_TIMEOUT_MS }, ) } catch (err) { const msg = err instanceof Error ? err.message : String(err) if (/^command timed out after \d+s$/.test(msg)) { console.error( ' sim did not respond — it is connected to the bridge but has not\n' + ' loaded an app (or its page is unresponsive). run `rnx list`\n' + ' and target a sim with a loaded app via --sim .', ) rnxExit(1) } throw err } } async function assertRecorderAvailable(bridge: WsBridge) { const ok = await probeSim(bridge, 'typeof window.__sootsimRecorder !== "undefined"') if (!ok) { console.error( ' window.__sootsimRecorder missing — is rnx engine running in this sim?', ) rnxExit(1) } } // frame-capturing recordings (video / gif / png / combined) drive their // encoder off requestAnimationFrame and throttled timers, which browsers // clamp to ~1fps when the rnx tab is hidden (backgrounded, or its chrome // window minimized). that silently produced a frozen-looking file and is // exactly why agents started pre-checking `document.hidden` by hand. refuse // up front with an actionable message instead. checkSimHealth already emits // the shared hidden-tab warning over the existing bridge probe (DRY — one // source for hidden detection); this only adds the hard stop. event-only // `live` recordings don't capture frames, so callers skip this guard there. async function assertTabVisibleForCapture(bridge: WsBridge) { const { hidden } = await checkSimHealth(bridge) if (hidden) { console.error( ' refusing to record: the rnx tab is hidden, so frame capture\n' + ' would be throttled to ~1fps and produce a frozen-looking file.\n' + ' bring the chrome window to the foreground (un-minimize / switch to\n' + ' its tab) or run the recording in headless playwright, then retry.', ) rnxExit(1) } } async function downloadBlob(bridge: WsBridge, outPath: string) { // stream the blob back in chunks to avoid a single huge base64 payload const chunks: Buffer[] = [] let offset = 0 while (true) { const result: { data: string size: number offset: number done: boolean mime: string } | null = await bridge.send({ type: 'evaluate', code: `window.__sootsimRecorder.getBlobBase64({ offset: ${offset}, chunk: ${ 2 * 1024 * 1024 } })`, }) if (!result) throw new Error('no blob available on recorder') chunks.push(Buffer.from(result.data, 'base64')) offset = result.offset if (result.done) break } writeFileSync(outPath, Buffer.concat(chunks)) } export function valueOf(args: string[], flag: string): string | undefined { const idx = args.indexOf(flag) if (idx < 0 || idx === args.length - 1) return undefined return args[idx + 1] } export function extToFormat(path?: string): OutputFormat | undefined { if (!path) return undefined const ext = extname(path).toLowerCase().replace(/^\./, '') if (ext === 'webm' || ext === 'mp4' || ext === 'gif') return ext as OutputFormat if (ext === 'png') return 'png' return undefined } export function resolveFormat( formatArg: string | undefined, outputArg: string | undefined, framesCount: number | null, ): OutputFormat { if (formatArg) return formatArg as OutputFormat if (framesCount != null) return 'png' return extToFormat(outputArg) ?? 'webm' } function stamp(): string { return new Date().toISOString().replace(/[:T]/g, '-').replace(/\..+/, '') } // default home for recordings when --output is omitted. writing into the // process cwd litters whatever directory the CLI was invoked from (the repo // root, most often) with rnx-*.mp4 / rnx-frames-*/ — F11-2. an // explicit --output still resolves relative to cwd as the caller expects. function recordingsDir(): string { const dir = join(homedir(), '.rnx', 'recordings') mkdirSync(dir, { recursive: true }) return dir } function resolveRecordingOutput( outputArg: string | undefined, defaultName: string, ): string { return outputArg ? resolve(process.cwd(), outputArg) : join(recordingsDir(), defaultName) } function formatBytes(n: number): string { if (n < 1024) return `${n}B` if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB` return `${(n / (1024 * 1024)).toFixed(2)}MB` } interface RecordingState { simId: string | null mode: RecordMode format: 'webm' | 'mp4' fps: number layers?: 'tenant' | 'shell' startedAt: string } function parseMode(raw: string | undefined): RecordMode { if (!raw) return 'video' if (raw === 'video' || raw === 'live' || raw === 'combined') return raw console.error(` invalid --mode "${raw}" — expected video | live | combined`) rnxExit(1) } function recordingStatePath(): string { return join(tmpdir(), `rnx-recording-${getCliIdentityKey()}.json`) } function readRecordingState(): RecordingState | null { const p = recordingStatePath() if (!existsSync(p)) return null try { const parsed = JSON.parse(readFileSync(p, 'utf8')) as Partial // default mode for state files written before --mode existed return { mode: 'video', ...parsed } as RecordingState } catch { rmSync(p, { force: true }) return null } } function writeRecordingState(state: RecordingState) { writeFileSync(recordingStatePath(), JSON.stringify(state, null, 2)) } function clearRecordingState() { rmSync(recordingStatePath(), { force: true }) } // the record state file is global — keyed by CLI identity, not per-sim — so // `status`/`stop`/`cancel` act on whichever `record start` session exists // regardless of --sim. when the caller targets a different sim than the one // the session was started on, say so instead of silently reporting/stopping // another sim's recording (F11-1). function noteSimMismatch( requestedSimId: string | null | undefined, state: RecordingState, ) { if (requestedSimId && state.simId && requestedSimId !== state.simId) { console.log( ` note: the tracked recording session is on sim ${state.simId}, not the\n` + ` requested sim ${requestedSimId}. record state is global (not per-sim);\n` + ` this command acts on that session regardless of --sim.`, ) } } async function recordStart(args: string[], opts: RecordOptions) { const existing = readRecordingState() if (existing) { console.error( ` recording already in progress (started ${existing.startedAt}, sim ${existing.simId ?? '?'}). run \`rnx record stop\` first, or \`rnx record cancel\` to discard.`, ) rnxExit(1) } const parsed = parseBridgeCliArgs(args, { port: opts.port, stripBooleanFlags: ['--no-shell', '--shell-only', '--lockstep'], stripValueFlags: [ '--mode', '--fps', '--format', '--max-width', '--origin', '--owner', '--repo', ], }) const mode = parseMode(valueOf(args, '--mode')) const layers: 'tenant' | 'shell' | undefined = args.includes('--shell-only') ? 'shell' : args.includes('--no-shell') ? 'tenant' : undefined const formatArg = valueOf(args, '--format') as OutputFormat | undefined const format = formatArg === 'mp4' ? 'mp4' : 'webm' if (formatArg && format !== formatArg) { console.error( ` record start only supports webm or mp4 (got: ${formatArg}). for gif/png use atomic mode: rnx record --format ${formatArg} --duration `, ) rnxExit(1) } const fps = Number(valueOf(args, '--fps') ?? '30') const repoScope = readRepoScope(args) const bridge = createBridgeFromParsed({ ...parsed, commandTimeoutMs: mode === 'live' || mode === 'combined' ? STORE_RECORD_START_TIMEOUT_MS : 15_000, }) try { if (mode === 'live' || mode === 'combined') { // live mode captures events only — --no-shell / --shell-only have no // video to act on there (F12-1). if (layers && mode === 'live') { console.log( ` note: --${layers === 'shell' ? 'shell-only' : 'no-shell'} is ignored for --mode live — live recordings capture events only, no video`, ) } // only upload-bearing modes hit the cloud — gate the recording // entitlement here, not on local-file webm/mp4 capture (F19). // allowGitHubAuth: the PR-preview runner (`record start --mode // combined`) authenticates with a GitHub installation token, not // RNX_API_KEY — the whole pipeline is zero-secrets by design. // mirrors `flow --record`. the bypass only triggers for github-kind // auth (CI); local paid users still hit the billing check. await ensureCliRecordingEntitlement('record', { originOverride: valueOf(args, '--origin'), allowGitHubAuth: true, }) await assertStoreBridgesAvailable(bridge) await assertGuestBundleLoaded(bridge, mode) if (mode === 'combined') await assertTabVisibleForCapture(bridge) await injectPreviewSessionAuth(bridge, valueOf(args, '--origin'), repoScope) const started = await evalStoreStart( bridge, mode, mode === 'combined' ? layers : undefined, ) if (!started.ok) { console.error( ` start failed: recording store refused to start (${mode})` + (started.error ? `: ${started.error}` : ''), ) rnxExit(1) } } else { await assertRecorderAvailable(bridge) await assertTabVisibleForCapture(bridge) const lockstep = args.includes('--lockstep') if (lockstep && format !== 'mp4') { console.error(' --lockstep requires --format mp4 (webm is realtime-clocked)') rnxExit(1) } const startOpts: { format: string fps: number layers?: string lockstep?: boolean } = { format, fps } if (layers) startOpts.layers = layers if (lockstep) startOpts.lockstep = true const result: { ok: boolean; error?: string } = await bridge.send({ type: 'evaluate', code: `window.__sootsimRecorder.start(${JSON.stringify(startOpts)})`, }) if (!result.ok) { console.error(` start failed: ${result.error ?? 'unknown error'}`) rnxExit(1) } } writeRecordingState({ simId: parsed.simId ?? null, mode, format, fps, layers, startedAt: new Date().toISOString(), }) if (mode === 'video') { console.log( ` recording ${format} @ ${fps}fps${layers ? ` (${layers})` : ''} — run \`rnx record stop --output \` when done`, ) } else { console.log( ` recording ${mode} — run \`rnx record stop\` when done (add --open to launch the preview URL)`, ) } } finally { bridge.close() } } async function recordPreludeStart(args: string[], opts: RecordOptions) { const existing = readRecordingState() if (existing) { console.error( ` recording already in progress (started ${existing.startedAt}, sim ${existing.simId ?? '?'}). run \`rnx record stop\` first, or \`rnx record cancel\` to discard.`, ) rnxExit(1) } const parsed = parseBridgeCliArgs(args, { port: opts.port, stripBooleanFlags: [], stripValueFlags: ['--origin'], }) const bridge = createBridgeFromParsed({ ...parsed, commandTimeoutMs: 15_000 }) try { await assertStoreBridgesAvailable(bridge) await assertTabVisibleForCapture(bridge) const ok: boolean = await bridge.send({ type: 'evaluate', code: `(() => { const start = window.SootSim?.bridges?.startRecordingPrelude if (typeof start !== 'function') return false return Promise.resolve(start()).then((value) => value === true) })()`, }) if (!ok) { const reason = await bridge .send({ type: 'evaluate', code: `(() => { const getError = window.SootSim?.bridges?.getRecordingStartError return typeof getError === 'function' ? getError() : null })()`, }) .catch(() => null) console.error( ` prelude start failed: recording store refused to start${ typeof reason === 'string' && reason ? ` (${reason})` : '' }`, ) rnxExit(1) } console.log( ' recording preview prelude — run `rnx record start --mode combined` when the visible proof segment is ready', ) } finally { bridge.close() } } // query the engine for the *actual* recording state. the local state file // only tracks `record start` sessions — an atomic `record --duration` run // or the in-browser record button records without writing it. probing the // engine lets `status`/`cancel` report the truth instead of lying "no // recording in progress" while one is plainly running (F52). // // two independent recorder surfaces exist and must both be checked: // - the recording store (`SootSim.bridges.getRecordingState`) drives // live/combined preview-share recordings // - the headless recorder (`__sootsimRecorder`) drives atomic video/gif/ // png capture — its state lives on a separate `active` flag // - the event recorder alone drives hidden PR-preview preludes before a // visible live/combined segment exists // returns null when the engine can't be reached. type EngineRecordingState = 'idle' | 'recording' | 'processing' | 'prelude' async function probeEngineRecordingState( bridge: WsBridge, ): Promise { try { const snap: { store?: string; headless?: string; eventRecording?: boolean } | null = await bridge.send({ type: 'evaluate', code: `(() => { const store = window.SootSim?.bridges?.getRecordingState?.(); const headless = window.__sootsimRecorder?.state?.(); const eventRecorder = window.SootSim?.bridges?.eventRecorder ?? window.__sootsimEventRecorder; const eventRecording = typeof eventRecorder?.isRecording === 'function' ? eventRecorder.isRecording() === true : false; return { store: store ? store.state : undefined, headless, eventRecording }; })()`, }) if (!snap) return null if (snap.store === 'recording' || snap.headless === 'recording') return 'recording' if (snap.store === 'processing') return 'processing' if (snap.eventRecording) return 'prelude' if (snap.store === 'idle' || snap.headless === 'idle') return 'idle' return null } catch { return null } } async function cancelEngineRecording(bridge: WsBridge): Promise<{ store: boolean headless: boolean }> { const storeCanceled: boolean = await bridge .send({ type: 'evaluate', code: `(() => { const store = window.SootSim?.bridges?.getRecordingState?.(); const eventRecorder = window.SootSim?.bridges?.eventRecorder ?? window.__sootsimEventRecorder; const hadStoreRecording = store && store.state !== 'idle'; const hadEventRecording = typeof eventRecorder?.isRecording === 'function' ? eventRecorder.isRecording() === true : false; const cancel = window.SootSim?.bridges?.cancelRecording; if (typeof cancel === 'function') cancel(); return Boolean(hadStoreRecording || hadEventRecording); })()`, }) .catch(() => false) const headlessCanceled: boolean = await bridge .send({ type: 'evaluate', code: `(async () => { const r = window.__sootsimRecorder; if (r && typeof r.forceRelease === 'function') return await r.forceRelease(); return false; })()`, }) .catch(() => false) return { store: storeCanceled, headless: headlessCanceled } } async function recordStatus(args: string[], opts: RecordOptions) { const state = readRecordingState() if (state) { noteSimMismatch(parseBridgeCliArgs(args, { port: opts.port }).simId, state) if (state.mode === 'video') { console.log( ` recording ${state.mode} (${state.format} @ ${state.fps}fps) on sim ${state.simId ?? '?'} since ${state.startedAt}`, ) } else { console.log( ` recording ${state.mode} on sim ${state.simId ?? '?'} since ${state.startedAt}`, ) } return } // no `record start` session — but an atomic run or the rail button may // still be recording. ask the engine before claiming nothing is happening. const parsed = parseBridgeCliArgs(args, { port: opts.port }) const bridge = createBridgeFromParsed({ ...parsed, commandTimeoutMs: 8_000 }) try { const engineState = await probeEngineRecordingState(bridge) if ( engineState === 'recording' || engineState === 'processing' || engineState === 'prelude' ) { if (engineState === 'prelude') { console.log( ' a recording preview prelude is active. hidden setup is being\n' + ' captured for the next live/combined preview recording. finish\n' + ' it with `rnx record start --mode combined`, or discard it\n' + ' with `rnx record cancel`.', ) return } console.log( ` a recording is in progress (engine state: ${engineState}), but not via\n` + ' `record start` — it was started by an atomic `record --duration`\n' + ' run or the in-browser record button, which own their own lifecycle.\n' + ' if that controlling run is still alive it will finish on its own\n' + ' (atomic `--mode video/gif/png` writes a local file from that\n' + ' process; live/combined upload to /preview/). if the run was\n' + ' killed, the recording is abandoned and will NOT finish — reclaim\n' + ' it now with `rnx record cancel`, or it auto-reclaims when the\n' + " abandoned capture's deadline lapses.", ) } else { console.log(' no recording in progress') } } finally { bridge.close() } } async function recordCancel(args: string[], opts: RecordOptions) { const state = readRecordingState() if (!state) { // no `record start` session to cancel — but check the engine so we // don't falsely claim nothing is happening while an atomic run records. const probeParsed = parseBridgeCliArgs(args, { port: opts.port }) const probeBridge = createBridgeFromParsed({ ...probeParsed, commandTimeoutMs: 8_000, }) try { const engineState = await probeEngineRecordingState(probeBridge) if ( engineState === 'recording' || engineState === 'processing' || engineState === 'prelude' ) { // `record cancel` is explicit intent: the caller wants whatever is // recording gone. force-reclaim the headless/store recorder rather // than refusing and telling them to wait out the deadline (QA // F22-1). this also un-wedges live/combined, which the killed // atomic `--mode video` bitmap lock was blocking. const reclaimed = await cancelEngineRecording(probeBridge) if (reclaimed.store && engineState === 'prelude') { console.log(' recording preview prelude cancelled') } else if (reclaimed.store || reclaimed.headless) { console.log( ' reclaimed an abandoned recording (started by an atomic\n' + ' `record --duration` run or the record button whose owner is\n' + ' gone). the recorder is idle again — live/combined can start.', ) } else { console.log( ' a recording is in progress and still owned by a live atomic\n' + ' `record --duration` run or the in-browser record button — it\n' + ' will finish on its own and nothing was reclaimed.', ) } } else { console.log(' no recording in progress') } } finally { probeBridge.close() } return } const parsed = parseBridgeCliArgs(args, { port: opts.port }) noteSimMismatch(parsed.simId, state) // the recorder (and its buffered blob) lives in the page of the sim the // recording was *started* on — `state.simId` wins over the requested // --sim, otherwise cancel would stop an idle recorder on the wrong sim // and leave the real recording running (F11-1). const simId = state.simId ?? parsed.simId ?? undefined const bridge = createBridgeFromParsed({ ...parsed, simId, commandTimeoutMs: 15_000, }) try { if (state.mode === 'live' || state.mode === 'combined') { await bridge.send({ type: 'evaluate', code: `void window.SootSim?.bridges?.cancelRecording?.()`, }) } else { await bridge.send({ type: 'evaluate', code: `window.__sootsimRecorder.stop()`, }) } } catch { // best-effort — always clear local state } finally { clearRecordingState() bridge.close() } console.log(' recording cancelled') } async function recordStop(args: string[], opts: RecordOptions) { const state = readRecordingState() if (!state) { console.error(' no recording in progress. start one with `rnx record start`.') rnxExit(1) } const parsed = parseBridgeCliArgs(args, { port: opts.port, stripBooleanFlags: ['--open'], stripValueFlags: ['--output'], }) noteSimMismatch(parsed.simId, state) // the recorder + buffered blob live in the sim the recording was started // on — route there, not to the requested --sim, or stop would drain an // idle recorder on the wrong sim and lose the capture (F11-1). const simId = state.simId ?? parsed.simId ?? undefined const openAfter = args.includes('--open') const bridge = createBridgeFromParsed({ ...parsed, simId, commandTimeoutMs: state.mode === 'live' || state.mode === 'combined' ? UPLOAD_TIMEOUT_MS : 60_000, }) try { if (state.mode === 'live' || state.mode === 'combined') { await evalStoreStop(bridge) const result = await pollStoreUpload(bridge) clearRecordingState() handleStoreUploadResult(result, openAfter, state.mode) return } const outputArg = valueOf(args, '--output') const out = resolveRecordingOutput(outputArg, `rnx-${stamp()}.${state.format}`) mkdirSync(dirname(out), { recursive: true }) const stopResult: { ok: boolean error?: string size?: number mime?: string durationMs?: number frameCount?: number } = await bridge.send({ type: 'evaluate', code: `window.__sootsimRecorder.stop()`, }) if (!stopResult.ok) { console.error(` stop failed: ${stopResult.error ?? 'unknown error'}`) clearRecordingState() rnxExit(1) } if (!stopResult.size) { console.error(' recorder returned an empty blob — nothing written') clearRecordingState() rnxExit(1) } await downloadBlob(bridge, out) clearRecordingState() console.log(` saved: ${out} (${formatBytes(stopResult.size)})`) if (stopResult.durationMs && stopResult.frameCount !== undefined) { console.log( ` source frames: ${stopResult.frameCount} over ${(stopResult.durationMs / 1000).toFixed(2)}s ` + `(${(stopResult.frameCount / (stopResult.durationMs / 1000)).toFixed(1)}fps)`, ) } } finally { bridge.close() } } // ─── store-backed recording helpers (live / combined) ───────────────────── async function assertStoreBridgesAvailable(bridge: WsBridge) { const ok = await probeSim( bridge, 'typeof window.SootSim?.bridges?.startRecording === "function" && typeof window.SootSim?.bridges?.stopRecording === "function"', ) if (!ok) { console.error( ' SootSim.bridges.startRecording missing — is rnx engine running in this sim?', ) rnxExit(1) } } // CLI-driven live/combined recording runs in a headless browser that was // never logged in, so browser-side upload has no bearer. push the CLI's own // auth — the same RNX_API_KEY / `rnx login` / GitHub token // ensureCliRecordingEntitlement just verified — into the page's shared-session // store before starting. GitHub PR-preview auth also needs its repo identity // in the upload init body so the server treats the bearer as a GitHub upload // rather than a contrast user session. // combined/live recordings upload the guest bundle to /preview/ so the // page can replay the app. a built-in shell app (photos, settings, the home // grid) has no guest bundle — `uploadLivePreview` only discovers this at the // END and throws "no bundle loaded" *after* the full timed recording already // ran (QA F19-3). probe the same `__sootsimCaptureBundle` snapshot the upload // uses, up front, and fail fast with an actionable message instead. async function assertGuestBundleLoaded(bridge: WsBridge, mode: RecordMode) { const hasBundle = await probeSim( bridge, `(() => { const fn = window.__sootsimCaptureBundle if (typeof fn !== 'function') return false const snap = fn() return !!(snap && snap.bundleUrl) })()`, ) if (!hasBundle) { console.error( ` --mode ${mode} needs a guest app bundle to upload to /preview/,\n` + ` but this sim has no bundle loaded (a built-in shell screen like\n` + ` photos/settings/home has none). open a metro/guest app first, or\n` + ` use \`--mode video\` for a local screen recording with no upload.`, ) rnxExit(1) } } async function injectPreviewSessionAuth( bridge: WsBridge, originOverride?: string, repoScope?: RepoScope, ): Promise { const auth = resolveCliAuth() // ensureCliRecordingEntitlement already exits the process when auth is // missing — this guard is purely defensive. if (!auth) return const token = auth.kind === 'api-key' ? auth.secret : auth.token const provenance = resolveRunProvenance() const gitContext = { branch: provenance.branch ?? undefined, commitSha: provenance.commitSha ?? undefined, githubUsername: provenance.githubUsername ?? undefined, pullRequestNumber: provenance.pullRequestNumber ?? undefined, pullRequestTitle: provenance.pullRequestTitle ?? undefined, } const uploadIdentity = auth.kind === 'github' ? { ...githubUploadIdentity(auth), owner: repoScope?.owner, repo: repoScope?.repo, ...gitContext, } : repoScope ? { owner: repoScope.owner, repo: repoScope.repo, ...gitContext, } : null const uploadOrigin = originOverride ? originOverride.replace(/\/$/, '') : null const ok: boolean = await bridge.send({ type: 'evaluate', code: `(() => { const set = window.SootSim && window.SootSim.bridges && window.SootSim.bridges.setSession if (typeof set !== 'function') return false set({ token: ${JSON.stringify(token)}, user: null }) window.__sootsimPreviewUploadIdentity = ${JSON.stringify(uploadIdentity)} window.__sootsimPreviewUseInjectedBearer = true ${ uploadOrigin ? `window.__sootsimUploadOrigin = ${JSON.stringify(uploadOrigin)}` : 'delete window.__sootsimUploadOrigin' } return true })()`, }) if (!ok) { // the usual cause is a stale tab — a sim that connected before the // engine was rebuilt keeps the old bundle until it reloads. an actually // outdated install is the rarer case, so lead with the cheap fix. console.error( ' SootSim.bridges.setSession missing — this sim is running an engine\n' + ' build without CLI-injected preview auth. reload the sim or open a\n' + ' fresh one (`rnx open --new`); if it persists, update rnx.', ) rnxExit(1) } } async function evalStoreStart( bridge: WsBridge, mode: RecordMode, layers?: 'tenant' | 'shell', ): Promise<{ ok: boolean; error: string | null }> { // the CLI already made the entitlement/auth decision before entering the // engine. pass that explicit fact through so GitHub PR-preview auth is not // rejected by the browser-session billing gate. const layersArg = layers ? JSON.stringify(layers) : 'undefined' const ok: boolean = await bridge.send({ type: 'evaluate', code: `window.SootSim.bridges.startRecording(${JSON.stringify(mode)}, ${layersArg}, { skipEntitlement: true })`, }) if (ok === true) return { ok: true, error: null } // the engine knows exactly why it refused — read the structured reason // back so the operator (and the PR-preview agent) sees it instead of an // opaque "recording store refused to start". const error: string | null = await bridge .send({ type: 'evaluate', code: `(window.SootSim?.bridges?.getRecordingStartError?.() ?? null)`, }) .catch(() => null) return { ok: false, error: error ?? null } } async function evalStoreStop(bridge: WsBridge): Promise { await bridge.send({ type: 'evaluate', code: `void window.SootSim.bridges.stopRecording()`, }) } interface StoreUploadResult { previewUrl?: string uploadError?: string eventCount?: number } async function pollStoreUpload(bridge: WsBridge): Promise { const deadline = Date.now() + UPLOAD_TIMEOUT_MS while (Date.now() < deadline) { const snap: { state: 'idle' | 'recording' | 'processing' lastUpload: { previewUrl?: string; eventCount?: number } | null uploadError: string | null } | null = await bridge.send({ type: 'evaluate', code: `(() => { const s = window.SootSim?.bridges?.getRecordingState?.(); return s ? { state: s.state, lastUpload: s.lastUpload, uploadError: s.uploadError } : null })()`, }) if (snap && snap.state === 'idle') { if (snap.uploadError) return { uploadError: snap.uploadError } if (snap.lastUpload?.previewUrl) { return { previewUrl: snap.lastUpload.previewUrl, eventCount: snap.lastUpload.eventCount, } } } await new Promise((r) => setTimeout(r, 300)) } return { uploadError: `upload did not settle within ${UPLOAD_TIMEOUT_MS / 1000}s` } } function handleStoreUploadResult( result: StoreUploadResult, openAfter: boolean, mode: RecordMode, ) { if (result.uploadError) { console.error(` upload failed: ${result.uploadError}`) rnxExit(1) } if (!result.previewUrl) { console.error(' upload returned no preview URL') rnxExit(1) } console.log(` preview: ${result.previewUrl}`) // the preview id is content-addressed (shareId = hash of the bundle), so // re-recording the *same* app bundle deliberately reuses this URL and // replaces the prior capture there — this is how stable demo shares work. // it's intentional, but it was silent: a quick throwaway re-record could // clobber a good earlier capture at a URL someone already shared, with no // warning (QA F19-4). say so once so the reuse is a known choice. console.log( ' note: this URL is content-addressed — re-recording the same bundle\n' + ' replaces this capture at the same /preview/.', ) // surface how many interaction events were captured (F14-7). without this // the CLI gave no signal that an event-capture regression shipped a stale // or empty stream — the only tell was opening the preview and watching // replay come up short. if ((mode === 'live' || mode === 'combined') && typeof result.eventCount === 'number') { console.log( ` captured ${result.eventCount} event${result.eventCount === 1 ? '' : 's'}`, ) } // a recording with no captured events uploads a valid share, but the // preview page's Live tab has nothing to replay — it just boots a fresh // interactive session, which reads as a broken link. say so rather than // letting the user discover it after sharing (F50). combined recordings // still have a playable Video tab, but Live has no action stream. if ((mode === 'live' || mode === 'combined') && result.eventCount === 0) { if (mode === 'combined') { console.log( ' note: this combined recording captured no events — the Live tab has\n' + ' nothing to replay. Video will play the captured screen recording.', ) } else { console.log( ' note: this live recording captured no events — the preview has nothing\n' + ' to replay and will boot a fresh interactive session instead.', ) } } if (openAfter) { void openUrl(result.previewUrl) } }