diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 623e886231..ac4243fa16 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -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("instance") -const cache = new Map>() -const project = makeRuntime(Project.Service, Project.defaultLayer) -const disposal = { - all: undefined as Promise | undefined, +export interface LoadInput { + directory: string + init?: () => Promise + worktree?: string + project?: Project.Info } -function boot(input: { directory: string; init?: () => Promise; 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 + readonly reload: (input: LoadInput) => Effect.Effect + readonly dispose: (ctx: InstanceContext) => Effect.Effect + readonly disposeAll: () => Effect.Effect +} + +export class InstanceStore extends Context.Service()("@opencode/InstanceStore") {} + +export const instanceStoreLayer: Layer.Layer = Layer.effect( + InstanceStore, + Effect.gen(function* () { + const project = yield* Project.Service + const cache = new Map>() + const disposal = { + all: undefined as Promise | 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) { - 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) { + 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 { + return instanceStoreRuntime.runPromise((store) => store.load(input)) + }, async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { - 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; 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()) }, } diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts index c80f1caeb6..81de3d739d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts @@ -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 { - 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 { + return store.load({ + directory: Filesystem.resolve(decode(directory)), + init: () => AppRuntime.runPromise(InstanceBootstrap), + }) } function provideInstanceContext( effect: Effect.Effect, + store: Store, ): Effect.Effect { 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( }) } -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) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index e6dedfe2c4..18d33218e5 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -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, diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts new file mode 100644 index 0000000000..a909a138f1 --- /dev/null +++ b/packages/opencode/test/project/instance.test.ts @@ -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() + }), + ) +}) diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts index 9dea20dd66..1e214d52e0 100644 --- a/packages/opencode/test/server/httpapi-instance-context.test.ts +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -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, ),