diff --git a/packages/core/src/location-service-map.ts b/packages/core/src/location-service-map.ts index 67a49616e0c..297baf8c3de 100644 --- a/packages/core/src/location-service-map.ts +++ b/packages/core/src/location-service-map.ts @@ -1,6 +1,8 @@ import { Context, Effect, Layer, LayerMap } from "effect" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Node } from "@opencode-ai/util/effect/app-node" +import { AbsolutePath } from "@opencode-ai/schema/schema" +import path from "path" import { Location } from "./location.js" import type { Instance } from "./instance.js" @@ -15,4 +17,12 @@ export class Service extends Context.Service< export const node = LayerNode.unbound(Service, Node.tags.values.global) +/** Normalize equivalent placements before they become resource-cache keys. */ +export function canonical(ref: Location.Ref) { + return Location.Ref.make({ + directory: AbsolutePath.make(process.platform === "win32" ? path.normalize(ref.directory) : ref.directory), + workspaceID: ref.workspaceID, + }) +} + export * as LocationServiceMap from "./location-service-map.js" diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index afe874c31bc..39d090f8301 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -1,11 +1,9 @@ import { Duration, Effect, Layer, LayerMap } from "effect" import { existsSync } from "fs" -import path from "path" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Instance } from "./instance.js" import { Location } from "./location.js" import { LocationServiceMap } from "./location-service-map.js" -import { AbsolutePath } from "./schema.js" export { LocationServiceMap } from "./location-service-map.js" @@ -15,13 +13,6 @@ export type LocationError = Instance.Error export function buildLocationServiceMap( replacements: LayerNode.Replacements = [], ): Layer.Layer { - // Structural Equal distinguishes optional-key shape and Windows separator style. - // The RcMap caches the raw key before the build callback, so normalize both here. - const canonical = (ref: Location.Ref) => - Location.Ref.make({ - directory: AbsolutePath.make(process.platform === "win32" ? path.normalize(ref.directory) : ref.directory), - workspaceID: ref.workspaceID, - }) return Layer.effect( LocationServiceMap.Service, Effect.map( @@ -35,10 +26,10 @@ export function buildLocationServiceMap( }), (inner) => ({ ...inner, - get: (ref: Location.Ref) => inner.get(canonical(ref)), - contextEffect: (ref: Location.Ref) => inner.contextEffect(canonical(ref)), - contextEffectOption: (ref: Location.Ref) => inner.contextEffectOption(canonical(ref)), - invalidate: (ref: Location.Ref) => inner.invalidate(canonical(ref)), + get: (ref: Location.Ref) => inner.get(LocationServiceMap.canonical(ref)), + contextEffect: (ref: Location.Ref) => inner.contextEffect(LocationServiceMap.canonical(ref)), + contextEffectOption: (ref: Location.Ref) => inner.contextEffectOption(LocationServiceMap.canonical(ref)), + invalidate: (ref: Location.Ref) => inner.invalidate(LocationServiceMap.canonical(ref)), }), ), ) diff --git a/packages/core/src/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts index e33383136ef..00bf06800d1 100644 --- a/packages/core/src/plugin/supervisor.ts +++ b/packages/core/src/plugin/supervisor.ts @@ -2,7 +2,7 @@ export * as PluginSupervisor from "./supervisor.js" export { Service, type Interface } from "./supervisor-service.js" import { Event } from "@opencode-ai/schema/config" -import { Cause, Effect, Latch, Layer, Stream } from "effect" +import { Cause, Effect, Exit, Latch, Layer, Stream } from "effect" import path from "path" import { ConfigPluginSource } from "../config/plugin/source.js" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" @@ -86,9 +86,8 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* ( } }) -export const layer = Layer.effect( - Service, - Effect.gen(function* () { +function make(failOnError = false) { + return Effect.gen(function* () { const registry = yield* Plugin.Service const sdk = yield* SdkPlugins.Service const instance = yield* InstancePlugins.Service @@ -97,6 +96,7 @@ export const layer = Layer.effect( const npm = yield* Npm.Service const ready = yield* Latch.make() let observed = 0 + let activation = Exit.void const activate = Effect.fn("PluginSupervisor.activate")(function* () { // Resolve OpenCode's internal plugins with their privileged Location services. @@ -148,15 +148,19 @@ export const layer = Layer.effect( Stream.debounce("100 millis"), Stream.runForEach((target) => Effect.gen(function* () { - yield* activate().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))) + activation = yield* Effect.exit(activate()) + if (Exit.isFailure(activation)) + yield* Effect.logError("failed to reload plugins", { cause: activation.cause }) if (observed === target) yield* ready.open }), ), Effect.forkScoped({ startImmediately: true }), ) - return Service.of({ flush: ready.await }) - }), -) + return Service.of({ flush: failOnError ? ready.await.pipe(Effect.andThen(() => activation)) : ready.await }) + }) +} + +export const layer = Layer.effect(Service, make()) const nodeDeps = [ Plugin.node, @@ -174,3 +178,8 @@ function pluginSource(target: string): Plugin.Source { } export const node = makeLocationNode({ service: Service, layer, deps: nodeDeps }) + +/** Opt into propagating failed plugin generations through flush instead of only logging them. */ +export function configured(options: { readonly failOnError?: boolean } = {}) { + return makeLocationNode({ service: Service, layer: Layer.effect(Service, make(options.failOnError)), deps: nodeDeps }) +} diff --git a/packages/sdk/README.md b/packages/sdk/README.md index ceb2309f274..ca45a806533 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -28,6 +28,53 @@ await using opencode = await OpenCode.create({ `close()` and `Symbol.asyncDispose` release router resources, Location services, fibers, and scoped plugin registrations. +## Session-Selected Plugins + +Use `instances` when Sessions in the same directory need different application plugins. The application selects a stable key from Session metadata; the SDK constructs and caches an instance for that key and the Session's current Location. + +```ts +import { OpenCode } from "@opencode-ai/sdk" +import { threads } from "./threads" +import { slackPlugin } from "./slack-plugin" + +await using opencode = await OpenCode.create({ + database: { path: "./sessions.db" }, + instances: { + key(session) { + const threadID = session.metadata?.threadID + if (typeof threadID !== "string") throw new Error("Session has no thread ID") + return threadID + }, + configure: async (threadID) => ({ + plugins: [slackPlugin(await threads.get(threadID))], + }), + }, +}) + +const session = await opencode.sessions.create({ + location: { directory: "/workspace" }, + metadata: { threadID: "thread-42" }, +}) +await opencode.sessions.prompt({ sessionID: session.id, text: "Review the changes" }) +``` + +`threads` and `slackPlugin` are application-owned modules. `key` is synchronous and should only select identity, not initialize plugins. `configure` returns plugin definitions; their setup receives the selected instance's `ctx.location`. + +- The same key and Location share one live instance. Different directories or workspace IDs always select separate instances, even with the same application key. +- `configure` runs on a cache miss, not on each prompt. Loaded instances live until the host closes; change the application key or restart the host to reconstruct their birth configuration. Plugin transforms and reloads remain available within that lifetime. +- Session reads and unloaded permission/form lists do not initialize plugins. Configuration or initial supplied-plugin setup failure prevents capability acquisition, without falling back to another instance. A subsequent request can retry a failed construction. +- Existing HTTP prompt middleware also acquires capabilities for an idempotent retry. After restart, that retry can reconstruct plugins before returning the original admission; prompt preparation and hooks do not rerun. Configuration failure can therefore block the retry even when its input was already saved. +- Instance selection is not an authorization or storage-isolation boundary. Plugin Session APIs and the existing plugin-ID-based durable storage retain their normal scope. +- Omitting `instances` preserves default Location sharing. Host-wide plugins remain separate from Session-selected configuration; retain host-wide catalog policy when locationless generation needs it. + +### Restart and Lifetime + +The selector is installed before automatic recovery starts. Its callbacks must be able to load application data without depending on the returned `opencode` handle or a later registration call. Functions are reconstructed, not serialized. + +Use a persistent `database.path` to recover Sessions after restart; the default database is in memory. Workerd uses its injected Durable Object storage. After restart, the next capability-dependent operation or recovery drain rebuilds the selected instance from saved Session metadata and application data. + +Promise plugin resources should be acquired in `setup` and released by its cleanup function. Effect configuration can acquire resources in its supplied instance Scope; capture application services before creating the SDK host. + ## Workerd Use the Workerd entrypoint inside a Cloudflare Durable Object. Hold one host for the lifetime of the object instance rather than creating one per request. @@ -70,3 +117,25 @@ const session = yield * opencode.sessions.get({ sessionID }) ``` The Effect Workerd entrypoint is `@opencode-ai/sdk/workerd/effect`. + +Effect configuration uses the same keys and lifetime rules, with canonical `Session.Info` values and an Effect-returning factory: + +```ts +import { OpenCode } from "@opencode-ai/sdk/effect" +import { Effect, Schema } from "effect" +import { threads } from "./threads-effect" +import { slackPlugin } from "./slack-plugin-effect" + +const threadMetadata = Schema.decodeUnknownSync(Schema.Struct({ threadID: Schema.String })) +const opencode = + yield * + OpenCode.create({ + database: { path: "./sessions.db" }, + instances: { + key: (session) => threadMetadata(session.metadata).threadID, + configure: (threadID) => threads.get(threadID).pipe(Effect.map((thread) => ({ plugins: [slackPlugin(thread)] }))), + }, + }) +``` + +Both Workerd entrypoints also accept `instances`. The public `OpenCode.InstanceOptions` and `OpenCode.InstanceConfiguration` types describe the corresponding Promise or Effect callbacks. diff --git a/packages/sdk/script/verify-package.ts b/packages/sdk/script/verify-package.ts index cac2c19b614..432a9c51cd1 100644 --- a/packages/sdk/script/verify-package.ts +++ b/packages/sdk/script/verify-package.ts @@ -105,9 +105,30 @@ import { OpenCodeWorkerd } from "@opencode-ai/sdk/workerd" export class OpenCodeDO { constructor(state) { + this.configurations = 0 this.opencode = state.blockConcurrencyWhile(() => OpenCodeWorkerd.create({ storage: state.storage, app: { version: "packed-workerd" }, + models: { fetch: false }, + instances: { + key: session => String(session.metadata.thread), + configure: key => { + this.configurations++ + return { + plugins: [{ + id: "packed-instance", + async setup(ctx) { + if (ctx.app.version !== "packed-workerd" || ctx.location.directory !== "/workspace") { + throw new Error("Selected instance did not inherit the host configuration") + } + await ctx.session.hook("prompt", event => { + event.prompt.text += ":" + key + }) + }, + }], + } + }, + }, })) } @@ -116,6 +137,18 @@ export class OpenCodeDO { throw new Error("Packed workerd SHA-256 mismatch") } const opencode = await this.opencode + const sessions = await Promise.all([1, 2].map(() => opencode.sessions.create({ + location: { directory: "/workspace" }, + metadata: { thread: "packed-thread" }, + }))) + const admitted = await Promise.all(sessions.map(session => opencode.sessions.prompt({ + sessionID: session.id, + text: "Packed prompt", + resume: false, + }))) + if (this.configurations !== 1 || admitted.some(item => item.payload.text !== "Packed prompt:packed-thread")) { + throw new Error("Packed instance configuration did not share or prepare prompts correctly") + } return Response.json(await opencode.health.get()) } } diff --git a/packages/sdk/src/effect/opencode.ts b/packages/sdk/src/effect/opencode.ts index d8199565fd8..6814062ea87 100644 --- a/packages/sdk/src/effect/opencode.ts +++ b/packages/sdk/src/effect/opencode.ts @@ -6,11 +6,14 @@ import { Context, Effect, Layer } from "effect" import type { Config, Scope } from "effect" import { FetchHttpClient } from "effect/unstable/http" import { EmbeddedHost } from "../internal/host" +import type { SdkInstances } from "../internal/instances" export type { LogEntry, LogLevel, LogOptions, LogWriter } from "../logging" export type CreateOptions = EmbeddedHost.CreateOptions export type EmbedOptions = EmbeddedHost.EmbedOptions +export type InstanceOptions = SdkInstances.Options +export type InstanceConfiguration = SdkInstances.Configuration export type Interface = Omit & { readonly sessions: OpenCodeClient["session"] diff --git a/packages/sdk/src/effect/workerd.ts b/packages/sdk/src/effect/workerd.ts index dc985be10d2..68ba69e8a9d 100644 --- a/packages/sdk/src/effect/workerd.ts +++ b/packages/sdk/src/effect/workerd.ts @@ -10,11 +10,15 @@ export type Configuration = WorkerdProfile.Configuration export interface CreateOptions extends WorkerdProfile.Options { readonly log?: OpenCode.CreateOptions["log"] readonly workspaceProviders?: OpenCode.CreateOptions["workspaceProviders"] + readonly instances?: OpenCode.CreateOptions["instances"] } -export const create = ({ log, workspaceProviders, ...options }: CreateOptions) => { +export const create = ({ log, workspaceProviders, instances, ...options }: CreateOptions) => { const profile = WorkerdProfile.make(options) - return OpenCode.create({ ...profile.options, log, workspaceProviders }, { overrides: profile.replacements }) + return OpenCode.create( + { ...profile.options, log, workspaceProviders, instances }, + { overrides: profile.replacements }, + ) } export const layer = (options: CreateOptions): Layer.Layer => diff --git a/packages/sdk/src/internal/host.ts b/packages/sdk/src/internal/host.ts index 66f8b6884a1..3061fd2b863 100644 --- a/packages/sdk/src/internal/host.ts +++ b/packages/sdk/src/internal/host.ts @@ -11,10 +11,12 @@ import { Context, Effect, Layer, ManagedRuntime, Scope } from "effect" import { HttpEffect, HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http" import { context, layer, type LogOptions } from "../logging" import { OwnedFetch } from "./fetch" +import { SdkInstances } from "./instances" export interface CreateOptions extends Omit { readonly log?: LogOptions readonly workspaceProviders?: Readonly> + readonly instances?: SdkInstances.Options } /** Host hooks for embedding opencode on a non-default runtime profile. */ @@ -26,7 +28,7 @@ export const create = Effect.fn("EmbeddedHost.create")(function* ( options: CreateOptions = {}, embed: EmbedOptions = {}, ) { - const { log, workspaceProviders, ...server } = options + const { log, workspaceProviders, instances, ...server } = options const runtime = ManagedRuntime.make( createEmbeddedRoutes( { @@ -37,6 +39,7 @@ export const create = Effect.fn("EmbeddedHost.create")(function* ( workspaceProviders ? [...(embed.overrides ?? []), WorkspaceDriver.node.replace(WorkspaceDriver.registryNode(workspaceProviders))] : embed.overrides, + instances ? (replacements) => SdkInstances.layer(instances, replacements) : undefined, ).pipe(Layer.provide(HttpServer.layerServices), Layer.provideMerge(layer(log))), ) diff --git a/packages/sdk/src/internal/instances.ts b/packages/sdk/src/internal/instances.ts new file mode 100644 index 00000000000..ca3d3cccf29 --- /dev/null +++ b/packages/sdk/src/internal/instances.ts @@ -0,0 +1,84 @@ +export * as SdkInstances from "./instances" + +import { Instance } from "@opencode-ai/core/instance" +import { LocationServiceMap } from "@opencode-ai/core/location-service-map" +import { Plugin } from "@opencode-ai/core/plugin" +import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" +import type { InstancePlugins } from "@opencode-ai/core/plugin/instance" +import { Location } from "@opencode-ai/schema/location" +import type { Session } from "@opencode-ai/schema/session" +import type { LayerNode } from "@opencode-ai/util/effect/layer-node" +import { Duration, Effect, Layer, LayerMap, Option, Scope } from "effect" + +export interface Configuration { + readonly plugins: InstancePlugins.List +} + +export interface Options { + /** Select a sharing key within the Session's current Location. Must not initialize plugins. */ + readonly key: (session: Session.Info) => string + /** Reconstruct configuration on a cache miss. Resources belong to the instance Scope. */ + readonly configure: (key: string) => Effect.Effect +} + +export function layer(options: Options, replacements: LayerNode.Replacements) { + return Layer.effect( + Instance.Service, + Effect.gen(function* () { + const scope = yield* Effect.scope + const key = (session: Session.Info) => ({ + key: options.key(session), + ...LocationServiceMap.canonical(session.location), + }) + const instances: LayerMap.LayerMap, Instance.Services> = yield* LayerMap.make( + (input: ReturnType) => + Layer.unwrap( + Effect.gen(function* () { + const configuration = yield* options.configure(input.key).pipe(Effect.orDie) + return Instance.layer(Location.Ref.make({ directory: input.directory, workspaceID: input.workspaceID }), { + plugins: configuration.plugins, + replacements: [ + PluginSupervisor.node.replace(PluginSupervisor.configured({ failOnError: true })), + ...replacements, + ], + }).pipe( + Layer.tap((context) => + Effect.gen(function* () { + const supervisor = yield* PluginSupervisor.Service + const plugins = yield* Plugin.Service + yield* supervisor.flush + const failed = (yield* plugins.list()).filter( + (plugin) => + plugin.state.status === "failed" && + configuration.plugins.some((configured) => configured.id === plugin.id), + ) + if (failed.length > 0) + yield* Effect.die( + new Error(`Instance plugin setup failed: ${failed.map((plugin) => plugin.id).join(", ")}`), + ) + }).pipe(Effect.provide(context)), + ), + ) + }), + ).pipe( + // Eviction can close the lookup's scope; do not make that fiber wait on itself. + Layer.tapCause(() => + instances.invalidate(input).pipe(Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid), + ), + ), + { idleTimeToLive: Duration.infinity }, + ) + return Instance.Service.of({ + provide: (session) => Effect.provide(instances.get(key(session))), + provideIfLoaded: (session) => (effect) => + Effect.scopedWith((scope) => + Effect.gen(function* () { + const context = yield* instances.contextEffectOption(key(session)).pipe(Scope.provide(scope)) + if (Option.isNone(context)) return Option.none() + return Option.some(yield* effect.pipe(Effect.provide(context.value))) + }), + ), + }) + }), + ) +} diff --git a/packages/sdk/src/opencode.ts b/packages/sdk/src/opencode.ts index 86c5f60da37..409feb1c795 100644 --- a/packages/sdk/src/opencode.ts +++ b/packages/sdk/src/opencode.ts @@ -3,6 +3,8 @@ import { PromiseSdk } from "./promise" export type { LogEntry, LogLevel, LogOptions, LogWriter } from "./logging" export type CreateOptions = PromiseSdk.CreateOptions +export type InstanceOptions = PromiseSdk.InstanceOptions +export type InstanceConfiguration = PromiseSdk.InstanceConfiguration export type Interface = PromiseSdk.Interface export const create = (options: CreateOptions = {}) => PromiseSdk.create(options) diff --git a/packages/sdk/src/promise.ts b/packages/sdk/src/promise.ts index a113df0ee75..3d770bb2c56 100644 --- a/packages/sdk/src/promise.ts +++ b/packages/sdk/src/promise.ts @@ -2,11 +2,24 @@ export * as PromiseSdk from "./promise" import { OpenCode, type OpenCodeClient } from "@opencode-ai/client" import type { Plugin } from "@opencode-ai/plugin" -import { Effect } from "effect" +import { Session } from "@opencode-ai/schema/session" +import { Effect, Schema } from "effect" import { EmbeddedHost } from "./internal/host" -export interface CreateOptions extends Omit { +export interface InstanceConfiguration { + readonly plugins: ReadonlyArray +} + +export interface InstanceOptions { + /** Select a sharing key within the Session's current Location. Must not initialize plugins. */ + readonly key: (session: typeof Session.Info.Encoded) => string + /** Reconstruct configuration on a cache miss, including after a host restart. */ + readonly configure: (key: string) => InstanceConfiguration | Promise +} + +export interface CreateOptions extends Omit { readonly plugins?: ReadonlyArray + readonly instances?: InstanceOptions } export type Interface = Omit & { @@ -18,8 +31,26 @@ export type Interface = Omit & { } export async function create(options: CreateOptions = {}, embed: EmbeddedHost.EmbedOptions = {}): Promise { - const { plugins, ...hostOptions } = options - const host = await Effect.runPromise(EmbeddedHost.create(hostOptions, embed)) + const { plugins, instances, ...hostOptions } = options + const host = await Effect.runPromise( + EmbeddedHost.create( + { + ...hostOptions, + instances: instances + ? { + key: (session) => instances.key(Schema.encodeSync(Session.Info)(session)), + configure: (key) => + Effect.gen(function* () { + const { PluginPromise } = yield* Effect.promise(() => import("@opencode-ai/core/plugin/promise")) + const configuration = yield* Effect.tryPromise(async () => instances.configure(key)) + return { plugins: configuration.plugins.map(PluginPromise.fromPromise) } + }), + } + : undefined, + }, + embed, + ), + ) const client = OpenCode.make({ baseUrl: "http://opencode.local", fetch: host.fetch }) const register = async (plugin: Plugin.Plugin) => { const { PluginPromise } = await import("@opencode-ai/core/plugin/promise") diff --git a/packages/sdk/src/workerd.ts b/packages/sdk/src/workerd.ts index c9c21c9ecbe..d4a9aa623d1 100644 --- a/packages/sdk/src/workerd.ts +++ b/packages/sdk/src/workerd.ts @@ -9,6 +9,7 @@ export type Configuration = WorkerdProfile.Configuration export interface CreateOptions extends WorkerdProfile.Options { readonly log?: LogOptions readonly plugins?: PromiseSdk.CreateOptions["plugins"] + readonly instances?: PromiseSdk.CreateOptions["instances"] } /** @@ -25,9 +26,9 @@ export interface CreateOptions extends WorkerdProfile.Options { * session operations plus the live `events.subscribe()` stream — served over * an in-process fetch transport, so no request leaves the isolate. */ -export const create = ({ log, plugins, ...options }: CreateOptions) => { +export const create = ({ log, plugins, instances, ...options }: CreateOptions) => { const profile = WorkerdProfile.make(options) - return PromiseSdk.create({ ...profile.options, log, plugins }, { overrides: profile.replacements }) + return PromiseSdk.create({ ...profile.options, log, plugins, instances }, { overrides: profile.replacements }) } export type Interface = PromiseSdk.Interface diff --git a/packages/sdk/test/instances-effect.test.ts b/packages/sdk/test/instances-effect.test.ts new file mode 100644 index 00000000000..2a0e1cb93fa --- /dev/null +++ b/packages/sdk/test/instances-effect.test.ts @@ -0,0 +1,236 @@ +import { expect } from "bun:test" +import path from "path" +import { LanguageModel, LLMClient } from "@opencode-ai/ai" +import { OpenAIChat } from "@opencode-ai/ai/protocols" +import { TestLLM } from "@opencode-ai/ai/testing" +import { llmClient } from "@opencode-ai/core/effect/app-node-platform" +import { makeMemoryDriver } from "@opencode-ai/core/environment/index" +import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" +import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver" +import { Plugin } from "@opencode-ai/plugin/effect" +import { Deferred, Effect, Exit, Layer, Schema, Scope } from "effect" +import { tmpdirScoped } from "../../core/test/fixture/tmpdir" +import { testEffect } from "../../core/test/lib/effect" +import { AbsolutePath, Agent, Location, OpenCode } from "../src/effect" + +const it = testEffect(Layer.empty) +const metadata = Schema.decodeUnknownSync(Schema.Struct({ threadID: Schema.String })) +const model = SessionRunnerModel.resolved( + LanguageModel.make({ id: "instance-model", provider: "test", route: OpenAIChat.route }), + { + capabilities: { tools: true, input: ["text"], output: ["text"] }, + cost: [], + limit: { context: 200_000, output: 8_192 }, + }, +) + +it.live( + "reconstructs configured instances before automatic recovery executes tools", + () => + Effect.gen(function* () { + const directory = yield* tmpdirScoped() + const scope = yield* Effect.scope + const firstScope = yield* Scope.fork(scope) + const secondScope = yield* Scope.fork(scope) + const started = yield* Deferred.make() + const release = yield* Deferred.make() + const llm = yield* TestLLM.Test.pipe(Effect.provide(TestLLM.testLayer())) + const configured: string[] = [] + const closed: number[] = [] + const executed: number[] = [] + const options: OpenCode.CreateOptions = { + database: { path: path.join(directory.path, "sessions.db") }, + app: { name: "instance-test", version: "1.2.3" }, + events: { persist: true }, + config: { directory: directory.path, project: false, content: "{}" }, + models: { fetch: false }, + fs: { filewatcher: false }, + instances: { + key: (session) => metadata(session.metadata).threadID, + configure: (key) => + Effect.gen(function* () { + const generation = configured.push(key) + yield* Effect.addFinalizer(() => Effect.sync(() => closed.push(generation))) + if (generation === 2) { + yield* Deferred.succeed(started, undefined) + yield* Deferred.await(release) + } + return { + plugins: [ + Plugin.define({ + id: "thread-tools", + effect: (ctx) => + Effect.gen(function* () { + expect(ctx.app).toMatchObject({ name: "instance-test", version: "1.2.3" }) + yield* ctx.agent.transform((draft) => + draft.update(Agent.ID.make("build"), (agent) => { + agent.permissions = [{ action: "*", resource: "*", effect: "allow" }] + }), + ) + yield* ctx.session.hook("context", (event) => + Effect.sync(() => { + event.generation.temperature = 0.25 + }), + ) + yield* ctx.tool.transform((draft) => + draft.add({ + name: "thread_echo", + description: "Report the configured thread", + input: Schema.Struct({}), + output: Schema.String, + options: { codemode: false }, + execute: (_, tool) => + Effect.gen(function* () { + executed.push(generation) + yield* ctx.session + .rename({ sessionID: tool.sessionID, title: `${key}:${generation}` }) + .pipe(Effect.orDie) + return { output: key, content: key } + }), + }), + ) + }), + }), + ], + } + }), + }, + } + const embed = { + overrides: [ + llmClient.replace(Layer.succeed(LLMClient.Service, llm)), + SessionRunnerModel.node.replace( + Layer.succeed(SessionRunnerModel.Service, { resolve: () => Effect.succeed(model) }), + ), + ], + } + yield* llm.push(TestLLM.hangAfter()) + const first = yield* OpenCode.create(options, embed).pipe(Scope.provide(firstScope)) + const session = yield* first.sessions.create({ + title: "Recovery fixture", + location: Location.Ref.make({ directory: AbsolutePath.make(directory.path) }), + model: model.ref, + metadata: { threadID: "thread-recovery" }, + }) + expect(configured).toEqual([]) + yield* first.sessions.prompt({ sessionID: session.id, text: "Use the thread tool" }) + yield* llm.wait(1).pipe(Effect.timeout("5 seconds")) + expect(configured).toEqual(["thread-recovery"]) + + // Closing the host interrupts active execution while preserving its durable recovery claim. + yield* Scope.close(firstScope, Exit.void) + expect(closed).toEqual([1]) + yield* llm.push(TestLLM.tool("recovered-tool", "thread_echo", {}), TestLLM.text("Recovered", "answer")) + const second = yield* OpenCode.create(options, embed).pipe(Scope.provide(secondScope)) + yield* Deferred.await(started).pipe(Effect.timeout("5 seconds")) + expect((yield* llm.requests()).length).toBe(1) + yield* Deferred.succeed(release, undefined) + yield* llm.wait(3).pipe(Effect.timeout("5 seconds")) + yield* second.sessions.wait({ sessionID: session.id }) + + expect(configured).toEqual(["thread-recovery", "thread-recovery"]) + expect(executed).toEqual([2]) + expect((yield* second.sessions.get({ sessionID: session.id })).title).toBe("thread-recovery:2") + expect( + (yield* llm.requests()).map((request) => ({ + temperature: request.generation?.temperature, + tools: request.tools?.filter((tool) => tool.name.startsWith("thread_")).map((tool) => tool.name), + })), + ).toEqual(Array.from({ length: 3 }, () => ({ temperature: 0.25, tools: ["thread_echo"] }))) + yield* Scope.close(secondScope, Exit.void) + expect(closed).toEqual([1, 2]) + }), + 15_000, +) + +it.live( + "qualifies application keys by workspace and reselects a moved Session", + () => + Effect.gen(function* () { + const directory = yield* tmpdirScoped() + const llm = yield* TestLLM.Test.pipe( + Effect.provide(TestLLM.testLayer({ fallback: TestLLM.text("Ready", "answer") })), + ) + const configured: string[] = [] + const placements: Location.Ref[] = [] + const driver = WorkspaceDriver.make({ + create: ({ workspaceID }) => Effect.succeed({ binding: { workspaceID } }), + connect: () => Effect.succeed(makeMemoryDriver()), + suspendForIdle: () => Effect.void, + destroy: () => Effect.void, + }) + const opencode = yield* OpenCode.create( + { + config: { directory: directory.path, project: false, content: "{}" }, + models: { fetch: false }, + fs: { filewatcher: false }, + workspaceProviders: { memory: driver }, + instances: { + key: (session) => metadata(session.metadata).threadID, + configure: (key) => + Effect.sync(() => { + configured.push(key) + return { + plugins: [ + Plugin.define({ + id: "placement-hook", + effect: (ctx) => + Effect.gen(function* () { + placements.push(Location.Ref.make(ctx.location)) + yield* ctx.session.hook("prompt", (event) => + Effect.sync(() => { + event.prompt.text += `:${ctx.location.workspaceID}` + }), + ) + }), + }), + ], + } + }), + }, + }, + { + overrides: [ + llmClient.replace(Layer.succeed(LLMClient.Service, llm)), + SessionRunnerModel.node.replace( + Layer.succeed(SessionRunnerModel.Service, { resolve: () => Effect.succeed(model) }), + ), + ], + }, + ) + const firstWorkspace = yield* opencode.workspace.create({ provider: "memory" }) + const secondWorkspace = yield* opencode.workspace.create({ provider: "memory" }) + const first = yield* opencode.sessions.create({ + title: "First placement", + location: Location.Ref.make({ directory: AbsolutePath.make(directory.path), workspaceID: firstWorkspace }), + model: model.ref, + metadata: { threadID: "same-thread" }, + }) + const second = yield* opencode.sessions.create({ + title: "Second placement", + location: Location.Ref.make({ directory: AbsolutePath.make(directory.path), workspaceID: secondWorkspace }), + model: model.ref, + metadata: { threadID: "same-thread" }, + }) + for (const session of [first, second]) { + const admitted = yield* opencode.sessions.prompt({ sessionID: session.id, text: "Hello" }) + expect(admitted.payload.text).toBe(`Hello:${session.location.workspaceID}`) + yield* opencode.sessions.wait({ sessionID: session.id }) + } + expect(configured).toEqual(["same-thread", "same-thread"]) + expect(placements.map((location) => location.workspaceID)).toEqual([firstWorkspace, secondWorkspace]) + + yield* opencode.sessions.move({ + sessionID: first.id, + directory: AbsolutePath.make(directory.path), + workspaceID: secondWorkspace, + }) + yield* opencode.sessions.wait({ sessionID: first.id }) + const moved = yield* opencode.sessions.get({ sessionID: first.id }) + expect(moved.location.workspaceID).toBe(secondWorkspace) + const admitted = yield* opencode.sessions.prompt({ sessionID: first.id, text: "Moved", resume: false }) + expect(admitted.payload.text).toBe(`Moved:${secondWorkspace}`) + expect(configured).toEqual(["same-thread", "same-thread"]) + }), + 15_000, +) diff --git a/packages/sdk/test/instances-lifecycle.test.ts b/packages/sdk/test/instances-lifecycle.test.ts new file mode 100644 index 00000000000..40f04da7ae9 --- /dev/null +++ b/packages/sdk/test/instances-lifecycle.test.ts @@ -0,0 +1,73 @@ +import { expect } from "bun:test" +import { Instance } from "@opencode-ai/core/instance/service" +import { Session } from "@opencode-ai/core/session" +import { Location } from "@opencode-ai/schema/location" +import { AbsolutePath } from "@opencode-ai/schema/schema" +import { Context, Deferred, Effect, Exit, Fiber, Layer, Option } from "effect" +import { tmpdirScoped } from "../../core/test/fixture/tmpdir" +import { testEffect } from "../../core/test/lib/effect" +import { EmbeddedHost } from "../src/internal/host" + +const it = testEffect(Layer.empty) + +it.live("a cancelled borrower cannot strand a later failed instance construction", () => + Effect.gen(function* () { + const directory = yield* tmpdirScoped() + const started = yield* Deferred.make>() + const release = yield* Deferred.make() + const acquired: number[] = [] + const released: number[] = [] + const host = yield* Effect.acquireRelease( + EmbeddedHost.create({ + config: { directory: directory.path, project: false, content: "{}" }, + models: { fetch: false }, + fs: { filewatcher: false }, + instances: { + key: () => "shared", + configure: () => + Effect.gen(function* () { + const attempt = acquired.length + 1 + yield* Effect.acquireRelease( + Effect.sync(() => acquired.push(attempt)), + () => Effect.sync(() => released.push(attempt)), + ) + if (attempt !== 1) return { plugins: [] } + yield* Effect.withFiber((fiber) => Deferred.succeed(started, fiber)) + yield* Deferred.await(release) + return yield* Effect.fail(new Error("Configuration unavailable")) + }), + }, + }), + (host) => Effect.promise(host.close), + ) + const services = yield* host.runtime.contextEffect + const instances = Context.get(services, Instance.Service) + const sessions = Context.get(services, Session.Service) + const session = yield* sessions.create({ + location: Location.Ref.make({ directory: AbsolutePath.make(directory.path) }), + }) + expect(yield* Effect.void.pipe(instances.provideIfLoaded(session))).toEqual(Option.none()) + expect(acquired).toEqual([]) + + const borrower = yield* Effect.void.pipe(instances.provide(session), Effect.forkScoped) + const lookup = yield* Deferred.await(started) + yield* Fiber.interrupt(borrower) + expect(released).toEqual([]) + yield* Deferred.succeed(release, undefined) + expect(Exit.isFailure(yield* Fiber.await(lookup).pipe(Effect.timeout("1 second")))).toBe(true) + expect(released).toEqual([1]) + + yield* Effect.void.pipe(instances.provide(session)) + expect(acquired).toEqual([1, 2]) + expect(released).toEqual([1]) + const callerScope = yield* Effect.scope + expect(Option.getOrThrow(yield* Effect.scope.pipe(instances.provideIfLoaded(session)))).toBe(callerScope) + expect(yield* Effect.void.pipe(instances.provideIfLoaded(session))).toEqual(Option.some(undefined)) + const error = new Error("Operation failed") + expect(yield* Effect.fail(error).pipe(instances.provideIfLoaded(session), Effect.flip)).toBe(error) + + yield* Effect.promise(host.close) + expect(released).toEqual([1, 2]) + expect(yield* Effect.void.pipe(instances.provideIfLoaded(session))).toEqual(Option.none()) + }), +) diff --git a/packages/sdk/test/instances.test.ts b/packages/sdk/test/instances.test.ts new file mode 100644 index 00000000000..e013beb5650 --- /dev/null +++ b/packages/sdk/test/instances.test.ts @@ -0,0 +1,353 @@ +import { expect, test } from "bun:test" +import { mkdir } from "node:fs/promises" +import { join } from "node:path" +import { Schema } from "effect" +import { tmpdir } from "../../core/test/fixture/tmpdir" +import { OpenCode, Session, SessionMessage } from "../src" + +const metadata = Schema.decodeUnknownSync(Schema.Struct({ account: Schema.String })) +const hostOptions = (directory: string) => ({ + database: { path: join(directory, "opencode.sqlite") }, + config: { directory, project: false, content: "{}" }, + models: { fetch: false }, + fs: { filewatcher: false }, +}) + +test("Promise instances are lazy, share by key and Location, and stay isolated between hosts", async () => { + await using directory = await tmpdir("opencode-promise-instances-") + const otherDirectory = join(directory.path, "other") + await mkdir(otherDirectory) + const configured: string[] = [] + const setups: string[] = [] + const cleanups: string[] = [] + const createHost = (name: string) => { + const instances: OpenCode.InstanceOptions = { + key(session) { + expect(typeof session.time.created).toBe("number") + return metadata(session.metadata).account + }, + configure(key): OpenCode.InstanceConfiguration { + configured.push(`${name}:${key}`) + return { + plugins: [ + { + id: "account-prompts", + async setup(ctx) { + const activation = `${name}:${key}@${ctx.location.directory}` + setups.push(activation) + await ctx.session.hook("prompt", (event) => { + event.prompt.text = `${activation}: ${event.prompt.text}` + }) + return () => { + cleanups.push(activation) + } + }, + }, + ], + } + }, + } + return OpenCode.create({ + ...hostOptions(directory.path), + database: { path: join(directory.path, `${name}.sqlite`) }, + instances, + }) + } + + await using first = await createHost("first") + await using second = await createHost("second") + const sessionID = Session.ID.create() + const original = await first.sessions.create({ + id: sessionID, + location: { directory: directory.path }, + metadata: { account: "alpha", labels: ["review", 2] }, + }) + const sameKey = await first.sessions.create({ + location: { directory: directory.path }, + metadata: original.metadata, + }) + const separateKey = await first.sessions.create({ + location: { directory: directory.path }, + metadata: { account: "beta" }, + }) + const separateLocation = await first.sessions.create({ + location: { directory: otherDirectory }, + metadata: { account: "alpha" }, + }) + const separateHost = await second.sessions.create({ + id: sessionID, + location: { directory: directory.path }, + metadata: original.metadata, + }) + + expect(await first.sessions.get({ sessionID })).toEqual(original) + expect((await first.sessions.list()).data).toHaveLength(4) + expect((await first.message.list({ sessionID })).data).toEqual([]) + expect(await first.sessions.context({ sessionID })).toEqual([]) + expect(await first.sessions.inbox.list({ sessionID })).toEqual([]) + expect(await first.permission.list({ sessionID })).toEqual([]) + expect(await first.form.list({ sessionID })).toEqual([]) + expect(await second.sessions.get({ sessionID })).toEqual(separateHost) + expect(configured).toEqual([]) + expect(setups).toEqual([]) + + await Promise.all( + [ + { host: first, session: original, prefix: `first:alpha@${directory.path}` }, + { host: first, session: sameKey, prefix: `first:alpha@${directory.path}` }, + { host: first, session: separateKey, prefix: `first:beta@${directory.path}` }, + { host: first, session: separateLocation, prefix: `first:alpha@${otherDirectory}` }, + { host: second, session: separateHost, prefix: `second:alpha@${directory.path}` }, + ].map(async (input) => { + const admitted = await input.host.sessions.prompt({ + sessionID: input.session.id, + text: "Review this change", + resume: false, + }) + expect(admitted.payload.text).toBe(`${input.prefix}: Review this change`) + expect(await input.host.sessions.inbox.list({ sessionID: input.session.id })).toEqual([admitted]) + }), + ) + + await first.sessions.switchAgent({ sessionID, agent: "plan" }) + const fork = await first.sessions.fork({ sessionID, boundary: { type: "through" } }) + expect(fork.metadata).toEqual(original.metadata) + expect(fork.location).toEqual(original.location) + expect(fork.fork?.sessionID).toBe(sessionID) + const inherited = await first.sessions.prompt({ sessionID: fork.id, text: "Review the fork", resume: false }) + expect(inherited.payload.text).toBe(`first:alpha@${directory.path}: Review the fork`) + expect(await first.sessions.inbox.list({ sessionID: fork.id })).toEqual([inherited]) + + expect(configured.toSorted()).toEqual(["first:alpha", "first:alpha", "first:beta", "second:alpha"]) + expect(setups.toSorted()).toEqual( + [ + `first:alpha@${directory.path}`, + `first:alpha@${otherDirectory}`, + `first:beta@${directory.path}`, + `second:alpha@${directory.path}`, + ].toSorted(), + ) + expect(cleanups).toEqual([]) + expect(await first.sessions.active()).toEqual({}) + await first.close() + expect(cleanups.toSorted()).toEqual(setups.filter((activation) => activation.startsWith("first:")).toSorted()) + + const continued = await second.sessions.prompt({ sessionID, text: "Keep working", resume: false }) + expect(continued.payload.text).toBe(`second:alpha@${directory.path}: Keep working`) + expect(await second.sessions.inbox.list({ sessionID })).toHaveLength(2) + expect(configured).toHaveLength(4) + await second.close() + expect(cleanups.toSorted()).toEqual(setups.toSorted()) +}, 20_000) + +test.each(["configuration", "plugin setup"])( + "Promise instance %s failure admits nothing, preserves healthy instances, and can be retried", + async (failure) => { + await using directory = await tmpdir("opencode-promise-instance-failure-") + const configured: string[] = [] + const setups: string[] = [] + const cleanups: string[] = [] + await using opencode = await OpenCode.create({ + ...hostOptions(directory.path), + instances: { + key: (session) => metadata(session.metadata).account, + async configure(key) { + configured.push(key) + if (key === "retry" && failure === "configuration" && configured.filter((item) => item === key).length === 1) + throw new Error("Account configuration unavailable") + return { + plugins: [ + { + id: "account-prompts", + async setup(ctx) { + setups.push(key) + await ctx.session.hook("prompt", (event) => { + event.prompt.text = `${key}: ${event.prompt.text}` + }) + if ( + key === "retry" && + failure === "plugin setup" && + setups.filter((item) => item === key).length === 1 + ) + throw new Error("Account plugin unavailable") + return () => { + cleanups.push(key) + } + }, + }, + ], + } + }, + }, + }) + const healthy = await opencode.sessions.create({ + location: { directory: directory.path }, + metadata: { account: "healthy" }, + }) + const retry = await opencode.sessions.create({ + location: { directory: directory.path }, + metadata: { account: "retry" }, + }) + const before = await opencode.sessions.prompt({ sessionID: healthy.id, text: "Before failure", resume: false }) + expect(before.payload.text).toBe("healthy: Before failure") + const input = { sessionID: retry.id, id: SessionMessage.ID.create(), text: "Retry this input", resume: false } + + expect(await opencode.sessions.prompt(input).catch((error: unknown) => error)).toMatchObject({ + name: "ClientError", + reason: "UnexpectedStatus", + }) + expect(await opencode.sessions.inbox.list({ sessionID: retry.id })).toEqual([]) + expect((await opencode.message.list({ sessionID: retry.id })).data).toEqual([]) + expect(await opencode.sessions.get({ sessionID: retry.id })).toEqual(retry) + expect(configured).toEqual(["healthy", "retry"]) + expect(cleanups).toEqual([]) + + const after = await opencode.sessions.prompt({ sessionID: healthy.id, text: "After failure", resume: false }) + expect(after.payload.text).toBe("healthy: After failure") + expect(await opencode.sessions.inbox.list({ sessionID: healthy.id })).toEqual([before, after]) + const admitted = await opencode.sessions.prompt(input) + expect(admitted.id).toBe(input.id) + expect(admitted.payload.text).toBe("retry: Retry this input") + expect(await opencode.sessions.inbox.list({ sessionID: retry.id })).toEqual([admitted]) + expect(configured).toEqual(["healthy", "retry", "retry"]) + expect(setups).toEqual(failure === "configuration" ? ["healthy", "retry"] : ["healthy", "retry", "retry"]) + expect(cleanups).toEqual([]) + await opencode.close() + expect(cleanups.toSorted()).toEqual(["healthy", "retry"]) + }, + 20_000, +) + +test("Promise instances reconstruct callbacks for persisted Sessions only on a cold prompt", async () => { + await using directory = await tmpdir("opencode-promise-instance-restart-") + const sessionID = Session.ID.create() + const configured: string[] = [] + const setups: number[] = [] + const cleanups: number[] = [] + const options: OpenCode.CreateOptions = { + ...hostOptions(directory.path), + instances: { + key: (session) => metadata(session.metadata).account, + configure(key) { + configured.push(key) + const generation = configured.length + return { + plugins: [ + { + id: "account-prompts", + async setup(ctx) { + setups.push(generation) + await ctx.session.hook("prompt", (event) => { + event.prompt.text = `${key}/${generation}: ${event.prompt.text}` + }) + return () => { + cleanups.push(generation) + } + }, + }, + ], + } + }, + }, + } + await using first = await OpenCode.create(options) + const created = await first.sessions.create({ + id: sessionID, + location: { directory: directory.path }, + metadata: { account: "alpha", labels: ["review"] }, + }) + const before = await first.sessions.prompt({ sessionID, text: "Before restart", resume: false }) + expect(before.payload.text).toBe("alpha/1: Before restart") + await first.close() + expect(cleanups).toEqual([1]) + + await using second = await OpenCode.create(options) + expect(await second.sessions.get({ sessionID })).toMatchObject({ + id: sessionID, + location: created.location, + metadata: created.metadata, + time: { created: created.time.created }, + }) + expect((await second.sessions.list()).data.map((session) => session.id)).toEqual([sessionID]) + expect(await second.sessions.inbox.list({ sessionID })).toEqual([before]) + expect((await second.message.list({ sessionID })).data).toEqual([]) + expect(await second.permission.list({ sessionID })).toEqual([]) + expect(await second.form.list({ sessionID })).toEqual([]) + expect(configured).toEqual(["alpha"]) + expect(setups).toEqual([1]) + + // The HTTP prompt boundary still acquires capabilities, even when Core reconciles an existing admission. + expect(await second.sessions.prompt({ sessionID, id: before.id, text: "Already admitted", resume: false })).toEqual( + before, + ) + expect(configured).toEqual(["alpha", "alpha"]) + expect(setups).toEqual([1, 2]) + expect(await second.sessions.active()).toEqual({}) + + const after = await second.sessions.prompt({ sessionID, text: "After restart", resume: false }) + expect(after.payload.text).toBe("alpha/2: After restart") + expect(await second.sessions.inbox.list({ sessionID })).toEqual([before, after]) + expect(configured).toEqual(["alpha", "alpha"]) + expect(setups).toEqual([1, 2]) + expect(cleanups).toEqual([1]) + await second.close() + expect(cleanups).toEqual([1, 2]) +}, 20_000) + +test("Promise instance plugin ID collisions reject admission and reconstruct on retry", async () => { + await using directory = await tmpdir("opencode-promise-instance-collision-") + const configured: string[] = [] + const setups: string[] = [] + await using opencode = await OpenCode.create({ + ...hostOptions(directory.path), + plugins: [ + { + id: "account-prompts", + setup() { + setups.push("host") + }, + }, + ], + instances: { + key: (session) => metadata(session.metadata).account, + configure(key) { + configured.push(key) + return { + plugins: [ + { + id: configured.length === 1 ? "account-prompts" : "instance-prompts", + async setup(ctx) { + setups.push(key) + await ctx.session.hook("prompt", (event) => { + event.prompt.text = `${key}: ${event.prompt.text}` + }) + }, + }, + ], + } + }, + }, + }) + const session = await opencode.sessions.create({ + location: { directory: directory.path }, + metadata: { account: "alpha" }, + }) + const input = { sessionID: session.id, id: SessionMessage.ID.create(), text: "Retry this input", resume: false } + expect(configured).toEqual([]) + + expect(await opencode.sessions.prompt(input).catch((error: unknown) => error)).toMatchObject({ + name: "ClientError", + reason: "UnexpectedStatus", + }) + expect(await opencode.sessions.inbox.list({ sessionID: session.id })).toEqual([]) + expect((await opencode.message.list({ sessionID: session.id })).data).toEqual([]) + expect(configured).toEqual(["alpha"]) + expect(setups).toEqual([]) + + const admitted = await opencode.sessions.prompt(input) + expect(admitted.id).toBe(input.id) + expect(admitted.payload.text).toBe("alpha: Retry this input") + expect(await opencode.sessions.inbox.list({ sessionID: session.id })).toEqual([admitted]) + expect(configured).toEqual(["alpha", "alpha"]) + expect(setups).toEqual(["host", "alpha"]) +}, 20_000) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index e537f3ccaa6..ce44807358d 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -89,8 +89,14 @@ export function createRoutes( ) } -export function createEmbeddedRoutes(options: ServerOptions = {}, overrides: LayerNode.Replacements = []) { - return makeRoutes(ServerAuth.Config.configLayer({ password: Option.none() }), options, () => [], overrides) +type InstanceLayer = (replacements: LayerNode.Replacements) => Layer.Layer + +export function createEmbeddedRoutes( + options: ServerOptions = {}, + overrides: LayerNode.Replacements = [], + instances?: InstanceLayer, +) { + return makeRoutes(ServerAuth.Config.configLayer({ password: Option.none() }), options, () => [], overrides, instances) } function makeRoutes( @@ -99,6 +105,7 @@ function makeRoutes( serviceURLs: () => ReadonlyArray, // Runtime-profile replacements (e.g. workerd) applied after the standard set, so later entries win. overrides: LayerNode.Replacements, + instances?: InstanceLayer, ) { const pluginRuntimeCell = PluginRuntime.makeCell() const standard: LayerNode.Replacements = [ @@ -130,16 +137,24 @@ function makeRoutes( PluginRuntime.node.replace(PluginRuntime.layerWithCell(pluginRuntimeCell)), PluginRuntime.providerNode.replace(PluginRuntime.providerNodeWithCell(pluginRuntimeCell)), ] - const replacements: LayerNode.Replacements = [...standard, ...overrides] + const build = (overrides: LayerNode.Replacements) => { + const replacements: LayerNode.Replacements = [ + ...standard, + // Resolve lazily so private instances inherit the complete host graph, including this selector. + ...(instances ? [Instance.byLocationNode.replace(Layer.suspend(() => instances(replacements)))] : []), + ...overrides, + ] + return AppNodeBuilder.build(applicationServices, replacements) + } const serviceLayer = options.simulation ? Layer.unwrap( Effect.gen(function* () { const { simulationReplacements } = yield* Effect.promise(() => import("@opencode-ai/simulation/backend")) const simulation = yield* simulationReplacements({ version: App.make(options.app).version }) - return AppNodeBuilder.build(applicationServices, [...replacements, ...simulation]) + return build([...overrides, ...simulation]) }), ) - : AppNodeBuilder.build(applicationServices, replacements) + : build(overrides) return serviceLayer.pipe( Layer.flatMap((context) => { const services = Layer.succeedContext(context)