From 8deb0e5780dc83160a9ec12e6476e36267f82948 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 7 Jul 2026 11:43:40 -0400 Subject: [PATCH] fix(core): preserve SDK plugins across location eviction (#35725) --- packages/client/src/effect/api/api.ts | 6 +++ .../client/src/effect/generated/client.ts | 10 +++- .../client/src/promise/generated/client.ts | 14 ++++++ .../client/src/promise/generated/types.ts | 8 ++++ packages/core/src/plugin/sdk.ts | 48 +++++++------------ packages/core/test/location-layer.test.ts | 36 ++++++++++++++ packages/protocol/src/client.ts | 1 + packages/protocol/src/groups/debug.ts | 17 ++++++- packages/sdk-next/src/opencode.ts | 44 ++++------------- packages/sdk-next/test/embedded.test.ts | 44 +++++++++++++++++ packages/server/src/handlers/debug.ts | 25 +++++++--- packages/server/src/process.ts | 13 ++--- packages/server/src/routes.ts | 48 ++++++++++--------- 13 files changed, 209 insertions(+), 105 deletions(-) diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 588d67cad6e..59202fe0efa 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -890,8 +890,14 @@ export interface VcsApi { export type Endpoint25_0Output = EffectValue> export type DebugLocationOperation = () => Effect.Effect +type Endpoint25_1Request = Parameters[0] +export type Endpoint25_1Input = { readonly location?: Endpoint25_1Request["query"]["location"] } +export type Endpoint25_1Output = EffectValue> +export type DebugEvictLocationOperation = (input?: Endpoint25_1Input) => Effect.Effect + export interface DebugApi { readonly location: DebugLocationOperation + readonly evictLocation: DebugEvictLocationOperation } export interface AppApi { diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index 945bd0ffc09..94de0970f30 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -1069,7 +1069,15 @@ const adaptGroup24 = (raw: RawClient["server.vcs"]) => ({ status: Endpoint24_0(r const Endpoint25_0 = (raw: RawClient["server.debug"]) => () => raw["debug.location"]({}).pipe(Effect.mapError(mapClientError)) -const adaptGroup25 = (raw: RawClient["server.debug"]) => ({ location: Endpoint25_0(raw) }) +type Endpoint25_1Request = Parameters[0] +type Endpoint25_1Input = { readonly location?: Endpoint25_1Request["query"]["location"] } +const Endpoint25_1 = (raw: RawClient["server.debug"]) => (input?: Endpoint25_1Input) => + raw["debug.location.evict"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup25 = (raw: RawClient["server.debug"]) => ({ + location: Endpoint25_0(raw), + evictLocation: Endpoint25_1(raw), +}) const adaptClient = (raw: RawClient) => ({ health: adaptGroup0(raw["server.health"]), diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 697d54d732d..4c57621d480 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -178,6 +178,8 @@ import type { VcsDiffInput, VcsDiffOutput, DebugLocationOutput, + DebugEvictLocationInput, + DebugEvictLocationOutput, } from "./types" import { ClientError } from "./client-error" @@ -1494,6 +1496,18 @@ export function make(options: ClientOptions) { }, requestOptions, ), + evictLocation: (input?: DebugEvictLocationInput, requestOptions?: RequestOptions) => + request( + { + method: "DELETE", + path: `/api/debug/location`, + query: { location: input?.["location"] }, + successStatus: 204, + declaredStatuses: [401, 400], + empty: true, + }, + requestOptions, + ), }, } } diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 7482d237258..aaee9778b3b 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -6130,3 +6130,11 @@ export type VcsDiffOutput = { } export type DebugLocationOutput = ReadonlyArray<{ readonly directory: string; readonly workspaceID?: string }> + +export type DebugEvictLocationInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type DebugEvictLocationOutput = void diff --git a/packages/core/src/plugin/sdk.ts b/packages/core/src/plugin/sdk.ts index f6ceb06a8fb..da14b4a61f1 100644 --- a/packages/core/src/plugin/sdk.ts +++ b/packages/core/src/plugin/sdk.ts @@ -7,14 +7,6 @@ import { EventV2 } from "../event" export const Updated = EventV2.ephemeral({ type: "sdk.plugin.updated", schema: {} }) -export interface Store { - readonly plugins: Map -} - -export const makeStore = (): Store => ({ plugins: new Map() }) - -const defaultStore = makeStore() - /** * Holds the plugins an embedder (the `@opencode-ai/sdk-next` host) contributes, * so `PluginSupervisor` can add them on every Location boot through the ordinary @@ -22,10 +14,9 @@ const defaultStore = makeStore() * config. Registration publishes an unlocated update so every booted Location * reloads its plugin generation from the shared store. * - * The store is shared explicitly between the SDK construction graph and the - * embedded route graph because `LocationServiceMap` builds Location layers lazily - * in a nested graph. Each embedded SDK creates its own store, so instances do not - * see each other's contributions. + * Each host-global layer owns one private store. Location graphs reuse that + * layer through Effect's memoization, so separate hosts remain isolated while + * every Location in one host sees the same registrations. */ export interface Interface { readonly register: (plugin: Plugin) => Effect.Effect @@ -34,26 +25,19 @@ export interface Interface { export class Service extends Context.Service()("@opencode/SdkPlugins") {} -export const layerWithStore = (store: Store) => - Layer.effect( - Service, - Effect.gen(function* () { - const events = yield* EventV2.Service - yield* Effect.addFinalizer(() => +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const events = yield* EventV2.Service + const plugins = new Map() + return Service.of({ + register: (plugin) => Effect.sync(() => { - store.plugins.clear() - }), - ) - return Service.of({ - register: (plugin) => - Effect.sync(() => { - store.plugins.set(plugin.id, plugin) - }).pipe(Effect.andThen(events.publish(Updated, {})), Effect.asVoid), - all: () => [...store.plugins.values()], - }) - }), - ) - -export const layer = layerWithStore(defaultStore) + plugins.set(plugin.id, plugin) + }).pipe(Effect.andThen(events.publish(Updated, {})), Effect.asVoid), + all: () => [...plugins.values()], + }) + }), +) export const node = makeGlobalNode({ service: Service, layer, deps: [EventV2.node] }) diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index f945cb2265c..e9eb2be4d96 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -12,6 +12,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LocationServiceMap } from "@opencode-ai/core/location-services" import { Location } from "@opencode-ai/core/location" import { PluginV2 } from "@opencode-ai/core/plugin" +import { SdkPlugins } from "@opencode-ai/core/plugin/sdk" import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" import { ModelV2 } from "@opencode-ai/core/model" import { ProjectV2 } from "@opencode-ai/core/project" @@ -28,8 +29,43 @@ import { Reference } from "../src/reference" import { ToolRegistry } from "../src/tool/registry" const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, LocationServiceMap.node]))) +const itWithSdk = testEffect( + AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SdkPlugins.node, LocationServiceMap.node])), +) describe("LocationServiceMap", () => { + itWithSdk.live("preserves embedded SDK plugins after Location eviction", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + const sdk = yield* SdkPlugins.Service + const locations = yield* LocationServiceMap.Service + const id = AgentV2.ID.make("persistent-sdk-agent") + const plugin = define({ + id: "persistent-sdk-plugin", + effect: (ctx) => ctx.agent.transform((agents) => agents.update(id, () => {})), + }) + yield* sdk.register(plugin) + + const ref = Location.Ref.make({ directory: AbsolutePath.make(dir.path) }) + const read = Effect.gen(function* () { + const supervisor = yield* PluginSupervisor.Service + yield* supervisor.ready + const agents = yield* AgentV2.Service + return yield* agents.get(id) + }) + + expect(yield* read.pipe(Effect.scoped, Effect.provide(locations.get(ref)))).toBeDefined() + yield* locations.invalidate(ref) + expect(yield* read.pipe(Effect.scoped, Effect.provide(locations.get(ref)))).toBeDefined() + }), + ), + ), + ) + it.live("applies ordered plugin config operations during boot", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), diff --git a/packages/protocol/src/client.ts b/packages/protocol/src/client.ts index 1abf6667966..652beb65958 100644 --- a/packages/protocol/src/client.ts +++ b/packages/protocol/src/client.ts @@ -61,6 +61,7 @@ export const groupNames = { } as const export const endpointNames = { + "debug.location.evict": "evictLocation", "session.messages": "list", "integration.connect.key": "connectKey", "integration.connect.oauth": "connectOauth", diff --git a/packages/protocol/src/groups/debug.ts b/packages/protocol/src/groups/debug.ts index b43e5bc6391..a4050441052 100644 --- a/packages/protocol/src/groups/debug.ts +++ b/packages/protocol/src/groups/debug.ts @@ -1,6 +1,7 @@ import { Location } from "@opencode-ai/schema/location" import { Schema } from "effect" -import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { LocationQuery, locationQueryOpenApi } from "./location.js" export const DebugGroup = HttpApiGroup.make("server.debug") .add( @@ -14,4 +15,18 @@ export const DebugGroup = HttpApiGroup.make("server.debug") }), ), ) + .add( + HttpApiEndpoint.delete("debug.location.evict", "/api/debug/location", { + query: LocationQuery, + success: HttpApiSchema.NoContent, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.debug.location.evict", + summary: "Evict a loaded location", + description: "Dispose the requested location's cached services so its next use boots them fresh.", + }), + ), + ) .annotateMerge(OpenApi.annotations({ title: "debug" })) diff --git a/packages/sdk-next/src/opencode.ts b/packages/sdk-next/src/opencode.ts index bcb06f53197..8ebe2307913 100644 --- a/packages/sdk-next/src/opencode.ts +++ b/packages/sdk-next/src/opencode.ts @@ -1,47 +1,23 @@ import { OpenCode } from "@opencode-ai/client/effect" -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" -import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { EventV2 } from "@opencode-ai/core/event" -import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { SdkPlugins } from "@opencode-ai/core/plugin/sdk" -import { Project } from "@opencode-ai/core/project" import { createEmbeddedRoutes } from "@opencode-ai/server/routes" -import { Context, Effect, Layer, Scope } from "effect" -import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http" +import { Context, Effect, Layer, ManagedRuntime } from "effect" +import { FetchHttpClient, HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http" export const create = Effect.fn("OpenCode.create")(function* () { - const scope = yield* Scope.Scope - const memoMap = yield* Layer.makeMemoMap - const sdkPlugins = SdkPlugins.makeStore() - const context = yield* Layer.buildWithMemoMap( - AppNodeBuilder.build(LayerNode.group([EventV2.node, PermissionSaved.node, Project.node, SdkPlugins.node]), [ - [SdkPlugins.node, SdkPlugins.layerWithStore(sdkPlugins)], - ]), - memoMap, - scope, + const runtime = yield* Effect.acquireRelease( + Effect.sync(() => ManagedRuntime.make(createEmbeddedRoutes().pipe(Layer.provide(HttpServer.layerServices)))), + (runtime) => runtime.disposeEffect, ) + const context = yield* runtime.contextEffect const plugins = Context.get(context, SdkPlugins.Service) - const permissions = Context.get(context, PermissionSaved.Service) - const project = Context.get(context, Project.Service) - const web = yield* Effect.acquireRelease( - Effect.sync(() => - HttpRouter.toWebHandler( - createEmbeddedRoutes(sdkPlugins).pipe( - HttpRouter.provideRequest(Layer.succeed(PermissionSaved.Service, permissions)), - HttpRouter.provideRequest(Layer.succeed(Project.Service, project)), - Layer.provide(HttpServer.layerServices), - ), - { disableLogger: true, memoMap }, - ), - ), - (web) => Effect.promise(web.dispose), - ) - const fetch = Object.assign((input: RequestInfo | URL, init?: RequestInit) => web.handler(new Request(input, init)), { + const router = Context.get(context, HttpRouter.HttpRouter) + const handler = HttpEffect.toWebHandler(router.asHttpEffect()) + const fetch = Object.assign((input: RequestInfo | URL, init?: RequestInit) => handler(new Request(input, init)), { preconnect: () => undefined, }) satisfies typeof globalThis.fetch const client = yield* OpenCode.make({ baseUrl: "http://opencode.local" }).pipe( - Effect.provide(FetchHttpClient.layer), - Effect.provideService(FetchHttpClient.Fetch, fetch), + Effect.provide(FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetch)), Layer.fresh)), ) return { ...client, diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts index b0efa629097..0b3b6390873 100644 --- a/packages/sdk-next/test/embedded.test.ts +++ b/packages/sdk-next/test/embedded.test.ts @@ -105,6 +105,50 @@ it.live( 25_000, ) +it.live( + "preserves SDK plugins across Location eviction", + () => + withEmbedded("opencode-embedded-plugin-eviction-", (fixture) => + Effect.gen(function* () { + const opencode = yield* fixture.sdk.OpenCode.create() + const ref = location(fixture) + const connected = yield* Latch.make(false) + const booted = yield* Deferred.make() + // The rebooted Location commits its second plugin generation. + const recommitted = yield* Deferred.make() + const generations = yield* Ref.make(0) + const id = `evicted-sdk-${crypto.randomUUID()}` + + yield* opencode.events.subscribe().pipe( + Stream.runForEach((event) => { + if (event.type === "server.connected") return connected.open + if (event.type !== "plugin.updated" || event.location?.directory !== fixture.directory) return Effect.void + return Ref.updateAndGet(generations, (total) => total + 1).pipe( + Effect.flatMap((total) => { + if (total === 1) return Deferred.succeed(booted, undefined) + if (total === 2) return Deferred.succeed(recommitted, undefined) + return Effect.void + }), + Effect.asVoid, + ) + }), + Effect.forkScoped, + ) + yield* connected.await + yield* opencode.plugin({ id, effect: () => Effect.void }) + + yield* opencode.plugin.list({ location: ref }) + yield* Deferred.await(booted).pipe(Effect.timeout("5 seconds")) + yield* opencode.debug.evictLocation({ location: ref }) + yield* opencode.plugin.list({ location: ref }) + yield* Deferred.await(recommitted).pipe(Effect.timeout("5 seconds")) + + expect((yield* opencode.plugin.list({ location: ref })).data.map((plugin) => String(plugin.id))).toContain(id) + }), + ), + 15_000, +) + it.live( "keeps SDK plugin registration isolated between embedded hosts", () => diff --git a/packages/server/src/handlers/debug.ts b/packages/server/src/handlers/debug.ts index f5ffd536e4d..ec797c00321 100644 --- a/packages/server/src/handlers/debug.ts +++ b/packages/server/src/handlers/debug.ts @@ -2,13 +2,24 @@ import { LocationServiceMap } from "@opencode-ai/core/location-service-map" import { Effect, Option, RcMap } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" +import { requestRef } from "../location" export const DebugHandler = HttpApiBuilder.group(Api, "server.debug", (handlers) => - handlers.handle( - "debug.location", - Effect.fn(function* () { - const locations = Option.getOrThrow(yield* Effect.serviceOption(LocationServiceMap.Service)) - return Array.from(yield* RcMap.keys(locations.rcMap)) - }), - ), + handlers + .handle( + "debug.location", + Effect.fn(function* () { + const locations = Option.getOrThrow(yield* Effect.serviceOption(LocationServiceMap.Service)) + return Array.from(yield* RcMap.keys(locations.rcMap)) + }), + ) + .handle( + "debug.location.evict", + Effect.fn(function* (ctx) { + const locations = Option.getOrThrow(yield* Effect.serviceOption(LocationServiceMap.Service)) + // Resolve through requestRef so the key matches the shape the location + // middleware cached the services under. + yield* locations.invalidate(requestRef(ctx.request)) + }), + ), ) diff --git a/packages/server/src/process.ts b/packages/server/src/process.ts index de6f4b328f5..971d77734b6 100644 --- a/packages/server/src/process.ts +++ b/packages/server/src/process.ts @@ -1,14 +1,9 @@ export * as ServerProcess from "./process" import { NodeHttpClient, NodeHttpServer } from "@effect/platform-node" -import { Credential } from "@opencode-ai/core/credential" -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" -import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { PermissionSaved } from "@opencode-ai/core/permission/saved" -import { Project } from "@opencode-ai/core/project" import { HealthGroup } from "@opencode-ai/protocol/groups/health" import { Context, Effect, Layer, Option } from "effect" -import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http" +import { HttpClient, HttpClientRequest, HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http" import { HttpApi, HttpApiClient } from "effect/unstable/httpapi" import { createServer } from "node:http" import { ServerAuth } from "./auth" @@ -53,9 +48,11 @@ function listen(options: Options) { function bind(hostname: string, port: number, password: string) { const server = createServer() return Layer.build( - HttpRouter.serve(createRoutes(password), { disableListenLog: true }).pipe( + createRoutes(password).pipe( + Layer.flatMap((context) => + HttpServer.serve(Context.get(context, HttpRouter.HttpRouter).asHttpEffect(), HttpMiddleware.logger), + ), Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: hostname })), - Layer.provide(AppNodeBuilder.build(LayerNode.group([Credential.node, PermissionSaved.node, Project.node]))), ), ).pipe( Effect.tap(() => Effect.addFinalizer(() => Effect.sync(() => server.closeAllConnections()))), diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 1c5a57f62ff..e4d1ae49307 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -19,7 +19,7 @@ import { SdkPlugins } from "@opencode-ai/core/plugin/sdk" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { HttpRouter, HttpServer } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" -import { Effect, Layer, Option } from "effect" +import { Context, Effect, Layer, Option } from "effect" import { Api } from "./api" import { ServerAuth } from "./auth" import { handlers } from "./handlers" @@ -40,6 +40,7 @@ const applicationServices = LayerNode.group([ Project.node, SessionV2.node, PluginRuntime.providerNode, + SdkPlugins.node, PermissionSaved.node, PtyTicket.node, Credential.node, @@ -55,26 +56,22 @@ export function createRoutes(password?: string) { ) } -export function createEmbeddedRoutes(sdkPlugins?: SdkPlugins.Store) { - return makeRoutes(ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() }), sdkPlugins) +export function createEmbeddedRoutes() { + return makeRoutes(ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() })) } -function makeRoutes( - auth: Layer.Layer, - sdkPlugins?: SdkPlugins.Store, -) { +function makeRoutes(auth: Layer.Layer) { const pluginRuntimeCell = PluginRuntime.makeCell() const replacements: LayerNode.Replacements = [ [SessionExecution.node, SessionExecutionLocal.node], [PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)], [PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)], - ...(sdkPlugins ? [[SdkPlugins.node, SdkPlugins.layerWithStore(sdkPlugins)] as const] : []), ] const serviceLayer = simulateEnabled() ? Layer.unwrap( Effect.gen(function* () { - const { simulationReplacements, startDriveServer } = yield* Effect.promise(() => - import("@opencode-ai/simulation/backend"), + const { simulationReplacements, startDriveServer } = yield* Effect.promise( + () => import("@opencode-ai/simulation/backend"), ) if (driveEnabled()) startDriveServer() return AppNodeBuilder.build(applicationServices, [ @@ -85,16 +82,24 @@ function makeRoutes( ) : AppNodeBuilder.build(applicationServices, replacements) - return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe( - Layer.provide(handlers.pipe(Layer.provide(serviceLayer))), - Layer.provide(formLocationLayer), - Layer.provide(sessionLocationLayer), - Layer.provide(layer), - Layer.provide(authorizationLayer), - Layer.provide(schemaErrorLayer), - Layer.provide(auth), - Layer.provide(serviceLayer), - Layer.provide(Observability.layer), + return serviceLayer.pipe( + Layer.flatMap((context) => { + const services = Layer.succeedContext(context) + const requestServices = Layer.succeedContext(Context.pick(PermissionSaved.Service, Project.Service)(context)) + return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe( + Layer.provide(handlers.pipe(Layer.provide(services))), + Layer.provide(formLocationLayer), + Layer.provide(sessionLocationLayer), + Layer.provide(layer), + Layer.provide(authorizationLayer), + Layer.provide(schemaErrorLayer), + Layer.provide(auth), + Layer.provide(Observability.layer), + HttpRouter.provideRequest(requestServices), + Layer.provideMerge(services), + Layer.provideMerge(HttpRouter.layer), + ) + }), ) } @@ -108,5 +113,4 @@ function driveEnabled() { export const routes = createRoutes() -export const webHandler = () => - HttpRouter.toWebHandler(routes.pipe(Layer.provide(HttpServer.layerServices))) +export const webHandler = () => HttpRouter.toWebHandler(routes.pipe(Layer.provide(HttpServer.layerServices)))