From 78b8207c5e80c765efe0b8ddf455dfa36c981bec Mon Sep 17 00:00:00 2001 From: James Long Date: Sun, 5 Jul 2026 00:23:05 +0000 Subject: [PATCH] latest snapshot --- .../script/campaign-provider-catalog-race.ts | 76 +++++++ .../script/provider-catalog-race-harness.ts | 150 ++++++++++++++ .../script/reproduce-provider-catalog-race.ts | 37 ++++ packages/core/src/config.ts | 71 ++++++- packages/core/src/location-mutation.ts | 51 ++++- packages/simulation/src/backend/control.ts | 22 +- packages/simulation/src/backend/filesystem.ts | 23 ++- packages/simulation/src/backend/fs-util.ts | 194 ++++++++++++++++-- packages/simulation/src/backend/index.ts | 11 + .../simulation/src/backend/llm-exchange.ts | 15 ++ packages/simulation/src/backend/network.ts | 4 + packages/simulation/src/backend/openai.ts | 2 + packages/simulation/src/frontend/actions.ts | 7 + .../simulation/src/frontend/event-stream.ts | 54 +++++ packages/simulation/src/frontend/server.ts | 25 ++- .../simulation/src/frontend/simulation.ts | 6 + packages/simulation/src/log.ts | 42 ++++ packages/simulation/src/protocol/index.ts | 4 + packages/tui/src/context/sdk.tsx | 4 + packages/tui/test/cli/tui/data.test.tsx | 54 +++++ 20 files changed, 824 insertions(+), 28 deletions(-) create mode 100644 packages/core/script/campaign-provider-catalog-race.ts create mode 100644 packages/core/script/provider-catalog-race-harness.ts create mode 100644 packages/core/script/reproduce-provider-catalog-race.ts create mode 100644 packages/simulation/src/frontend/event-stream.ts create mode 100644 packages/simulation/src/log.ts diff --git a/packages/core/script/campaign-provider-catalog-race.ts b/packages/core/script/campaign-provider-catalog-race.ts new file mode 100644 index 000000000000..33e1c710b4d6 --- /dev/null +++ b/packages/core/script/campaign-provider-catalog-race.ts @@ -0,0 +1,76 @@ +import fs from "node:fs/promises" +import path from "node:path" +import { runScenario, type Delay, type Result, type Scenario } from "./provider-catalog-race-harness" + +const count = numberArg("--count", 64) +const seed = numberArg("--seed", 42000) +const out = path.resolve(stringArg("--out", "/tmp/opencode-provider-catalog-race-campaign")) +await fs.rm(out, { recursive: true, force: true }) +await fs.mkdir(out, { recursive: true }) + +const results: Result[] = [] +for (let index = 0; index < count; index++) { + const scenario = generate(seed + index, index) + const directory = path.join(out, `case-${String(index + 1).padStart(3, "0")}-${seed + index}`) + const result = await runScenario(scenario, path.join(directory, "state")) + await fs.mkdir(directory, { recursive: true }) + await fs.writeFile(path.join(directory, "scenario.json"), JSON.stringify(scenario, undefined, 2) + "\n") + await fs.writeFile(path.join(directory, "result.json"), JSON.stringify(result, undefined, 2) + "\n") + results.push(result) + console.log(`[${index + 1}/${count}] ${scenario.id}: ${result.reproduced ? "RACE" : "ok"}`) +} + +const summary = { + seed, + count, + reproduced: results.filter((item) => item.reproduced).length, + firstAttemptFailures: results.filter((item) => item.snapshots[0]?.error).length, + recoveredOnLaterAttempt: results.filter( + (item) => item.snapshots[0]?.error && item.snapshots.slice(1).some((snapshot) => snapshot.resolved), + ).length, + byDelay: Object.fromEntries( + (["none", "yield", "1ms", "10ms"] as Delay[]).map((delay) => [ + delay, + { + count: results.filter((item) => item.scenario.delay === delay).length, + reproduced: results.filter((item) => item.scenario.delay === delay && item.reproduced).length, + }, + ]), + ), +} +await fs.writeFile(path.join(out, "summary.json"), JSON.stringify(summary, undefined, 2) + "\n") +console.log(JSON.stringify(summary, undefined, 2)) +console.log(`Artifacts: ${out}`) +process.exitCode = summary.reproduced > 0 ? 1 : 0 + +function generate(value: number, index: number): Scenario { + const random = rng(value) + const delays: Delay[] = ["none", "yield", "1ms", "10ms"] + return { + id: `seed-${value}`, + delay: delays[index % delays.length]!, + providerID: `provider-${index % 8}`, + modelID: `model-${Math.floor(random() * 8)}`, + configuredDefault: random() < 0.75, + apiKey: true, + disabled: random() < 0.1, + repeats: 3, + } +} + +function rng(seed: number) { + let state = seed >>> 0 + return () => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0 + return state / 0x1_0000_0000 + } +} + +function stringArg(name: string, fallback: string) { + const index = process.argv.indexOf(name) + return index < 0 ? fallback : (process.argv[index + 1] ?? fallback) +} + +function numberArg(name: string, fallback: number) { + return Number(stringArg(name, String(fallback))) +} diff --git a/packages/core/script/provider-catalog-race-harness.ts b/packages/core/script/provider-catalog-race-harness.ts new file mode 100644 index 000000000000..bb0c62e39bb6 --- /dev/null +++ b/packages/core/script/provider-catalog-race-harness.ts @@ -0,0 +1,150 @@ +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { DateTime, Effect, Layer } from "effect" +import { AppNodeBuilder } from "../src/effect/app-node-builder" +import { LayerNode } from "../src/effect/layer-node" +import { Database } from "../src/database/database" +import { EventV2 } from "../src/event" +import { Catalog } from "../src/catalog" +import { Location } from "../src/location" +import { LocationServiceMap } from "../src/location-services" +import { ModelV2 } from "../src/model" +import { ProjectV2 } from "../src/project" +import { ProviderV2 } from "../src/provider" +import { AbsolutePath } from "../src/schema" +import { SessionV2 } from "../src/session" +import { SessionRunnerModel } from "../src/session/runner/model" + +export type Delay = "none" | "yield" | "1ms" | "10ms" + +export interface Scenario { + id: string + delay: Delay + providerID: string + modelID: string + configuredDefault: boolean + apiKey: boolean + disabled: boolean + repeats: number +} + +export interface Snapshot { + attempt: number + providers: string[] + models: string[] + availableModels: string[] + resolved?: { providerID: string; modelID: string } + error?: { tag: string; message: string } +} + +export interface Result { + scenario: Scenario + directory: string + snapshots: Snapshot[] + reproduced: boolean +} + +const app = AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, LocationServiceMap.node])) + +export async function runScenario(scenario: Scenario, root?: string): Promise { + const directory = root ?? (await fs.mkdtemp(path.join(os.tmpdir(), "opencode-provider-race-"))) + await fs.mkdir(directory, { recursive: true }) + await fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify(config(scenario), undefined, 2) + "\n") + const location = Location.Ref.make({ directory: AbsolutePath.make(directory) }) + + const program = Effect.gen(function* () { + const locations = yield* LocationServiceMap.Service + const context = yield* locations.contextEffect(location) + const output: Snapshot[] = [] + for (let attempt = 0; attempt < scenario.repeats; attempt++) { + yield* delay(scenario.delay) + output.push( + yield* Effect.gen(function* () { + const catalog = yield* Catalog.Service + const providers = (yield* catalog.provider.all()).map((item) => item.id).sort() + const models = (yield* catalog.model.all()).map((item) => `${item.providerID}/${item.id}`).sort() + const availableModels = (yield* catalog.model.available()) + .map((item) => `${item.providerID}/${item.id}`) + .sort() + const resolved = yield* SessionRunnerModel.Service.use((service) => + service.resolve(session(scenario, location)), + ).pipe( + Effect.map((item) => ({ providerID: String(item.ref.providerID), modelID: String(item.ref.id) })), + Effect.catch((error) => + Effect.succeed({ + error: { + tag: typeof error === "object" && error && "_tag" in error ? String(error._tag) : "Unknown", + message: error instanceof Error ? error.message : String(error), + }, + }), + ), + ) + return { + attempt, + providers, + models, + availableModels, + ...(resolved && "error" in resolved ? { error: resolved.error } : { resolved }), + } + }).pipe(Effect.provide(context)), + ) + } + return output + }).pipe(Effect.scoped, Effect.provide(app)) + const snapshots = await Effect.runPromise(program) + + const expected = `${scenario.providerID}/${scenario.modelID}` + return { + scenario, + directory, + snapshots, + reproduced: snapshots.some( + (item) => !item.models.includes(expected) || item.error?.tag === "SessionRunnerModel.ModelUnavailableError", + ), + } +} + +function config(scenario: Scenario) { + return { + ...(scenario.configuredDefault ? { model: `${scenario.providerID}/${scenario.modelID}` } : {}), + providers: { + [scenario.providerID]: { + name: `Probe ${scenario.providerID}`, + api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://probe.invalid/v1" }, + request: { body: scenario.apiKey ? { apiKey: "probe-key" } : {} }, + models: { + [scenario.modelID]: { + name: `Probe ${scenario.modelID}`, + disabled: scenario.disabled, + capabilities: { tools: true, input: ["text"], output: ["text"] }, + limit: { context: 8192, output: 2048 }, + }, + }, + }, + }, + } +} + +function session(scenario: Scenario, location: Location.Ref) { + return SessionV2.Info.make({ + id: SessionV2.ID.make(`ses_${scenario.id.replace(/[^a-zA-Z0-9_-]/g, "_")}`), + projectID: ProjectV2.ID.global, + title: scenario.id, + model: { + providerID: ProviderV2.ID.make(scenario.providerID), + id: ModelV2.ID.make(scenario.modelID), + }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, + location, + }) +} + +function delay(value: Delay) { + if (value === "yield") return Effect.yieldNow + if (value === "1ms") return Effect.sleep("1 millis") + if (value === "10ms") return Effect.sleep("10 millis") + return Effect.void +} diff --git a/packages/core/script/reproduce-provider-catalog-race.ts b/packages/core/script/reproduce-provider-catalog-race.ts new file mode 100644 index 000000000000..d0c69f41825e --- /dev/null +++ b/packages/core/script/reproduce-provider-catalog-race.ts @@ -0,0 +1,37 @@ +import fs from "node:fs/promises" +import path from "node:path" +import { runScenario } from "./provider-catalog-race-harness" + +const out = path.resolve(process.env.OPENCODE_PROBE_OUT ?? "/tmp/opencode-provider-catalog-race-repro") +await fs.rm(out, { recursive: true, force: true }) +await fs.mkdir(out, { recursive: true }) + +const result = await runScenario( + { + id: "cold-immediate", + delay: "none", + providerID: "console-openai", + modelID: "gpt-5.6-sol", + configuredDefault: true, + apiKey: true, + disabled: false, + repeats: 2, + }, + path.join(out, "state"), +) + +await fs.writeFile(path.join(out, "result.json"), JSON.stringify(result, undefined, 2) + "\n") +for (const snapshot of result.snapshots) { + console.log( + JSON.stringify({ + attempt: snapshot.attempt, + providers: snapshot.providers, + models: snapshot.models, + resolved: snapshot.resolved, + error: snapshot.error, + }), + ) +} +console.log(`${result.reproduced ? "REPRODUCED" : "NOT REPRODUCED"}: cold location provider catalog startup race`) +console.log(`Artifacts: ${out}`) +process.exitCode = result.reproduced ? 1 : 0 diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 9d03d09c38dd..2db5219ca801 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -2,6 +2,7 @@ export * as Config from "./config" import { makeLocationNode } from "./effect/app-node" import path from "path" +import nodeFs from "fs" import { type ParseError, parse } from "jsonc-parser" import { Context, Effect, Layer, Option, Schema } from "effect" import { Permission } from "@opencode-ai/schema/permission" @@ -26,6 +27,38 @@ import { ConfigWatcher } from "./config/watcher" import { ConfigV1 } from "./v1/config/config" import { ConfigMigrateV1 } from "./v1/config/migrate" +function simulationLog(type: string, data?: unknown) { + if (!process.env.OPENCODE_SIMULATION) return + try { + const file = process.env.OPENCODE_SIMULATION_LOG || "/tmp/opencode-simulation.log" + nodeFs.mkdirSync(path.dirname(file), { recursive: true }) + nodeFs.appendFileSync(file, JSON.stringify({ time: new Date().toISOString(), pid: process.pid, type, data }) + "\n") + } catch { + return + } +} + +function asRecord(input: unknown): Record | undefined { + return typeof input === "object" && input !== null && !Array.isArray(input) ? (input as Record) : undefined +} + +function configShape(input: unknown) { + const record = asRecord(input) + const providers = asRecord(record?.providers) + return { + keys: Object.keys(record ?? {}).sort(), + model: typeof record?.model === "string" ? record.model : undefined, + default_agent: typeof record?.default_agent === "string" ? record.default_agent : undefined, + providers: Object.keys(providers ?? {}).sort(), + providerModels: Object.fromEntries( + Object.entries(providers ?? {}).map(([id, provider]) => [ + id, + Object.keys(asRecord(asRecord(provider)?.models) ?? {}).sort(), + ]), + ), + } +} + export class Info extends Schema.Class("Config.Info")({ $schema: Schema.optional(Schema.String).annotate({ description: "JSON schema reference for configuration validation", @@ -146,18 +179,28 @@ const layer = Layer.effect( const loadFile = Effect.fnUntraced(function* (filepath: string) { const text = yield* fs.readFileStringSafe(filepath) + simulationLog("config.file.read", { filepath, found: text !== undefined, bytes: text?.length }) if (!text) return const errors: ParseError[] = [] const input: unknown = parse(text, errors, { allowTrailingComma: true }) - if (errors.length) return - - const info = Option.getOrUndefined( - ConfigMigrateV1.isV1(input) - ? decodeV1Info(input).pipe(Option.map(ConfigMigrateV1.migrate), Option.flatMap(decodeInfo)) - : decodeInfo(input), - ) - if (!info) return + if (errors.length) { + simulationLog("config.file.parse.error", { + filepath, + errors: errors.map((error) => ({ error: error.error, offset: error.offset, length: error.length })), + }) + return + } + + const decoded = ConfigMigrateV1.isV1(input) + ? decodeV1Info(input).pipe(Option.map(ConfigMigrateV1.migrate), Option.flatMap(decodeInfo)) + : decodeInfo(input) + const info = Option.getOrUndefined(decoded) + if (!info) { + simulationLog("config.file.decode.error", { filepath, shape: configShape(input) }) + return + } + simulationLog("config.file.loaded", { filepath, v1: ConfigMigrateV1.isV1(input), shape: configShape(info) }) return new Document({ type: "document", path: filepath, info }) }) @@ -172,6 +215,12 @@ const layer = Layer.effect( const globalDirectory = AbsolutePath.make(global.config) const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config) + simulationLog("config.service.start", { + global: global.config, + location: location.directory, + project: location.project.directory, + locationIsGlobal, + }) // Read configuration once when this location opens. Later calls reuse these // values until the location is reopened. const discovered = locationIsGlobal @@ -183,6 +232,7 @@ const layer = Layer.effect( stop: location.project.directory, }) .pipe(Effect.orDie) + simulationLog("config.discovered", { discovered }) const directories = [ globalDirectory, ...discovered @@ -201,6 +251,11 @@ const layer = Layer.effect( // Apply general settings first and more specific settings last: // global config, project files, then `.opencode` files. const configs = [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()] + simulationLog("config.entries", { + entries: configs.map((entry) => + entry.type === "document" ? { type: entry.type, path: entry.path, shape: configShape(entry.info) } : entry, + ), + }) // Rules use the opposite order so a user-global rule can override a // repository rule. Statement order inside each file stays unchanged. yield* policy.load( diff --git a/packages/core/src/location-mutation.ts b/packages/core/src/location-mutation.ts index 8887d7b774aa..a9d03bae37ac 100644 --- a/packages/core/src/location-mutation.ts +++ b/packages/core/src/location-mutation.ts @@ -2,6 +2,7 @@ export * as LocationMutation from "./location-mutation" import { makeLocationNode } from "./effect/app-node" import path from "path" +import nodeFs from "fs" import { Context, Effect, Layer, Schema } from "effect" import { FSUtil } from "./fs-util" import { Location } from "./location" @@ -78,21 +79,35 @@ interface ResolvedPath { const slash = (value: string) => value.replaceAll("\\", "/") +function simulationLog(type: string, data?: unknown) { + if (!process.env.OPENCODE_SIMULATION) return + try { + const file = process.env.OPENCODE_SIMULATION_LOG || "/tmp/opencode-simulation.log" + nodeFs.mkdirSync(path.dirname(file), { recursive: true }) + nodeFs.appendFileSync(file, JSON.stringify({ time: new Date().toISOString(), pid: process.pid, type, data }) + "\n") + } catch { + return + } +} + const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service const location = yield* Location.Service const locationRoot = yield* fs.realPath(location.directory) + simulationLog("location-mutation.layer", { directory: location.directory, locationRoot }) function notFound(effect: Effect.Effect) { return effect.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined))) } const resolvePath = Effect.fnUntraced(function* (absolute: string) { + simulationLog("location-mutation.resolvePath.start", { absolute }) const existing = yield* notFound(fs.realPath(absolute)) if (existing !== undefined) { const info = yield* fs.stat(existing) + simulationLog("location-mutation.resolvePath.existing", { absolute, existing, type: info.type }) return { canonical: existing, type: info.type, @@ -102,10 +117,18 @@ const layer = Layer.effect( let anchor = path.dirname(absolute) while (true) { + simulationLog("location-mutation.resolvePath.anchor", { absolute, anchor }) const canonical = yield* notFound(fs.realPath(anchor)) if (canonical !== undefined) { const info = yield* fs.stat(canonical) + simulationLog("location-mutation.resolvePath.anchor.found", { absolute, anchor, canonical, type: info.type }) if (info.type !== "Directory") { + simulationLog("location-mutation.resolvePath.error", { + absolute, + anchor, + canonical, + reason: "non_directory_ancestor", + }) return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" }) } return { @@ -114,7 +137,10 @@ const layer = Layer.effect( } satisfies ResolvedPath } const parent = path.dirname(anchor) - if (parent === anchor) return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" }) + if (parent === anchor) { + simulationLog("location-mutation.resolvePath.error", { absolute, anchor, reason: "non_directory_ancestor" }) + return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" }) + } anchor = parent } }) @@ -123,10 +149,27 @@ const layer = Layer.effect( const relative = !path.isAbsolute(input.path) const absolute = path.resolve(location.directory, input.path) const lexicallyInternal = FSUtil.contains(location.directory, absolute) - if (relative && !lexicallyInternal) return yield* new PathError({ path: input.path, reason: "relative_escape" }) + simulationLog("location-mutation.resolve.start", { + input, + locationDirectory: location.directory, + locationRoot, + relative, + absolute, + lexicallyInternal, + }) + if (relative && !lexicallyInternal) { + simulationLog("location-mutation.resolve.error", { input, absolute, reason: "relative_escape" }) + return yield* new PathError({ path: input.path, reason: "relative_escape" }) + } const resolved = yield* resolvePath(absolute) if (lexicallyInternal && !FSUtil.contains(locationRoot, resolved.canonical)) { + simulationLog("location-mutation.resolve.error", { + input, + absolute, + resolved, + reason: "location_escape", + }) return yield* new PathError({ path: input.path, reason: "location_escape" }) } @@ -137,7 +180,7 @@ const layer = Layer.effect( const externalDirectory = input.kind === "directory" && resolved.type === "Directory" ? resolved.canonical : resolved.directory const externalResource = slash(path.join(externalDirectory, "*")) - return { + const target = { canonical: resolved.canonical, resource, externalDirectory: external @@ -154,6 +197,8 @@ const layer = Layer.effect( } : undefined, } satisfies Target + simulationLog("location-mutation.resolve.result", { input, absolute, resolved, target }) + return target }) return Service.of({ resolve }) diff --git a/packages/simulation/src/backend/control.ts b/packages/simulation/src/backend/control.ts index 10bded13af96..ab4bfc470c77 100644 --- a/packages/simulation/src/backend/control.ts +++ b/packages/simulation/src/backend/control.ts @@ -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 { + 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) }, } diff --git a/packages/simulation/src/backend/filesystem.ts b/packages/simulation/src/backend/filesystem.ts index 01a7b003291e..eda25560deb4 100644 --- a/packages/simulation/src/backend/filesystem.ts +++ b/packages/simulation/src/backend/filesystem.ts @@ -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) => ) 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 = {} 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 } diff --git a/packages/simulation/src/backend/fs-util.ts b/packages/simulation/src/backend/fs-util.ts index e24d0c9c4bee..5d322f7d683a 100644 --- a/packages/simulation/src/backend/fs-util.ts +++ b/packages/simulation/src/backend/fs-util.ts @@ -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[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[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 + }) + + 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 }) - return FSUtil.Service.of({ ...base, readDirectoryEntries, resolve, glob, globUp }) + 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] }) diff --git a/packages/simulation/src/backend/index.ts b/packages/simulation/src/backend/index.ts index dd735d40ddf3..2852978f5418 100644 --- a/packages/simulation/src/backend/index.ts +++ b/packages/simulation/src/backend/index.ts @@ -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() diff --git a/packages/simulation/src/backend/llm-exchange.ts b/packages/simulation/src/backend/llm-exchange.ts index 759f52866c5f..e95791e2e801 100644 --- a/packages/simulation/src/backend/llm-exchange.ts +++ b/packages/simulation/src/backend/llm-exchange.ts @@ -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() 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 }) } } diff --git a/packages/simulation/src/backend/network.ts b/packages/simulation/src/backend/network.ts index 7b002fcb4ec0..5bacacc8cd5b 100644 --- a/packages/simulation/src/backend/network.ts +++ b/packages/simulation/src/backend/network.ts @@ -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)(() => diff --git a/packages/simulation/src/backend/openai.ts b/packages/simulation/src/backend/openai.ts index c13e7772ef71..55fe9be5ea10 100644 --- a/packages/simulation/src/backend/openai.ts +++ b/packages/simulation/src/backend/openai.ts @@ -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, diff --git a/packages/simulation/src/frontend/actions.ts b/packages/simulation/src/frontend/actions.ts index d03e4a5f75af..a45c3644ade0 100644 --- a/packages/simulation/src/frontend/actions.ts +++ b/packages/simulation/src/frontend/actions.ts @@ -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) diff --git a/packages/simulation/src/frontend/event-stream.ts b/packages/simulation/src/frontend/event-stream.ts new file mode 100644 index 000000000000..403ad415d747 --- /dev/null +++ b/packages/simulation/src/frontend/event-stream.ts @@ -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((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" diff --git a/packages/simulation/src/frontend/server.ts b/packages/simulation/src/frontend/server.ts index c6a6c8784c4a..9bf25795a1dd 100644 --- a/packages/simulation/src/frontend/server.ts +++ b/packages/simulation/src/frontend/server.ts @@ -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) }, } diff --git a/packages/simulation/src/frontend/simulation.ts b/packages/simulation/src/frontend/simulation.ts index ff24e0459d8f..aded76c70ddd 100644 --- a/packages/simulation/src/frontend/simulation.ts +++ b/packages/simulation/src/frontend/simulation.ts @@ -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 { + 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()) } diff --git a/packages/simulation/src/log.ts b/packages/simulation/src/log.ts new file mode 100644 index 000000000000..c6a6515aa844 --- /dev/null +++ b/packages/simulation/src/log.ts @@ -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" diff --git a/packages/simulation/src/protocol/index.ts b/packages/simulation/src/protocol/index.ts index 6ff63c9bb3c1..b05f24d501a7 100644 --- a/packages/simulation/src/protocol/index.ts +++ b/packages/simulation/src/protocol/index.ts @@ -127,6 +127,9 @@ export namespace Backend { }) export interface FinishParams extends Schema.Schema.Type {} + export const DisconnectParams = Schema.Struct({ id: Schema.String }) + export interface DisconnectParams extends Schema.Schema.Type {} + export const OpenedExchange = Schema.Struct({ id: Schema.String, url: Schema.String, body: Schema.Json }) export interface OpenedExchange extends Schema.Schema.Type {} @@ -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" diff --git a/packages/tui/src/context/sdk.tsx b/packages/tui/src/context/sdk.tsx index 0ad516ceaccf..65c98121f3ee 100644 --- a/packages/tui/src/context/sdk.tsx +++ b/packages/tui/src/context/sdk.tsx @@ -1,6 +1,7 @@ import type { OpenCodeClient } from "@opencode-ai/client/promise" import type { OpencodeClient, V2Event } from "@opencode-ai/sdk/v2" import { createGlobalEmitter } from "@solid-primitives/event-bus" +import { SimulationEventStream } from "@opencode-ai/simulation/frontend/event-stream" import { onCleanup, onMount } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "./helper" @@ -46,7 +47,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ void (async () => { let attempt = 0 while (!abort.signal.aborted && !controller.signal.aborted) { + await SimulationEventStream.beforeConnect(controller.signal) const connection = new AbortController() + const detachSimulation = SimulationEventStream.attach(connection) const cancel = () => connection.abort(controller.signal.reason) const timeout = setTimeout( () => connection.abort(new Error("Timed out connecting to server")), @@ -81,6 +84,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ })() .catch((error) => error) .finally(() => { + detachSimulation() clearTimeout(timeout) controller.signal.removeEventListener("abort", cancel) }) diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index a71bdc47d8e2..6170f666c8b8 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -357,6 +357,60 @@ test("tracks session status from active sessions and execution events", async () } }) +test("clears stale running status after reconnect", async () => { + const events = createEventStream() + let activeRequests = 0 + const calls = createFetch((url) => { + if (url.pathname === "/api/session/active") { + activeRequests++ + return json({ data: {}, watermarks: {} }) + } + }, events) + let data!: ReturnType + + function Probe() { + data = useData() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await wait(() => data.connection.status() === "connected") + emitEvent(events, { + id: "evt_reconnect_step_started", + created: 0, + type: "step.started", + durable: durable("session-reconnect"), + data: { + sessionID: "session-reconnect", + assistantMessageID: "message-reconnect", + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + }) + await wait(() => data.session.status("session-reconnect") === "running") + + events.disconnect() + await wait(() => data.connection.status() === "connecting") + await wait(() => data.connection.status() === "connected", 4000) + await wait(() => activeRequests >= 2) + expect(data.session.status("session-reconnect")).toBe("idle") + } finally { + app.renderer.destroy() + } +}) + test("refreshes integrations after integration updates", async () => { const events = createEventStream() const requests = { integration: 0, model: 0, provider: 0 }