feat(simulation): add named drive instances (#35648)
This commit is contained in:
parent
04b673432c
commit
fcb1d4b418
12 changed files with 199 additions and 173 deletions
|
|
@ -22,9 +22,6 @@ import { SimulationNetwork } from "./network"
|
|||
* - `network.log` simulated network request log
|
||||
*/
|
||||
|
||||
const DefaultPort = 40950
|
||||
const MaxPortAttempts = 100
|
||||
|
||||
type ControlSocket = Bun.ServerWebSocket<{ unsubscribe?: () => void }>
|
||||
|
||||
function parseRequest(input: string | Buffer) {
|
||||
|
|
@ -68,46 +65,35 @@ async function handle(socket: ControlSocket, request: SimulationProtocol.JsonRpc
|
|||
throw new Error(`Unknown simulation control method: ${request.method}`)
|
||||
}
|
||||
|
||||
function serve(port = DefaultPort, attempts = MaxPortAttempts): Bun.Server<{ unsubscribe?: () => void }> {
|
||||
try {
|
||||
return Bun.serve<{ unsubscribe?: () => void }>({
|
||||
hostname: "127.0.0.1",
|
||||
port,
|
||||
fetch(request, server) {
|
||||
if (server.upgrade(request, { data: {} })) return undefined
|
||||
return new Response("opencode simulation control websocket", { status: 426 })
|
||||
export function start(endpoint: string) {
|
||||
const url = new URL(endpoint)
|
||||
const server = Bun.serve<{ unsubscribe?: () => void }>({
|
||||
hostname: url.hostname,
|
||||
port: Number(url.port),
|
||||
fetch(request, server) {
|
||||
if (server.upgrade(request, { data: {} })) return undefined
|
||||
return new Response("opencode drive backend websocket", { status: 426 })
|
||||
},
|
||||
websocket: {
|
||||
close(socket) {
|
||||
socket.data.unsubscribe?.()
|
||||
},
|
||||
websocket: {
|
||||
close(socket) {
|
||||
socket.data.unsubscribe?.()
|
||||
},
|
||||
async message(socket, message) {
|
||||
let request: SimulationProtocol.JsonRpc.Request | undefined
|
||||
try {
|
||||
request = parseRequest(message)
|
||||
const result = await handle(socket, request)
|
||||
const response = SimulationProtocol.JsonRpc.success(request.id, result)
|
||||
if (response) socket.send(JSON.stringify(response))
|
||||
} catch (error) {
|
||||
socket.send(JSON.stringify(SimulationProtocol.JsonRpc.failure(request?.id, error)))
|
||||
}
|
||||
},
|
||||
async message(socket, message) {
|
||||
let request: SimulationProtocol.JsonRpc.Request | undefined
|
||||
try {
|
||||
request = parseRequest(message)
|
||||
const result = await handle(socket, request)
|
||||
const response = SimulationProtocol.JsonRpc.success(request.id, result)
|
||||
if (response) socket.send(JSON.stringify(response))
|
||||
} catch (error) {
|
||||
socket.send(JSON.stringify(SimulationProtocol.JsonRpc.failure(request?.id, error)))
|
||||
}
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase()
|
||||
const unavailable = message.includes("eaddrinuse") || message.includes("in use")
|
||||
if (!unavailable || attempts <= 1 || port >= 65535) throw error
|
||||
return serve(port + 1, attempts - 1)
|
||||
}
|
||||
}
|
||||
|
||||
export function start() {
|
||||
const server = serve()
|
||||
const url = `ws://${server.hostname}:${server.port}`
|
||||
process.stderr.write(`opencode simulation backend control websocket: ${url}\n`)
|
||||
},
|
||||
})
|
||||
process.stderr.write(`opencode drive backend websocket: ${endpoint}\n`)
|
||||
return {
|
||||
url,
|
||||
url: endpoint,
|
||||
stop: () => {
|
||||
server.stop(true)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -328,31 +328,31 @@ export function make(options: Options): FileSystem.FileSystem {
|
|||
* Lazily constructed layer so the root defaults to `process.cwd()` at
|
||||
* layer-build time (the simulation anchor directory), not at import time.
|
||||
*
|
||||
* When `OPENCODE_SIMULATION_STATE` points at a snapshot directory, its
|
||||
* `project/` contents are read from the host once at build time and seeded
|
||||
* When `OPENCODE_SIMULATE_STATE` points at a snapshot directory, its
|
||||
* `files/` contents are read from the host once at build time and seeded
|
||||
* into the in-memory tree, joined onto the anchor root.
|
||||
*/
|
||||
export const layer = (options?: Partial<Options>) =>
|
||||
Layer.sync(FileSystem.FileSystem)(() =>
|
||||
make({
|
||||
root: options?.root ?? process.cwd(),
|
||||
files: { ...loadSnapshotFiles(process.env.OPENCODE_SIMULATION_STATE), ...options?.files },
|
||||
files: { ...loadSnapshotFiles(process.env.OPENCODE_SIMULATE_STATE), ...options?.files },
|
||||
}),
|
||||
)
|
||||
|
||||
function loadSnapshotFiles(stateDirectory: string | undefined) {
|
||||
if (!stateDirectory) return {}
|
||||
const project = path.join(stateDirectory, "project")
|
||||
if (!nodeFs.existsSync(project)) return {}
|
||||
const snapshot = path.join(stateDirectory, "files")
|
||||
if (!nodeFs.existsSync(snapshot)) return {}
|
||||
const files: Record<string, Uint8Array> = {}
|
||||
const walk = (dir: string) => {
|
||||
for (const entry of nodeFs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const file = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) walk(file)
|
||||
if (entry.isFile()) files[path.relative(project, file)] = new Uint8Array(nodeFs.readFileSync(file))
|
||||
if (entry.isFile()) files[path.relative(snapshot, file)] = new Uint8Array(nodeFs.readFileSync(file))
|
||||
}
|
||||
}
|
||||
walk(project)
|
||||
walk(snapshot)
|
||||
return files
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { filesystem, httpClient } from "@opencode-ai/core/effect/app-node-platform"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { DriveManifest } from "../manifest"
|
||||
import { SimulationControl } from "./control"
|
||||
import { SimulationFileSystem } from "./filesystem"
|
||||
import { SimulationFSUtil } from "./fs-util"
|
||||
|
|
@ -10,19 +11,15 @@ import { SimulationOpenAI } from "./openai"
|
|||
/**
|
||||
* Layer replacements applied when the server is built in simulation mode.
|
||||
*
|
||||
* The server merges these into the app node build when `OPENCODE_SIMULATION`
|
||||
* The server merges these into the app node build when `OPENCODE_SIMULATE`
|
||||
* is enabled, via a dynamic import so this module is never loaded eagerly.
|
||||
*
|
||||
* - Filesystem: in-memory tree rooted at `OPENCODE_SIMULATION_ROOT` (the real,
|
||||
* empty anchor directory the runner created and chdir'd into). Everything
|
||||
* under the root lives in memory; paths outside it fail loudly.
|
||||
* - Filesystem: in-memory tree rooted at the current working directory.
|
||||
* Everything under the root lives in memory; paths outside it fail loudly.
|
||||
* - Network: all outbound HTTP resolves against the simulated route table;
|
||||
* unknown destinations are denied. The driver-answered OpenAI endpoint is
|
||||
* registered here as the first route.
|
||||
*
|
||||
* Loading this module also starts the backend simulation control WebSocket,
|
||||
* which drivers connect to directly for LLM exchange control and network
|
||||
* inspection (standalone topology; also the headless-simulation interface).
|
||||
*/
|
||||
|
||||
SimulationNetwork.register(SimulationOpenAI.route)
|
||||
|
|
@ -30,10 +27,12 @@ SimulationNetwork.register(SimulationOpenAI.route)
|
|||
// an empty catalog; providers come from seeded config instead.
|
||||
SimulationNetwork.register(SimulationNetwork.json("GET", "https://models.dev/api.json", {}))
|
||||
|
||||
SimulationControl.start()
|
||||
export function startDriveServer() {
|
||||
return SimulationControl.start(DriveManifest.resolve().endpoints.backend)
|
||||
}
|
||||
|
||||
export const simulationReplacements: LayerNode.Replacements = [
|
||||
[filesystem, SimulationFileSystem.layer({ root: process.env.OPENCODE_SIMULATION_ROOT })],
|
||||
[filesystem, SimulationFileSystem.layer()],
|
||||
[FSUtil.node, SimulationFSUtil.node],
|
||||
[httpClient, SimulationNetwork.layer],
|
||||
]
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ const setups = new WeakMap<CliRenderer, TestRendererSetup>()
|
|||
export async function create(options: CliRendererConfig): Promise<CliRenderer> {
|
||||
const setup = await createTestRenderer({
|
||||
...options,
|
||||
width: Number(process.env.OPENCODE_SIMULATION_TUI_WIDTH) || 100,
|
||||
height: Number(process.env.OPENCODE_SIMULATION_TUI_HEIGHT) || 40,
|
||||
width: 100,
|
||||
height: 40,
|
||||
})
|
||||
setups.set(setup.renderer, setup)
|
||||
return setup.renderer
|
||||
|
|
|
|||
|
|
@ -2,23 +2,11 @@ import { SimulationProtocol } from "../protocol"
|
|||
import { SimulationActions, type Harness } from "./actions"
|
||||
import { SimulationTrace } from "./trace"
|
||||
|
||||
const DefaultPort = 40900
|
||||
const MaxPortAttempts = 100
|
||||
|
||||
export interface Server {
|
||||
readonly url: string
|
||||
readonly stop: () => void
|
||||
}
|
||||
|
||||
function isEnabled() {
|
||||
return process.env.OPENCODE_SIMULATION === "1" || process.env.OPENCODE_SIMULATION === "true"
|
||||
}
|
||||
|
||||
function isPortUnavailable(error: unknown) {
|
||||
const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase()
|
||||
return message.includes("eaddrinuse") || message.includes("address already in use") || message.includes(" in use")
|
||||
}
|
||||
|
||||
function actionParam(params: unknown) {
|
||||
return SimulationProtocol.Frontend.decodeActionParams(params).action
|
||||
}
|
||||
|
|
@ -53,54 +41,40 @@ async function handle(harness: Harness, request: SimulationProtocol.JsonRpc.Requ
|
|||
throw new Error(`Unknown simulation method: ${request.method}`)
|
||||
}
|
||||
|
||||
function serve(
|
||||
harness: Harness,
|
||||
port = DefaultPort,
|
||||
attempts = MaxPortAttempts,
|
||||
): Bun.Server<{ readonly simulation: true }> {
|
||||
try {
|
||||
return Bun.serve<{ readonly simulation: true }>({
|
||||
hostname: "127.0.0.1",
|
||||
port,
|
||||
fetch(request, server) {
|
||||
if (server.upgrade(request, { data: { simulation: true } })) return undefined
|
||||
return new Response("opencode simulation websocket", { status: 426 })
|
||||
export function start(harness: Harness, endpoint: string): Server {
|
||||
const url = new URL(endpoint)
|
||||
const server = Bun.serve<{ readonly drive: true }>({
|
||||
hostname: url.hostname,
|
||||
port: Number(url.port),
|
||||
fetch(request, server) {
|
||||
if (server.upgrade(request, { data: { drive: true } })) return undefined
|
||||
return new Response("opencode drive ui websocket", { status: 426 })
|
||||
},
|
||||
websocket: {
|
||||
open() {
|
||||
SimulationTrace.add("control.connect")
|
||||
},
|
||||
websocket: {
|
||||
open() {
|
||||
SimulationTrace.add("control.connect")
|
||||
},
|
||||
close() {
|
||||
SimulationTrace.add("control.disconnect")
|
||||
},
|
||||
async message(socket, message) {
|
||||
let request: SimulationProtocol.JsonRpc.Request | undefined
|
||||
try {
|
||||
request = parseRequest(message)
|
||||
const result = await handle(harness, request)
|
||||
const next = SimulationProtocol.JsonRpc.success(request.id, result)
|
||||
if (next) socket.send(JSON.stringify(next))
|
||||
} catch (error) {
|
||||
socket.send(JSON.stringify(SimulationProtocol.JsonRpc.failure(request?.id, error)))
|
||||
}
|
||||
},
|
||||
close() {
|
||||
SimulationTrace.add("control.disconnect")
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (!isPortUnavailable(error) || attempts <= 1 || port >= 65535) throw error
|
||||
return serve(harness, port + 1, attempts - 1)
|
||||
}
|
||||
}
|
||||
|
||||
export function start(harness: Harness): Server | undefined {
|
||||
if (!isEnabled()) return
|
||||
const server = serve(harness)
|
||||
const url = `ws://${server.hostname}:${server.port}`
|
||||
SimulationTrace.add("control.start", { url })
|
||||
async message(socket, message) {
|
||||
let request: SimulationProtocol.JsonRpc.Request | undefined
|
||||
try {
|
||||
request = parseRequest(message)
|
||||
const result = await handle(harness, request)
|
||||
const next = SimulationProtocol.JsonRpc.success(request.id, result)
|
||||
if (next) socket.send(JSON.stringify(next))
|
||||
} catch (error) {
|
||||
socket.send(JSON.stringify(SimulationProtocol.JsonRpc.failure(request?.id, error)))
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
SimulationTrace.add("control.start", { url: endpoint })
|
||||
return {
|
||||
url,
|
||||
url: endpoint,
|
||||
stop: () => {
|
||||
SimulationTrace.add("control.stop", { url })
|
||||
SimulationTrace.add("control.stop", { url: endpoint })
|
||||
server.stop(true)
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,27 +1,29 @@
|
|||
import { createCliRenderer, type CliRenderer, type CliRendererConfig } from "@opentui/core"
|
||||
import { DriveManifest } from "../manifest"
|
||||
import { SimulationActions } from "./actions"
|
||||
import { SimulationRenderer } from "./renderer"
|
||||
import { SimulationServer } from "./server"
|
||||
|
||||
/**
|
||||
* Simulation-mode renderer entry point.
|
||||
* Drive-mode renderer entry point.
|
||||
*
|
||||
* Creates the renderer (fake when OPENCODE_SIMULATION_RENDERER=fake, the
|
||||
* normal visible renderer otherwise) and starts the simulation control
|
||||
* Creates the renderer (fake when OPENCODE_DRIVE_RENDERER=fake, the normal
|
||||
* visible renderer otherwise) and starts the UI control
|
||||
* server against it. The server stops when the renderer is destroyed, so the
|
||||
* caller only manages the renderer lifecycle.
|
||||
*/
|
||||
export async function createSimulation(options: CliRendererConfig): Promise<CliRenderer> {
|
||||
export async function create(options: CliRendererConfig): Promise<CliRenderer> {
|
||||
const renderer =
|
||||
process.env.OPENCODE_SIMULATION_RENDERER === "fake"
|
||||
process.env.OPENCODE_DRIVE_RENDERER === "fake"
|
||||
? await SimulationRenderer.create(options)
|
||||
: await createCliRenderer(options)
|
||||
const server = SimulationServer.start(SimulationActions.createHarness(renderer))
|
||||
if (server) {
|
||||
process.stderr.write(`opencode simulation websocket: ${server.url}\n`)
|
||||
renderer.once("destroy", () => server.stop())
|
||||
}
|
||||
const server = SimulationServer.start(
|
||||
SimulationActions.createHarness(renderer),
|
||||
DriveManifest.resolve().endpoints.ui,
|
||||
)
|
||||
process.stderr.write(`opencode drive ui websocket: ${server.url}\n`)
|
||||
renderer.once("destroy", () => server.stop())
|
||||
return renderer
|
||||
}
|
||||
|
||||
export * as Simulation from "./simulation"
|
||||
export * as Drive from "./simulation"
|
||||
|
|
|
|||
57
packages/simulation/src/manifest.ts
Normal file
57
packages/simulation/src/manifest.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { existsSync, readFileSync } from "node:fs"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
|
||||
export interface Manifest {
|
||||
readonly endpoints: {
|
||||
readonly ui: string
|
||||
readonly backend: string
|
||||
}
|
||||
}
|
||||
|
||||
export const defaults: Manifest = {
|
||||
endpoints: {
|
||||
ui: "ws://127.0.0.1:40900",
|
||||
backend: "ws://127.0.0.1:40950",
|
||||
},
|
||||
}
|
||||
|
||||
export function resolve() {
|
||||
const name = process.env.OPENCODE_DRIVE
|
||||
if (!name) throw new Error("OPENCODE_DRIVE must contain a drive instance name")
|
||||
if (name === "1") return defaults
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(name)) throw new Error(`Invalid drive instance name: ${name}`)
|
||||
|
||||
const directory =
|
||||
process.env.DRIVE_REGISTRY_DIR ??
|
||||
join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "opencode-drive", "instances")
|
||||
const file = join(directory, `${name}.json`)
|
||||
if (!existsSync(file)) throw new Error(`Drive manifest not found: ${file}`)
|
||||
|
||||
const manifest: unknown = JSON.parse(readFileSync(file, "utf8"))
|
||||
if (!isManifest(manifest)) throw new Error(`Invalid drive manifest: ${file}`)
|
||||
validateEndpoint(manifest.endpoints.ui, "ui")
|
||||
validateEndpoint(manifest.endpoints.backend, "backend")
|
||||
return manifest
|
||||
}
|
||||
|
||||
function isManifest(value: unknown): value is Manifest {
|
||||
if (typeof value !== "object" || value === null) return false
|
||||
if (!("endpoints" in value) || typeof value.endpoints !== "object" || value.endpoints === null) return false
|
||||
return (
|
||||
"ui" in value.endpoints &&
|
||||
typeof value.endpoints.ui === "string" &&
|
||||
"backend" in value.endpoints &&
|
||||
typeof value.endpoints.backend === "string"
|
||||
)
|
||||
}
|
||||
|
||||
function validateEndpoint(value: string, name: string) {
|
||||
const endpoint = new URL(value)
|
||||
const port = Number(endpoint.port)
|
||||
if (endpoint.protocol !== "ws:" || endpoint.hostname !== "127.0.0.1" || !Number.isInteger(port) || port < 1) {
|
||||
throw new Error(`Invalid drive ${name} endpoint: ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
export * as DriveManifest from "./manifest"
|
||||
Loading…
Add table
Add a link
Reference in a new issue