latest snapshot

This commit is contained in:
James Long 2026-07-05 00:23:05 +00:00
commit 78b8207c5e
20 changed files with 824 additions and 28 deletions

View file

@ -2,6 +2,7 @@ import { Effect } from "effect"
import { SimulationProtocol } from "../protocol"
import { SimulationLLMExchange } from "./llm-exchange"
import { SimulationNetwork } from "./network"
import { SimulationLog } from "../log"
/**
* Backend-hosted simulation control WebSocket.
@ -17,6 +18,7 @@ import { SimulationNetwork } from "./network"
* as `llm.request` notifications
* - `llm.chunk` { id, items } append response items to an exchange
* - `llm.finish` { id, reason? } finish an exchange
* - `llm.disconnect` { id } abruptly terminate an exchange without a finish
* - `llm.pending` list open exchanges
* - `network.log` simulated network request log
*/
@ -30,7 +32,13 @@ function parseRequest(input: string | Buffer) {
return SimulationProtocol.JsonRpc.decodeRequest(JSON.parse(typeof input === "string" ? input : input.toString()))
}
function configuredPort() {
const port = Number(process.env.OPENCODE_SIMULATION_BACKEND_PORT)
return Number.isInteger(port) && port > 0 ? port : undefined
}
async function handle(socket: ControlSocket, request: SimulationProtocol.JsonRpc.Request): Promise<unknown> {
SimulationLog.add("backend.control.request", { method: request.method, id: request.id })
switch (request.method) {
case "llm.attach": {
socket.data.unsubscribe?.()
@ -54,6 +62,11 @@ async function handle(socket: ControlSocket, request: SimulationProtocol.JsonRpc
await Effect.runPromise(SimulationLLMExchange.push(params.id, [{ type: "finish", reason: params.reason }]))
return { ok: true }
}
case "llm.disconnect": {
const params = await SimulationProtocol.Backend.decodeDisconnectParams(request.params)
await Effect.runPromise(SimulationLLMExchange.disconnect(params.id))
return { ok: true }
}
case "llm.pending":
return { exchanges: SimulationLLMExchange.pending() }
case "network.log":
@ -83,6 +96,10 @@ function serve(port = DefaultPort, attempts = MaxPortAttempts): Bun.Server<{ uns
const response = SimulationProtocol.JsonRpc.success(request.id, result)
if (response) socket.send(JSON.stringify(response))
} catch (error) {
SimulationLog.add("backend.control.error", {
method: request?.method,
message: error instanceof Error ? error.message : String(error),
})
socket.send(JSON.stringify(SimulationProtocol.JsonRpc.failure(request?.id, error)))
}
},
@ -97,12 +114,15 @@ function serve(port = DefaultPort, attempts = MaxPortAttempts): Bun.Server<{ uns
}
export function start() {
const server = serve()
const port = configuredPort()
const server = serve(port ?? DefaultPort, port === undefined ? MaxPortAttempts : 1)
const url = `ws://${server.hostname}:${server.port}`
SimulationLog.add("backend.control.start", { url })
process.stderr.write(`opencode simulation backend control websocket: ${url}\n`)
return {
url,
stop: () => {
SimulationLog.add("backend.control.stop", { url })
server.stop(true)
},
}

View file

@ -2,6 +2,7 @@ import { Effect, FileSystem, Layer, Option, Stream } from "effect"
import { systemError, type PlatformError, type SystemErrorTag } from "effect/PlatformError"
import nodeFs from "fs"
import path from "path"
import { SimulationLog } from "../log"
/**
* In-memory simulated `FileSystem.FileSystem`.
@ -43,6 +44,10 @@ export function make(options: Options): FileSystem.FileSystem {
const temp = { value: 0 }
const encoder = new TextEncoder()
store.set(root, makeDirectoryEntry())
SimulationLog.add("filesystem.make", {
root,
seedFiles: Object.keys(options.files ?? {}).sort(),
})
const within = (resolved: string) => resolved === root || resolved.startsWith(withSep(root))
@ -120,6 +125,7 @@ export function make(options: Options): FileSystem.FileSystem {
Effect.suspend(() => {
const resolved = path.resolve(root, file)
const entry = within(resolved) ? store.get(resolved) : undefined
SimulationLog.add("filesystem.probe", { method, file, resolved, found: entry !== undefined, type: entry?.type })
if (!entry) return fail("NotFound", method, file)
return Effect.succeed(entry)
})
@ -142,6 +148,7 @@ export function make(options: Options): FileSystem.FileSystem {
requireEntry("readFile", file).pipe(
Effect.flatMap(([, entry]) => {
if (entry.type !== "File") return fail("BadResource", "readFile", file, "path is a directory")
SimulationLog.add("filesystem.readFile", { file, bytes: entry.content.length })
return Effect.succeed(entry.content.slice())
}),
)
@ -153,6 +160,7 @@ export function make(options: Options): FileSystem.FileSystem {
if (existing?.type === "Directory") return fail("BadResource", "writeFile", file, "path is a directory")
return requireParentDirectory("writeFile", resolved, file).pipe(
Effect.map(() => {
SimulationLog.add("filesystem.writeFile", { file, resolved, bytes: data.length })
store.set(resolved, {
type: "File",
content: data.slice(),
@ -185,7 +193,9 @@ export function make(options: Options): FileSystem.FileSystem {
const names = readOptions?.recursive
? children.map((key) => path.relative(resolved, key))
: children.filter((key) => path.dirname(key) === resolved).map((key) => path.basename(key))
return Effect.succeed(names.sort((a, b) => a.localeCompare(b)))
const sorted = names.sort((a, b) => a.localeCompare(b))
SimulationLog.add("filesystem.readDirectory", { file, resolved, recursive: readOptions?.recursive, names: sorted })
return Effect.succeed(sorted)
}),
)
@ -341,9 +351,15 @@ export const layer = (options?: Partial<Options>) =>
)
function loadSnapshotFiles(stateDirectory: string | undefined) {
if (!stateDirectory) return {}
if (!stateDirectory) {
SimulationLog.add("snapshot.skip", { reason: "OPENCODE_SIMULATION_STATE not set" })
return {}
}
const project = path.join(stateDirectory, "project")
if (!nodeFs.existsSync(project)) return {}
if (!nodeFs.existsSync(project)) {
SimulationLog.add("snapshot.skip", { stateDirectory, project, reason: "project directory not found" })
return {}
}
const files: Record<string, Uint8Array> = {}
const walk = (dir: string) => {
for (const entry of nodeFs.readdirSync(dir, { withFileTypes: true })) {
@ -353,6 +369,7 @@ function loadSnapshotFiles(stateDirectory: string | undefined) {
}
}
walk(project)
SimulationLog.add("snapshot.load", { stateDirectory, project, files: Object.keys(files).sort() })
return files
}

View file

@ -4,29 +4,142 @@ import { Glob } from "@opencode-ai/core/util/glob"
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
import { filesystem } from "@opencode-ai/core/effect/app-node-platform"
import path from "path"
import { SimulationLog } from "../log"
/**
* Simulation replacement for `FSUtil`.
*
* The real `FSUtil` layer builds most helpers on the injected
* `FileSystem.FileSystem`, but `readDirectoryEntries`, `glob`, and `globUp`
* reach for node `fs/promises` and the `glob` package directly, and `resolve`
* canonicalizes through the host filesystem. This wraps the real layer and
* reroutes those through the injected `FileSystem`/lexical path resolution so
* every read observes the in-memory tree.
* This implementation is intentionally self-contained and only uses the
* injected simulated `FileSystem.FileSystem`. The default FSUtil layer has a
* few helpers that reach host-node APIs directly; depending on it here makes it
* easy for mutation paths to escape or miss the in-memory project tree.
*/
const layer = Layer.effect(
FSUtil.Service,
Effect.gen(function* () {
const base = yield* FSUtil.Service
const fs = yield* FileSystem.FileSystem
const resolve = Effect.fn("SimulationFSUtil.resolve")(function* (input: string) {
return input
const existsSafe = Effect.fn("SimulationFSUtil.existsSafe")(function* (file: string) {
const result = yield* fs.exists(file).pipe(Effect.orElseSucceed(() => false))
SimulationLog.add("fsutil.existsSafe", { file, result })
return result
})
const isDir = Effect.fn("SimulationFSUtil.isDir")(function* (file: string) {
const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
const result = info?.type === "Directory"
SimulationLog.add("fsutil.isDir", { file, result, type: info?.type })
return result
})
const isFile = Effect.fn("SimulationFSUtil.isFile")(function* (file: string) {
const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
const result = info?.type === "File"
SimulationLog.add("fsutil.isFile", { file, result, type: info?.type })
return result
})
const realPath = Effect.fn("SimulationFSUtil.realPath")(function* (file: string) {
SimulationLog.add("fsutil.realPath", { file })
const result = yield* fs.realPath(file)
SimulationLog.add("fsutil.realPath.result", { file, result })
return result
})
const stat = Effect.fn("SimulationFSUtil.stat")(function* (file: string) {
SimulationLog.add("fsutil.stat", { file })
const result = yield* fs.stat(file)
SimulationLog.add("fsutil.stat.result", { file, type: result.type })
return result
})
const readFile = Effect.fn("SimulationFSUtil.readFile")(function* (file: string) {
SimulationLog.add("fsutil.readFile", { file })
const result = yield* fs.readFile(file)
SimulationLog.add("fsutil.readFile.result", { file, bytes: result.length })
return result
})
const readFileString = Effect.fn("SimulationFSUtil.readFileString")(function* (file: string) {
SimulationLog.add("fsutil.readFileString", { file })
const result = yield* fs.readFileString(file)
SimulationLog.add("fsutil.readFileString.result", { file, bytes: result.length })
return result
})
const readFileStringSafe = Effect.fn("SimulationFSUtil.readFileStringSafe")(function* (file: string) {
const result = yield* fs
.readFileString(file)
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
SimulationLog.add("fsutil.readFileStringSafe", { file, found: result !== undefined, bytes: result?.length })
return result
})
const readJson = Effect.fn("SimulationFSUtil.readJson")(function* (file: string) {
const text = yield* readFileString(file)
return JSON.parse(text) as unknown
})
const writeFile = Effect.fn("SimulationFSUtil.writeFile")(function* (
file: string,
data: Uint8Array,
options?: Parameters<typeof fs.writeFile>[2],
) {
SimulationLog.add("fsutil.writeFile", { file, bytes: data.length })
const result = yield* fs.writeFile(file, data, options)
SimulationLog.add("fsutil.writeFile.result", { file, bytes: data.length })
return result
})
const writeFileString = Effect.fn("SimulationFSUtil.writeFileString")(function* (
file: string,
data: string,
options?: Parameters<typeof fs.writeFileString>[2],
) {
SimulationLog.add("fsutil.writeFileString", { file, bytes: data.length })
const result = yield* fs.writeFileString(file, data, options)
SimulationLog.add("fsutil.writeFileString.result", { file, bytes: data.length })
return result
})
const makeDirectory: FileSystem.FileSystem["makeDirectory"] = (file, options) => fs.makeDirectory(file, options)
const ensureDir = Effect.fn("SimulationFSUtil.ensureDir")(function* (file: string) {
SimulationLog.add("fsutil.ensureDir", { file })
yield* fs.makeDirectory(file, { recursive: true })
SimulationLog.add("fsutil.ensureDir.result", { file })
})
const writeWithDirs = Effect.fn("SimulationFSUtil.writeWithDirs")(function* (
file: string,
content: string | Uint8Array,
mode?: number,
) {
SimulationLog.add("fsutil.writeWithDirs", {
file,
bytes: typeof content === "string" ? content.length : content.length,
})
const write =
typeof content === "string"
? fs.writeFileString(file, content)
: fs.writeFile(file, content)
yield* write.pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
fs.makeDirectory(path.dirname(file), { recursive: true }).pipe(Effect.andThen(write)),
),
)
if (mode !== undefined) yield* fs.chmod(file, mode)
SimulationLog.add("fsutil.writeWithDirs.result", { file })
})
const writeJson = Effect.fn("SimulationFSUtil.writeJson")(function* (file: string, data: unknown, mode?: number) {
yield* writeFileString(file, JSON.stringify(data, null, 2))
if (mode !== undefined) yield* fs.chmod(file, mode)
})
const readDirectoryEntries = Effect.fn("SimulationFSUtil.readDirectoryEntries")(function* (dirPath: string) {
SimulationLog.add("fsutil.readDirectoryEntries", { dirPath })
const names = yield* fs.readDirectory(dirPath)
return yield* Effect.forEach(names, (name) =>
fs.stat(path.join(dirPath, name)).pipe(
@ -48,8 +161,15 @@ const layer = Layer.effect(
)
})
const resolve = Effect.fn("SimulationFSUtil.resolve")(function* (input: string) {
const result = path.resolve(input)
SimulationLog.add("fsutil.resolve", { input, result })
return result
})
const glob = Effect.fn("SimulationFSUtil.glob")(function* (pattern: string, options?: Glob.Options) {
const cwd = path.resolve(options?.cwd ?? process.cwd())
SimulationLog.add("fsutil.glob", { pattern, cwd, options })
const entries = yield* fs
.readDirectory(cwd, { recursive: true })
.pipe(Effect.orElseSucceed(() => [] as string[]))
@ -59,15 +179,18 @@ const layer = Layer.effect(
Effect.orElseSucceed(() => undefined),
),
)
return matches
const result = matches
.filter((item) => item !== undefined)
.filter((item) => options?.include === "all" || item.type === "File")
.filter((item) => Glob.match(pattern, item.entry))
.map((item) => (options?.absolute ? path.join(cwd, item.entry) : item.entry))
.sort((a, b) => a.localeCompare(b))
SimulationLog.add("fsutil.glob.result", { pattern, cwd, result })
return result
})
const globUp = Effect.fn("SimulationFSUtil.globUp")(function* (pattern: string, start: string, stop?: string) {
SimulationLog.add("fsutil.globUp", { pattern, start, stop })
const result: string[] = []
let current = path.resolve(start)
while (true) {
@ -77,12 +200,59 @@ const layer = Layer.effect(
if (parent === current) break
current = parent
}
SimulationLog.add("fsutil.globUp.result", { pattern, start, stop, result })
return result
})
return FSUtil.Service.of({ ...base, readDirectoryEntries, resolve, glob, globUp })
const up = Effect.fn("SimulationFSUtil.up")(function* (options: { targets: string[]; start: string; stop?: string }) {
SimulationLog.add("fsutil.up", options)
const result: string[] = []
let current = path.resolve(options.start)
while (true) {
for (const target of options.targets) {
const search = path.join(current, target)
if (yield* fs.exists(search)) result.push(search)
}
if (options.stop === current) break
const parent = path.dirname(current)
if (parent === current) break
current = parent
}
SimulationLog.add("fsutil.up.result", { ...options, result })
return result
})
const findUp = Effect.fn("SimulationFSUtil.findUp")(function* (target: string, start: string, stop?: string) {
return yield* up({ targets: [target], start, stop })
})
return FSUtil.Service.of({
...fs,
realPath,
stat,
readFile,
readFileString,
writeFile,
writeFileString,
makeDirectory,
isDir,
isFile,
existsSafe,
readFileStringSafe,
readJson,
writeJson,
ensureDir,
writeWithDirs,
readDirectoryEntries,
resolve,
findUp,
up,
globUp,
glob,
globMatch: Glob.match,
})
}),
).pipe(Layer.provide(FSUtil.layer))
)
export const node = makeGlobalNode({ service: FSUtil.Service, layer, deps: [filesystem] })

View file

@ -6,6 +6,7 @@ import { SimulationFileSystem } from "./filesystem"
import { SimulationFSUtil } from "./fs-util"
import { SimulationNetwork } from "./network"
import { SimulationOpenAI } from "./openai"
import { SimulationLog } from "../log"
/**
* Layer replacements applied when the server is built in simulation mode.
@ -25,10 +26,20 @@ import { SimulationOpenAI } from "./openai"
* inspection (standalone topology; also the headless-simulation interface).
*/
SimulationLog.add("backend.load", {
cwd: process.cwd(),
root: process.env.OPENCODE_SIMULATION_ROOT,
state: process.env.OPENCODE_SIMULATION_STATE,
config: process.env.OPENCODE_CONFIG_DIR,
db: process.env.OPENCODE_DB,
log: SimulationLog.filePath(),
})
SimulationNetwork.register(SimulationOpenAI.route)
SimulationLog.add("network.route.register", { name: "openai-chat" })
// ModelsDev dies when its catalog fetch fails, so simulation answers it with
// an empty catalog; providers come from seeded config instead.
SimulationNetwork.register(SimulationNetwork.json("GET", "https://models.dev/api.json", {}))
SimulationLog.add("network.route.register", { method: "GET", url: "https://models.dev/api.json" })
SimulationControl.start()

View file

@ -1,4 +1,5 @@
import { Effect, Queue } from "effect"
import { SimulationLog } from "../log"
/**
* Pending driver-answered LLM exchanges.
@ -59,6 +60,7 @@ export const open = (input: { readonly url: string; readonly body: unknown }) =>
const queue = yield* Queue.unbounded<Chunk>()
const exchange: Exchange = { id, url: input.url, body: input.body, queue }
state.exchanges.set(id, exchange)
SimulationLog.add("llm.open", { id, url: input.url, body: input.body, pending: state.exchanges.size })
for (const listener of state.listeners) listener({ id, url: input.url, body: input.body })
return exchange
})
@ -68,6 +70,7 @@ export const close = (id: string) =>
Effect.suspend(() => {
const exchange = state.exchanges.get(id)
state.exchanges.delete(id)
SimulationLog.add("llm.close", { id, found: exchange !== undefined, pending: state.exchanges.size })
if (!exchange) return Effect.void
return Queue.shutdown(exchange.queue).pipe(Effect.asVoid)
})
@ -77,9 +80,19 @@ export const push = (id: string, chunks: readonly Chunk[]) =>
Effect.gen(function* () {
const exchange = state.exchanges.get(id)
if (!exchange) return yield* Effect.fail(new ExchangeNotFoundError(id))
SimulationLog.add("llm.push", { id, chunks })
yield* Queue.offerAll(exchange.queue, chunks)
})
/** Abruptly ends the provider body without a finish chunk or SSE sentinel. */
export const disconnect = (id: string) =>
Effect.gen(function* () {
const exchange = state.exchanges.get(id)
if (!exchange) return yield* Effect.fail(new ExchangeNotFoundError(id))
SimulationLog.add("llm.disconnect", { id })
yield* Queue.shutdown(exchange.queue)
})
/**
* Registers a listener for newly opened exchanges and immediately replays
* currently-pending ones, so a late-attaching driver observes requests that
@ -87,9 +100,11 @@ export const push = (id: string, chunks: readonly Chunk[]) =>
*/
export function subscribe(listener: (exchange: OpenedExchange) => void) {
state.listeners.add(listener)
SimulationLog.add("llm.subscribe", { listeners: state.listeners.size, pending: state.exchanges.size })
for (const exchange of pending()) listener(exchange)
return () => {
state.listeners.delete(listener)
SimulationLog.add("llm.unsubscribe", { listeners: state.listeners.size, pending: state.exchanges.size })
}
}

View file

@ -2,6 +2,7 @@ import { Effect, Layer } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { HttpClientError, TransportError } from "effect/unstable/http/HttpClientError"
import type { HttpClientRequest } from "effect/unstable/http"
import { SimulationLog } from "../log"
/**
* Simulated network.
@ -40,9 +41,11 @@ const LOG_LIMIT = 1000
export function register(route: Route) {
state.routes.push(route)
SimulationLog.add("network.register", { routes: state.routes.length })
return () => {
const index = state.routes.indexOf(route)
if (index >= 0) state.routes.splice(index, 1)
SimulationLog.add("network.unregister", { routes: state.routes.length })
}
}
@ -69,6 +72,7 @@ export function log(): readonly LogEntry[] {
function record(entry: LogEntry) {
state.log.push(entry)
if (state.log.length > LOG_LIMIT) state.log.splice(0, state.log.length - LOG_LIMIT)
SimulationLog.add("network.request", entry)
}
export const layer = Layer.sync(HttpClient.HttpClient)(() =>

View file

@ -3,6 +3,7 @@ import { HttpClientResponse } from "effect/unstable/http"
import { OpenAIChatEvent, DEFAULT_BASE_URL, PATH } from "@opencode-ai/llm/protocols/openai-chat"
import { SimulationLLMExchange } from "./llm-exchange"
import { SimulationNetwork } from "./network"
import { SimulationLog } from "../log"
/**
* Driver-answered OpenAI endpoint for the simulated network.
@ -74,6 +75,7 @@ export const route: SimulationNetwork.Route = {
if (url.origin + url.pathname !== DEFAULT_BASE_URL + PATH) return undefined
return Effect.gen(function* () {
const body = request.body._tag === "Uint8Array" ? JSON.parse(new TextDecoder().decode(request.body.body)) : {}
SimulationLog.add("openai.match", { url: url.toString(), body })
const exchange = yield* SimulationLLMExchange.open({ url: url.toString(), body })
return HttpClientResponse.fromWeb(
request,

View file

@ -3,6 +3,7 @@ import { createMockKeys, createMockMouse, type MockInput, type MockMouse } from
import type { SimulationProtocol } from "../protocol"
import { SimulationRenderer } from "./renderer"
import { SimulationTrace } from "./trace"
import { SimulationLog } from "../log"
export type Action = SimulationProtocol.Frontend.Action
export type Element = SimulationProtocol.Frontend.Element
@ -112,6 +113,11 @@ export function actions(renderer: CliRenderer, options: { text?: string } = {}):
}
export function state(harness: Harness) {
SimulationLog.add("ui.state.snapshot", {
focused: harness.renderer.currentFocusedRenderable?.num,
editor: Boolean(harness.renderer.currentFocusedEditor),
elements: elements(harness.renderer).length,
})
return {
screen: harness.screen(),
focused: {
@ -125,6 +131,7 @@ export function state(harness: Harness) {
export async function execute(harness: Harness, action: Action) {
SimulationTrace.add("ui.action", { action })
SimulationLog.add("ui.action", { action })
switch (action.type) {
case "typeText":
await harness.mockInput.typeText(action.text)

View file

@ -0,0 +1,54 @@
import { SimulationLog } from "../log"
export type State = "connected" | "paused" | "reconnecting"
const state = {
paused: false,
connection: undefined as AbortController | undefined,
resume: new Set<() => void>(),
}
export function attach(connection: AbortController) {
state.connection = connection
SimulationLog.add("event-stream.attach")
return () => {
if (state.connection === connection) state.connection = undefined
SimulationLog.add("event-stream.detach")
}
}
export function pause() {
state.paused = true
state.connection?.abort(new Error("Simulation paused the event stream"))
SimulationLog.add("event-stream.pause")
}
export function resume() {
state.paused = false
for (const resolve of state.resume) resolve()
state.resume.clear()
SimulationLog.add("event-stream.resume")
}
export async function beforeConnect(signal: AbortSignal) {
if (!state.paused) return
await new Promise<void>((resolve, reject) => {
const abort = () => {
state.resume.delete(done)
reject(signal.reason)
}
const done = () => {
signal.removeEventListener("abort", abort)
resolve()
}
state.resume.add(done)
signal.addEventListener("abort", abort, { once: true })
})
}
export function current(): State {
if (state.paused) return "paused"
return state.connection === undefined ? "reconnecting" : "connected"
}
export * as SimulationEventStream from "./event-stream"

View file

@ -1,6 +1,8 @@
import { SimulationProtocol } from "../protocol"
import { SimulationActions, type Harness } from "./actions"
import { SimulationTrace } from "./trace"
import { SimulationLog } from "../log"
import { SimulationEventStream } from "./event-stream"
const DefaultPort = 40900
const MaxPortAttempts = 100
@ -19,6 +21,11 @@ function isPortUnavailable(error: unknown) {
return message.includes("eaddrinuse") || message.includes("address already in use") || message.includes(" in use")
}
function configuredPort() {
const port = Number(process.env.OPENCODE_SIMULATION_UI_PORT)
return Number.isInteger(port) && port > 0 ? port : undefined
}
function actionParam(params: unknown) {
return SimulationProtocol.Frontend.decodeActionParams(params).action
}
@ -28,6 +35,7 @@ function parseRequest(input: string | Buffer) {
}
async function handle(harness: Harness, request: SimulationProtocol.JsonRpc.Request) {
SimulationLog.add("frontend.request", { method: request.method, id: request.id })
switch (request.method) {
case "ui.state": {
const result = SimulationActions.state(harness)
@ -42,6 +50,14 @@ async function handle(harness: Harness, request: SimulationProtocol.JsonRpc.Requ
SimulationTrace.add("ui.render", { elements: result.elements.length, actions: result.actions.length })
return result
}
case "event.pause":
SimulationEventStream.pause()
return { state: SimulationEventStream.current() }
case "event.resume":
SimulationEventStream.resume()
return { state: SimulationEventStream.current() }
case "event.state":
return { state: SimulationEventStream.current() }
case "trace.list":
return { records: SimulationTrace.list() }
case "trace.clear":
@ -81,6 +97,10 @@ function serve(
const next = SimulationProtocol.JsonRpc.success(request.id, result)
if (next) socket.send(JSON.stringify(next))
} catch (error) {
SimulationLog.add("frontend.error", {
method: request?.method,
message: error instanceof Error ? error.message : String(error),
})
socket.send(JSON.stringify(SimulationProtocol.JsonRpc.failure(request?.id, error)))
}
},
@ -94,13 +114,16 @@ function serve(
export function start(harness: Harness): Server | undefined {
if (!isEnabled()) return
const server = serve(harness)
const port = configuredPort()
const server = serve(harness, port ?? DefaultPort, port === undefined ? MaxPortAttempts : 1)
const url = `ws://${server.hostname}:${server.port}`
SimulationTrace.add("control.start", { url })
SimulationLog.add("frontend.start", { url })
return {
url,
stop: () => {
SimulationTrace.add("control.stop", { url })
SimulationLog.add("frontend.stop", { url })
server.stop(true)
},
}

View file

@ -2,6 +2,7 @@ import { createCliRenderer, type CliRenderer, type CliRendererConfig } from "@op
import { SimulationActions } from "./actions"
import { SimulationRenderer } from "./renderer"
import { SimulationServer } from "./server"
import { SimulationLog } from "../log"
/**
* Simulation-mode renderer entry point.
@ -12,12 +13,17 @@ import { SimulationServer } from "./server"
* caller only manages the renderer lifecycle.
*/
export async function createSimulation(options: CliRendererConfig): Promise<CliRenderer> {
SimulationLog.add("frontend.create", {
renderer: process.env.OPENCODE_SIMULATION_RENDERER === "fake" ? "fake" : "visible",
log: SimulationLog.filePath(),
})
const renderer =
process.env.OPENCODE_SIMULATION_RENDERER === "fake"
? await SimulationRenderer.create(options)
: await createCliRenderer(options)
const server = SimulationServer.start(SimulationActions.createHarness(renderer))
if (server) {
process.stderr.write(`opencode simulation log: ${SimulationLog.filePath()}\n`)
process.stderr.write(`opencode simulation websocket: ${server.url}\n`)
renderer.once("destroy", () => server.stop())
}

View file

@ -0,0 +1,42 @@
import fs from "fs"
import path from "path"
const DefaultPath = "/tmp/opencode-simulation.log"
let reportedFailure = false
export function filePath() {
return process.env.OPENCODE_SIMULATION_LOG || DefaultPath
}
export function add(type: string, data?: unknown) {
if (!process.env.OPENCODE_SIMULATION) return
try {
const output = filePath()
fs.mkdirSync(path.dirname(output), { recursive: true })
fs.appendFileSync(
output,
JSON.stringify({
time: new Date().toISOString(),
pid: process.pid,
type,
...(data === undefined ? {} : { data: sanitize(data) }),
}) + "\n",
)
} catch (error) {
if (reportedFailure) return
reportedFailure = true
process.stderr.write(`opencode simulation log failed: ${error instanceof Error ? error.message : String(error)}\n`)
}
}
function sanitize(input: unknown): unknown {
try {
JSON.stringify(input)
return input
} catch {
return String(input)
}
}
export * as SimulationLog from "./log"

View file

@ -127,6 +127,9 @@ export namespace Backend {
})
export interface FinishParams extends Schema.Schema.Type<typeof FinishParams> {}
export const DisconnectParams = Schema.Struct({ id: Schema.String })
export interface DisconnectParams extends Schema.Schema.Type<typeof DisconnectParams> {}
export const OpenedExchange = Schema.Struct({ id: Schema.String, url: Schema.String, body: Schema.Json })
export interface OpenedExchange extends Schema.Schema.Type<typeof OpenedExchange> {}
@ -140,6 +143,7 @@ export namespace Backend {
export const decodeChunkParams = Schema.decodeUnknownPromise(ChunkParams)
export const decodeFinishParams = Schema.decodeUnknownPromise(FinishParams)
export const decodeDisconnectParams = Schema.decodeUnknownPromise(DisconnectParams)
}
export * as SimulationProtocol from "./index"