// shared helpers for inspect subcommands. // // pulled out of the monolithic inspect.ts so each subcommand can live in its // own file without reaching back into a 4000-line switch. everything here is // call-agnostic: just bridge-facing helpers that any subcommand can use. import { callInBridge, callInBridgeWrite, createBridge, type ParsedBridgeCliArgs, type WsBridge, } from '../../ws-bridge' import { getShellState, inspectWaitReady, isShellCommandUnavailable } from './core' import type { SootSimScreenTransitionWaitResult } from '@rnx/globals' // shell-state helpers live in the transport-agnostic kernel module so the CLI // and the in-browser agent share one implementation. re-exported here so // existing subcommand imports (`./shared`) keep working. export { getShellState, isShellCommandUnavailable } export const SCREEN_TRANSITION_PREFLIGHT = { timeoutMs: 1800, settleMs: 48, startWindowMs: 64, } as const export function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) } // true when the caller passed --json. read verbs use this to route their // payload to stdout-as-json and send everything else (headers, hints, // warnings) to stderr so `jq` on stdout never sees framing noise. export function wantsJson(args: string[]): boolean { return args.includes('--json') } // emit a stable JSON document on stdout. no trailing prose, no ANSI, // no colors. the 2-space indent matches the rest of the CLI and is // cheap enough that pretty-printing by default is fine for bisect // scripts — consumers who care pipe through `jq -c`. export function printJson(payload: unknown): void { process.stdout.write(`${JSON.stringify(payload ?? null, null, 2)}\n`) } // warnings, hints, "screen transition still active" messages — anything // that isn't the payload. always goes to stderr so stdout stays parseable. export function printWarn(msg: string): void { process.stderr.write(`${msg}\n`) } // 24-hour wall-clock + relative offset for log/error/network timestamps. // agents reading these care most about "is this new or stale" — append // "+s" for things younger than 60s. older entries keep the relative // stamp in m/h/d so it's still scannable without diffing wall-clock times. // // returns "HH:MM:SS +Xs" where wall clock is locale-independent 24-hour. // callers can wrap it in their own padding/dim ANSI. export function formatLogTimestamp(ts: number, nowMs: number = Date.now()): string { const d = new Date(ts) const wall = `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}` const deltaMs = nowMs - ts if (deltaMs < 0) return wall const sec = Math.round(deltaMs / 1000) if (sec < 60) return `${wall} +${sec}s` if (sec < 3600) return `${wall} +${Math.round(sec / 60)}m` if (sec < 86400) return `${wall} +${Math.round(sec / 3600)}h` return `${wall} +${Math.round(sec / 86400)}d` } function pad2(n: number): string { return n < 10 ? `0${n}` : String(n) } export async function callTestBridge( bridge: WsBridge, method: string, ...args: unknown[] ): Promise { return callInBridge(bridge, `__sootsimTest.${method}`, ...args) } export async function callShellCommand( bridge: WsBridge, method: 'launchApp' | 'goHome' | 'openSwitcher' | 'openSwitcherCard', ...args: unknown[] ): Promise { return callInBridgeWrite(bridge, `SootSim.bridges.mainShell.${method}`, ...args) } export async function maybeWaitForStartedScreenTransitions( bridge: WsBridge, opts: { verbose?: boolean } = {}, ) { if (bridge.plane === 'cloud') return try { const result = await callTestBridge( bridge, 'waitForScreenTransitions', SCREEN_TRANSITION_PREFLIGHT, ) if (!opts.verbose || !result?.started) return if (result.timedOut) { console.log( ` screen transition still active after ${result.waitedMs}ms; continuing`, ) return } if (result.waitedMs > 0) { console.log(` waited ${result.waitedMs}ms for screen transition settle`) } } catch { // best-effort preflight only — older runtimes or boot races should not // block the actual command. } } export async function callShellCommandWhenReady( bridge: WsBridge, method: 'launchApp' | 'goHome' | 'openSwitcher' | 'openSwitcherCard', readyTimeoutMs: number, ...args: unknown[] ): Promise { const deadline = Date.now() + Math.max(0, readyTimeoutMs) while (true) { try { return await callShellCommand(bridge, method, ...args) } catch (error) { if (!isShellCommandUnavailable(error) || Date.now() >= deadline) throw error await sleep(50) } } } export async function waitForSimReady( wsPort: number, commandTimeoutMs: number, simId?: string, opts: { attempts?: number intervalMs?: number minNodeCount?: number simIdSource?: ParsedBridgeCliArgs['simIdSource'] } = {}, ) { const attempts = opts.attempts ?? 30 const intervalMs = opts.intervalMs ?? 500 const minNodeCount = opts.minNodeCount ?? 10 for (let i = 0; i < attempts; i++) { const bridge = createBridge(wsPort, { commandTimeoutMs, simId, simIdSource: opts.simIdSource, }) try { const count = await bridge.send({ type: 'evaluate', code: '(async () => (await window.__sootsimTest?.getNodeCount()) || 0)()', }) if (typeof count === 'number' && count > minNodeCount) { return { bridge, count } } } catch { // still loading or disconnected } bridge.close() await sleep(intervalMs) } return null } // waits until a bridge can evaluate a trivial expression. used by the reload // full-page path after window.location.reload() to re-establish a bridge // before handing off to pollForReloadReady. intentionally does not gate on // node count — that check belongs in the subsequent ready poll, which uses // the __sootsimExternalAppReady flag as its primary signal. export async function waitForBridgeConnected( wsPort: number, commandTimeoutMs: number, simId: string | undefined, opts: { timeoutMs?: number intervalMs?: number simIdSource?: ParsedBridgeCliArgs['simIdSource'] } = {}, ): Promise { const timeoutMs = opts.timeoutMs ?? 8000 const intervalMs = opts.intervalMs ?? 250 const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { const bridge = createBridge(wsPort, { commandTimeoutMs, simId, simIdSource: opts.simIdSource, }) try { await bridge.send({ type: 'evaluate', code: '1' }) return bridge } catch { bridge.close() } await sleep(intervalMs) } return null } export interface ReloadReadyResult { ready: boolean source: 'flag' | 'nodes-fallback' | 'error-bail' | 'timeout' elapsedMs: number nodes: number targets: number liveFrameActive: boolean liveFrameChannels: number liveFramePublishes: number flag: unknown loadingText: string externalReady: boolean | null externalStatus: string externalError: string errors: number } // post-reload readiness is the same engine-owned condition as `rnx wait ready`. // keeping reload on that primitive prevents another polling implementation // from drifting on loading text, live frames, or tree stability. export async function pollForReloadReady( bridge: WsBridge, opts: { timeoutMs?: number } = {}, ): Promise { const timeoutMs = opts.timeoutMs ?? 10000 const status = await inspectWaitReady(bridge, timeoutMs) const source = status.ready ? status.flag === true ? 'flag' : 'nodes-fallback' : status.errors > 0 && status.flag !== true ? 'error-bail' : 'timeout' return { ready: status.ready, source, elapsedMs: status.elapsedMs, nodes: status.nodes, targets: status.targets, liveFrameActive: status.liveFrameActive, liveFrameChannels: status.liveFrameChannels, liveFramePublishes: status.liveFramePublishes, flag: status.flag, loadingText: status.loadingText, externalReady: status.externalReady, externalStatus: status.externalStatus, externalError: status.externalError, errors: status.errors, } }