fix: keep httpapi instance reloads in layer store

This commit is contained in:
Kit Langton 2026-05-01 08:52:36 -04:00
parent f1470c1a88
commit f0136f947b
8 changed files with 205 additions and 189 deletions

View file

@ -0,0 +1,10 @@
import { LocalContext } from "@/util/local-context"
import type * as Project from "./project"
export interface InstanceContext {
directory: string
worktree: string
project: Project.Info
}
export const context = LocalContext.create<InstanceContext>("instance")

View file

@ -0,0 +1,159 @@
import { GlobalBus } from "@/bus/global"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { disposeInstance } from "@/effect/instance-registry"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import * as Log from "@opencode-ai/core/util/log"
import { Context, Effect, Layer } from "effect"
import { iife } from "@/util/iife"
import { context, type InstanceContext } from "./instance-context"
import * as Project from "./project"
export interface LoadInput {
directory: string
init?: () => Promise<unknown>
worktree?: string
project?: Project.Info
}
export interface Interface {
readonly load: (input: LoadInput) => Effect.Effect<InstanceContext>
readonly reload: (input: LoadInput) => Effect.Effect<InstanceContext>
readonly dispose: (ctx: InstanceContext) => Effect.Effect<void>
readonly disposeAll: () => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/InstanceStore") {}
export const layer: Layer.Layer<Service, never, Project.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const project = yield* Project.Service
const cache = new Map<string, Promise<InstanceContext>>()
const disposal = {
all: undefined as Promise<void> | undefined,
}
const boot = Effect.fn("InstanceStore.boot")(function* (input: LoadInput & { directory: string }) {
const ctx =
input.project && input.worktree
? {
directory: input.directory,
worktree: input.worktree,
project: input.project,
}
: yield* project.fromDirectory(input.directory).pipe(
Effect.map((result) => ({
directory: input.directory,
worktree: result.sandbox,
project: result.project,
})),
)
const init = input.init
if (init) yield* Effect.promise(() => context.provide(ctx, init))
return ctx
})
function track(directory: string, next: Promise<InstanceContext>) {
const task = next.catch((error) => {
if (cache.get(directory) === task) cache.delete(directory)
throw error
})
cache.set(directory, task)
return task
}
const load = Effect.fn("InstanceStore.load")(function* (input: LoadInput) {
const directory = AppFileSystem.resolve(input.directory)
const existing = cache.get(directory)
if (existing) return yield* Effect.promise(() => existing)
Log.Default.info("creating instance", { directory })
return yield* Effect.promise(() => track(directory, Effect.runPromise(boot({ ...input, directory }))))
})
const reload = Effect.fn("InstanceStore.reload")(function* (input: LoadInput) {
const directory = AppFileSystem.resolve(input.directory)
Log.Default.info("reloading instance", { directory })
yield* Effect.promise(() => disposeInstance(directory))
cache.delete(directory)
const next = track(directory, Effect.runPromise(boot({ ...input, directory })))
GlobalBus.emit("event", {
directory,
project: input.project?.id,
workspace: WorkspaceContext.workspaceID,
payload: {
type: "server.instance.disposed",
properties: {
directory,
},
},
})
return yield* Effect.promise(() => next)
})
const dispose = Effect.fn("InstanceStore.dispose")(function* (ctx: InstanceContext) {
Log.Default.info("disposing instance", { directory: ctx.directory })
yield* Effect.promise(() => disposeInstance(ctx.directory))
cache.delete(ctx.directory)
GlobalBus.emit("event", {
directory: ctx.directory,
project: ctx.project.id,
workspace: WorkspaceContext.workspaceID,
payload: {
type: "server.instance.disposed",
properties: {
directory: ctx.directory,
},
},
})
})
const disposeAll = Effect.fn("InstanceStore.disposeAll")(function* () {
if (disposal.all) return yield* Effect.promise(() => disposal.all!)
disposal.all = iife(async () => {
Log.Default.info("disposing all instances")
const entries = [...cache.entries()]
for (const [key, value] of entries) {
if (cache.get(key) !== value) continue
const ctx = await value.catch((error) => {
Log.Default.warn("instance dispose failed", { key, error })
return undefined
})
if (!ctx) {
if (cache.get(key) === value) cache.delete(key)
continue
}
if (cache.get(key) !== value) continue
await Effect.runPromise(dispose(ctx))
}
}).finally(() => {
disposal.all = undefined
})
return yield* Effect.promise(() => disposal.all!)
})
yield* Effect.addFinalizer(() => disposeAll().pipe(Effect.ignore))
return Service.of({
load,
reload,
dispose,
disposeAll,
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Project.defaultLayer))
export const runtime = makeRuntime(Service, defaultLayer)
export * as InstanceStore from "./instance-store"

View file

@ -1,172 +1,14 @@
import { GlobalBus } from "@/bus/global"
import { disposeInstance } from "@/effect/instance-registry"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { iife } from "@/util/iife"
import * as Log from "@opencode-ai/core/util/log"
import { LocalContext } from "@/util/local-context"
import * as Project from "./project"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { Context, Effect, Layer } from "effect"
import { context, type InstanceContext } from "./instance-context"
import { InstanceStore } from "./instance-store"
export interface InstanceContext {
directory: string
worktree: string
project: Project.Info
}
const context = LocalContext.create<InstanceContext>("instance")
export interface LoadInput {
directory: string
init?: () => Promise<unknown>
worktree?: string
project?: Project.Info
}
export interface Interface {
readonly load: (input: LoadInput) => Effect.Effect<InstanceContext>
readonly reload: (input: LoadInput) => Effect.Effect<InstanceContext>
readonly dispose: (ctx: InstanceContext) => Effect.Effect<void>
readonly disposeAll: () => Effect.Effect<void>
}
export class InstanceStore extends Context.Service<InstanceStore, Interface>()("@opencode/InstanceStore") {}
export const instanceStoreLayer: Layer.Layer<InstanceStore, never, Project.Service> = Layer.effect(
InstanceStore,
Effect.gen(function* () {
const project = yield* Project.Service
const cache = new Map<string, Promise<InstanceContext>>()
const disposal = {
all: undefined as Promise<void> | undefined,
}
const boot = Effect.fn("InstanceStore.boot")(function* (input: LoadInput & { directory: string }) {
const ctx =
input.project && input.worktree
? {
directory: input.directory,
worktree: input.worktree,
project: input.project,
}
: yield* project.fromDirectory(input.directory).pipe(
Effect.map((result) => ({
directory: input.directory,
worktree: result.sandbox,
project: result.project,
})),
)
const init = input.init
if (init) yield* Effect.promise(() => context.provide(ctx, init))
return ctx
})
function track(directory: string, next: Promise<InstanceContext>) {
const task = next.catch((error) => {
if (cache.get(directory) === task) cache.delete(directory)
throw error
})
cache.set(directory, task)
return task
}
const load = Effect.fn("InstanceStore.load")(function* (input: LoadInput) {
const directory = AppFileSystem.resolve(input.directory)
const existing = cache.get(directory)
if (existing) return yield* Effect.promise(() => existing)
Log.Default.info("creating instance", { directory })
return yield* Effect.promise(() => track(directory, Effect.runPromise(boot({ ...input, directory }))))
})
const reload = Effect.fn("InstanceStore.reload")(function* (input: LoadInput) {
const directory = AppFileSystem.resolve(input.directory)
Log.Default.info("reloading instance", { directory })
yield* Effect.promise(() => disposeInstance(directory))
cache.delete(directory)
const next = track(directory, Effect.runPromise(boot({ ...input, directory })))
GlobalBus.emit("event", {
directory,
project: input.project?.id,
workspace: WorkspaceContext.workspaceID,
payload: {
type: "server.instance.disposed",
properties: {
directory,
},
},
})
return yield* Effect.promise(() => next)
})
const dispose = Effect.fn("InstanceStore.dispose")(function* (ctx: InstanceContext) {
Log.Default.info("disposing instance", { directory: ctx.directory })
yield* Effect.promise(() => disposeInstance(ctx.directory))
cache.delete(ctx.directory)
GlobalBus.emit("event", {
directory: ctx.directory,
project: ctx.project.id,
workspace: WorkspaceContext.workspaceID,
payload: {
type: "server.instance.disposed",
properties: {
directory: ctx.directory,
},
},
})
})
const disposeAll = Effect.fn("InstanceStore.disposeAll")(function* () {
if (disposal.all) return yield* Effect.promise(() => disposal.all!)
disposal.all = iife(async () => {
Log.Default.info("disposing all instances")
const entries = [...cache.entries()]
for (const [key, value] of entries) {
if (cache.get(key) !== value) continue
const ctx = await value.catch((error) => {
Log.Default.warn("instance dispose failed", { key, error })
return undefined
})
if (!ctx) {
if (cache.get(key) === value) cache.delete(key)
continue
}
if (cache.get(key) !== value) continue
await Effect.runPromise(dispose(ctx))
}
}).finally(() => {
disposal.all = undefined
})
return yield* Effect.promise(() => disposal.all!)
})
yield* Effect.addFinalizer(() => disposeAll().pipe(Effect.ignore))
return InstanceStore.of({
load,
reload,
dispose,
disposeAll,
})
}),
)
export const instanceStoreDefaultLayer = instanceStoreLayer.pipe(Layer.provide(Project.defaultLayer))
const instanceStoreRuntime = makeRuntime(InstanceStore, instanceStoreDefaultLayer)
export type { InstanceContext } from "./instance-context"
export type { LoadInput } from "./instance-store"
export const Instance = {
load(input: LoadInput): Promise<InstanceContext> {
return instanceStoreRuntime.runPromise((store) => store.load(input))
load(input: InstanceStore.LoadInput): Promise<InstanceContext> {
return InstanceStore.runtime.runPromise((store) => store.load(input))
},
async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
return context.provide(await Instance.load(input), async () => input.fn())
@ -215,12 +57,12 @@ export const Instance = {
return context.provide(ctx, fn)
},
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
return instanceStoreRuntime.runPromise((store) => store.reload(input))
return InstanceStore.runtime.runPromise((store) => store.reload(input))
},
async dispose() {
return instanceStoreRuntime.runPromise((store) => store.dispose(Instance.current))
return InstanceStore.runtime.runPromise((store) => store.dispose(Instance.current))
},
async disposeAll() {
return instanceStoreRuntime.runPromise((store) => store.disposeAll())
return InstanceStore.runtime.runPromise((store) => store.disposeAll())
},
}

View file

@ -2,11 +2,13 @@ import type { WorkspaceID } from "@/control-plane/schema"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { WorkspaceRef } from "@/effect/instance-ref"
import { Instance, type InstanceContext } from "@/project/instance"
import { InstanceStore } from "@/project/instance-store"
import { Effect } from "effect"
import { HttpEffect, HttpMiddleware, HttpServerRequest } from "effect/unstable/http"
type MarkedInstance = {
ctx: InstanceContext
store: InstanceStore.Interface
workspaceID?: WorkspaceID
}
@ -17,17 +19,17 @@ const disposeAfterResponse = new WeakMap<object, MarkedInstance>()
const mark = (ctx: InstanceContext) =>
Effect.gen(function* () {
return { ctx, workspaceID: yield* WorkspaceRef }
return { ctx, store: yield* InstanceStore.Service, workspaceID: yield* WorkspaceRef }
})
// Instance.dispose/reload still publish events through legacy ALS helpers.
// InstanceStore lifecycle operations still publish events through legacy ALS helpers.
// Effect request handlers carry these values in services, so bridge them back
// into the legacy contexts only around the lifecycle operation.
const restoreMarked = <A>(marked: MarkedInstance, fn: () => A) =>
const restoreMarked = <A>(marked: MarkedInstance, effect: Effect.Effect<A>) =>
Effect.promise(() =>
WorkspaceContext.provide({
workspaceID: marked.workspaceID,
fn: () => Instance.restore(marked.ctx, fn),
fn: () => Instance.restore(marked.ctx, () => Effect.runPromise(effect)),
}),
)
@ -43,11 +45,11 @@ export const markInstanceForDisposal = (ctx: InstanceContext) =>
)
})
export const markInstanceForReload = (ctx: InstanceContext, next: Parameters<typeof Instance.reload>[0]) =>
export const markInstanceForReload = (ctx: InstanceContext, next: InstanceStore.LoadInput) =>
Effect.gen(function* () {
const marked = yield* mark(ctx)
return yield* HttpEffect.appendPreResponseHandler((_request, response) =>
Effect.as(Effect.uninterruptible(restoreMarked(marked, () => Instance.reload(next))), response),
Effect.as(Effect.uninterruptible(restoreMarked(marked, marked.store.reload(next))), response),
)
})
@ -58,6 +60,6 @@ export const disposeMiddleware: HttpMiddleware.HttpMiddleware = (effect) =>
const marked = disposeAfterResponse.get(request.source)
if (!marked) return response
disposeAfterResponse.delete(request.source)
yield* Effect.uninterruptible(restoreMarked(marked, () => Instance.dispose()))
yield* Effect.uninterruptible(restoreMarked(marked, marked.store.dispose(marked.ctx)))
return response
})

View file

@ -1,9 +1,10 @@
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { AppRuntime } from "@/effect/app-runtime"
import { InstanceBootstrap } from "@/project/bootstrap"
import { InstanceStore, type InstanceContext } from "@/project/instance"
import type { InstanceContext } from "@/project/instance"
import { InstanceStore } from "@/project/instance-store"
import { Filesystem } from "@/util/filesystem"
import { Context, Effect, Layer } from "effect"
import { Effect, Layer } from "effect"
import { HttpRouter, HttpServerResponse } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
import { WorkspaceRouteContext } from "./workspace-routing"
@ -24,7 +25,7 @@ function decode(input: string): string {
}
function makeInstanceContext(
store: Context.Service.Shape<typeof InstanceStore>,
store: InstanceStore.Interface,
directory: string,
): Effect.Effect<InstanceContext> {
return store.load({
@ -35,7 +36,7 @@ function makeInstanceContext(
function provideInstanceContext<E>(
effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E>,
store: Context.Service.Shape<typeof InstanceStore>,
store: InstanceStore.Interface,
): Effect.Effect<HttpServerResponse.HttpServerResponse, E, WorkspaceRouteContext> {
return Effect.gen(function* () {
const route = yield* WorkspaceRouteContext
@ -50,14 +51,14 @@ function provideInstanceContext<E>(
export const instanceContextLayer = Layer.effect(
InstanceContextMiddleware,
Effect.gen(function* () {
const store = yield* InstanceStore
const store = yield* InstanceStore.Service
return InstanceContextMiddleware.of((effect) => provideInstanceContext(effect, store))
}),
)
export const instanceRouterMiddleware = HttpRouter.middleware()(
Effect.gen(function* () {
const store = yield* InstanceStore
const store = yield* InstanceStore.Service
return (effect) => provideInstanceContext(effect, store)
}),
)

View file

@ -17,7 +17,7 @@ import { LSP } from "@/lsp/lsp"
import { MCP } from "@/mcp"
import { Permission } from "@/permission"
import { Installation } from "@/installation"
import { instanceStoreDefaultLayer } from "@/project/instance"
import { InstanceStore } from "@/project/instance-store"
import { Project } from "@/project/project"
import { ProviderAuth } from "@/provider/auth"
import { Provider } from "@/provider/provider"
@ -146,7 +146,7 @@ export function createRoutes(corsOptions?: CorsOptions) {
Format.defaultLayer,
LSP.defaultLayer,
Installation.defaultLayer,
instanceStoreDefaultLayer,
InstanceStore.defaultLayer,
MCP.defaultLayer,
Permission.defaultLayer,
Project.defaultLayer,

View file

@ -1,11 +1,12 @@
import { afterEach, describe, expect } from "bun:test"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Effect, Layer } from "effect"
import { Instance, InstanceStore, instanceStoreDefaultLayer } from "../../src/project/instance"
import { Instance } from "../../src/project/instance"
import { InstanceStore } from "../../src/project/instance-store"
import { tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(instanceStoreDefaultLayer, CrossSpawnSpawner.defaultLayer))
const it = testEffect(Layer.mergeAll(InstanceStore.defaultLayer, CrossSpawnSpawner.defaultLayer))
afterEach(async () => {
await Instance.disposeAll()
@ -15,7 +16,7 @@ describe("InstanceStore", () => {
it.live("loads instance context without installing ALS for the caller", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const store = yield* InstanceStore
const store = yield* InstanceStore.Service
const ctx = yield* store.load({ directory: dir })
expect(ctx.directory).toBe(dir)
@ -27,7 +28,7 @@ describe("InstanceStore", () => {
it.live("runs load init inside the loaded legacy instance context", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const store = yield* InstanceStore
const store = yield* InstanceStore.Service
let initializedDirectory: string | undefined
yield* store.load({
@ -45,7 +46,7 @@ describe("InstanceStore", () => {
it.live("caches loaded instance context by directory", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const store = yield* InstanceStore
const store = yield* InstanceStore.Service
let initialized = 0
const first = yield* store.load({

View file

@ -11,7 +11,8 @@ import { registerAdapter } from "../../src/control-plane/adapters"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
import { Instance, instanceStoreDefaultLayer } from "../../src/project/instance"
import { Instance } from "../../src/project/instance"
import { InstanceStore } from "../../src/project/instance-store"
import { Project } from "../../src/project/project"
import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle"
import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
@ -40,7 +41,7 @@ const it = testEffect(
testStateLayer,
NodeHttpServer.layerTest,
NodeServices.layer,
instanceStoreDefaultLayer,
InstanceStore.defaultLayer,
Project.defaultLayer,
Workspace.defaultLayer,
),