#!/usr/bin/env bun /** * Simulation script runner. * * Drives a simulation script against a running `bun dev simulate` process via * the simulation MCP HTTP endpoint. Loads the script, steps through it in * configurable chunks, and checks the in-memory log buffer after each chunk. * Stops on the first error (configurable level) and prints the failing entries * plus the most recent step that was executed. * * Usage: * bun test/testing/simulation/scripts/run.ts [options] * * Options: * --mcp MCP endpoint. Default: http://127.0.0.1:43110/mcp * --chunk Actions per step batch. Default: 3 * --max-steps Hard cap on step calls. Default: unlimited. * --level Stop on entries at or above this level. * One of DEBUG | INFO | WARN | ERROR. Default: ERROR. * --message-includes Only stop when matching message substring. * --service-includes Only stop when matching tag.service substring. * --reset Reset simulation state + restart TUI before loading. * --no-reset Skip the reset/restart (default). * --keep-going Don't stop on errors; print them and continue. * --quiet Suppress per-batch progress output. * --json Emit a single JSON summary at the end. * --check-every Check logs only every N batches. Default: 1. * * Example: * bun test/testing/simulation/scripts/run.ts patches.json \ * --reset --chunk 3 --level ERROR */ interface Options { scriptPath: string mcpUrl: string chunk: number maxSteps: number level: "DEBUG" | "INFO" | "WARN" | "ERROR" messageIncludes?: string serviceIncludes?: string reset: boolean keepGoing: boolean quiet: boolean json: boolean checkEvery: number } function parseArgs(argv: string[]): Options { if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") { printHelp() process.exit(0) } const out: Options = { scriptPath: argv[0]!, mcpUrl: "http://127.0.0.1:43110/mcp", chunk: 3, maxSteps: -1, level: "ERROR", reset: false, keepGoing: false, quiet: false, json: false, checkEvery: 1, } for (let i = 1; i < argv.length; i++) { const a = argv[i]! const next = () => { const v = argv[++i] if (v === undefined) throw new Error(`${a} requires a value`) return v } switch (a) { case "--mcp": out.mcpUrl = next() break case "--chunk": out.chunk = Number(next()) break case "--max-steps": out.maxSteps = Number(next()) break case "--level": { const v = next().toUpperCase() if (!["DEBUG", "INFO", "WARN", "ERROR"].includes(v)) { throw new Error(`--level must be DEBUG|INFO|WARN|ERROR, got ${v}`) } out.level = v as Options["level"] break } case "--message-includes": out.messageIncludes = next() break case "--service-includes": out.serviceIncludes = next() break case "--reset": out.reset = true break case "--no-reset": out.reset = false break case "--keep-going": out.keepGoing = true break case "--quiet": out.quiet = true break case "--json": out.json = true break case "--check-every": out.checkEvery = Number(next()) break case "--help": case "-h": printHelp() process.exit(0) default: throw new Error(`Unknown argument: ${a}`) } } return out } function printHelp() { console.log(`Simulation script runner. Usage: bun run.ts [options] Options: --mcp MCP endpoint (default http://127.0.0.1:43110/mcp) --chunk Actions per step batch (default 3) --max-steps Hard cap on step calls (default unlimited) --level Stop level: DEBUG|INFO|WARN|ERROR (default ERROR) --message-includes Only stop when message includes substring --service-includes Only stop when tag.service includes substring --reset Reset sim state + restart TUI before load --no-reset Skip reset (default) --keep-going Don't stop on errors; continue to end --quiet Suppress per-batch progress --json Emit JSON summary at the end --check-every Check logs every N batches (default 1) `) } // ─── MCP client ────────────────────────────────────────────────────────────── let rpcId = 0 async function mcpCall(mcpUrl: string, name: string, args: Record = {}) { const response = await fetch(mcpUrl, { method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream", }, body: JSON.stringify({ jsonrpc: "2.0", id: ++rpcId, method: "tools/call", params: { name, arguments: args }, }), }) if (!response.ok) throw new Error(`${name}: HTTP ${response.status}`) const text = await response.text() // The MCP server may respond with a single JSON line or with SSE-style // chunks. Find the first `{"result"` line either way. const jsonLine = text .split("\n") .map((line) => line.replace(/^data:\s*/, "")) .find((line) => line.trim().startsWith('{"result"') || line.trim().startsWith('{"error"')) if (!jsonLine) throw new Error(`${name}: no JSON-RPC response in:\n${text.slice(0, 500)}`) const envelope = JSON.parse(jsonLine) if (envelope.error) { throw new Error(`${name}: ${envelope.error.message ?? JSON.stringify(envelope.error)}`) } const content = envelope.result?.content?.[0]?.text if (typeof content !== "string") { throw new Error(`${name}: unexpected MCP envelope:\n${JSON.stringify(envelope).slice(0, 500)}`) } // Tool result `text` payloads are JSON-encoded. return JSON.parse(content) } // ─── Logic ─────────────────────────────────────────────────────────────────── interface LogEntry { time: string level: "DEBUG" | "INFO" | "WARN" | "ERROR" tags: Record message: string } async function run(opts: Options) { const log = (msg: string) => { if (!opts.quiet) console.log(msg) } if (opts.reset) { log("Resetting simulation state...") await mcpCall(opts.mcpUrl, "simulation_control_reset") log("Restarting TUI...") await mcpCall(opts.mcpUrl, "simulation_restart") } log("Clearing log buffer...") await mcpCall(opts.mcpUrl, "simulation_log_clear") log(`Loading script: ${opts.scriptPath}`) const loaded = await mcpCall(opts.mcpUrl, "simulation_script_load", { path: opts.scriptPath, replace: true, }) log(` id=${loaded.id} total=${loaded.total}`) const total = loaded.total as number let batches = 0 let cursor = 0 let stopReason: "completed" | "max-steps" | "error" = "completed" let stoppingEntries: LogEntry[] | undefined let stoppingExecuted: unknown[] | undefined while (cursor < total) { if (opts.maxSteps > 0 && batches >= opts.maxSteps) { stopReason = "max-steps" break } const step = await mcpCall(opts.mcpUrl, "simulation_script_step", { steps: opts.chunk }) cursor = step.state.cursor as number batches++ if (!opts.quiet) { const lastKinds = (step.executed as { type: string }[]).map((a) => a.type).join(",") log(`batch ${batches}: cursor ${cursor}/${total} — ${lastKinds}`) } if (batches % opts.checkEvery !== 0 && cursor < total) continue const logResp = await mcpCall(opts.mcpUrl, "simulation_log_get", { level: opts.level, ...(opts.messageIncludes ? { messageIncludes: opts.messageIncludes } : {}), ...(opts.serviceIncludes ? { serviceIncludes: opts.serviceIncludes } : {}), }) const entries = (logResp.entries ?? []) as LogEntry[] if (entries.length > 0) { if (opts.keepGoing) { if (!opts.quiet) { console.log( ` ⚠ ${entries.length} ${opts.level} entries — continuing (--keep-going)`, ) } // Clear so we only see new ones in subsequent batches. await mcpCall(opts.mcpUrl, "simulation_log_clear") } else { stopReason = "error" stoppingEntries = entries stoppingExecuted = step.executed break } } } const status = await mcpCall(opts.mcpUrl, "simulation_script_status") if (opts.json) { process.stdout.write( JSON.stringify( { stopReason, batches, cursor, total, status: status.status, stoppingEntries, stoppingExecuted, }, null, 2, ) + "\n", ) return } console.log() console.log(`Stopped: ${stopReason}`) console.log(`Batches: ${batches}`) console.log(`Cursor: ${cursor}/${total}`) if (stopReason === "error" && stoppingEntries) { console.log() console.log(`${stoppingEntries.length} ${opts.level} entries — failing batch executed:`) console.log(JSON.stringify(stoppingExecuted, null, 2)) console.log() console.log(`${opts.level} entries:`) console.log(JSON.stringify(stoppingEntries, null, 2)) process.exitCode = 1 } } try { await run(parseArgs(process.argv.slice(2))) } catch (err) { console.error(err instanceof Error ? err.message : err) process.exit(1) }