// rnx get diagnosis — composed read commands for "what just went wrong?" // // the demo-friendly compact diagnostic surface called out in // plans/expensify-sootsim-agent-device-demo.md. one command, one round // of bridge calls, one paste-able output that beats the agent-device // sed/grep workflow in both speed and information density. // // usage: // rnx get diagnosis [--since 90s] [--include react,network,console,screen,fetch,alert] // rnx get diagnosis --json // // the output is grouped by source (timeline, network, react, console) // rather than chronological. for chronological order use // `rnx what-happened` directly. `recent` is the only subcommand // today; `diagnose ` may be added later (e.g. boot, // scrolling-jank, idle-burn). // // design note: every dimension here is a single bridge call against an // existing primitive. this command exists to remove the agent's tax of // chaining commands and reformatting four output styles, not to add a // new data source. when a new data source becomes worth surfacing in a // "diagnose" pass, add it as one more parallel call here. import { getCliIdentityKey } from '../current-sim' import { callInBridge, createBridgeFromParsed, parseBridgeCliArgs, type WsBridge, } from '../ws-bridge' import type { SootSimTimelineEvent, SootSimTimelineQuery, SootSimTimelineQueryResult, } from '@rnx/globals' interface DiagnoseOptions { port?: number verbose?: boolean } interface NetworkEntry { id: string url: string displayUrl?: string method?: string status?: number | null durationMs?: number | null error?: string | null // observability store uses startTs (epoch ms) as the entry's start // time. accept either name for forward-compat with any future // rename. startTs?: number startedAt?: number responseAt?: number | null responseSize?: number | null size?: number | null } interface SlowRow { displayName: string totalMs: number avgMs: number commitCount: number slowestCommit: number slowestMs: number } interface RerenderRow { displayName: string count?: number renders?: number causes?: Record } interface LogEntry { level: string // observability ring uses `ts` (epoch ms) and `args` (string[]). // older shapes had `t` + `message`. accept both. ts?: number t?: number args?: unknown[] message?: string source?: string context?: string } function logTs(e: LogEntry): number { return e.ts ?? e.t ?? 0 } function logMessage(e: LogEntry): string { if (typeof e.message === 'string' && e.message.length > 0) return e.message if (Array.isArray(e.args)) { return e.args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ') } return '' } const DEFAULT_INCLUDES = new Set(['timeline', 'network', 'react', 'console', 'screen']) function printHelp(): void { console.log(` rnx get diagnosis — compact "what just went wrong?" report usage: rnx get diagnosis [--since ] [--include ] [--json] options: --since absolute window for timeline + network (e.g. 90s, 2m, 500ms; default 90s) --include comma-separated dimensions: timeline, network, react, console, screen (default: all) --slow-threshold network slow-request threshold in ms (default 1000) --limit rows to print per react table (default 8) --json emit a single structured JSON document the diagnose report runs every dimension in parallel against the same sim and returns a grouped summary. use \`rnx what-happened\` for chronological events, \`rnx network\` for full request inspection, \`rnx perf react summary\` for the React tables on their own. examples: rnx get diagnosis rnx get diagnosis --since 30s --include network,react rnx get diagnosis --json > /tmp/diagnosis.json `) } function parseSinceMs(value: string | undefined, fallback: number): number { if (!value) return fallback const m = /^(\d+(?:\.\d+)?)(ms|s|m)?$/.exec(value.trim()) if (!m) return fallback const n = Number(m[1]) const unit = m[2] ?? 'ms' return unit === 's' ? n * 1000 : unit === 'm' ? n * 60_000 : n } function parseCsv(value: string | undefined): Set { if (!value) return DEFAULT_INCLUDES const out = new Set() for (const raw of value.split(',')) { const trimmed = raw.trim().toLowerCase() if (trimmed) out.add(trimmed) } return out } function valueOf(args: string[], flag: string): string | undefined { const idx = args.indexOf(flag) if (idx >= 0 && idx + 1 < args.length) return args[idx + 1] const prefix = `${flag}=` return args.find((a) => a.startsWith(prefix))?.slice(prefix.length) } function numberFlag(args: string[], flag: string, fallback: number): number { const raw = valueOf(args, flag) if (raw === undefined) return fallback const n = Number(raw) return Number.isFinite(n) && n > 0 ? Math.round(n) : fallback } interface TimelineSlice { events: SootSimTimelineEvent[] byKind: Record total: number errors: SootSimTimelineEvent[] alerts: SootSimTimelineEvent[] screens: SootSimTimelineEvent[] } async function fetchTimeline(bridge: WsBridge, sinceMs: number): Promise { const query: SootSimTimelineQuery = { limit: 500, since: Date.now() - sinceMs, } const result = await callInBridge( bridge, 'SootSim.bridges.timeline.recent', query, ) const events = result.events ?? [] const byKind: Record = {} const errors: SootSimTimelineEvent[] = [] const alerts: SootSimTimelineEvent[] = [] const screens: SootSimTimelineEvent[] = [] for (const ev of events) { byKind[ev.kind] = (byKind[ev.kind] ?? 0) + 1 const data = ev.data as Record | null const level = data && typeof data === 'object' ? (data.level as string) : undefined if (ev.kind === 'console' && (level === 'error' || level === 'warn')) { errors.push(ev) } if (ev.kind === 'alert' || ev.kind === 'actionsheet' || ev.kind === 'picker') { alerts.push(ev) } if (ev.kind === 'screen' || ev.kind === 'route') { screens.push(ev) } } return { events, byKind, total: events.length, errors, alerts, screens, } } interface NetworkSlice { total: number failed: NetworkEntry[] slow: NetworkEntry[] inFlight: number slowThresholdMs: number } async function fetchNetwork( bridge: WsBridge, sinceMs: number, slowThresholdMs: number, ): Promise { // re-uses the observability store the existing `network` command // reads from. local filtering keeps this command independent of any // new bridge surface and lets it run against today's engine. const result = (await bridge.send({ type: 'evaluate', code: `(() => { const obs = window.__sootsimObservability; if (!obs) return { ok: false }; return { ok: true, entries: obs.network.getSnapshot() }; })()`, })) as { ok: boolean; entries?: NetworkEntry[] } if (!result || !result.ok) { return { error: 'observability bridge not installed' } } const all = result.entries ?? [] const cutoff = Date.now() - sinceMs // observability ring uses startTs; older shapes used startedAt. accept // both so this command works against both shapes without a feature // detection round-trip. const inWindow = all.filter((e) => (e.startTs ?? e.startedAt ?? 0) >= cutoff) const failed = inWindow.filter( (e) => !!e.error || (e.status != null && e.status >= 400), ) const slow = inWindow .filter((e) => e.durationMs != null && e.durationMs >= slowThresholdMs) .sort((a, b) => (b.durationMs ?? 0) - (a.durationMs ?? 0)) const inFlight = inWindow.filter((e) => e.durationMs == null).length return { total: inWindow.length, failed, slow, inFlight, slowThresholdMs, } } interface ReactSlice { commits: number slow: SlowRow[] rerenders: RerenderRow[] durationWarning?: string error?: string } async function fetchReact(bridge: WsBridge, limit: number): Promise { const [slowResult, rerendersResult] = (await Promise.all([ callInBridge(bridge, 'SootSim.bridges.reactProfile.slow', { limit }), callInBridge(bridge, 'SootSim.bridges.reactProfile.rerenders', { limit }), ])) as [ { rows?: SlowRow[] commits?: number error?: string durationWarning?: string }, { rows?: RerenderRow[]; commits?: number; error?: string }, ] return { commits: slowResult.commits ?? rerendersResult.commits ?? 0, slow: slowResult.rows ?? [], rerenders: rerendersResult.rows ?? [], durationWarning: slowResult.durationWarning, error: slowResult.error ?? rerendersResult.error, } } async function fetchConsoleErrors( bridge: WsBridge, sinceMs: number, ): Promise<{ entries: LogEntry[]; total: number } | { error: string }> { const result = (await bridge.send({ type: 'evaluate', code: `(() => { const obs = window.__sootsimObservability; if (!obs) return { ok: false }; return { ok: true, entries: obs.logs.getSnapshot() }; })()`, })) as { ok: boolean; entries?: LogEntry[] } if (!result || !result.ok) { return { error: 'observability bridge not installed' } } const cutoff = Date.now() - sinceMs const all = result.entries ?? [] const errs = all.filter( (e) => logTs(e) >= cutoff && (e.level === 'error' || e.level === 'warn'), ) return { entries: errs.slice(-20), total: errs.length } } function formatNetworkRow(entry: NetworkEntry): string { const status = entry.error ? 'err' : entry.status != null ? String(entry.status) : '...' const method = (entry.method ?? 'GET').padEnd(6) const dur = entry.durationMs != null ? `${entry.durationMs.toFixed(0)}ms` : '...' const url = entry.displayUrl ?? entry.url // shorten long urls to 96 chars in the diagnose report — full // detail is one `rnx network get ` away. const shortUrl = url.length > 96 ? `${url.slice(0, 92)}…` : url return ` ${status.padStart(3)} ${method} ${dur.padStart(7)} ${shortUrl} (${entry.id})` } function formatTimelineRow(ev: SootSimTimelineEvent, anchorMs: number): string { const dt = ((ev.t - anchorMs) / 1000).toFixed(2) const sign = ev.t >= anchorMs ? '-' : '+' const data = ev.data as Record | null let payload = '' if (data && typeof data === 'object') { const message = (data.message ?? '').toString().slice(0, 120) const title = (data.title ?? data.name ?? data.path ?? data.url ?? '').toString() const phase = (data.phase ?? '').toString() payload = [phase, title, message].filter(Boolean).join(' · ') } return ` -${dt.padStart(5)}s [${ev.kind}] ${payload}` } function formatTable(headers: string[], rows: string[][], indent: string): string { if (rows.length === 0) return `${indent}(no rows)` const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((row) => row[i]?.length ?? 0)), ) const line = (cells: string[]) => cells .map((cell, i) => cell.padEnd(widths[i])) .join(' ') .trimEnd() return [ line(headers), line(headers.map((h) => '-'.repeat(h.length))), ...rows.map(line), ] .map((row) => `${indent}${row}`) .join('\n') } export async function runDiagnose( args: string[], opts: DiagnoseOptions, ): Promise { if (args.includes('--help') || args.includes('-h')) { printHelp() return 0 } const parsed = parseBridgeCliArgs(args, { port: opts.port, stripBooleanFlags: ['--json', '--help', '-h'], stripValueFlags: ['--since', '--include', '--limit', '--slow-threshold'], }) const sub = parsed.positional[0] ?? 'recent' if (sub !== 'recent') { console.error(` unknown subcommand: ${sub}`) console.error(' did you mean: rnx get diagnosis ?') return 1 } const sinceMs = parseSinceMs(valueOf(args, '--since'), 90_000) const include = parseCsv(valueOf(args, '--include')) const reactLimit = numberFlag(args, '--limit', 8) const slowThresholdMs = numberFlag(args, '--slow-threshold', 1000) const wantsJson = args.includes('--json') const bridge = createBridgeFromParsed(parsed) try { // run every requested dimension in parallel — one bridge round-trip // per primitive, no chained CLI invocations. on a healthy sim // this finishes in a single tick. const tasks: Array> = [] const ordered: string[] = [] if (include.has('timeline') || include.has('screen') || include.has('alert')) { ordered.push('timeline') tasks.push(fetchTimeline(bridge, sinceMs)) } if (include.has('network') || include.has('fetch')) { ordered.push('network') tasks.push(fetchNetwork(bridge, sinceMs, slowThresholdMs)) } if (include.has('react')) { ordered.push('react') tasks.push(fetchReact(bridge, reactLimit)) } if (include.has('console')) { ordered.push('console') tasks.push(fetchConsoleErrors(bridge, sinceMs)) } // always probe the engine build id (one cheap eval). a reused sim // session on a stale engine silently produced false QA verdicts // (QA F20-5); surfacing the build here makes the staleness checkable. const engineBuild = await bridge .send({ type: 'evaluate', code: `globalThis.__sootsimEngineBuild ?? null`, }) .then((v) => (typeof v === 'string' ? v : null)) .catch(() => null) const results = await Promise.all(tasks) const slices: Record = {} for (let i = 0; i < ordered.length; i++) slices[ordered[i]] = results[i] if (wantsJson) { console.log( JSON.stringify( { generatedAt: Date.now(), engineBuild, sinceMs, include: Array.from(include), slices, }, null, 2, ), ) return 0 } // human render — one section per slice. terse, scannable, demo-paste-friendly. const now = Date.now() console.log( ` diagnose recent (last ${(sinceMs / 1000).toFixed(0)}s, include: ${Array.from(include).sort().join(',')}):`, ) console.log( ` engine build: ${engineBuild ?? 'unknown (no build stamp — likely a stale or pre-F20-5 engine)'}`, ) if (slices.timeline) { const t = slices.timeline as TimelineSlice console.log(`\n timeline — ${t.total} event(s) in window`) const summary = Object.entries(t.byKind) .sort((a, b) => b[1] - a[1]) .map(([k, n]) => `${n} ${k}`) .join(' · ') if (summary) console.log(` summary: ${summary}`) if (t.screens.length) { const last = t.screens[t.screens.length - 1] const data = last.data as Record | null const name = data ? (data.name ?? data.activeName ?? data.path ?? '?') : '?' console.log(` last screen: ${String(name)}`) } if (t.alerts.length) { console.log(` alerts (${t.alerts.length}):`) for (const a of t.alerts.slice(-5)) { console.log(formatTimelineRow(a, now)) } } if (t.errors.length) { console.log(` console errors/warns from timeline (${t.errors.length}):`) for (const e of t.errors.slice(-5)) { console.log(formatTimelineRow(e, now)) } } } if (slices.network) { const n = slices.network as NetworkSlice | { error: string } if ('error' in n) { console.log(`\n network — ${n.error}`) } else { console.log( `\n network — ${n.total} request(s), ${n.failed.length} failed, ${n.slow.length} slow (>${n.slowThresholdMs}ms), ${n.inFlight} in-flight`, ) if (n.failed.length) { console.log(` failed:`) for (const e of n.failed.slice(-8)) console.log(formatNetworkRow(e)) } if (n.slow.length) { console.log(` slowest:`) for (const e of n.slow.slice(0, 5)) console.log(formatNetworkRow(e)) } } } if (slices.react) { const r = slices.react as ReactSlice console.log(`\n react — ${r.commits} commit(s)`) if (r.error) console.log(` warning: ${r.error}`) if (r.durationWarning) console.log(` warning: ${r.durationWarning}`) const allZero = r.slow.length > 0 && r.slow.every((row) => row.totalMs <= 0 && row.slowestMs <= 0) if (allZero && !r.durationWarning) { console.log( ` warning: every commit reports actualDuration=0ms — bundle has React profiler timer disabled`, ) } if (r.slow.length) { console.log(' slowest:') console.log( formatTable( ['component', 'total', 'commits', 'slowest'], r.slow .slice(0, reactLimit) .map((row) => [ row.displayName, `${row.totalMs.toFixed(2)}ms`, String(row.commitCount), `${row.slowestMs.toFixed(2)}ms`, ]), ' ', ), ) } if (r.rerenders.length) { console.log(' most rerenders:') console.log( formatTable( ['component', 'renders', 'top causes'], r.rerenders.slice(0, reactLimit).map((row) => { const causes = Object.entries(row.causes ?? {}) .filter(([, v]) => v > 0) .sort((a, b) => b[1] - a[1]) .slice(0, 3) .map(([k, v]) => `${k}:${v}`) .join(', ') return [ row.displayName, String(row.renders ?? row.count ?? 0), causes || '-', ] }), ' ', ), ) } } if (slices.console) { const c = slices.console as | { entries: LogEntry[]; total: number } | { error: string } if ('error' in c) { console.log(`\n console — ${c.error}`) } else { console.log(`\n console errors/warns — ${c.total} in window`) for (const log of c.entries.slice(-8)) { const dt = ((logTs(log) - now) / 1000).toFixed(2) const msg = logMessage(log).slice(0, 160) console.log(` -${dt.padStart(5)}s [${log.level}] ${msg}`) } } } // close with the per-identity cursor advance hint, mirroring // what-happened's footer. const identityKey = getCliIdentityKey() if (identityKey) { console.log( `\n next: rnx what-happened (cursor advances) · rnx network get · rnx perf react why `, ) } return 0 } catch (err) { // a bridge call rejecting (most commonly "no sim connected") must not // escape as a raw stack trace with a 0 exit code — `get diagnosis` is the // "what just went wrong" command; failing it should read cleanly and // exit non-zero so `rnx get diagnosis && …` chains behave. const msg = err instanceof Error ? err.message : String(err) if (/no sim connected/i.test(msg)) { console.error(' no sim connected — open one first: rnx open ') } else { console.error(` diagnose failed: ${msg}`) } return 1 } finally { bridge.close() } }