cloudaxe-opencode/packages/opencode/test/testing/simulation/scripts/run.ts

307 lines
9.6 KiB
TypeScript
Raw Permalink Normal View History

2026-05-18 01:48:00 +00:00
#!/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 <script.json> [options]
*
* Options:
* --mcp <url> MCP endpoint. Default: http://127.0.0.1:43110/mcp
* --chunk <n> Actions per step batch. Default: 3
* --max-steps <n> Hard cap on step calls. Default: unlimited.
* --level <lvl> Stop on entries at or above this level.
* One of DEBUG | INFO | WARN | ERROR. Default: ERROR.
* --message-includes <s> Only stop when matching message substring.
* --service-includes <s> 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 <n> 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 <script.json> [options]
Options:
--mcp <url> MCP endpoint (default http://127.0.0.1:43110/mcp)
--chunk <n> Actions per step batch (default 3)
--max-steps <n> Hard cap on step calls (default unlimited)
--level <lvl> Stop level: DEBUG|INFO|WARN|ERROR (default ERROR)
--message-includes <s> Only stop when message includes substring
--service-includes <s> 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 <n> Check logs every N batches (default 1)
`)
}
// ─── MCP client ──────────────────────────────────────────────────────────────
let rpcId = 0
async function mcpCall(mcpUrl: string, name: string, args: Record<string, unknown> = {}) {
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<string, unknown>
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)
}