refactor: move instance loading into service

This commit is contained in:
Kit Langton
2026-05-01 08:20:35 -04:00
parent 478156456e
commit f5398e7e1e
5 changed files with 256 additions and 128 deletions

View File

@@ -7,6 +7,7 @@ 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"
export interface InstanceContext {
directory: string
@@ -15,63 +16,160 @@ export interface InstanceContext {
}
const context = LocalContext.create<InstanceContext>("instance")
const cache = new Map<string, Promise<InstanceContext>>()
const project = makeRuntime(Project.Service, Project.defaultLayer)
const disposal = {
all: undefined as Promise<void> | undefined,
export interface LoadInput {
directory: string
init?: () => Promise<unknown>
worktree?: string
project?: Project.Info
}
function boot(input: { directory: string; init?: () => Promise<any>; worktree?: string; project?: Project.Info }) {
return iife(async () => {
const ctx =
input.project && input.worktree
? {
directory: input.directory,
worktree: input.worktree,
project: input.project,
}
: await project
.runPromise((svc) => svc.fromDirectory(input.directory))
.then(({ project, sandbox }) => ({
export interface Store {
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, Store>()("@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: sandbox,
project,
}))
await context.provide(ctx, async () => {
await input.init?.()
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
})
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
}
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 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> {
const directory = AppFileSystem.resolve(input.directory)
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()
})
return context.provide(await Instance.load(input), async () => input.fn())
},
get current() {
return context.use()
@@ -117,74 +215,12 @@ export const Instance = {
return context.provide(ctx, fn)
},
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
const directory = AppFileSystem.resolve(input.directory)
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
return instanceStoreRuntime.runPromise((store) => store.reload(input))
},
async dispose() {
const directory = Instance.directory
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,
},
},
})
return instanceStoreRuntime.runPromise((store) => store.dispose(Instance.current))
},
async disposeAll() {
if (disposal.all) return 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 context.provide(ctx, async () => {
await Instance.dispose()
})
}
}).finally(() => {
disposal.all = undefined
})
return disposal.all
return instanceStoreRuntime.runPromise((store) => store.disposeAll())
},
}

View File

@@ -1,8 +1,7 @@
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { AppRuntime } from "@/effect/app-runtime"
import { InstanceBootstrap } from "@/project/bootstrap"
import { Instance } from "@/project/instance"
import type { InstanceContext } from "@/project/instance"
import { InstanceStore, type InstanceContext, type Store } from "@/project/instance"
import { Filesystem } from "@/util/filesystem"
import { Effect, Layer } from "effect"
import { HttpRouter, HttpServerResponse } from "effect/unstable/http"
@@ -24,22 +23,20 @@ function decode(input: string): string {
}
}
function makeInstanceContext(directory: string): Effect.Effect<InstanceContext> {
return Effect.promise(() =>
Instance.provide({
directory: Filesystem.resolve(decode(directory)),
init: () => AppRuntime.runPromise(InstanceBootstrap),
fn: () => Instance.current,
}),
)
function makeInstanceContext(store: Store, directory: string): Effect.Effect<InstanceContext> {
return store.load({
directory: Filesystem.resolve(decode(directory)),
init: () => AppRuntime.runPromise(InstanceBootstrap),
})
}
function provideInstanceContext<E>(
effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E>,
store: Store,
): Effect.Effect<HttpServerResponse.HttpServerResponse, E, WorkspaceRouteContext> {
return Effect.gen(function* () {
const route = yield* WorkspaceRouteContext
const ctx = yield* makeInstanceContext(route.directory)
const ctx = yield* makeInstanceContext(store, route.directory)
return yield* effect.pipe(
Effect.provideService(InstanceRef, ctx),
Effect.provideService(WorkspaceRef, route.workspaceID),
@@ -47,9 +44,17 @@ function provideInstanceContext<E>(
})
}
export const instanceContextLayer = Layer.succeed(
export const instanceContextLayer = Layer.effect(
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 { Permission } from "@/permission"
import { Installation } from "@/installation"
import { instanceStoreDefaultLayer } from "@/project/instance"
import { Project } from "@/project/project"
import { ProviderAuth } from "@/provider/auth"
import { Provider } from "@/provider/provider"
@@ -145,6 +146,7 @@ export function createRoutes(corsOptions?: CorsOptions) {
Format.defaultLayer,
LSP.defaultLayer,
Installation.defaultLayer,
instanceStoreDefaultLayer,
MCP.defaultLayer,
Permission.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 { Workspace } from "../../src/control-plane/workspace"
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 { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle"
import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
@@ -40,6 +40,7 @@ const it = testEffect(
testStateLayer,
NodeHttpServer.layerTest,
NodeServices.layer,
instanceStoreDefaultLayer,
Project.defaultLayer,
Workspace.defaultLayer,
),