diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 661e9795eb..7e52aea140 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -5,29 +5,22 @@ import { Catalog } from "./catalog" import { CommandV2 } from "./command" import { Config } from "./config" import { LayerNode } from "./effect/layer-node" -import { makeLocationNode, Node } from "./effect/app-node" -import { httpClient } from "./effect/app-node-platform" +import { Node } from "./effect/app-node" import { EventV2 } from "./event" import { FileMutation } from "./file-mutation" import { FileSystem } from "./filesystem" import { FileSystemSearch } from "./filesystem/search" -import { FSUtil } from "./fs-util" import { Generate } from "./generate" import { Form } from "./form" -import { Global } from "./global" -import { LocationWatcher } from "./filesystem/location-watcher" import { Image } from "./image" +import { LocationWatcher } from "./filesystem/location-watcher" import { Integration } from "./integration" import { Location } from "./location" import { LocationMutation } from "./location-mutation" import { LocationServiceMap } from "./location-service-map" import { MCP } from "./mcp/index" -import { ModelsDev } from "./models-dev" -import { Npm } from "./npm" import { PermissionV2 } from "./permission" import { PluginV2 } from "./plugin" -import { PluginRuntime } from "./plugin/runtime" -import { SdkPlugins } from "./plugin/sdk" import { PluginSupervisor } from "./plugin/supervisor" import { ProjectCopy } from "./project/copy" import { Pty } from "./pty" @@ -35,7 +28,6 @@ import { QuestionV2 } from "./question" import { Shell } from "./shell" import { Reference } from "./reference" import { ReferenceGuidance } from "./reference/guidance" -import { Ripgrep } from "./ripgrep" import { SessionRunnerLLM } from "./session/runner/llm" import { SessionRunnerModel } from "./session/runner/model" import { SessionCompaction } from "./session/compaction" @@ -51,49 +43,11 @@ import { SessionInstructions } from "./session/instructions" import { McpTool } from "./tool/mcp" import { ReadToolFileSystem } from "./tool/read-filesystem" import { ToolRegistry } from "./tool/registry" -import { WebSearchTool } from "./tool/websearch" import { ToolOutputStore } from "./tool-output-store" import { Vcs } from "./vcs" export { LocationServiceMap } from "./location-service-map" -const pluginSupervisorNode = makeLocationNode({ - service: PluginSupervisor.Service, - layer: PluginSupervisor.layer, - deps: [ - PluginV2.node, - SdkPlugins.node, - AgentV2.node, - Catalog.node, - CommandV2.node, - Config.node, - EventV2.node, - FileMutation.node, - FileSystem.node, - FSUtil.node, - Global.node, - httpClient, - Image.node, - Integration.node, - Location.node, - LocationMutation.node, - ModelsDev.node, - Npm.node, - PermissionV2.node, - PluginRuntime.node, - Form.node, - ReadToolFileSystem.node, - Reference.node, - Ripgrep.node, - SessionInstructions.node, - SessionTodo.node, - Shell.node, - SkillV2.node, - ToolRegistry.toolsNode, - WebSearchTool.configNode, - ], -}) - const locationServiceNodes = [ Location.node, Config.node, @@ -104,7 +58,7 @@ const locationServiceNodes = [ Catalog.node, AISDK.node, PluginV2.node, - pluginSupervisorNode, + PluginSupervisor.node, ProjectCopy.node, ProjectCopy.refreshNode, FileSystemSearch.node, diff --git a/packages/core/src/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts index 6bd4f6bfac..5b29d643ab 100644 --- a/packages/core/src/plugin/supervisor.ts +++ b/packages/core/src/plugin/supervisor.ts @@ -2,18 +2,42 @@ export * as PluginSupervisor from "./supervisor" import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin" import { Event } from "@opencode-ai/schema/config" -import { Context, Effect, Fiber, Layer, Option, Schema, Semaphore, Stream } from "effect" +import { Context, Deferred, Effect, Layer, Option, Schema, Semaphore, Stream } from "effect" import path from "path" import { fileURLToPath, pathToFileURL } from "url" +import { AgentV2 } from "../agent" +import { Catalog } from "../catalog" +import { CommandV2 } from "../command" import { Config } from "../config" import { ConfigPlugin } from "../config/plugin" +import { makeLocationNode } from "../effect/app-node" +import { httpClient } from "../effect/app-node-platform" import { EventV2 } from "../event" +import { FileMutation } from "../file-mutation" +import { FileSystem } from "../filesystem" +import { Form } from "../form" import { FSUtil } from "../fs-util" +import { Global } from "../global" +import { Image } from "../image" +import { Integration } from "../integration" import { Location } from "../location" +import { LocationMutation } from "../location-mutation" +import { ModelsDev } from "../models-dev" import { Npm } from "../npm" +import { PermissionV2 } from "../permission" import { PluginV2 } from "../plugin" import { PluginPromise } from "../plugin/promise" +import { Reference } from "../reference" +import { Ripgrep } from "../ripgrep" +import { SessionInstructions } from "../session/instructions" +import { SessionTodo } from "../session/todo" +import { Shell } from "../shell" +import { SkillV2 } from "../skill" +import { ReadToolFileSystem } from "../tool/read-filesystem" +import { ToolRegistry } from "../tool/registry" +import { WebSearchTool } from "../tool/websearch" import { PluginInternal } from "./internal" +import { PluginRuntime } from "./runtime" import { SdkPlugins } from "./sdk" const PluginModule = Schema.Struct({ @@ -246,7 +270,8 @@ const resolvePackageEntrypoint = Effect.fnUntraced(function* (fs: FSUtil.Interfa }) export interface Interface { - readonly ready: Effect.Effect + /** Wait for the initial plugin generation and startup updates to settle. */ + readonly flush: Effect.Effect } export class Service extends Context.Service()("@opencode/PluginSupervisor") {} @@ -259,35 +284,96 @@ const layer = Layer.effect( const config = yield* Config.Service const events = yield* EventV2.Service const lock = Semaphore.makeUnsafe(1) - const reload = Effect.fn("PluginSupervisor.reload")(() => - lock.withPermit( + const ready = yield* Deferred.make() + let observed = 0 + let applied = -1 + + const activate = Effect.fn("PluginSupervisor.activate")(function* (target: number) { + yield* lock.withPermit( Effect.gen(function* () { + if (applied >= target) return // Resolve OpenCode's internal plugins with their privileged Location services. const internal = yield* PluginInternal.list() // Combine internal plugins with host-contributed SDK plugins in boot order. const pre = [...internal.pre, ...sdk.all()] - // Read the current layered config before resolving plugin directives and packages. - const entries = yield* config.entries() - const operations = yield* scan(entries) + const operations = yield* scan(yield* config.entries()) // Apply config operations and load enabled package plugins into one ordered generation. const plugins = yield* resolve(pre, internal.post, operations) // Replace the active generation in one scoped, batched activation. yield* registry.activate(plugins) + applied = target }), - ), - ) - yield* events.subscribe([Event.Updated, SdkPlugins.Updated]).pipe( - Stream.runForEach(() => - reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))), + ) + }) + const updates = yield* events + .subscribe([Event.Updated, SdkPlugins.Updated]) + .pipe(Stream.toQueue({ capacity: 1, strategy: "sliding" })) + const signals = yield* Stream.concat( + Stream.succeed(0), + Stream.fromQueue(updates).pipe(Stream.mapEffect(() => Effect.sync(() => ++observed))), + ).pipe(Stream.broadcast({ capacity: 1, strategy: "sliding", replay: 1 })) + const attempt = (target: number) => + activate(target).pipe( + Effect.map(() => observed === target), + Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }).pipe(Effect.as(false))), + ) + + yield* signals.pipe( + Stream.runForEach((target) => + activate(target).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))), ), Effect.forkScoped({ startImmediately: true }), ) - const fiber = yield* reload().pipe( - Effect.withSpan("PluginSupervisor.boot"), + yield* signals.pipe( + Stream.debounce("100 millis"), + Stream.mapEffect(attempt), + Stream.filter((settled) => settled), + Stream.take(1), + Stream.runDrain, + Effect.andThen(Deferred.succeed(ready, undefined)), Effect.forkScoped({ startImmediately: true }), ) - return Service.of({ ready: Fiber.join(fiber) }) + return Service.of({ flush: Deferred.await(ready) }) }), ) +const nodeLayer = layer as Layer.Layer + +export const node = makeLocationNode({ + service: Service, + layer: nodeLayer, + deps: [ + PluginV2.node, + SdkPlugins.node, + AgentV2.node, + Catalog.node, + CommandV2.node, + Config.node, + EventV2.node, + FileMutation.node, + FileSystem.node, + FSUtil.node, + Global.node, + httpClient, + Image.node, + Integration.node, + Location.node, + LocationMutation.node, + ModelsDev.node, + Npm.node, + PermissionV2.node, + PluginRuntime.node, + Form.node, + ReadToolFileSystem.node, + Reference.node, + Ripgrep.node, + SessionInstructions.node, + SessionTodo.node, + Shell.node, + SkillV2.node, + ToolRegistry.toolsNode, + WebSearchTool.configNode, + ], +}) + export { layer } diff --git a/packages/core/src/session/error.ts b/packages/core/src/session/error.ts index 7842390f3b..3de8af3a56 100644 --- a/packages/core/src/session/error.ts +++ b/packages/core/src/session/error.ts @@ -1,4 +1,5 @@ import { Schema } from "effect" +import { Agent } from "@opencode-ai/schema/agent" import { SessionMessage } from "./message" import { SessionSchema } from "./schema" import { SessionError } from "@opencode-ai/schema/session-error" @@ -12,6 +13,15 @@ export class MessageDecodeError extends Schema.TaggedErrorClass()("Session.AgentNotFoundError", { + sessionID: SessionSchema.ID, + agent: Agent.ID, +}) { + override get message() { + return `Agent not found: "${this.agent}"` + } +} + export class StepFailedError extends Schema.TaggedErrorClass()("Session.StepFailedError", { error: SessionError.Error, }) { diff --git a/packages/core/src/session/runner/index.ts b/packages/core/src/session/runner/index.ts index d40efb7945..910b05e745 100644 --- a/packages/core/src/session/runner/index.ts +++ b/packages/core/src/session/runner/index.ts @@ -3,7 +3,7 @@ export * as SessionRunner from "./index" import type { LLMError } from "@opencode-ai/llm" import { Context, Effect } from "effect" import { SessionSchema } from "../schema" -import type { MessageDecodeError, StepFailedError, UserInterruptedError } from "../error" +import type { AgentNotFoundError, MessageDecodeError, StepFailedError, UserInterruptedError } from "../error" import { SessionRunnerModel } from "./model" import type { Instructions } from "../../instructions/index" import type { ToolOutputStore } from "../../tool-output-store" @@ -12,6 +12,7 @@ export type RunError = | LLMError | SessionRunnerModel.Error | MessageDecodeError + | AgentNotFoundError | StepFailedError | UserInterruptedError | Instructions.InitializationBlocked diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index da8bb3c8c6..168ceabeca 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -48,12 +48,13 @@ import { SessionRunnerSystemPrompt } from "./system-prompt" import { Snapshot } from "../../snapshot" import { makeLocationNode } from "../../effect/app-node" import { llmClient } from "../../effect/app-node-platform" -import { StepFailedError, UserInterruptedError } from "../error" +import { AgentNotFoundError, StepFailedError, UserInterruptedError } from "../error" import { toSessionError } from "../to-session-error" import { SessionRunnerRetry } from "./retry" import { AgentTelemetry } from "../../observability/agent" import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session" import { PluginHooks } from "../../plugin/hooks" +import { PluginSupervisor } from "../../plugin/supervisor" type StepTokens = { readonly input: number @@ -150,6 +151,7 @@ const layer = Layer.effect( const db = (yield* Database.Service).db const compaction = yield* SessionCompaction.Service const title = yield* SessionTitle.Service + const plugins = yield* PluginSupervisor.Service // Title generation is a side effect of the first step; it must not delay step continuation. // Tracked per process so repeated wakes before the second user message arrives don't // re-fire a redundant LLM call; `SessionTitle` itself is idempotent based on durable history. @@ -212,17 +214,21 @@ const layer = Layer.effect( const session = yield* AgentTelemetry.stage("session", getSession(sessionID)) if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID) return yield* Effect.interrupt + yield* AgentTelemetry.stage("plugin_readiness", plugins.flush) const agent = yield* AgentTelemetry.stage("agent", agents.select(session.agent)) yield* AgentTelemetry.identify({ agent: agent.id, parentSessionID: session.parentID }) + if (!agent.info) + return yield* AgentTelemetry.stage( + "agent", + new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id }), + ) + const agentInfo = agent.info // Establish what the model knows before admitting what the user said, so // a blocked first step leaves pending inputs untouched. const checkpoint = yield* AgentTelemetry.stage( "instructions", InstructionCheckpoint.prepare(db, events, loadInstructions(agent, session.id), session.id), ) - const toolFibers = yield* FiberSet.make() - const ownedToolFibers: Array> = [] - let needsContinuation = false let currentStep = step let currentTrigger = trigger let inputDelivery: SessionInput.Delivery | undefined @@ -248,21 +254,18 @@ const layer = Layer.effect( SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq), ) const context = entries.map((entry) => entry.message) - const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps + const isLastStep = agentInfo.steps !== undefined && currentStep >= agentInfo.steps const toolMaterialization = isLastStep ? undefined : yield* AgentTelemetry.stage( "tool_materialization", - tools.materialize({ permissions: agent.info?.permissions, model }), + tools.materialize({ permissions: agentInfo.permissions, model }), ) const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id const request = LLM.request({ model, providerOptions: { openai: { promptCacheKey } }, - system: [ - agent.info?.system ? agent.info.system : SessionRunnerSystemPrompt.provider(model), - checkpoint.baseline, - ] + system: [agentInfo.system ? agentInfo.system : SessionRunnerSystemPrompt.provider(model), checkpoint.baseline] .filter((part): part is string => part !== undefined && part.length > 0) .map(SystemPart.make), messages: [ @@ -272,6 +275,9 @@ const layer = Layer.effect( tools: toolMaterialization?.definitions ?? [], toolChoice: isLastStep ? "none" : undefined, }) + const toolFibers = yield* FiberSet.make() + const ownedToolFibers: Array> = [] + let needsContinuation = false const availableTools = new Map(request.tools.map((tool) => [tool.name, tool])) const requestEvent: SessionHooks["request"] = { sessionID: session.id, @@ -477,7 +483,7 @@ const layer = Layer.effect( const error = toSessionError(llmFailure) const retryable = SessionRunnerRetry.isRetryable(llmFailure) const hasOutput = publisher.hasRetryEvidence() - const stepLimited = agent.info?.steps !== undefined && currentStep >= agent.info.steps + const stepLimited = agentInfo.steps !== undefined && currentStep >= agentInfo.steps if (retryable && !hasOutput && !stepLimited) { return yield* new SessionRunnerRetry.RetryableFailure({ cause: llmFailure, @@ -801,5 +807,6 @@ export const node = makeLocationNode({ Config.node, Snapshot.node, Database.node, + PluginSupervisor.node, ], }) diff --git a/packages/core/src/session/to-session-error.ts b/packages/core/src/session/to-session-error.ts index df84a46668..28a015dd7c 100644 --- a/packages/core/src/session/to-session-error.ts +++ b/packages/core/src/session/to-session-error.ts @@ -4,7 +4,7 @@ import { PermissionV2 } from "../permission" import { QuestionV2 } from "../question" import { Integration } from "../integration" import { ToolOutputStore } from "../tool-output-store" -import { StepFailedError, UserInterruptedError } from "./error" +import { AgentNotFoundError, StepFailedError, UserInterruptedError } from "./error" import { SessionRunnerModel } from "./runner/model" export function toSessionError(cause: unknown): SessionError.Error { @@ -41,6 +41,7 @@ export function toSessionError(cause: unknown): SessionError.Error { if (cause instanceof ToolFailure) return cause.error === undefined ? { type: "tool.execution", message: cause.message } : toSessionError(cause.error) if (cause instanceof StepFailedError) return cause.error + if (cause instanceof AgentNotFoundError) return { type: "unknown", message: cause.message } if (cause instanceof UserInterruptedError) return { type: "aborted", message: cause.message } if ( cause instanceof SessionRunnerModel.ModelNotSelectedError || diff --git a/packages/core/test/config/plugin.test.ts b/packages/core/test/config/plugin.test.ts index 2bfe1d6c74..75faeef99d 100644 --- a/packages/core/test/config/plugin.test.ts +++ b/packages/core/test/config/plugin.test.ts @@ -229,7 +229,7 @@ describe("PluginSupervisor config", () => { const ready = Effect.fnUntraced(function* () { const supervisor = yield* PluginSupervisor.Service - yield* supervisor.ready + yield* supervisor.flush }) function withLocation( diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index dcfecdb0ae..60e788f7b1 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -4,7 +4,7 @@ import { describe, expect } from "bun:test" import { Config } from "@opencode-ai/schema/config" import { Plugin } from "@opencode-ai/schema/plugin" import { Money } from "@opencode-ai/schema/money" -import { Context, DateTime, Effect, Equal, Hash, RcMap, Schema, Stream } from "effect" +import { Context, DateTime, Deferred, Effect, Equal, Fiber, Hash, RcMap, Schema, Stream } from "effect" import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect" import { AgentV2 } from "@opencode-ai/core/agent" import { Catalog } from "@opencode-ai/core/catalog" @@ -54,7 +54,7 @@ describe("LocationServiceMap", () => { const ref = Location.Ref.make({ directory: AbsolutePath.make(dir.path) }) const read = Effect.gen(function* () { const supervisor = yield* PluginSupervisor.Service - yield* supervisor.ready + yield* supervisor.flush const agents = yield* AgentV2.Service return yield* agents.get(id) }) @@ -67,6 +67,268 @@ describe("LocationServiceMap", () => { ), ) + itWithSdk.live("waits for explorer activation to complete", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + const started = yield* Deferred.make() + const release = yield* Deferred.make() + const sdk = yield* SdkPlugins.Service + yield* sdk.register( + EffectPlugin.define({ + id: "blocked-initial-activation", + effect: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release))), + }), + ) + + const locations = yield* LocationServiceMap.Service + const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })) + yield* Deferred.await(started) + + const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe( + Effect.provide(context), + Effect.forkChild, + ) + expect(flushFiber.pollUnsafe()).toBeUndefined() + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(flushFiber) + yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe( + Effect.provide(context), + Effect.timeout("1 second"), + ) + + const explorer = yield* Effect.gen(function* () { + const agents = yield* AgentV2.Service + return yield* agents.resolve("explore") + }).pipe(Effect.provide(context)) + + expect(explorer).toBeDefined() + expect(explorer?.permissions.length).toBeGreaterThan(0) + }), + ), + ), + ) + + itWithSdk.live("reruns activation for SDK plugins registered during startup", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + const firstStarted = yield* Deferred.make() + const releaseFirst = yield* Deferred.make() + const secondStarted = yield* Deferred.make() + const releaseSecond = yield* Deferred.make() + const sdk = yield* SdkPlugins.Service + yield* sdk.register( + EffectPlugin.define({ + id: "fixed-target-first-plugin", + effect: () => + Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))), + }), + ) + + const locations = yield* LocationServiceMap.Service + const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })) + yield* Deferred.await(firstStarted) + + const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe( + Effect.provide(context), + Effect.forkChild({ startImmediately: true }), + ) + yield* Effect.yieldNow + yield* sdk.register( + EffectPlugin.define({ + id: "fixed-target-second-plugin", + effect: () => + Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseSecond))), + }), + ) + + yield* Deferred.succeed(releaseFirst, undefined) + yield* Deferred.await(secondStarted) + expect(flushFiber.pollUnsafe()).toBeUndefined() + + yield* Deferred.succeed(releaseSecond, undefined) + yield* Fiber.join(flushFiber) + }), + ), + ), + ) + + itWithSdk.live("reruns activation for Config updates during startup", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + const activations = { count: 0 } + const file = path.join(dir.path, "opencode.json") + yield* Effect.promise(() => fs.writeFile(file, "{}")) + const firstStarted = yield* Deferred.make() + const releaseFirst = yield* Deferred.make() + const secondStarted = yield* Deferred.make() + const releaseSecond = yield* Deferred.make() + const sdk = yield* SdkPlugins.Service + yield* sdk.register( + EffectPlugin.define({ + id: "blocked-config-reload", + effect: () => + Effect.sync(() => ++activations.count).pipe( + Effect.flatMap((activation) => + activation === 1 + ? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseFirst))) + : Deferred.succeed(secondStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseSecond))), + ), + ), + }), + ) + + const locations = yield* LocationServiceMap.Service + const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })) + yield* Deferred.await(firstStarted) + + const events = yield* EventV2.Service + const updated = yield* events.subscribe(Config.Event.Updated).pipe( + Stream.filter((event) => event.location?.directory === dir.path), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ) + yield* Effect.promise(() => + fs.writeFile( + file, + JSON.stringify({ plugins: [path.join(import.meta.dir, "plugin/fixtures/config-effect-plugin.ts")] }), + ), + ) + yield* Fiber.join(updated) + + const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe( + Effect.provide(context), + Effect.forkChild, + ) + yield* Deferred.succeed(releaseFirst, undefined) + yield* Deferred.await(secondStarted) + expect(flushFiber.pollUnsafe()).toBeUndefined() + yield* Deferred.succeed(releaseSecond, undefined) + yield* Fiber.join(flushFiber) + expect(activations.count).toBe(2) + }), + ), + ), + ) + + itWithSdk.live("keeps flush pending while startup updates continue", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + const locations = yield* LocationServiceMap.Service + const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })) + const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe( + Effect.provide(context), + Effect.forkChild({ startImmediately: true }), + ) + const events = yield* EventV2.Service + + yield* Effect.forEach( + Array.from({ length: 5 }), + () => events.publish(SdkPlugins.Updated, {}).pipe(Effect.andThen(Effect.sleep("50 millis"))), + { discard: true }, + ) + expect(flushFiber.pollUnsafe()).toBeUndefined() + yield* Fiber.join(flushFiber) + }), + ), + ), + ) + + itWithSdk.live("keeps flush open while later hot reload runs", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + const locations = yield* LocationServiceMap.Service + const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })) + yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(Effect.provide(context)) + + const started = yield* Deferred.make() + const release = yield* Deferred.make() + const completed = yield* Deferred.make() + const sdk = yield* SdkPlugins.Service + yield* sdk.register( + EffectPlugin.define({ + id: "post-ready-plugin", + effect: () => + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen(Deferred.succeed(completed, undefined)), + ), + }), + ) + yield* Deferred.await(started) + + yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe( + Effect.provide(context), + Effect.timeout("1 second"), + ) + yield* Deferred.succeed(release, undefined) + yield* Deferred.await(completed) + }), + ), + ), + ) + + itWithSdk.live("does not cancel activation when a flush waiter is interrupted", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + const started = yield* Deferred.make() + const release = yield* Deferred.make() + const completed = yield* Deferred.make() + const sdk = yield* SdkPlugins.Service + yield* sdk.register( + EffectPlugin.define({ + id: "interrupted-waiter-plugin", + effect: () => + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen(Deferred.succeed(completed, undefined)), + ), + }), + ) + + const locations = yield* LocationServiceMap.Service + const context = yield* locations.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(dir.path) })) + yield* Deferred.await(started) + const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe( + Effect.provide(context), + Effect.forkChild({ startImmediately: true }), + ) + yield* Fiber.interrupt(flushFiber) + + yield* Deferred.succeed(release, undefined) + yield* Deferred.await(completed) + yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe( + Effect.provide(context), + Effect.timeout("500 millis"), + ) + }), + ), + ), + ) + it.live("applies ordered plugin config operations during boot", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), @@ -79,7 +341,7 @@ describe("LocationServiceMap", () => { ) const plugins = yield* Effect.gen(function* () { const plugins = yield* PluginV2.Service - yield* (yield* PluginSupervisor.Service).ready + yield* (yield* PluginSupervisor.Service).flush return yield* plugins.list() }).pipe( Effect.scoped, @@ -106,7 +368,7 @@ describe("LocationServiceMap", () => { yield* Effect.gen(function* () { const registry = yield* PluginV2.Service const supervisor = yield* PluginSupervisor.Service - yield* supervisor.ready + yield* supervisor.flush expect((yield* registry.list()).map((plugin) => String(plugin.id))).toEqual(["opencode.agent"]) yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ plugins: ["-*", "opencode.command"] }))) diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 9333275ba6..55d3c14aca 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -43,6 +43,7 @@ import { SkillGuidance } from "@opencode-ai/core/skill/guidance" import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance" import { McpGuidance } from "@opencode-ai/core/mcp/guidance" import { SessionTelemetry } from "@opencode-ai/core/observability/session" +import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" import { describe, expect } from "bun:test" import { eq } from "drizzle-orm" import { Effect, Layer, References, Tracer } from "effect" @@ -83,6 +84,7 @@ const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.suc const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(Instructions.empty) }) const mcpGuidance = Layer.mock(McpGuidance.Service, { load: () => Effect.succeed(Instructions.empty) }) const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })) +const pluginSupervisor = Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void })) const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ [Snapshot.node, Snapshot.noopLayer], [LayerNodePlatform.llmClient, client], @@ -96,6 +98,7 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ [Config.node, config], [PermissionV2.node, permission], [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + [PluginSupervisor.node, pluginSupervisor], ]) const spans: Tracer.NativeSpan[] = [] const tracer = Tracer.make({ @@ -162,6 +165,7 @@ const it = testEffect( [ReferenceGuidance.node, referenceGuidance], [Config.node, config], [Snapshot.node, Snapshot.noopLayer], + [PluginSupervisor.node, pluginSupervisor], [SessionExecution.node, execution], ], ), @@ -172,6 +176,12 @@ describe("SessionRunnerLLM recorded", () => { it.effect("executes one recorded V2 prompt through the recorded HTTP transport", () => Effect.gen(function* () { spans.length = 0 + const agents = yield* AgentV2.Service + yield* agents.transform((draft) => + draft.update(AgentV2.ID.make("build"), (agent) => { + agent.mode = "primary" + }), + ) const { db } = yield* Database.Service yield* db .insert(ProjectTable) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 19d24947ae..4bc1790325 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -75,6 +75,7 @@ import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { SessionRunnerSystemPrompt } from "@opencode-ai/core/session/runner/system-prompt" import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" import { QuestionTool } from "@opencode-ai/core/tool/question" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { AgentV2 } from "@opencode-ai/core/agent" @@ -344,6 +345,13 @@ const config = Layer.succeed( ]), }), ) +let pluginFlushHook = Effect.void +const pluginSupervisor = Layer.succeed( + PluginSupervisor.Service, + PluginSupervisor.Service.of({ + flush: Effect.suspend(() => pluginFlushHook), + }), +) const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ [Snapshot.node, Snapshot.noopLayer], [LayerNodePlatform.llmClient, client], @@ -357,6 +365,7 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ [Config.node, config], [McpGuidance.node, mcpGuidance], [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + [PluginSupervisor.node, pluginSupervisor], ]) const spans: Tracer.NativeSpan[] = [] const tracer = Tracer.make({ @@ -429,6 +438,7 @@ const it = testEffect( [SessionExecution.node, execution], [Config.node, config], [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + [PluginSupervisor.node, pluginSupervisor], ], ), ) @@ -466,6 +476,7 @@ const setup = Effect.gen(function* () { systemUnavailable = false systemLoadHook = Effect.void modelResolveHook = Effect.void + pluginFlushHook = Effect.void currentModel = model skillBaselines.clear() responses = undefined @@ -479,6 +490,12 @@ const setup = Effect.gen(function* () { activeToolExecutions = 0 maxActiveToolExecutions = 0 spans.length = 0 + const agents = yield* AgentV2.Service + yield* agents.transform((draft) => + draft.update(AgentV2.ID.make("build"), (agent) => { + agent.mode = "primary" + }), + ) yield* db .insert(ProjectTable) .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) @@ -1307,10 +1324,64 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("fails before the model request when the selected agent is unavailable", () => + Effect.gen(function* () { + yield* setup + const { db } = yield* Database.Service + yield* db + .update(SessionTable) + .set({ agent: "explore" }) + .where(eq(SessionTable.id, sessionID)) + .run() + .pipe(Effect.orDie) + const session = yield* SessionV2.Service + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Inspect files" }), resume: false }) + + requests.length = 0 + response = [] + const failure = yield* session.resume(sessionID).pipe(Effect.flip) + + expect(failure).toMatchObject({ + _tag: "Session.AgentNotFoundError", + sessionID, + agent: "explore", + }) + expect(requests).toHaveLength(0) + }), + ) + + it.effect("waits for initial plugin readiness before constructing the model request", () => + Effect.gen(function* () { + yield* setup + const release = yield* Deferred.make() + pluginFlushHook = Deferred.await(release) + const session = yield* SessionV2.Service + yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Wait for plugins" }), resume: false }) + + requests.length = 0 + response = [] + const running = yield* session.resume(sessionID).pipe(Effect.forkChild({ startImmediately: true })) + yield* Effect.yieldNow + + expect(requests).toHaveLength(0) + expect(running.pollUnsafe()).toBeUndefined() + + yield* Deferred.succeed(release, undefined) + yield* Fiber.join(running) + expect(requests).toHaveLength(1) + }), + ) + it.effect("updates selected-agent skill guidance after an agent switch", () => Effect.gen(function* () { const session = yield* setup const events = yield* EventV2.Service + const agents = yield* AgentV2.Service + yield* agents.transform((draft) => + draft.update(AgentV2.ID.make("reviewer"), (agent) => { + agent.mode = "primary" + }), + ) skillBaselines.set(AgentV2.ID.make("build"), "Build skills") yield* admit(session, "First") diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts index 8f2045cfa7..81efda5cb5 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -25,6 +25,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { SessionStore } from "@opencode-ai/core/session/store" import { PluginRuntime } from "@opencode-ai/core/plugin/runtime" +import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" import { SubagentTool } from "@opencode-ai/core/tool/subagent" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" @@ -125,6 +126,7 @@ const it = testEffect(layer) const withSubagent = (location: Location.Ref) => Effect.gen(function* () { const locations = yield* LocationServiceMap.Service + yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(Effect.provide(locations.get(location))) yield* AgentV2.Service.use((agents) => agents.transform((draft) => { // The caller identity used by executeTool; subagent permission asserts against it. diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index cd0eb32f7c..5c819e521d 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -101,7 +101,7 @@ const layer = Layer.effect( const skillDirs = yield* skill.dirs() const referenceDirs = Object.keys(cfg.references ?? cfg.reference ?? {}).length ? yield* Effect.gen(function* () { - yield* (yield* PluginSupervisor.Service).ready + yield* (yield* PluginSupervisor.Service).flush return (yield* (yield* Reference.Service).list()).map((reference) => reference.path) }).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) })))) : [] diff --git a/packages/server/src/handlers/model.ts b/packages/server/src/handlers/model.ts index b5d6ef37cf..a6ebdb84d7 100644 --- a/packages/server/src/handlers/model.ts +++ b/packages/server/src/handlers/model.ts @@ -20,7 +20,7 @@ export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers) "model.default", Effect.fn(function* () { const plugins = yield* PluginSupervisor.Service - yield* plugins.ready.pipe( + yield* plugins.flush.pipe( Effect.timeoutOrElse({ duration: "5 seconds", orElse: () => diff --git a/packages/simulation/src/backend/filesystem.ts b/packages/simulation/src/backend/filesystem.ts deleted file mode 100644 index 8405198b87..0000000000 --- a/packages/simulation/src/backend/filesystem.ts +++ /dev/null @@ -1,390 +0,0 @@ -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_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) => - Layer.sync(FileSystem.FileSystem)(() => - make({ - root: options?.root ?? process.cwd(), - files: { ...loadSnapshotFiles(process.env.OPENCODE_SIMULATE_STATE), ...options?.files }, - }), - ) - -function loadSnapshotFiles(stateDirectory: string | undefined) { - if (!stateDirectory) return {} - const snapshot = path.join(stateDirectory, "files") - if (!nodeFs.existsSync(snapshot)) 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(snapshot, file)] = new Uint8Array(nodeFs.readFileSync(file)) - } - } - walk(snapshot) - 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/simulation/src/backend/fs-util.ts b/packages/simulation/src/backend/fs-util.ts deleted file mode 100644 index 4ee267fd64..0000000000 --- a/packages/simulation/src/backend/fs-util.ts +++ /dev/null @@ -1,215 +0,0 @@ -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`. - * - * This implementation is intentionally self-contained and only uses the - * injected simulated `FileSystem.FileSystem`. The default FSUtil layer has a - * few helpers that reach host-node APIs directly; depending on it here makes it - * easy for mutation paths to escape or miss the in-memory project tree. - */ - -const layer = Layer.effect( - FSUtil.Service, - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - - const existsSafe = Effect.fn("SimulationFSUtil.existsSafe")(function* (file: string) { - return yield* fs.exists(file).pipe(Effect.orElseSucceed(() => false)) - }) - - const isDir = Effect.fn("SimulationFSUtil.isDir")(function* (file: string) { - const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined))) - return info?.type === "Directory" - }) - - const isFile = Effect.fn("SimulationFSUtil.isFile")(function* (file: string) { - const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined))) - return info?.type === "File" - }) - - const realPath = Effect.fn("SimulationFSUtil.realPath")(function* (file: string) { - return yield* fs.realPath(file) - }) - - const stat = Effect.fn("SimulationFSUtil.stat")(function* (file: string) { - return yield* fs.stat(file) - }) - - const readFile = Effect.fn("SimulationFSUtil.readFile")(function* (file: string) { - return yield* fs.readFile(file) - }) - - const readFileString = Effect.fn("SimulationFSUtil.readFileString")(function* (file: string) { - return yield* fs.readFileString(file) - }) - - const readFileStringSafe = Effect.fn("SimulationFSUtil.readFileStringSafe")(function* (file: string) { - return yield* fs - .readFileString(file) - .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined))) - }) - - const readJson = Effect.fn("SimulationFSUtil.readJson")(function* (file: string) { - const text = yield* readFileString(file) - return JSON.parse(text) as unknown - }) - - const writeFile = Effect.fn("SimulationFSUtil.writeFile")(function* ( - file: string, - data: Uint8Array, - options?: Parameters[2], - ) { - return yield* fs.writeFile(file, data, options) - }) - - const writeFileString = Effect.fn("SimulationFSUtil.writeFileString")(function* ( - file: string, - data: string, - options?: Parameters[2], - ) { - return yield* fs.writeFileString(file, data, options) - }) - - const makeDirectory: FileSystem.FileSystem["makeDirectory"] = (file, options) => fs.makeDirectory(file, options) - - const ensureDir = Effect.fn("SimulationFSUtil.ensureDir")(function* (file: string) { - yield* fs.makeDirectory(file, { recursive: true }) - }) - - const writeWithDirs = Effect.fn("SimulationFSUtil.writeWithDirs")(function* ( - file: string, - content: string | Uint8Array, - mode?: number, - ) { - 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) - }) - - 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 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 resolve = Effect.fn("SimulationFSUtil.resolve")(function* (input: string) { - return path.resolve(input) - }) - - 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 - }) - - const up = Effect.fn("SimulationFSUtil.up")(function* (options: { targets: string[]; start: string; stop?: string }) { - 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 - } - 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, - }) - }), -) - -export const node = makeGlobalNode({ service: FSUtil.Service, layer, deps: [filesystem] }) - -export * as SimulationFSUtil from "./fs-util" diff --git a/packages/simulation/src/backend/index.ts b/packages/simulation/src/backend/index.ts index 8610867a7a..2b8bb876e6 100644 --- a/packages/simulation/src/backend/index.ts +++ b/packages/simulation/src/backend/index.ts @@ -1,10 +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 { httpClient } from "@opencode-ai/core/effect/app-node-platform" import { DriveManifest } from "../manifest" import { SimulationControl } from "./control" -import { SimulationFileSystem } from "./filesystem" -import { SimulationFSUtil } from "./fs-util" import { SimulationNetwork } from "./network" import { SimulationOpenAI } from "./openai" @@ -14,8 +11,6 @@ import { SimulationOpenAI } from "./openai" * 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 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. @@ -32,8 +27,6 @@ export function startDriveServer() { } export const simulationReplacements: LayerNode.Replacements = [ - [filesystem, SimulationFileSystem.layer()], - [FSUtil.node, SimulationFSUtil.node], [httpClient, SimulationNetwork.layer], ]