mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-20 19:06:22 +00:00
refactor: move instance loading into service
This commit is contained in:
@@ -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())
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
84
packages/opencode/test/project/instance.test.ts
Normal file
84
packages/opencode/test/project/instance.test.ts
Normal 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()
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user