Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 02d67a6f96 |
@@ -226,8 +226,11 @@ export function hoist<A, E, T extends Tag, const Items extends Replacements = re
|
||||
return { ...node, dependencies: node.dependencies.map(context.visit) }
|
||||
}
|
||||
if (node.tag === tag) {
|
||||
// Replacement rewriting can produce copies of the same node with
|
||||
// rewritten dependencies, so compare implementations rather than
|
||||
// object identity: only a different layer is a real conflict.
|
||||
const existing = hoisted.get(node.name)
|
||||
if (existing && existing !== node) {
|
||||
if (existing && existing.implementation !== node.implementation) {
|
||||
throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`)
|
||||
}
|
||||
hoisted.set(node.name, node)
|
||||
|
||||
@@ -49,7 +49,9 @@ export namespace FSUtil {
|
||||
|
||||
export const use = serviceUse(Service)
|
||||
|
||||
const layer = Layer.effect(
|
||||
// Exported so simulation can wrap this layer and override the methods that
|
||||
// bypass the injected FileSystem (readDirectoryEntries, glob, globUp).
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
# Simulated Network And Driver-Scripted LLM
|
||||
|
||||
Status: design for the Phase 2 network and LLM items in `simulation-phases.md`.
|
||||
|
||||
## Summary
|
||||
|
||||
Simulation replaces the `HttpClient.HttpClient` platform node with a simulated network. The LLM is not a separate fake: it is one registered route in that network (`api.openai.com`), answered by the **external driver** over the existing control WebSocket. When the app issues a provider request, the backend forwards it to the driver and the driver streams response chunks back. There is no enqueueing and no scripted-response store; the driver is the model.
|
||||
|
||||
Everything above the HTTP boundary runs real: catalog and auth resolution, `LLMClient`, request body construction, SSE framing, the OpenAI protocol event schema, the `step` state machine, `Lifecycle` grammar, tool-argument accumulation, the session runner, tools, and permissions.
|
||||
|
||||
## Why the network seam
|
||||
|
||||
`LLMClient.stream` sits on a stack that ends in one platform node:
|
||||
|
||||
```
|
||||
LLMClient.stream(request)
|
||||
route.body.from LLMRequest -> 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<HttpClientResponse>`.
|
||||
- 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<Item | Error | Done>, 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.
|
||||
@@ -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(...)`.
|
||||
|
||||
@@ -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<string, string | Uint8Array>
|
||||
}
|
||||
|
||||
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<string, Entry>()
|
||||
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<never, PlatformError> =>
|
||||
Effect.fail(
|
||||
systemError({ _tag: tag, module: "SimulationFileSystem", method, description, pathOrDescriptor: file }),
|
||||
)
|
||||
|
||||
const locate = (method: string, file: string): Effect.Effect<string, PlatformError> => {
|
||||
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<readonly [string, Entry], PlatformError> =>
|
||||
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<void, PlatformError> => {
|
||||
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<void, PlatformError> =>
|
||||
Effect.suspend(() => {
|
||||
const segments = path.relative(root, resolved).split(path.sep).filter(Boolean)
|
||||
const conflict = segments.reduce<string | Effect.Effect<never, PlatformError>>((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<Entry, PlatformError> =>
|
||||
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<Options>) =>
|
||||
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<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))
|
||||
}
|
||||
}
|
||||
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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user