latest snapshot
This commit is contained in:
parent
37b26e495b
commit
78b8207c5e
20 changed files with 824 additions and 28 deletions
76
packages/core/script/campaign-provider-catalog-race.ts
Normal file
76
packages/core/script/campaign-provider-catalog-race.ts
Normal file
|
|
@ -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)))
|
||||||
|
}
|
||||||
150
packages/core/script/provider-catalog-race-harness.ts
Normal file
150
packages/core/script/provider-catalog-race-harness.ts
Normal file
|
|
@ -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<Result> {
|
||||||
|
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
|
||||||
|
}
|
||||||
37
packages/core/script/reproduce-provider-catalog-race.ts
Normal file
37
packages/core/script/reproduce-provider-catalog-race.ts
Normal file
|
|
@ -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
|
||||||
|
|
@ -2,6 +2,7 @@ export * as Config from "./config"
|
||||||
|
|
||||||
import { makeLocationNode } from "./effect/app-node"
|
import { makeLocationNode } from "./effect/app-node"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import nodeFs from "fs"
|
||||||
import { type ParseError, parse } from "jsonc-parser"
|
import { type ParseError, parse } from "jsonc-parser"
|
||||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||||
import { Permission } from "@opencode-ai/schema/permission"
|
import { Permission } from "@opencode-ai/schema/permission"
|
||||||
|
|
@ -26,6 +27,38 @@ import { ConfigWatcher } from "./config/watcher"
|
||||||
import { ConfigV1 } from "./v1/config/config"
|
import { ConfigV1 } from "./v1/config/config"
|
||||||
import { ConfigMigrateV1 } from "./v1/config/migrate"
|
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<string, unknown> | undefined {
|
||||||
|
return typeof input === "object" && input !== null && !Array.isArray(input) ? (input as Record<string, unknown>) : 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<Info>("Config.Info")({
|
export class Info extends Schema.Class<Info>("Config.Info")({
|
||||||
$schema: Schema.optional(Schema.String).annotate({
|
$schema: Schema.optional(Schema.String).annotate({
|
||||||
description: "JSON schema reference for configuration validation",
|
description: "JSON schema reference for configuration validation",
|
||||||
|
|
@ -146,18 +179,28 @@ const layer = Layer.effect(
|
||||||
|
|
||||||
const loadFile = Effect.fnUntraced(function* (filepath: string) {
|
const loadFile = Effect.fnUntraced(function* (filepath: string) {
|
||||||
const text = yield* fs.readFileStringSafe(filepath)
|
const text = yield* fs.readFileStringSafe(filepath)
|
||||||
|
simulationLog("config.file.read", { filepath, found: text !== undefined, bytes: text?.length })
|
||||||
if (!text) return
|
if (!text) return
|
||||||
|
|
||||||
const errors: ParseError[] = []
|
const errors: ParseError[] = []
|
||||||
const input: unknown = parse(text, errors, { allowTrailingComma: true })
|
const input: unknown = parse(text, errors, { allowTrailingComma: true })
|
||||||
if (errors.length) 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 info = Option.getOrUndefined(
|
const decoded = ConfigMigrateV1.isV1(input)
|
||||||
ConfigMigrateV1.isV1(input)
|
? decodeV1Info(input).pipe(Option.map(ConfigMigrateV1.migrate), Option.flatMap(decodeInfo))
|
||||||
? decodeV1Info(input).pipe(Option.map(ConfigMigrateV1.migrate), Option.flatMap(decodeInfo))
|
: decodeInfo(input)
|
||||||
: decodeInfo(input),
|
const info = Option.getOrUndefined(decoded)
|
||||||
)
|
if (!info) {
|
||||||
if (!info) return
|
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 })
|
return new Document({ type: "document", path: filepath, info })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -172,6 +215,12 @@ const layer = Layer.effect(
|
||||||
|
|
||||||
const globalDirectory = AbsolutePath.make(global.config)
|
const globalDirectory = AbsolutePath.make(global.config)
|
||||||
const locationIsGlobal = path.resolve(location.directory) === path.resolve(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
|
// Read configuration once when this location opens. Later calls reuse these
|
||||||
// values until the location is reopened.
|
// values until the location is reopened.
|
||||||
const discovered = locationIsGlobal
|
const discovered = locationIsGlobal
|
||||||
|
|
@ -183,6 +232,7 @@ const layer = Layer.effect(
|
||||||
stop: location.project.directory,
|
stop: location.project.directory,
|
||||||
})
|
})
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
|
simulationLog("config.discovered", { discovered })
|
||||||
const directories = [
|
const directories = [
|
||||||
globalDirectory,
|
globalDirectory,
|
||||||
...discovered
|
...discovered
|
||||||
|
|
@ -201,6 +251,11 @@ const layer = Layer.effect(
|
||||||
// Apply general settings first and more specific settings last:
|
// Apply general settings first and more specific settings last:
|
||||||
// global config, project files, then `.opencode` files.
|
// global config, project files, then `.opencode` files.
|
||||||
const configs = [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()]
|
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
|
// Rules use the opposite order so a user-global rule can override a
|
||||||
// repository rule. Statement order inside each file stays unchanged.
|
// repository rule. Statement order inside each file stays unchanged.
|
||||||
yield* policy.load(
|
yield* policy.load(
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ export * as LocationMutation from "./location-mutation"
|
||||||
|
|
||||||
import { makeLocationNode } from "./effect/app-node"
|
import { makeLocationNode } from "./effect/app-node"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import nodeFs from "fs"
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
import { Context, Effect, Layer, Schema } from "effect"
|
||||||
import { FSUtil } from "./fs-util"
|
import { FSUtil } from "./fs-util"
|
||||||
import { Location } from "./location"
|
import { Location } from "./location"
|
||||||
|
|
@ -78,21 +79,35 @@ interface ResolvedPath {
|
||||||
|
|
||||||
const slash = (value: string) => value.replaceAll("\\", "/")
|
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(
|
const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* FSUtil.Service
|
const fs = yield* FSUtil.Service
|
||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
const locationRoot = yield* fs.realPath(location.directory)
|
const locationRoot = yield* fs.realPath(location.directory)
|
||||||
|
simulationLog("location-mutation.layer", { directory: location.directory, locationRoot })
|
||||||
|
|
||||||
function notFound<A>(effect: Effect.Effect<A, FSUtil.Error>) {
|
function notFound<A>(effect: Effect.Effect<A, FSUtil.Error>) {
|
||||||
return effect.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
return effect.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvePath = Effect.fnUntraced(function* (absolute: string) {
|
const resolvePath = Effect.fnUntraced(function* (absolute: string) {
|
||||||
|
simulationLog("location-mutation.resolvePath.start", { absolute })
|
||||||
const existing = yield* notFound(fs.realPath(absolute))
|
const existing = yield* notFound(fs.realPath(absolute))
|
||||||
if (existing !== undefined) {
|
if (existing !== undefined) {
|
||||||
const info = yield* fs.stat(existing)
|
const info = yield* fs.stat(existing)
|
||||||
|
simulationLog("location-mutation.resolvePath.existing", { absolute, existing, type: info.type })
|
||||||
return {
|
return {
|
||||||
canonical: existing,
|
canonical: existing,
|
||||||
type: info.type,
|
type: info.type,
|
||||||
|
|
@ -102,10 +117,18 @@ const layer = Layer.effect(
|
||||||
|
|
||||||
let anchor = path.dirname(absolute)
|
let anchor = path.dirname(absolute)
|
||||||
while (true) {
|
while (true) {
|
||||||
|
simulationLog("location-mutation.resolvePath.anchor", { absolute, anchor })
|
||||||
const canonical = yield* notFound(fs.realPath(anchor))
|
const canonical = yield* notFound(fs.realPath(anchor))
|
||||||
if (canonical !== undefined) {
|
if (canonical !== undefined) {
|
||||||
const info = yield* fs.stat(canonical)
|
const info = yield* fs.stat(canonical)
|
||||||
|
simulationLog("location-mutation.resolvePath.anchor.found", { absolute, anchor, canonical, type: info.type })
|
||||||
if (info.type !== "Directory") {
|
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 yield* new PathError({ path: absolute, reason: "non_directory_ancestor" })
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|
@ -114,7 +137,10 @@ const layer = Layer.effect(
|
||||||
} satisfies ResolvedPath
|
} satisfies ResolvedPath
|
||||||
}
|
}
|
||||||
const parent = path.dirname(anchor)
|
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
|
anchor = parent
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -123,10 +149,27 @@ const layer = Layer.effect(
|
||||||
const relative = !path.isAbsolute(input.path)
|
const relative = !path.isAbsolute(input.path)
|
||||||
const absolute = path.resolve(location.directory, input.path)
|
const absolute = path.resolve(location.directory, input.path)
|
||||||
const lexicallyInternal = FSUtil.contains(location.directory, absolute)
|
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)
|
const resolved = yield* resolvePath(absolute)
|
||||||
if (lexicallyInternal && !FSUtil.contains(locationRoot, resolved.canonical)) {
|
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" })
|
return yield* new PathError({ path: input.path, reason: "location_escape" })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -137,7 +180,7 @@ const layer = Layer.effect(
|
||||||
const externalDirectory =
|
const externalDirectory =
|
||||||
input.kind === "directory" && resolved.type === "Directory" ? resolved.canonical : resolved.directory
|
input.kind === "directory" && resolved.type === "Directory" ? resolved.canonical : resolved.directory
|
||||||
const externalResource = slash(path.join(externalDirectory, "*"))
|
const externalResource = slash(path.join(externalDirectory, "*"))
|
||||||
return {
|
const target = {
|
||||||
canonical: resolved.canonical,
|
canonical: resolved.canonical,
|
||||||
resource,
|
resource,
|
||||||
externalDirectory: external
|
externalDirectory: external
|
||||||
|
|
@ -154,6 +197,8 @@ const layer = Layer.effect(
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
} satisfies Target
|
} satisfies Target
|
||||||
|
simulationLog("location-mutation.resolve.result", { input, absolute, resolved, target })
|
||||||
|
return target
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({ resolve })
|
return Service.of({ resolve })
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Effect } from "effect"
|
||||||
import { SimulationProtocol } from "../protocol"
|
import { SimulationProtocol } from "../protocol"
|
||||||
import { SimulationLLMExchange } from "./llm-exchange"
|
import { SimulationLLMExchange } from "./llm-exchange"
|
||||||
import { SimulationNetwork } from "./network"
|
import { SimulationNetwork } from "./network"
|
||||||
|
import { SimulationLog } from "../log"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Backend-hosted simulation control WebSocket.
|
* Backend-hosted simulation control WebSocket.
|
||||||
|
|
@ -17,6 +18,7 @@ import { SimulationNetwork } from "./network"
|
||||||
* as `llm.request` notifications
|
* as `llm.request` notifications
|
||||||
* - `llm.chunk` { id, items } append response items to an exchange
|
* - `llm.chunk` { id, items } append response items to an exchange
|
||||||
* - `llm.finish` { id, reason? } finish an exchange
|
* - `llm.finish` { id, reason? } finish an exchange
|
||||||
|
* - `llm.disconnect` { id } abruptly terminate an exchange without a finish
|
||||||
* - `llm.pending` list open exchanges
|
* - `llm.pending` list open exchanges
|
||||||
* - `network.log` simulated network request log
|
* - `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()))
|
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> {
|
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) {
|
switch (request.method) {
|
||||||
case "llm.attach": {
|
case "llm.attach": {
|
||||||
socket.data.unsubscribe?.()
|
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 }]))
|
await Effect.runPromise(SimulationLLMExchange.push(params.id, [{ type: "finish", reason: params.reason }]))
|
||||||
return { ok: true }
|
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":
|
case "llm.pending":
|
||||||
return { exchanges: SimulationLLMExchange.pending() }
|
return { exchanges: SimulationLLMExchange.pending() }
|
||||||
case "network.log":
|
case "network.log":
|
||||||
|
|
@ -83,6 +96,10 @@ function serve(port = DefaultPort, attempts = MaxPortAttempts): Bun.Server<{ uns
|
||||||
const response = SimulationProtocol.JsonRpc.success(request.id, result)
|
const response = SimulationProtocol.JsonRpc.success(request.id, result)
|
||||||
if (response) socket.send(JSON.stringify(response))
|
if (response) socket.send(JSON.stringify(response))
|
||||||
} catch (error) {
|
} 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)))
|
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() {
|
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}`
|
const url = `ws://${server.hostname}:${server.port}`
|
||||||
|
SimulationLog.add("backend.control.start", { url })
|
||||||
process.stderr.write(`opencode simulation backend control websocket: ${url}\n`)
|
process.stderr.write(`opencode simulation backend control websocket: ${url}\n`)
|
||||||
return {
|
return {
|
||||||
url,
|
url,
|
||||||
stop: () => {
|
stop: () => {
|
||||||
|
SimulationLog.add("backend.control.stop", { url })
|
||||||
server.stop(true)
|
server.stop(true)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Effect, FileSystem, Layer, Option, Stream } from "effect"
|
||||||
import { systemError, type PlatformError, type SystemErrorTag } from "effect/PlatformError"
|
import { systemError, type PlatformError, type SystemErrorTag } from "effect/PlatformError"
|
||||||
import nodeFs from "fs"
|
import nodeFs from "fs"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { SimulationLog } from "../log"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In-memory simulated `FileSystem.FileSystem`.
|
* In-memory simulated `FileSystem.FileSystem`.
|
||||||
|
|
@ -43,6 +44,10 @@ export function make(options: Options): FileSystem.FileSystem {
|
||||||
const temp = { value: 0 }
|
const temp = { value: 0 }
|
||||||
const encoder = new TextEncoder()
|
const encoder = new TextEncoder()
|
||||||
store.set(root, makeDirectoryEntry())
|
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))
|
const within = (resolved: string) => resolved === root || resolved.startsWith(withSep(root))
|
||||||
|
|
||||||
|
|
@ -120,6 +125,7 @@ export function make(options: Options): FileSystem.FileSystem {
|
||||||
Effect.suspend(() => {
|
Effect.suspend(() => {
|
||||||
const resolved = path.resolve(root, file)
|
const resolved = path.resolve(root, file)
|
||||||
const entry = within(resolved) ? store.get(resolved) : undefined
|
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)
|
if (!entry) return fail("NotFound", method, file)
|
||||||
return Effect.succeed(entry)
|
return Effect.succeed(entry)
|
||||||
})
|
})
|
||||||
|
|
@ -142,6 +148,7 @@ export function make(options: Options): FileSystem.FileSystem {
|
||||||
requireEntry("readFile", file).pipe(
|
requireEntry("readFile", file).pipe(
|
||||||
Effect.flatMap(([, entry]) => {
|
Effect.flatMap(([, entry]) => {
|
||||||
if (entry.type !== "File") return fail("BadResource", "readFile", file, "path is a directory")
|
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())
|
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")
|
if (existing?.type === "Directory") return fail("BadResource", "writeFile", file, "path is a directory")
|
||||||
return requireParentDirectory("writeFile", resolved, file).pipe(
|
return requireParentDirectory("writeFile", resolved, file).pipe(
|
||||||
Effect.map(() => {
|
Effect.map(() => {
|
||||||
|
SimulationLog.add("filesystem.writeFile", { file, resolved, bytes: data.length })
|
||||||
store.set(resolved, {
|
store.set(resolved, {
|
||||||
type: "File",
|
type: "File",
|
||||||
content: data.slice(),
|
content: data.slice(),
|
||||||
|
|
@ -185,7 +193,9 @@ export function make(options: Options): FileSystem.FileSystem {
|
||||||
const names = readOptions?.recursive
|
const names = readOptions?.recursive
|
||||||
? children.map((key) => path.relative(resolved, key))
|
? children.map((key) => path.relative(resolved, key))
|
||||||
: children.filter((key) => path.dirname(key) === resolved).map((key) => path.basename(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) {
|
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")
|
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 files: Record<string, Uint8Array> = {}
|
||||||
const walk = (dir: string) => {
|
const walk = (dir: string) => {
|
||||||
for (const entry of nodeFs.readdirSync(dir, { withFileTypes: true })) {
|
for (const entry of nodeFs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
|
@ -353,6 +369,7 @@ function loadSnapshotFiles(stateDirectory: string | undefined) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
walk(project)
|
walk(project)
|
||||||
|
SimulationLog.add("snapshot.load", { stateDirectory, project, files: Object.keys(files).sort() })
|
||||||
return files
|
return files
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,29 +4,142 @@ import { Glob } from "@opencode-ai/core/util/glob"
|
||||||
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
|
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
|
||||||
import { filesystem } from "@opencode-ai/core/effect/app-node-platform"
|
import { filesystem } from "@opencode-ai/core/effect/app-node-platform"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { SimulationLog } from "../log"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simulation replacement for `FSUtil`.
|
* Simulation replacement for `FSUtil`.
|
||||||
*
|
*
|
||||||
* The real `FSUtil` layer builds most helpers on the injected
|
* This implementation is intentionally self-contained and only uses the
|
||||||
* `FileSystem.FileSystem`, but `readDirectoryEntries`, `glob`, and `globUp`
|
* injected simulated `FileSystem.FileSystem`. The default FSUtil layer has a
|
||||||
* reach for node `fs/promises` and the `glob` package directly, and `resolve`
|
* few helpers that reach host-node APIs directly; depending on it here makes it
|
||||||
* canonicalizes through the host filesystem. This wraps the real layer and
|
* easy for mutation paths to escape or miss the in-memory project tree.
|
||||||
* reroutes those through the injected `FileSystem`/lexical path resolution so
|
|
||||||
* every read observes the in-memory tree.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const layer = Layer.effect(
|
const layer = Layer.effect(
|
||||||
FSUtil.Service,
|
FSUtil.Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const base = yield* FSUtil.Service
|
|
||||||
const fs = yield* FileSystem.FileSystem
|
const fs = yield* FileSystem.FileSystem
|
||||||
|
|
||||||
const resolve = Effect.fn("SimulationFSUtil.resolve")(function* (input: string) {
|
const existsSafe = Effect.fn("SimulationFSUtil.existsSafe")(function* (file: string) {
|
||||||
return input
|
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) {
|
const readDirectoryEntries = Effect.fn("SimulationFSUtil.readDirectoryEntries")(function* (dirPath: string) {
|
||||||
|
SimulationLog.add("fsutil.readDirectoryEntries", { dirPath })
|
||||||
const names = yield* fs.readDirectory(dirPath)
|
const names = yield* fs.readDirectory(dirPath)
|
||||||
return yield* Effect.forEach(names, (name) =>
|
return yield* Effect.forEach(names, (name) =>
|
||||||
fs.stat(path.join(dirPath, name)).pipe(
|
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 glob = Effect.fn("SimulationFSUtil.glob")(function* (pattern: string, options?: Glob.Options) {
|
||||||
const cwd = path.resolve(options?.cwd ?? process.cwd())
|
const cwd = path.resolve(options?.cwd ?? process.cwd())
|
||||||
|
SimulationLog.add("fsutil.glob", { pattern, cwd, options })
|
||||||
const entries = yield* fs
|
const entries = yield* fs
|
||||||
.readDirectory(cwd, { recursive: true })
|
.readDirectory(cwd, { recursive: true })
|
||||||
.pipe(Effect.orElseSucceed(() => [] as string[]))
|
.pipe(Effect.orElseSucceed(() => [] as string[]))
|
||||||
|
|
@ -59,15 +179,18 @@ const layer = Layer.effect(
|
||||||
Effect.orElseSucceed(() => undefined),
|
Effect.orElseSucceed(() => undefined),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return matches
|
const result = matches
|
||||||
.filter((item) => item !== undefined)
|
.filter((item) => item !== undefined)
|
||||||
.filter((item) => options?.include === "all" || item.type === "File")
|
.filter((item) => options?.include === "all" || item.type === "File")
|
||||||
.filter((item) => Glob.match(pattern, item.entry))
|
.filter((item) => Glob.match(pattern, item.entry))
|
||||||
.map((item) => (options?.absolute ? path.join(cwd, item.entry) : item.entry))
|
.map((item) => (options?.absolute ? path.join(cwd, item.entry) : item.entry))
|
||||||
.sort((a, b) => a.localeCompare(b))
|
.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) {
|
const globUp = Effect.fn("SimulationFSUtil.globUp")(function* (pattern: string, start: string, stop?: string) {
|
||||||
|
SimulationLog.add("fsutil.globUp", { pattern, start, stop })
|
||||||
const result: string[] = []
|
const result: string[] = []
|
||||||
let current = path.resolve(start)
|
let current = path.resolve(start)
|
||||||
while (true) {
|
while (true) {
|
||||||
|
|
@ -77,12 +200,59 @@ const layer = Layer.effect(
|
||||||
if (parent === current) break
|
if (parent === current) break
|
||||||
current = parent
|
current = parent
|
||||||
}
|
}
|
||||||
|
SimulationLog.add("fsutil.globUp.result", { pattern, start, stop, result })
|
||||||
return 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] })
|
export const node = makeGlobalNode({ service: FSUtil.Service, layer, deps: [filesystem] })
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { SimulationFileSystem } from "./filesystem"
|
||||||
import { SimulationFSUtil } from "./fs-util"
|
import { SimulationFSUtil } from "./fs-util"
|
||||||
import { SimulationNetwork } from "./network"
|
import { SimulationNetwork } from "./network"
|
||||||
import { SimulationOpenAI } from "./openai"
|
import { SimulationOpenAI } from "./openai"
|
||||||
|
import { SimulationLog } from "../log"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Layer replacements applied when the server is built in simulation mode.
|
* 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).
|
* 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)
|
SimulationNetwork.register(SimulationOpenAI.route)
|
||||||
|
SimulationLog.add("network.route.register", { name: "openai-chat" })
|
||||||
// ModelsDev dies when its catalog fetch fails, so simulation answers it with
|
// ModelsDev dies when its catalog fetch fails, so simulation answers it with
|
||||||
// an empty catalog; providers come from seeded config instead.
|
// an empty catalog; providers come from seeded config instead.
|
||||||
SimulationNetwork.register(SimulationNetwork.json("GET", "https://models.dev/api.json", {}))
|
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()
|
SimulationControl.start()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Effect, Queue } from "effect"
|
import { Effect, Queue } from "effect"
|
||||||
|
import { SimulationLog } from "../log"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pending driver-answered LLM exchanges.
|
* 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 queue = yield* Queue.unbounded<Chunk>()
|
||||||
const exchange: Exchange = { id, url: input.url, body: input.body, queue }
|
const exchange: Exchange = { id, url: input.url, body: input.body, queue }
|
||||||
state.exchanges.set(id, exchange)
|
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 })
|
for (const listener of state.listeners) listener({ id, url: input.url, body: input.body })
|
||||||
return exchange
|
return exchange
|
||||||
})
|
})
|
||||||
|
|
@ -68,6 +70,7 @@ export const close = (id: string) =>
|
||||||
Effect.suspend(() => {
|
Effect.suspend(() => {
|
||||||
const exchange = state.exchanges.get(id)
|
const exchange = state.exchanges.get(id)
|
||||||
state.exchanges.delete(id)
|
state.exchanges.delete(id)
|
||||||
|
SimulationLog.add("llm.close", { id, found: exchange !== undefined, pending: state.exchanges.size })
|
||||||
if (!exchange) return Effect.void
|
if (!exchange) return Effect.void
|
||||||
return Queue.shutdown(exchange.queue).pipe(Effect.asVoid)
|
return Queue.shutdown(exchange.queue).pipe(Effect.asVoid)
|
||||||
})
|
})
|
||||||
|
|
@ -77,9 +80,19 @@ export const push = (id: string, chunks: readonly Chunk[]) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const exchange = state.exchanges.get(id)
|
const exchange = state.exchanges.get(id)
|
||||||
if (!exchange) return yield* Effect.fail(new ExchangeNotFoundError(id))
|
if (!exchange) return yield* Effect.fail(new ExchangeNotFoundError(id))
|
||||||
|
SimulationLog.add("llm.push", { id, chunks })
|
||||||
yield* Queue.offerAll(exchange.queue, 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
|
* Registers a listener for newly opened exchanges and immediately replays
|
||||||
* currently-pending ones, so a late-attaching driver observes requests that
|
* 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) {
|
export function subscribe(listener: (exchange: OpenedExchange) => void) {
|
||||||
state.listeners.add(listener)
|
state.listeners.add(listener)
|
||||||
|
SimulationLog.add("llm.subscribe", { listeners: state.listeners.size, pending: state.exchanges.size })
|
||||||
for (const exchange of pending()) listener(exchange)
|
for (const exchange of pending()) listener(exchange)
|
||||||
return () => {
|
return () => {
|
||||||
state.listeners.delete(listener)
|
state.listeners.delete(listener)
|
||||||
|
SimulationLog.add("llm.unsubscribe", { listeners: state.listeners.size, pending: state.exchanges.size })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Effect, Layer } from "effect"
|
||||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||||
import { HttpClientError, TransportError } from "effect/unstable/http/HttpClientError"
|
import { HttpClientError, TransportError } from "effect/unstable/http/HttpClientError"
|
||||||
import type { HttpClientRequest } from "effect/unstable/http"
|
import type { HttpClientRequest } from "effect/unstable/http"
|
||||||
|
import { SimulationLog } from "../log"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simulated network.
|
* Simulated network.
|
||||||
|
|
@ -40,9 +41,11 @@ const LOG_LIMIT = 1000
|
||||||
|
|
||||||
export function register(route: Route) {
|
export function register(route: Route) {
|
||||||
state.routes.push(route)
|
state.routes.push(route)
|
||||||
|
SimulationLog.add("network.register", { routes: state.routes.length })
|
||||||
return () => {
|
return () => {
|
||||||
const index = state.routes.indexOf(route)
|
const index = state.routes.indexOf(route)
|
||||||
if (index >= 0) state.routes.splice(index, 1)
|
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) {
|
function record(entry: LogEntry) {
|
||||||
state.log.push(entry)
|
state.log.push(entry)
|
||||||
if (state.log.length > LOG_LIMIT) state.log.splice(0, state.log.length - LOG_LIMIT)
|
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)(() =>
|
export const layer = Layer.sync(HttpClient.HttpClient)(() =>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { HttpClientResponse } from "effect/unstable/http"
|
||||||
import { OpenAIChatEvent, DEFAULT_BASE_URL, PATH } from "@opencode-ai/llm/protocols/openai-chat"
|
import { OpenAIChatEvent, DEFAULT_BASE_URL, PATH } from "@opencode-ai/llm/protocols/openai-chat"
|
||||||
import { SimulationLLMExchange } from "./llm-exchange"
|
import { SimulationLLMExchange } from "./llm-exchange"
|
||||||
import { SimulationNetwork } from "./network"
|
import { SimulationNetwork } from "./network"
|
||||||
|
import { SimulationLog } from "../log"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Driver-answered OpenAI endpoint for the simulated network.
|
* 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
|
if (url.origin + url.pathname !== DEFAULT_BASE_URL + PATH) return undefined
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const body = request.body._tag === "Uint8Array" ? JSON.parse(new TextDecoder().decode(request.body.body)) : {}
|
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 })
|
const exchange = yield* SimulationLLMExchange.open({ url: url.toString(), body })
|
||||||
return HttpClientResponse.fromWeb(
|
return HttpClientResponse.fromWeb(
|
||||||
request,
|
request,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { createMockKeys, createMockMouse, type MockInput, type MockMouse } from
|
||||||
import type { SimulationProtocol } from "../protocol"
|
import type { SimulationProtocol } from "../protocol"
|
||||||
import { SimulationRenderer } from "./renderer"
|
import { SimulationRenderer } from "./renderer"
|
||||||
import { SimulationTrace } from "./trace"
|
import { SimulationTrace } from "./trace"
|
||||||
|
import { SimulationLog } from "../log"
|
||||||
|
|
||||||
export type Action = SimulationProtocol.Frontend.Action
|
export type Action = SimulationProtocol.Frontend.Action
|
||||||
export type Element = SimulationProtocol.Frontend.Element
|
export type Element = SimulationProtocol.Frontend.Element
|
||||||
|
|
@ -112,6 +113,11 @@ export function actions(renderer: CliRenderer, options: { text?: string } = {}):
|
||||||
}
|
}
|
||||||
|
|
||||||
export function state(harness: Harness) {
|
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 {
|
return {
|
||||||
screen: harness.screen(),
|
screen: harness.screen(),
|
||||||
focused: {
|
focused: {
|
||||||
|
|
@ -125,6 +131,7 @@ export function state(harness: Harness) {
|
||||||
|
|
||||||
export async function execute(harness: Harness, action: Action) {
|
export async function execute(harness: Harness, action: Action) {
|
||||||
SimulationTrace.add("ui.action", { action })
|
SimulationTrace.add("ui.action", { action })
|
||||||
|
SimulationLog.add("ui.action", { action })
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "typeText":
|
case "typeText":
|
||||||
await harness.mockInput.typeText(action.text)
|
await harness.mockInput.typeText(action.text)
|
||||||
|
|
|
||||||
54
packages/simulation/src/frontend/event-stream.ts
Normal file
54
packages/simulation/src/frontend/event-stream.ts
Normal 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"
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { SimulationProtocol } from "../protocol"
|
import { SimulationProtocol } from "../protocol"
|
||||||
import { SimulationActions, type Harness } from "./actions"
|
import { SimulationActions, type Harness } from "./actions"
|
||||||
import { SimulationTrace } from "./trace"
|
import { SimulationTrace } from "./trace"
|
||||||
|
import { SimulationLog } from "../log"
|
||||||
|
import { SimulationEventStream } from "./event-stream"
|
||||||
|
|
||||||
const DefaultPort = 40900
|
const DefaultPort = 40900
|
||||||
const MaxPortAttempts = 100
|
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")
|
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) {
|
function actionParam(params: unknown) {
|
||||||
return SimulationProtocol.Frontend.decodeActionParams(params).action
|
return SimulationProtocol.Frontend.decodeActionParams(params).action
|
||||||
}
|
}
|
||||||
|
|
@ -28,6 +35,7 @@ function parseRequest(input: string | Buffer) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handle(harness: Harness, request: SimulationProtocol.JsonRpc.Request) {
|
async function handle(harness: Harness, request: SimulationProtocol.JsonRpc.Request) {
|
||||||
|
SimulationLog.add("frontend.request", { method: request.method, id: request.id })
|
||||||
switch (request.method) {
|
switch (request.method) {
|
||||||
case "ui.state": {
|
case "ui.state": {
|
||||||
const result = SimulationActions.state(harness)
|
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 })
|
SimulationTrace.add("ui.render", { elements: result.elements.length, actions: result.actions.length })
|
||||||
return result
|
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":
|
case "trace.list":
|
||||||
return { records: SimulationTrace.list() }
|
return { records: SimulationTrace.list() }
|
||||||
case "trace.clear":
|
case "trace.clear":
|
||||||
|
|
@ -81,6 +97,10 @@ function serve(
|
||||||
const next = SimulationProtocol.JsonRpc.success(request.id, result)
|
const next = SimulationProtocol.JsonRpc.success(request.id, result)
|
||||||
if (next) socket.send(JSON.stringify(next))
|
if (next) socket.send(JSON.stringify(next))
|
||||||
} catch (error) {
|
} 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)))
|
socket.send(JSON.stringify(SimulationProtocol.JsonRpc.failure(request?.id, error)))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -94,13 +114,16 @@ function serve(
|
||||||
|
|
||||||
export function start(harness: Harness): Server | undefined {
|
export function start(harness: Harness): Server | undefined {
|
||||||
if (!isEnabled()) return
|
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}`
|
const url = `ws://${server.hostname}:${server.port}`
|
||||||
SimulationTrace.add("control.start", { url })
|
SimulationTrace.add("control.start", { url })
|
||||||
|
SimulationLog.add("frontend.start", { url })
|
||||||
return {
|
return {
|
||||||
url,
|
url,
|
||||||
stop: () => {
|
stop: () => {
|
||||||
SimulationTrace.add("control.stop", { url })
|
SimulationTrace.add("control.stop", { url })
|
||||||
|
SimulationLog.add("frontend.stop", { url })
|
||||||
server.stop(true)
|
server.stop(true)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { createCliRenderer, type CliRenderer, type CliRendererConfig } from "@op
|
||||||
import { SimulationActions } from "./actions"
|
import { SimulationActions } from "./actions"
|
||||||
import { SimulationRenderer } from "./renderer"
|
import { SimulationRenderer } from "./renderer"
|
||||||
import { SimulationServer } from "./server"
|
import { SimulationServer } from "./server"
|
||||||
|
import { SimulationLog } from "../log"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simulation-mode renderer entry point.
|
* Simulation-mode renderer entry point.
|
||||||
|
|
@ -12,12 +13,17 @@ import { SimulationServer } from "./server"
|
||||||
* caller only manages the renderer lifecycle.
|
* caller only manages the renderer lifecycle.
|
||||||
*/
|
*/
|
||||||
export async function createSimulation(options: CliRendererConfig): Promise<CliRenderer> {
|
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 =
|
const renderer =
|
||||||
process.env.OPENCODE_SIMULATION_RENDERER === "fake"
|
process.env.OPENCODE_SIMULATION_RENDERER === "fake"
|
||||||
? await SimulationRenderer.create(options)
|
? await SimulationRenderer.create(options)
|
||||||
: await createCliRenderer(options)
|
: await createCliRenderer(options)
|
||||||
const server = SimulationServer.start(SimulationActions.createHarness(renderer))
|
const server = SimulationServer.start(SimulationActions.createHarness(renderer))
|
||||||
if (server) {
|
if (server) {
|
||||||
|
process.stderr.write(`opencode simulation log: ${SimulationLog.filePath()}\n`)
|
||||||
process.stderr.write(`opencode simulation websocket: ${server.url}\n`)
|
process.stderr.write(`opencode simulation websocket: ${server.url}\n`)
|
||||||
renderer.once("destroy", () => server.stop())
|
renderer.once("destroy", () => server.stop())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
42
packages/simulation/src/log.ts
Normal file
42
packages/simulation/src/log.ts
Normal 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"
|
||||||
|
|
@ -127,6 +127,9 @@ export namespace Backend {
|
||||||
})
|
})
|
||||||
export interface FinishParams extends Schema.Schema.Type<typeof FinishParams> {}
|
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 const OpenedExchange = Schema.Struct({ id: Schema.String, url: Schema.String, body: Schema.Json })
|
||||||
export interface OpenedExchange extends Schema.Schema.Type<typeof OpenedExchange> {}
|
export interface OpenedExchange extends Schema.Schema.Type<typeof OpenedExchange> {}
|
||||||
|
|
||||||
|
|
@ -140,6 +143,7 @@ export namespace Backend {
|
||||||
|
|
||||||
export const decodeChunkParams = Schema.decodeUnknownPromise(ChunkParams)
|
export const decodeChunkParams = Schema.decodeUnknownPromise(ChunkParams)
|
||||||
export const decodeFinishParams = Schema.decodeUnknownPromise(FinishParams)
|
export const decodeFinishParams = Schema.decodeUnknownPromise(FinishParams)
|
||||||
|
export const decodeDisconnectParams = Schema.decodeUnknownPromise(DisconnectParams)
|
||||||
}
|
}
|
||||||
|
|
||||||
export * as SimulationProtocol from "./index"
|
export * as SimulationProtocol from "./index"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { OpenCodeClient } from "@opencode-ai/client/promise"
|
import type { OpenCodeClient } from "@opencode-ai/client/promise"
|
||||||
import type { OpencodeClient, V2Event } from "@opencode-ai/sdk/v2"
|
import type { OpencodeClient, V2Event } from "@opencode-ai/sdk/v2"
|
||||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||||
|
import { SimulationEventStream } from "@opencode-ai/simulation/frontend/event-stream"
|
||||||
import { onCleanup, onMount } from "solid-js"
|
import { onCleanup, onMount } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
|
|
@ -46,7 +47,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||||
void (async () => {
|
void (async () => {
|
||||||
let attempt = 0
|
let attempt = 0
|
||||||
while (!abort.signal.aborted && !controller.signal.aborted) {
|
while (!abort.signal.aborted && !controller.signal.aborted) {
|
||||||
|
await SimulationEventStream.beforeConnect(controller.signal)
|
||||||
const connection = new AbortController()
|
const connection = new AbortController()
|
||||||
|
const detachSimulation = SimulationEventStream.attach(connection)
|
||||||
const cancel = () => connection.abort(controller.signal.reason)
|
const cancel = () => connection.abort(controller.signal.reason)
|
||||||
const timeout = setTimeout(
|
const timeout = setTimeout(
|
||||||
() => connection.abort(new Error("Timed out connecting to server")),
|
() => connection.abort(new Error("Timed out connecting to server")),
|
||||||
|
|
@ -81,6 +84,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||||
})()
|
})()
|
||||||
.catch((error) => error)
|
.catch((error) => error)
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
detachSimulation()
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
controller.signal.removeEventListener("abort", cancel)
|
controller.signal.removeEventListener("abort", cancel)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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<typeof useData>
|
||||||
|
|
||||||
|
function Probe() {
|
||||||
|
data = useData()
|
||||||
|
return <box />
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await testRender(() => (
|
||||||
|
<TestTuiContexts>
|
||||||
|
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
|
||||||
|
<ProjectProvider>
|
||||||
|
<DataProvider>
|
||||||
|
<Probe />
|
||||||
|
</DataProvider>
|
||||||
|
</ProjectProvider>
|
||||||
|
</SDKProvider>
|
||||||
|
</TestTuiContexts>
|
||||||
|
))
|
||||||
|
|
||||||
|
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 () => {
|
test("refreshes integrations after integration updates", async () => {
|
||||||
const events = createEventStream()
|
const events = createEventStream()
|
||||||
const requests = { integration: 0, model: 0, provider: 0 }
|
const requests = { integration: 0, model: 0, provider: 0 }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue