From 02d67a6f96032372bf017c08ce50836fe98d74a8 Mon Sep 17 00:00:00 2001 From: James Long Date: Fri, 3 Jul 2026 02:35:07 +0000 Subject: [PATCH] filesystem simulation --- packages/core/src/effect/layer-node.ts | 5 +- packages/core/src/fs-util.ts | 4 +- .../specs/simulation/simulated-network-llm.md | 151 +++++++ .../specs/simulation/simulation-phases.md | 19 + packages/server/src/simulation/filesystem.ts | 390 ++++++++++++++++++ packages/server/src/simulation/fs-util.ts | 85 ++++ packages/server/src/simulation/index.ts | 21 +- 7 files changed, 667 insertions(+), 8 deletions(-) create mode 100644 packages/opencode/specs/simulation/simulated-network-llm.md create mode 100644 packages/server/src/simulation/filesystem.ts create mode 100644 packages/server/src/simulation/fs-util.ts diff --git a/packages/core/src/effect/layer-node.ts b/packages/core/src/effect/layer-node.ts index b58692a155..3e5c846530 100644 --- a/packages/core/src/effect/layer-node.ts +++ b/packages/core/src/effect/layer-node.ts @@ -226,8 +226,11 @@ export function hoist OpenAI JSON body (real) + transport.prepare body + endpoint + auth -> HttpRequest (real) + RequestExecutor.execute status/error taxonomy (real) + HttpClient.HttpClient <- replaced by the simulated network + Framing.sse bytes -> frames (real) + protocol.stream.event frame -> OpenAIChatEvent, validated (real) + protocol.stream.step state machine -> LLMEvents (real) +``` + +Replacing `httpClient` (already a `LayerNode` in `app-node-platform.ts`, already used by `simulationReplacements` mechanics) keeps the entire pipeline under test and gives wire-fidelity observation of what would have been sent to the provider. Failure injection (429s, malformed SSE, truncated streams) exercises real error paths that a typed `LLMClient` fake cannot reach. + +## Components + +### 1. Simulated network (`packages/server/src/simulation/network.ts`) + +Replaces `httpClient` in `simulationReplacements`. An in-memory route table: + +- `register(matcher, responder)` where matcher is method + URL pattern and responder is `(HttpClientRequest) => Effect`. +- Unknown requests fail loudly with a typed simulation error (spec: deny unknown external network by default). +- Optional loopback allowance for the app's own server is not required server-side (the server does not call itself over HTTP); revisit if a consumer needs it. +- Every request/response summary is traced. + +### 2. OpenAI endpoint route (`packages/server/src/simulation/openai.ts`) + +Registered in the network at startup for `POST {DEFAULT_BASE_URL}{PATH}` from `protocols/openai-chat.ts` (`https://api.openai.com/v1/chat/completions`). + +On request: + +1. Allocate an exchange id. Parse the real OpenAI request body (available to the driver for assertions). +2. Publish a `request` record to the LLM exchange service (below) and create a chunk `Queue`. +3. Return `HttpClientResponse` with `content-type: text/event-stream` whose body stream reads from the queue, encoding each item as an SSE `data:` frame, terminated by `[DONE]`. + +Chunks are constructed through the `OpenAIChatEvent` schema so drift in the protocol schema breaks the build, not the runtime. + +The response stream is interruptible like a real HTTP response: if the runner cancels (user interrupt), the exchange closes and the driver is notified. + +### 3. LLM exchange service (`packages/server/src/simulation/llm-exchange.ts`) + +Process-global simulation service owning pending exchanges: + +``` +Exchange = { id, body, queue: Queue, deferred lifecycle } +``` + +- `requests()` — stream of newly opened exchanges (consumed by the control route). +- `push(id, item)` — append one response item to an open exchange. +- `finish(id, reason)` / `fail(id, failure)` — terminate the exchange. +- Exchanges that receive no driver within a configurable timeout fail the provider request with a simulation error (surfaces in the real provider-error path). + +### 4. Backend control routes (simulation-gated, private) + +Mounted only when `OPENCODE_SIMULATION` is set. Not for external use; the frontend simulation server proxies them (spec: external drivers connect only to the frontend WebSocket). + +- `GET /experimental/simulation/llm/requests` — SSE stream of opened exchanges `{id, body}`. +- `POST /experimental/simulation/llm/:id/chunk` — append items. +- `POST /experimental/simulation/llm/:id/finish` — `{reason}`. +- `POST /experimental/simulation/llm/:id/fail` — `{status, body}` for failure injection (HTTP-level: the exchange responds with that status instead of SSE). +- `GET /experimental/simulation/network/log` — traced network activity. + +### 5. Frontend WebSocket protocol (TUI control server) + +The existing JSON-RPC server in `packages/tui/src/simulation/server.ts` gains LLM proxying. The TUI simulation module subscribes to the backend `llm/requests` SSE using its normal server connection and forwards over the WebSocket. + +New server -> driver notification: + +``` +{ "jsonrpc": "2.0", "method": "llm.request", + "params": { "id": "ex_1", "model": "gpt-...", "body": { ...openai request body... } } } +``` + +New driver -> server methods (proxied to the backend routes): + +``` +llm.chunk { id, items: Item[] } +llm.finish { id, reason: "stop" | "tool-calls" | "length" | ... } +llm.fail { id, status, body? } +``` + +`Item` is the response vocabulary the driver speaks: + +``` +{ type: "textDelta", id, text } +{ type: "reasoningDelta", id, text } +{ type: "toolCall", id, name, input } +{ type: "raw", chunk } // escape hatch: raw OpenAIChatEvent JSON +``` + +The backend compiles items to OpenAI chunks (`delta.content`, `delta.tool_calls[].function.arguments`, `finish_reason`); `raw` passes through schema validation only. Streaming granularity is the driver's choice: many small `llm.chunk` calls stream word by word; one call with many items plus `llm.finish` responds at once. + +Driver connection lifecycle: `llm.request` notifications are sent to control connections that have called `llm.attach`. If no driver is attached, exchanges fail after the timeout. Multiple drivers are out of scope; last attach wins. + +### 6. Pacing and the clock + +No server-side pacing by default: the driver controls timing by when it sends chunks, which is the point of driver-in-the-loop. A convenience `llm.chunk` option `{ delayMs }` may sleep via `Effect.sleep` between items server-side; because that uses the fiber `Clock`, scoping a controllable clock to the exchange stream (`Stream.provideService(Clock.Clock, simClock)`) remains available for deterministic replay without touching app time. Defer until replay work needs it. + +### 7. Catalog and auth seeding + +The driver-facing model must be selectable in the TUI. Simulation seeds config (via the snapshot filesystem) defining a provider on the openai-chat route with `baseURL` left at the OpenAI default and a dummy `apiKey` (satisfies `Catalog.available()`). No catalog code changes. + +## End-to-end flow + +``` +driver TUI sim server backend + | | | + |-- ui.action (submit) ----->| | + | |-- (normal app HTTP) ---->| session runner starts + | | | llm.stream -> HttpClient + | | | simulated network matches openai route + | |<== SSE llm/requests ====| exchange ex_1 opened + |<== llm.request {ex_1} =====| | + |-- llm.chunk {ex_1,[...]}-->|-- POST .../chunk ------->| SSE frames flow into the real + |-- llm.chunk {ex_1,[...]}-->|-- POST .../chunk ------->| decode -> step -> LLMEvents -> + |-- llm.finish {ex_1} ------>|-- POST .../finish ------>| runner publishes, TUI renders + | | | + | (if toolCall was sent: runner executes the real tool against the + | fake filesystem, then issues the next provider turn -> new exchange + | ex_2 -> driver decides the next response) +``` + +The driver observes the TUI through `ui.state` while chunks stream, so mid-stream UI assertions need no clock control at all: the driver simply has not sent the rest yet. + +## Implementation order + +1. `network.ts`: simulated `HttpClient` + route table + deny-unknown + trace. Replace `httpClient` in `simulationReplacements`. +2. `llm-exchange.ts` + `openai.ts`: exchange service and the OpenAI SSE route (schema-constructed chunks, `[DONE]`, interruption). +3. Backend control routes (simulation-gated) exposing requests/chunk/finish/fail. +4. TUI sim server: `llm.attach`, `llm.request` forwarding, `llm.chunk|finish|fail` proxying. +5. Config seeding for the sim provider; end-to-end TUI run driven by `simulation-drive.ts` extended with an LLM auto-responder. +6. Trace records for network and LLM exchange activity. + +## Consequences + +- No enqueue/script store to keep consistent; the driver is the single source of model behavior. +- Deterministic tests write drivers (respond to `llm.request` programmatically) instead of pre-baked scripts; replay (Phase 4) records exchanges and replays them as an automatic driver. +- Provider-coupling is confined to `openai.ts` (one wire encoder against a schema that lives in the repo); a second simulated provider (e.g. Anthropic) is another route file if ever needed. diff --git a/packages/opencode/specs/simulation/simulation-phases.md b/packages/opencode/specs/simulation/simulation-phases.md index ef7aa41e3f..93ade9c591 100644 --- a/packages/opencode/specs/simulation/simulation-phases.md +++ b/packages/opencode/specs/simulation/simulation-phases.md @@ -51,6 +51,25 @@ Out of scope: Goal: make the app safe and controlled by swapping the lowest layers, not app logic. +Implementation checklist: + +- [x] Add `packages/server/src/simulation/` as the home for all simulation layer replacements, exported from `index.ts` as `simulationReplacements`. +- [x] Wire simulation replacements through the server's `makeRoutes` via `Layer.unwrap` + dynamic `import("./simulation")` gated on `OPENCODE_SIMULATION`, so the simulation module is never loaded eagerly and `makeRoutes` stays synchronous. +- [x] Implement in-memory `FileSystem.FileSystem` (`simulation/filesystem.ts`) replacing the `NodeFileSystem` platform node. Backed by a flat path map; implements the operations the app uses (stat, access, chmod, realPath, read/write file, make/read directory, remove, rename, copy, copyFile, temp dirs, read-only open handles); unused operations die with a clear defect; `watch` fails as unsupported. +- [x] Root the fake filesystem at `OPENCODE_SIMULATION_ROOT` (falling back to `process.cwd()` at layer-build time). The anchor is a real, empty host directory the runner creates and cds into. +- [x] Deny host filesystem escapes loudly: content/mutation operations outside the root fail with `PermissionDenied` simulation errors. Probe operations (`stat`/`access`/`exists`) report `NotFound` outside the root so walk-up loops (project discovery, `findUp`, `globUp`) terminate naturally. +- [x] Add `SimulationFSUtil` replacement (`simulation/fs-util.ts`): wraps the real `FSUtil` layer and reroutes `readDirectoryEntries`, `glob`, and `globUp` — which bypass the injected `FileSystem` via node `fs/promises` and the `glob` package — through the simulated filesystem. +- [x] Fix `LayerNode.hoist` conflict detection to compare node implementations instead of object identity; replacement rewriting produces dependency-rewritten copies of the same node, which previously false-positived as "conflicting implementations". +- [x] Add snapshot seeding from `OPENCODE_SIMULATION_STATE`: `project/` contents of the snapshot directory are read from the host once at layer-build time and seeded into the in-memory tree joined onto the anchor root. +- [x] Verify end to end: `opencode serve` boots with `OPENCODE_SIMULATION=1` + `OPENCODE_SIMULATION_ROOT` + path/DB env seams (`OPENCODE_CONFIG_DIR`, `OPENCODE_TEST_HOME`, `OPENCODE_DB=:memory:`); `fs.list`/`fs.read` observe only seeded in-memory files; the anchor directory on the host remains empty after the run. +- [ ] Create the anchor directory + `chdir` + env seam setup automatically in CLI startup when simulation mode is enabled (currently set manually by the runner). +- [ ] Assert the anchor directory is still empty at the end of the run. +- [ ] Add simulated network registry and deny unknown external network by default (design: `simulated-network-llm.md`). +- [ ] Add driver-scripted LLM as an OpenAI route in the simulated network; the driver answers `llm.request` notifications over the control WebSocket and streams chunks back — no enqueue store (design: `simulated-network-llm.md`). +- [ ] Add simulated process registry (shell via `just-bash`, minimal fake `git`, deny unsupported spawns). +- [ ] Add simulation-gated backend control routes proxied through the frontend WebSocket. +- [ ] Trace filesystem, network, LLM, process, and backend control activity. + Scope: - Wire simulation replacements through `AppNodeBuilder.build(...)` and `AppNodeBuilderV1.build(...)`. diff --git a/packages/server/src/simulation/filesystem.ts b/packages/server/src/simulation/filesystem.ts new file mode 100644 index 0000000000..01a7b00329 --- /dev/null +++ b/packages/server/src/simulation/filesystem.ts @@ -0,0 +1,390 @@ +import { Effect, FileSystem, Layer, Option, Stream } from "effect" +import { systemError, type PlatformError, type SystemErrorTag } from "effect/PlatformError" +import nodeFs from "fs" +import path from "path" + +/** + * In-memory simulated `FileSystem.FileSystem`. + * + * Replaces the `NodeFileSystem` platform node when the server runs in + * simulation mode. Backed by a flat map of absolute paths to entries and + * rooted at a single directory (the simulation anchor): paths that resolve + * outside the root fail with `PermissionDenied` so host filesystem escapes + * are loud. Only the operations the app actually uses are implemented; + * everything else dies with a clear defect. + * + * Inspired by the V1 prototype on `jlongster/simulation-rebase`, rewritten + * for the V2 platform node shape without the `just-bash` dependency. + */ + +export interface Options { + readonly root: string + readonly files?: Record +} + +interface FileEntry { + readonly type: "File" + content: Uint8Array + mode: number + mtime: Date +} + +interface DirectoryEntry { + readonly type: "Directory" + mode: number + mtime: Date +} + +type Entry = FileEntry | DirectoryEntry + +export function make(options: Options): FileSystem.FileSystem { + const root = path.resolve(options.root) + const store = new Map() + const temp = { value: 0 } + const encoder = new TextEncoder() + store.set(root, makeDirectoryEntry()) + + const within = (resolved: string) => resolved === root || resolved.startsWith(withSep(root)) + + const childrenOf = (resolved: string) => [...store.keys()].filter((key) => key.startsWith(withSep(resolved))) + + const fail = ( + tag: SystemErrorTag, + method: string, + file: string, + description?: string, + ): Effect.Effect => + Effect.fail( + systemError({ _tag: tag, module: "SimulationFileSystem", method, description, pathOrDescriptor: file }), + ) + + const locate = (method: string, file: string): Effect.Effect => { + const resolved = path.resolve(root, file) + if (within(resolved)) return Effect.succeed(resolved) + return fail("PermissionDenied", method, file, "path escapes the simulated filesystem root") + } + + const requireEntry = (method: string, file: string): Effect.Effect => + locate(method, file).pipe( + Effect.flatMap((resolved) => { + const entry = store.get(resolved) + if (!entry) return fail("NotFound", method, file) + return Effect.succeed([resolved, entry] as const) + }), + ) + + const requireParentDirectory = ( + method: string, + resolved: string, + file: string, + ): Effect.Effect => { + const parent = store.get(path.dirname(resolved)) + if (parent?.type === "Directory") return Effect.void + return fail("NotFound", method, file, "parent directory does not exist") + } + + // Creates every missing directory between root and resolved (inclusive). + const ensureDirectories = (method: string, file: string, resolved: string): Effect.Effect => + Effect.suspend(() => { + const segments = path.relative(root, resolved).split(path.sep).filter(Boolean) + const conflict = segments.reduce>((current, segment) => { + if (typeof current !== "string") return current + const next = path.join(current, segment) + const entry = store.get(next) + if (entry && entry.type !== "Directory") + return fail("AlreadyExists", method, file, "path component is not a directory") + if (!entry) store.set(next, makeDirectoryEntry()) + return next + }, root) + return typeof conflict === "string" ? Effect.void : conflict + }) + + // Seed initial files, creating parents as needed. Entries outside the root are ignored. + for (const [file, content] of Object.entries(options.files ?? {})) { + const resolved = path.resolve(root, file) + if (!within(resolved)) continue + Effect.runSync(ensureDirectories("seed", file, path.dirname(resolved))) + store.set(resolved, { + type: "File", + content: typeof content === "string" ? encoder.encode(content) : content.slice(), + mode: 0o644, + mtime: new Date(), + }) + } + + // Probe operations report NotFound outside the root instead of + // PermissionDenied: walk-up loops (project discovery, findUp, globUp) + // legitimately probe ancestor directories of the anchor and must observe + // "nothing there". Content access and mutation outside the root stay loud. + const probe = (method: string, file: string): Effect.Effect => + Effect.suspend(() => { + const resolved = path.resolve(root, file) + const entry = within(resolved) ? store.get(resolved) : undefined + if (!entry) return fail("NotFound", method, file) + return Effect.succeed(entry) + }) + + const stat: FileSystem.FileSystem["stat"] = (file) => probe("stat", file).pipe(Effect.map(toInfo)) + + const access: FileSystem.FileSystem["access"] = (file) => probe("access", file).pipe(Effect.asVoid) + + const chmod: FileSystem.FileSystem["chmod"] = (file, mode) => + requireEntry("chmod", file).pipe( + Effect.map(([, entry]) => { + entry.mode = mode + }), + ) + + const realPath: FileSystem.FileSystem["realPath"] = (file) => + requireEntry("realPath", file).pipe(Effect.map(([resolved]) => resolved)) + + const readFile: FileSystem.FileSystem["readFile"] = (file) => + requireEntry("readFile", file).pipe( + Effect.flatMap(([, entry]) => { + if (entry.type !== "File") return fail("BadResource", "readFile", file, "path is a directory") + return Effect.succeed(entry.content.slice()) + }), + ) + + const writeFile: FileSystem.FileSystem["writeFile"] = (file, data, writeOptions) => + locate("writeFile", file).pipe( + Effect.flatMap((resolved) => { + const existing = store.get(resolved) + if (existing?.type === "Directory") return fail("BadResource", "writeFile", file, "path is a directory") + return requireParentDirectory("writeFile", resolved, file).pipe( + Effect.map(() => { + store.set(resolved, { + type: "File", + content: data.slice(), + mode: writeOptions?.mode ?? existing?.mode ?? 0o644, + mtime: new Date(), + }) + }), + ) + }), + ) + + const makeDirectory: FileSystem.FileSystem["makeDirectory"] = (file, dirOptions) => + locate("makeDirectory", file).pipe( + Effect.flatMap((resolved) => { + if (dirOptions?.recursive) return ensureDirectories("makeDirectory", file, resolved) + if (store.has(resolved)) return fail("AlreadyExists", "makeDirectory", file) + return requireParentDirectory("makeDirectory", resolved, file).pipe( + Effect.map(() => { + store.set(resolved, { type: "Directory", mode: dirOptions?.mode ?? 0o755, mtime: new Date() }) + }), + ) + }), + ) + + const readDirectory: FileSystem.FileSystem["readDirectory"] = (file, readOptions) => + requireEntry("readDirectory", file).pipe( + Effect.flatMap(([resolved, entry]) => { + if (entry.type !== "Directory") return fail("BadResource", "readDirectory", file, "path is not a directory") + const children = childrenOf(resolved) + const names = readOptions?.recursive + ? children.map((key) => path.relative(resolved, key)) + : children.filter((key) => path.dirname(key) === resolved).map((key) => path.basename(key)) + return Effect.succeed(names.sort((a, b) => a.localeCompare(b))) + }), + ) + + const remove: FileSystem.FileSystem["remove"] = (file, removeOptions) => + locate("remove", file).pipe( + Effect.flatMap((resolved) => { + const entry = store.get(resolved) + if (!entry) return removeOptions?.force ? Effect.void : fail("NotFound", "remove", file) + const children = childrenOf(resolved) + if (entry.type === "Directory" && children.length > 0 && !removeOptions?.recursive) + return fail("Unknown", "remove", file, "directory is not empty") + for (const key of children) store.delete(key) + store.delete(resolved) + // The root itself must always exist. + if (resolved === root) store.set(root, makeDirectoryEntry()) + return Effect.void + }), + ) + + const rename: FileSystem.FileSystem["rename"] = (oldPath, newPath) => + Effect.all([locate("rename", oldPath), locate("rename", newPath)]).pipe( + Effect.flatMap(([from, to]) => { + const entry = store.get(from) + if (!entry) return fail("NotFound", "rename", oldPath) + return requireParentDirectory("rename", to, newPath).pipe( + Effect.map(() => { + const moved = [from, ...childrenOf(from)].map((key) => [key, store.get(key)!] as const) + for (const [key] of moved) store.delete(key) + for (const key of [to, ...childrenOf(to)]) store.delete(key) + for (const [key, value] of moved) store.set(key === from ? to : to + key.slice(from.length), value) + }), + ) + }), + ) + + const copy: FileSystem.FileSystem["copy"] = (fromPath, toPath) => + Effect.all([locate("copy", fromPath), locate("copy", toPath)]).pipe( + Effect.flatMap(([from, to]) => { + const entry = store.get(from) + if (!entry) return fail("NotFound", "copy", fromPath) + return requireParentDirectory("copy", to, toPath).pipe( + Effect.map(() => { + for (const key of [from, ...childrenOf(from)]) { + const source = store.get(key)! + const target = key === from ? to : to + key.slice(from.length) + store.set( + target, + source.type === "File" + ? { ...source, content: source.content.slice(), mtime: new Date() } + : { ...source, mtime: new Date() }, + ) + } + }), + ) + }), + ) + + const copyFile: FileSystem.FileSystem["copyFile"] = (fromPath, toPath) => + readFile(fromPath).pipe(Effect.flatMap((content) => writeFile(toPath, content))) + + const makeTempDirectory: FileSystem.FileSystem["makeTempDirectory"] = (tempOptions) => + Effect.suspend(() => { + const directory = tempOptions?.directory ?? path.join(root, ".simulation-tmp") + const file = path.join(directory, `${tempOptions?.prefix ?? "tmp-"}${++temp.value}`) + return makeDirectory(file, { recursive: true }).pipe(Effect.map(() => file)) + }) + + const makeTempDirectoryScoped: FileSystem.FileSystem["makeTempDirectoryScoped"] = (tempOptions) => + Effect.acquireRelease(makeTempDirectory(tempOptions), (directory) => + remove(directory, { recursive: true, force: true }).pipe(Effect.ignore), + ) + + // Read-only file handle: enough for the read tool's stat/seek/readAlloc use. + const open: FileSystem.FileSystem["open"] = (file) => + requireEntry("open", file).pipe( + Effect.map(([resolved]) => { + const position = { value: 0 } + const contentOf = () => { + const current = store.get(resolved) + return current?.type === "File" ? current.content : new Uint8Array() + } + return { + [FileSystem.FileTypeId]: FileSystem.FileTypeId, + fd: FileSystem.FileDescriptor(0), + stat: Effect.suspend(() => stat(resolved)), + seek: (offset, from) => + Effect.sync(() => { + position.value = from === "start" ? Number(offset) : position.value + Number(offset) + }), + sync: Effect.void, + read: (buffer) => + Effect.sync(() => { + const chunk = contentOf().subarray(position.value, position.value + buffer.length) + buffer.set(chunk) + position.value += chunk.length + return FileSystem.Size(chunk.length) + }), + readAlloc: (size) => + Effect.sync(() => { + const chunk = contentOf().slice(position.value, position.value + Number(size)) + position.value += chunk.length + return chunk.length === 0 ? Option.none() : Option.some(chunk) + }), + truncate: () => unimplemented("File.truncate"), + write: () => unimplemented("File.write"), + writeAll: () => unimplemented("File.writeAll"), + } satisfies FileSystem.File + }), + ) + + return FileSystem.make({ + access, + chmod, + chown: () => unimplemented("chown"), + copy, + copyFile, + link: () => unimplemented("link"), + makeDirectory, + makeTempDirectory, + makeTempDirectoryScoped, + makeTempFile: () => unimplemented("makeTempFile"), + makeTempFileScoped: () => unimplemented("makeTempFileScoped"), + open, + readDirectory, + readFile, + readLink: () => unimplemented("readLink"), + realPath, + remove, + rename, + stat, + symlink: () => unimplemented("symlink"), + truncate: () => unimplemented("truncate"), + utimes: () => unimplemented("utimes"), + watch: () => Stream.die(new Error("SimulationFileSystem.watch is not implemented in simulation")), + writeFile, + }) +} + +/** + * 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 + * into the in-memory tree, joined onto the anchor root. + */ +export const layer = (options?: Partial) => + Layer.sync(FileSystem.FileSystem)(() => + make({ + root: options?.root ?? process.cwd(), + files: { ...loadSnapshotFiles(process.env.OPENCODE_SIMULATION_STATE), ...options?.files }, + }), + ) + +function loadSnapshotFiles(stateDirectory: string | undefined) { + if (!stateDirectory) return {} + const project = path.join(stateDirectory, "project") + if (!nodeFs.existsSync(project)) return {} + const files: Record = {} + 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)) + } + } + walk(project) + return files +} + +function makeDirectoryEntry(): Entry { + return { type: "Directory", mode: 0o755, mtime: new Date() } +} + +function withSep(dir: string) { + return dir.endsWith(path.sep) ? dir : dir + path.sep +} + +function toInfo(entry: Entry): FileSystem.File.Info { + return { + type: entry.type, + mtime: Option.some(entry.mtime), + atime: Option.some(entry.mtime), + birthtime: Option.some(entry.mtime), + dev: 0, + ino: Option.none(), + mode: entry.mode, + nlink: Option.none(), + uid: Option.none(), + gid: Option.none(), + rdev: Option.none(), + size: FileSystem.Size(entry.type === "File" ? entry.content.length : 0), + blksize: Option.none(), + blocks: Option.none(), + } +} + +function unimplemented(method: string) { + return Effect.die(new Error(`SimulationFileSystem.${method} is not implemented in simulation`)) +} + +export * as SimulationFileSystem from "./filesystem" diff --git a/packages/server/src/simulation/fs-util.ts b/packages/server/src/simulation/fs-util.ts new file mode 100644 index 0000000000..231c330937 --- /dev/null +++ b/packages/server/src/simulation/fs-util.ts @@ -0,0 +1,85 @@ +import { Effect, FileSystem, Layer } from "effect" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Glob } from "@opencode-ai/core/util/glob" +import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" +import { filesystem } from "@opencode-ai/core/effect/app-node-platform" +import path from "path" + +/** + * Simulation replacement for `FSUtil`. + * + * The real `FSUtil` layer builds most helpers on the injected + * `FileSystem.FileSystem`, but `readDirectoryEntries`, `glob`, and `globUp` + * reach for node `fs/promises` and the `glob` package directly, bypassing the + * simulated filesystem. This wraps the real layer and reroutes those three + * through the injected `FileSystem` so every read observes the in-memory + * tree. + */ + +const layer = Layer.effect( + FSUtil.Service, + Effect.gen(function* () { + const base = yield* FSUtil.Service + const fs = yield* FileSystem.FileSystem + + const readDirectoryEntries = Effect.fn("SimulationFSUtil.readDirectoryEntries")(function* (dirPath: string) { + const names = yield* fs.readDirectory(dirPath) + return yield* Effect.forEach(names, (name) => + fs.stat(path.join(dirPath, name)).pipe( + Effect.map( + (info): FSUtil.DirEntry => ({ + name, + type: + info.type === "Directory" + ? "directory" + : info.type === "File" + ? "file" + : info.type === "SymbolicLink" + ? "symlink" + : "other", + }), + ), + Effect.orElseSucceed((): FSUtil.DirEntry => ({ name, type: "other" })), + ), + ) + }) + + const glob = Effect.fn("SimulationFSUtil.glob")(function* (pattern: string, options?: Glob.Options) { + const cwd = path.resolve(options?.cwd ?? process.cwd()) + const entries = yield* fs + .readDirectory(cwd, { recursive: true }) + .pipe(Effect.orElseSucceed(() => [] as string[])) + const matches = yield* Effect.forEach(entries, (entry) => + fs.stat(path.join(cwd, entry)).pipe( + Effect.map((info) => ({ entry, type: info.type })), + Effect.orElseSucceed(() => undefined), + ), + ) + return matches + .filter((item) => item !== undefined) + .filter((item) => options?.include === "all" || item.type === "File") + .filter((item) => Glob.match(pattern, item.entry)) + .map((item) => (options?.absolute ? path.join(cwd, item.entry) : item.entry)) + .sort((a, b) => a.localeCompare(b)) + }) + + const globUp = Effect.fn("SimulationFSUtil.globUp")(function* (pattern: string, start: string, stop?: string) { + const result: string[] = [] + let current = path.resolve(start) + while (true) { + result.push(...(yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true }))) + if (stop === current) break + const parent = path.dirname(current) + if (parent === current) break + current = parent + } + return result + }) + + return FSUtil.Service.of({ ...base, readDirectoryEntries, glob, globUp }) + }), +).pipe(Layer.provide(FSUtil.layer)) + +export const node = makeGlobalNode({ service: FSUtil.Service, layer, deps: [filesystem] }) + +export * as SimulationFSUtil from "./fs-util" diff --git a/packages/server/src/simulation/index.ts b/packages/server/src/simulation/index.ts index 3f499a5bf8..47394f6b40 100644 --- a/packages/server/src/simulation/index.ts +++ b/packages/server/src/simulation/index.ts @@ -1,14 +1,23 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { filesystem } from "@opencode-ai/core/effect/app-node-platform" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { SimulationFileSystem } from "./filesystem" +import { SimulationFSUtil } from "./fs-util" /** * Layer replacements applied when the server is built in simulation mode. * - * Empty for now; simulation-mode implementations will populate this with - * replacement nodes/layers that swap real services for simulated ones (e.g. - * a fake filesystem). The server merges these into the app node build when - * `OPENCODE_SIMULATION` is enabled, via a dynamic import so this module is - * never loaded eagerly. + * The server merges these into the app node build when `OPENCODE_SIMULATION` + * is enabled, via a dynamic import so this module is never loaded eagerly. + * + * The fake filesystem is rooted at `OPENCODE_SIMULATION_ROOT` (the real, + * empty anchor directory the runner created and chdir'd into), falling back + * to the process working directory. Everything under the root lives in + * memory; paths outside it fail loudly. */ -export const simulationReplacements: LayerNode.Replacements = [] +export const simulationReplacements: LayerNode.Replacements = [ + [filesystem, SimulationFileSystem.layer({ root: process.env.OPENCODE_SIMULATION_ROOT })], + [FSUtil.node, SimulationFSUtil.node], +] export * as Simulation from "./index"