refactor: move instance loading into service

This commit is contained in:
Kit Langton 2026-05-01 08:20:35 -04:00
commit f5398e7e1e
5 changed files with 255 additions and 127 deletions

View file

@ -7,6 +7,7 @@ import * as Log from "@opencode-ai/core/util/log"
import { LocalContext } from "@/util/local-context" import { LocalContext } from "@/util/local-context"
import * as Project from "./project" import * as Project from "./project"
import { WorkspaceContext } from "@/control-plane/workspace-context" import { WorkspaceContext } from "@/control-plane/workspace-context"
import { Context, Effect, Layer } from "effect"
export interface InstanceContext { export interface InstanceContext {
directory: string directory: string
@ -15,63 +16,160 @@ export interface InstanceContext {
} }
const context = LocalContext.create<InstanceContext>("instance") const context = LocalContext.create<InstanceContext>("instance")
const cache = new Map<string, Promise<InstanceContext>>()
const project = makeRuntime(Project.Service, Project.defaultLayer)
const disposal = { export interface LoadInput {
all: undefined as Promise<void> | undefined, directory: string
init?: () => Promise<unknown>
worktree?: string
project?: Project.Info
} }
function boot(input: { directory: string; init?: () => Promise<any>; worktree?: string; project?: Project.Info }) { export interface Store {
return iife(async () => { readonly load: (input: LoadInput) => Effect.Effect<InstanceContext>
const ctx = readonly reload: (input: LoadInput) => Effect.Effect<InstanceContext>
input.project && input.worktree readonly dispose: (ctx: InstanceContext) => Effect.Effect<void>
? { readonly disposeAll: () => Effect.Effect<void>
directory: input.directory, }
worktree: input.worktree,
project: input.project, export class InstanceStore extends Context.Service<InstanceStore, Store>()("@opencode/InstanceStore") {}
}
: await project export const instanceStoreLayer: Layer.Layer<InstanceStore, never, Project.Service> = Layer.effect(
.runPromise((svc) => svc.fromDirectory(input.directory)) InstanceStore,
.then(({ project, sandbox }) => ({ 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, directory: input.directory,
worktree: sandbox, worktree: input.worktree,
project, project: input.project,
})) }
await context.provide(ctx, async () => { : yield* project.fromDirectory(input.directory).pipe(
await input.init?.() 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
}) })
return ctx
})
}
function track(directory: string, next: Promise<InstanceContext>) { function track(directory: string, next: Promise<InstanceContext>) {
const task = next.catch((error) => { const task = next.catch((error) => {
if (cache.get(directory) === task) cache.delete(directory) if (cache.get(directory) === task) cache.delete(directory)
throw error throw error
}) })
cache.set(directory, task) cache.set(directory, task)
return 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 const Instance = { export const Instance = {
load(input: LoadInput): Promise<InstanceContext> {
return instanceStoreRuntime.runPromise((store) => store.load(input))
},
async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> { async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
const directory = AppFileSystem.resolve(input.directory) return context.provide(await Instance.load(input), async () => input.fn())
let existing = cache.get(directory)
if (!existing) {
Log.Default.info("creating instance", { directory })
existing = track(
directory,
boot({
directory,
init: input.init,
}),
)
}
const ctx = await existing
return context.provide(ctx, async () => {
return input.fn()
})
}, },
get current() { get current() {
return context.use() return context.use()
@ -117,74 +215,12 @@ export const Instance = {
return context.provide(ctx, fn) return context.provide(ctx, fn)
}, },
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) { async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
const directory = AppFileSystem.resolve(input.directory) return instanceStoreRuntime.runPromise((store) => store.reload(input))
Log.Default.info("reloading instance", { directory })
await disposeInstance(directory)
cache.delete(directory)
const next = track(directory, boot({ ...input, directory }))
GlobalBus.emit("event", {
directory,
project: input.project?.id,
workspace: WorkspaceContext.workspaceID,
payload: {
type: "server.instance.disposed",
properties: {
directory,
},
},
})
return await next
}, },
async dispose() { async dispose() {
const directory = Instance.directory return instanceStoreRuntime.runPromise((store) => store.dispose(Instance.current))
const project = Instance.project
Log.Default.info("disposing instance", { directory })
await disposeInstance(directory)
cache.delete(directory)
GlobalBus.emit("event", {
directory,
project: project.id,
workspace: WorkspaceContext.workspaceID,
payload: {
type: "server.instance.disposed",
properties: {
directory,
},
},
})
}, },
async disposeAll() { async disposeAll() {
if (disposal.all) return disposal.all return instanceStoreRuntime.runPromise((store) => store.disposeAll())
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 context.provide(ctx, async () => {
await Instance.dispose()
})
}
}).finally(() => {
disposal.all = undefined
})
return disposal.all
}, },
} }

View file

@ -1,8 +1,7 @@
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { AppRuntime } from "@/effect/app-runtime" import { AppRuntime } from "@/effect/app-runtime"
import { InstanceBootstrap } from "@/project/bootstrap" import { InstanceBootstrap } from "@/project/bootstrap"
import { Instance } from "@/project/instance" import { InstanceStore, type InstanceContext, type Store } from "@/project/instance"
import type { InstanceContext } from "@/project/instance"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { HttpRouter, HttpServerResponse } from "effect/unstable/http" import { HttpRouter, HttpServerResponse } from "effect/unstable/http"
@ -24,22 +23,20 @@ function decode(input: string): string {
} }
} }
function makeInstanceContext(directory: string): Effect.Effect<InstanceContext> { function makeInstanceContext(store: Store, directory: string): Effect.Effect<InstanceContext> {
return Effect.promise(() => return store.load({
Instance.provide({ directory: Filesystem.resolve(decode(directory)),
directory: Filesystem.resolve(decode(directory)), init: () => AppRuntime.runPromise(InstanceBootstrap),
init: () => AppRuntime.runPromise(InstanceBootstrap), })
fn: () => Instance.current,
}),
)
} }
function provideInstanceContext<E>( function provideInstanceContext<E>(
effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E>, effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E>,
store: Store,
): Effect.Effect<HttpServerResponse.HttpServerResponse, E, WorkspaceRouteContext> { ): Effect.Effect<HttpServerResponse.HttpServerResponse, E, WorkspaceRouteContext> {
return Effect.gen(function* () { return Effect.gen(function* () {
const route = yield* WorkspaceRouteContext const route = yield* WorkspaceRouteContext
const ctx = yield* makeInstanceContext(route.directory) const ctx = yield* makeInstanceContext(store, route.directory)
return yield* effect.pipe( return yield* effect.pipe(
Effect.provideService(InstanceRef, ctx), Effect.provideService(InstanceRef, ctx),
Effect.provideService(WorkspaceRef, route.workspaceID), Effect.provideService(WorkspaceRef, route.workspaceID),
@ -47,9 +44,17 @@ function provideInstanceContext<E>(
}) })
} }
export const instanceContextLayer = Layer.succeed( export const instanceContextLayer = Layer.effect(
InstanceContextMiddleware, InstanceContextMiddleware,
InstanceContextMiddleware.of((effect) => provideInstanceContext(effect)), Effect.gen(function* () {
const store = yield* InstanceStore
return InstanceContextMiddleware.of((effect) => provideInstanceContext(effect, store))
}),
) )
export const instanceRouterMiddleware = HttpRouter.middleware()((effect) => provideInstanceContext(effect)) export const instanceRouterMiddleware = HttpRouter.middleware()(
Effect.gen(function* () {
const store = yield* InstanceStore
return (effect) => provideInstanceContext(effect, store)
}),
)

View file

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

View file

@ -0,0 +1,84 @@
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 { tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(instanceStoreDefaultLayer, CrossSpawnSpawner.defaultLayer))
afterEach(async () => {
await Instance.disposeAll()
})
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 ctx = yield* store.load({ directory: dir })
expect(ctx.directory).toBe(dir)
expect(ctx.worktree).toBe(dir)
expect(() => Instance.current).toThrow()
}),
)
it.live("runs load init inside the loaded legacy instance context", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const store = yield* InstanceStore
let initializedDirectory: string | undefined
yield* store.load({
directory: dir,
init: async () => {
initializedDirectory = Instance.directory
},
})
expect(initializedDirectory).toBe(dir)
expect(() => Instance.current).toThrow()
}),
)
it.live("caches loaded instance context by directory", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const store = yield* InstanceStore
let initialized = 0
const first = yield* store.load({
directory: dir,
init: async () => {
initialized++
},
})
const second = yield* store.load({
directory: dir,
init: async () => {
initialized++
},
})
expect(second).toBe(first)
expect(initialized).toBe(1)
}),
)
it.live("keeps Instance.provide as the legacy ALS wrapper", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const directory = yield* Effect.promise(() =>
Instance.provide({
directory: dir,
fn: () => Instance.directory,
}),
)
expect(directory).toBe(dir)
expect(() => Instance.current).toThrow()
}),
)
})

View file

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