diff --git a/packages/opencode/script/seed-e2e.ts b/packages/opencode/script/seed-e2e.ts index 1d196cd1d..e42edfb40 100644 --- a/packages/opencode/script/seed-e2e.ts +++ b/packages/opencode/script/seed-e2e.ts @@ -56,7 +56,6 @@ const seed = async () => { await prepareConfigDependencies() const { Instance } = await import("../src/project/instance") - const { InstanceBootstrap } = await import("../src/project/bootstrap") const { Config } = await import("../src/config/config") const { Session } = await import("../src/session") const { MessageID, PartID } = await import("../src/session/schema") @@ -66,7 +65,6 @@ const seed = async () => { try { await Instance.provide({ directory: dir, - init: InstanceBootstrap, fn: async () => { await Config.waitForDependencies() diff --git a/packages/opencode/src/bus/index.ts b/packages/opencode/src/bus/index.ts index db03e0f20..5969b15fc 100644 --- a/packages/opencode/src/bus/index.ts +++ b/packages/opencode/src/bus/index.ts @@ -7,6 +7,7 @@ import { GlobalBus } from "./global" import { WorkspaceContext } from "@/control-plane/workspace-context" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" +import { LocalContext } from "@/util/local-context" export namespace Bus { const log = Log.create({ service: "bus" }) @@ -177,7 +178,15 @@ export namespace Bus { // runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe, // Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw. export async function publish(def: D, properties: z.output) { - return runPromise((svc) => svc.publish(def, properties)) + try { + return await runPromise((svc) => svc.publish(def, properties)) + } catch (error) { + if (!(error instanceof LocalContext.NotFound) || error.name !== "instance") throw error + GlobalBus.emit("event", { + directory: "global", + payload: { type: def.type, properties }, + }) + } } export function subscribe( diff --git a/packages/opencode/src/cli/bootstrap.ts b/packages/opencode/src/cli/bootstrap.ts index eb2714b9b..4151f4b9c 100644 --- a/packages/opencode/src/cli/bootstrap.ts +++ b/packages/opencode/src/cli/bootstrap.ts @@ -1,11 +1,8 @@ -import { AppRuntime } from "@/effect/app-runtime" -import { InstanceBootstrap } from "../project/bootstrap" import { Instance } from "../project/instance" export async function bootstrap(directory: string, cb: () => Promise) { return Instance.provide({ directory, - init: InstanceBootstrap, fn: async () => { try { const result = await cb() diff --git a/packages/opencode/src/cli/cmd/cmd.ts b/packages/opencode/src/cli/cmd/cmd.ts index fe6d62d7b..05af009b8 100644 --- a/packages/opencode/src/cli/cmd/cmd.ts +++ b/packages/opencode/src/cli/cmd/cmd.ts @@ -1,6 +1,6 @@ import type { CommandModule } from "yargs" -type WithDoubleDash = T & { "--"?: string[] } +export type WithDoubleDash = T & { "--"?: string[] } export function cmd(input: CommandModule>) { return input diff --git a/packages/opencode/src/cli/cmd/models.ts b/packages/opencode/src/cli/cmd/models.ts index 4670aa5f2..96297ac21 100644 --- a/packages/opencode/src/cli/cmd/models.ts +++ b/packages/opencode/src/cli/cmd/models.ts @@ -1,13 +1,13 @@ import type { Argv } from "yargs" -import { Instance } from "../../project/instance" +import { Effect } from "effect" import { Provider } from "../../provider/provider" import { ProviderID } from "../../provider/schema" import { ModelsDev } from "../../provider/models" -import { cmd } from "./cmd" +import { CliError, effectCmd, fail } from "../effect-cmd" import { UI } from "../ui" import { EOL } from "os" -export const ModelsCommand = cmd({ +export const ModelsCommand = effectCmd({ command: "models [provider]", describe: "list all available models", builder: (yargs: Argv) => { @@ -26,53 +26,52 @@ export const ModelsCommand = cmd({ type: "boolean", }) }, - handler: async (args) => { + handler: Effect.fn("Cli.models")(function* (args) { if (args.refresh) { - await ModelsDev.refresh(true) + yield* Effect.tryPromise({ + try: () => ModelsDev.refresh(true), + catch: (cause) => + new CliError({ + message: `Failed to refresh models cache: ${cause instanceof Error ? cause.message : String(cause)}`, + }), + }) UI.println(UI.Style.TEXT_SUCCESS_BOLD + "Models cache refreshed" + UI.Style.TEXT_NORMAL) } - await Instance.provide({ - directory: process.cwd(), - async fn() { - const providers = await Provider.list() + const provider = yield* Provider.Service + const providers = yield* provider.list() - function printModels(providerID: ProviderID, verbose?: boolean) { - const provider = providers[providerID] - const sortedModels = Object.entries(provider.models).sort(([a], [b]) => a.localeCompare(b)) - for (const [modelID, model] of sortedModels) { - process.stdout.write(`${providerID}/${modelID}`) - process.stdout.write(EOL) - if (verbose) { - process.stdout.write(JSON.stringify(model, null, 2)) - process.stdout.write(EOL) - } - } + function printModels(providerID: ProviderID, verbose?: boolean) { + const provider = providers[providerID] + const sortedModels = Object.entries(provider.models).sort(([a], [b]) => a.localeCompare(b)) + for (const [modelID, model] of sortedModels) { + process.stdout.write(`${providerID}/${modelID}`) + process.stdout.write(EOL) + if (verbose) { + process.stdout.write(JSON.stringify(model, null, 2)) + process.stdout.write(EOL) } + } + } - if (args.provider) { - const provider = providers[ProviderID.make(args.provider)] - if (!provider) { - UI.error(`Provider not found: ${args.provider}`) - return - } + if (args.provider) { + const provider = providers[ProviderID.make(args.provider)] + if (!provider) return yield* fail(`Provider not found: ${args.provider}`) - printModels(ProviderID.make(args.provider), args.verbose) - return - } - - const providerIDs = Object.keys(providers).sort((a, b) => { - const aIsOpencode = a.startsWith("opencode") - const bIsOpencode = b.startsWith("opencode") - if (aIsOpencode && !bIsOpencode) return -1 - if (!aIsOpencode && bIsOpencode) return 1 - return a.localeCompare(b) - }) + printModels(ProviderID.make(args.provider), args.verbose) + return + } - for (const providerID of providerIDs) { - printModels(ProviderID.make(providerID), args.verbose) - } - }, + const providerIDs = Object.keys(providers).sort((a, b) => { + const aIsOpencode = a.startsWith("opencode") + const bIsOpencode = b.startsWith("opencode") + if (aIsOpencode && !bIsOpencode) return -1 + if (!aIsOpencode && bIsOpencode) return 1 + return a.localeCompare(b) }) - }, + + for (const providerID of providerIDs) { + printModels(ProviderID.make(providerID), args.verbose) + } + }), }) diff --git a/packages/opencode/src/cli/effect-cmd.ts b/packages/opencode/src/cli/effect-cmd.ts new file mode 100644 index 000000000..2438858c4 --- /dev/null +++ b/packages/opencode/src/cli/effect-cmd.ts @@ -0,0 +1,55 @@ +import type { Argv } from "yargs" +import { Effect, Schema } from "effect" +import { AppRuntime, type AppServices } from "@/effect/app-runtime" +import { InstanceRef } from "@/effect/instance-ref" +import { Instance } from "@/project/instance" +import { InstanceStore } from "@/project/instance-store" +import { cmd, type WithDoubleDash } from "./cmd/cmd" + +export class CliError extends Schema.TaggedErrorClass()("CliError", { + message: Schema.String, + exitCode: Schema.optional(Schema.Number), +}) {} + +export const fail = (message: string, exitCode = 1) => Effect.fail(new CliError({ message, exitCode })) + +interface EffectCmdOpts { + command: string | readonly string[] + aliases?: string | readonly string[] + describe: string | false + builder?: (yargs: Argv) => Argv + instance?: boolean | ((args: WithDoubleDash) => boolean) + directory?: (args: WithDoubleDash) => string + handler: (args: WithDoubleDash) => Effect.Effect +} + +export const effectCmd = (opts: EffectCmdOpts) => + cmd<{}, Args>({ + command: opts.command, + aliases: opts.aliases, + describe: opts.describe, + builder: opts.builder as never, + async handler(rawArgs) { + const args = rawArgs as unknown as WithDoubleDash + const useInstance = typeof opts.instance === "function" ? opts.instance(args) : opts.instance !== false + if (!useInstance) { + await AppRuntime.runPromise(opts.handler(args)) + return + } + + const directory = opts.directory?.(args) ?? process.cwd() + const { store, ctx } = await AppRuntime.runPromise( + InstanceStore.Service.use((store) => store.load({ directory }).pipe(Effect.map((ctx) => ({ store, ctx })))), + ) + + try { + await Instance.restore(ctx, () => + AppRuntime.runPromise(opts.handler(args).pipe(Effect.provideService(InstanceRef, ctx))), + ) + } finally { + // A yargs command is a one-shot boundary. Dispose here so watchers and + // subscriptions cannot keep CLI processes alive after the handler returns. + await AppRuntime.runPromise(store.dispose(ctx)) + } + }, + }) diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts index fdddf3d9d..67d82e7e4 100644 --- a/packages/opencode/src/cli/error.ts +++ b/packages/opencode/src/cli/error.ts @@ -19,6 +19,12 @@ function isTaggedError(error: unknown, tag: string): boolean { } export function FormatError(input: unknown) { + if (isTaggedError(input, "CliError")) { + const data = input as ErrorLike & { exitCode?: number } + if (data.exitCode != null) process.exitCode = data.exitCode + return data.message ?? "" + } + // MCPFailed: { name: string } if (hasName(input, "MCPFailed")) { return `MCP server "${(input as ErrorLike).data?.name}" failed. Note, opencode does not support MCP authentication yet.` diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 3d3c22ff3..b56aaa053 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -51,6 +51,7 @@ import { ShareNext } from "@/share/share-next" import { SessionShare } from "@/share/session" import { ShareRuntime } from "@/share/runtime" import { memoMap } from "@opencode-ai/core/effect/memo-map" +import { InstanceLayer } from "@/project/instance-layer" export const AppLayer = Layer.mergeAll( Observability.layer, @@ -102,10 +103,11 @@ export const AppLayer = Layer.mergeAll( ShareNext.defaultLayer, SessionShare.defaultLayer, ShareRuntime.cloudShareGateDefaultLayer, -) +).pipe(Layer.provideMerge(InstanceLayer.layer)) const rt = ManagedRuntime.make(AppLayer, { memoMap }) type Runtime = Pick +export type AppServices = ManagedRuntime.ManagedRuntime.Services const wrap = (effect: Parameters[0]) => attach(effect as never) as never export const AppRuntime: Runtime = { diff --git a/packages/opencode/src/effect/run-service.ts b/packages/opencode/src/effect/run-service.ts index 28f1068c3..28bf2237d 100644 --- a/packages/opencode/src/effect/run-service.ts +++ b/packages/opencode/src/effect/run-service.ts @@ -1,4 +1,4 @@ -import { Effect, Layer, ManagedRuntime } from "effect" +import { Effect, Fiber, Layer, ManagedRuntime } from "effect" import * as Context from "effect/Context" import { Instance } from "@/project/instance" import { LocalContext } from "@/util/local-context" @@ -24,15 +24,26 @@ export function attachWith(effect: Effect.Effect, refs: Refs): } export function attach(effect: Effect.Effect): Effect.Effect { - try { - return attachWith(effect, { - instance: Instance.current, - workspace: WorkspaceContext.workspaceID, - }) - } catch (err) { - if (!(err instanceof LocalContext.NotFound)) throw err - } - return effect + const workspace = (() => { + try { + return WorkspaceContext.workspaceID + } catch (err) { + if (!(err instanceof LocalContext.NotFound)) throw err + } + })() + const instance = (() => { + try { + return Instance.current + } catch (err) { + if (!(err instanceof LocalContext.NotFound)) throw err + } + })() + if (instance && workspace !== undefined) return attachWith(effect, { instance, workspace }) + const fiber = Fiber.getCurrent() + return attachWith(effect, { + instance: instance ?? (fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined), + workspace: workspace ?? (fiber ? Context.getReferenceUnsafe(fiber.context, WorkspaceRef) : undefined), + }) } export function makeRuntime(service: Context.Service, layer: Layer.Layer) { diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 706043ddd..59532638a 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -11,7 +11,7 @@ import ignore from "ignore" import path from "path" import z from "zod" import { Global } from "../global" -import { Instance } from "../project/instance" +import { containsPath as containsPathInContext, type InstanceContext } from "../project/instance-context" import { Log } from "@opencode-ai/core/util/log" import { Protected } from "./protected" import { Ripgrep } from "./ripgrep" @@ -356,8 +356,10 @@ export namespace File { ) const scan = Effect.fn("File.scan")(function* () { - if (Instance.directory === path.parse(Instance.directory).root) return - const isGlobalHome = Instance.directory === Global.Path.home && Instance.project.id === "global" + const ctx = yield* InstanceState.context + const directory = ctx.directory + if (directory === path.parse(directory).root) return + const isGlobalHome = directory === Global.Path.home && ctx.project.id === "global" const next: Entry = { files: [], dirs: [] } if (isGlobalHome) { @@ -366,14 +368,14 @@ export namespace File { const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"]) const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name) const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name) - const top = yield* appFs.readDirectoryEntries(Instance.directory).pipe(Effect.orElseSucceed(() => [])) + const top = yield* appFs.readDirectoryEntries(directory).pipe(Effect.orElseSucceed(() => [])) for (const entry of top) { if (entry.type !== "directory") continue if (shouldIgnoreName(entry.name)) continue dirs.add(entry.name + "/") - const base = path.join(Instance.directory, entry.name) + const base = path.join(directory, entry.name) const children = yield* appFs.readDirectoryEntries(base).pipe(Effect.orElseSucceed(() => [])) for (const child of children) { if (child.type !== "directory") continue @@ -384,7 +386,7 @@ export namespace File { next.dirs = Array.from(dirs).toSorted() } else { - const files = yield* rg.files({ cwd: Instance.directory }).pipe( + const files = yield* rg.files({ cwd: directory }).pipe( Stream.runCollect, Effect.map((c) => [...c]), ) @@ -415,8 +417,8 @@ export namespace File { cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void))) }) - const gitText = Effect.fnUntraced(function* (args: string[]) { - return (yield* git.run(args, { cwd: Instance.directory })).text() + const gitText = Effect.fnUntraced(function* (ctx: InstanceContext, args: string[]) { + return (yield* git.run(args, { cwd: ctx.directory })).text() }) const init = Effect.fn("File.init")(function* () { @@ -424,9 +426,10 @@ export namespace File { }) const status = Effect.fn("File.status")(function* () { - if (Instance.project.vcs !== "git") return [] + const ctx = yield* InstanceState.context + if (ctx.project.vcs !== "git") return [] - const diffOutput = yield* gitText([ + const diffOutput = yield* gitText(ctx, [ "-c", "core.fsmonitor=false", "-c", @@ -450,7 +453,7 @@ export namespace File { } } - const untrackedOutput = yield* gitText([ + const untrackedOutput = yield* gitText(ctx, [ "-c", "core.fsmonitor=false", "-c", @@ -463,7 +466,7 @@ export namespace File { if (untrackedOutput.trim()) { for (const file of untrackedOutput.trim().split("\n")) { const content = yield* appFs - .readFileString(path.join(Instance.directory, file)) + .readFileString(path.join(ctx.directory, file)) .pipe(Effect.catch(() => Effect.succeed(undefined))) if (content === undefined) continue changed.push({ @@ -475,7 +478,7 @@ export namespace File { } } - const deletedOutput = yield* gitText([ + const deletedOutput = yield* gitText(ctx, [ "-c", "core.fsmonitor=false", "-c", @@ -498,19 +501,20 @@ export namespace File { } return changed.map((item) => { - const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path) + const full = path.isAbsolute(item.path) ? item.path : path.join(ctx.directory, item.path) return { ...item, - path: path.relative(Instance.directory, full), + path: path.relative(ctx.directory, full), } }) }) const read: Interface["read"] = Effect.fn("File.read")(function* (file: string) { using _ = log.time("read", { file }) - const full = path.join(Instance.directory, file) + const ctx = yield* InstanceState.context + const full = path.join(ctx.directory, file) - if (!Instance.containsPath(full)) throw new Error("Access denied: path escapes project directory") + if (!containsPathInContext(full, ctx)) throw new Error("Access denied: path escapes project directory") if (isImageByExtension(file)) { const exists = yield* appFs.existsSafe(full) @@ -553,13 +557,13 @@ export namespace File { Effect.catch(() => Effect.succeed("")), ) - if (Instance.project.vcs === "git") { - let diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--", file]) + if (ctx.project.vcs === "git") { + let diff = yield* gitText(ctx, ["-c", "core.fsmonitor=false", "diff", "--", file]) if (!diff.trim()) { - diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file]) + diff = yield* gitText(ctx, ["-c", "core.fsmonitor=false", "diff", "--staged", "--", file]) } if (diff.trim()) { - const original = yield* git.show(Instance.directory, "HEAD", file) + const original = yield* git.show(ctx.directory, "HEAD", file) const patch = structuredPatch(file, file, original, content, "old", "new", { context: Infinity, ignoreWhitespace: true, @@ -573,21 +577,22 @@ export namespace File { }) const list = Effect.fn("File.list")(function* (dir?: string) { + const ctx = yield* InstanceState.context const exclude = [".git", ".DS_Store"] let ignored = (_: string) => false - if (Instance.project.vcs === "git") { + if (ctx.project.vcs === "git") { const ig = ignore() - const gitignore = path.join(Instance.project.worktree, ".gitignore") + const gitignore = path.join(ctx.project.worktree, ".gitignore") const gitignoreText = yield* appFs.readFileString(gitignore).pipe(Effect.catch(() => Effect.succeed(""))) if (gitignoreText) ig.add(gitignoreText) - const ignoreFile = path.join(Instance.project.worktree, ".ignore") + const ignoreFile = path.join(ctx.project.worktree, ".ignore") const ignoreText = yield* appFs.readFileString(ignoreFile).pipe(Effect.catch(() => Effect.succeed(""))) if (ignoreText) ig.add(ignoreText) ignored = ig.ignores.bind(ig) } - const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory - if (!Instance.containsPath(resolved)) throw new Error("Access denied: path escapes project directory") + const resolved = dir ? path.join(ctx.directory, dir) : ctx.directory + if (!containsPathInContext(resolved, ctx)) throw new Error("Access denied: path escapes project directory") const entries = yield* appFs.readDirectoryEntries(resolved).pipe(Effect.orElseSucceed(() => [])) @@ -595,7 +600,7 @@ export namespace File { for (const entry of entries) { if (exclude.includes(entry.name)) continue const absolute = path.join(resolved, entry.name) - const file = path.relative(Instance.directory, absolute) + const file = path.relative(ctx.directory, absolute) const type = entry.type === "directory" ? "directory" : "file" nodes.push({ name: entry.name, diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index 858f01533..dea15a1b0 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -74,35 +74,39 @@ export namespace FileWatcher { const state = yield* InstanceState.make( Effect.fn("FileWatcher.state")( - function* () { + function* (ctx) { if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return - log.info("init", { directory: Instance.directory }) + log.info("init", { directory: ctx.directory }) const backend = getBackend() if (!backend) { - log.error("watcher backend not supported", { directory: Instance.directory, platform: process.platform }) + log.error("watcher backend not supported", { directory: ctx.directory, platform: process.platform }) return } const w = watcher() if (!w) return - log.info("watcher backend", { directory: Instance.directory, platform: process.platform, backend }) + log.info("watcher backend", { directory: ctx.directory, platform: process.platform, backend }) const subs: ParcelWatcher.AsyncSubscription[] = [] yield* Effect.addFinalizer(() => Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))), ) - const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => { - if (err) return - for (const evt of evts) { - if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" }) - if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" }) - if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" }) - } - }) + const cb: ParcelWatcher.SubscribeCallback = (err, evts) => + Instance.restore(ctx, () => { + if (err) { + log.error("watcher callback error", { err }) + return + } + for (const evt of evts) { + if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" }) + if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" }) + if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" }) + } + }) const subscribe = (dir: string, ignore: string[]) => { const pending = w.subscribe(dir, cb, { ignore, backend }) @@ -123,19 +127,19 @@ export namespace FileWatcher { const cfgIgnores = cfg.watcher?.ignore ?? [] if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) { - yield* subscribe(Instance.directory, [ + yield* subscribe(ctx.directory, [ ...FileIgnore.PATTERNS, ...cfgIgnores, - ...protecteds(Instance.directory), + ...protecteds(ctx.directory), ]) } - if (Instance.project.vcs === "git") { + if (ctx.project.vcs === "git") { const result = yield* git.run(["rev-parse", "--git-dir"], { - cwd: Instance.project.worktree, + cwd: ctx.project.worktree, }) const vcsDir = - result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined + result.exitCode === 0 ? path.resolve(ctx.project.worktree, result.text().trim()) : undefined if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) { const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter( (entry) => entry !== "HEAD", diff --git a/packages/opencode/src/project/bootstrap-service.ts b/packages/opencode/src/project/bootstrap-service.ts new file mode 100644 index 000000000..f2ae88b3d --- /dev/null +++ b/packages/opencode/src/project/bootstrap-service.ts @@ -0,0 +1,9 @@ +import { Context, Effect } from "effect" + +export interface Interface { + readonly run: Effect.Effect +} + +export class Service extends Context.Service()("@opencode/InstanceBootstrap") {} + +export * as InstanceBootstrap from "./bootstrap-service" diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index a4d8b954e..50fdc8b00 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -1,32 +1,106 @@ -import { Plugin } from "../plugin" -import { Format } from "../format" -import { LSP } from "../lsp" -import { File } from "../file" -import { Snapshot } from "../snapshot" -import { Project } from "./project" -import { Vcs } from "./vcs" -import { Bus } from "../bus" -import { Command } from "../command" -import { Instance } from "./instance" -import { Log } from "@opencode-ai/core/util/log" -import { BootstrapRuntime } from "@/effect/bootstrap-runtime" +import { Bus } from "@/bus" +import { Command } from "@/command" +import { Config } from "@/config" +import { InstanceState } from "@/effect/instance-state" +import { File } from "@/file" import { FileWatcher } from "@/file/watcher" +import { Format } from "@/format" +import { LSP } from "@/lsp" +import { Plugin } from "@/plugin" +import { Project } from "@/project/project" +import { Snapshot } from "@/snapshot" import { ShareNext } from "@/share/share-next" +import { Log } from "@opencode-ai/core/util/log" +import { Effect, Layer } from "effect" +import { registerDisposer } from "@/effect/instance-registry" +import { InstanceBootstrap as BootstrapService } from "./bootstrap-service" +import { Vcs } from "./vcs" + +const log = Log.create({ service: "instance.bootstrap" }) + +export { Service } from "./bootstrap-service" +export type { Interface } from "./bootstrap-service" -export async function InstanceBootstrap() { - Log.Default.info("bootstrapping", { directory: Instance.directory }) - await Plugin.init() - void BootstrapRuntime.runPromise(ShareNext.Service.use((svc) => svc.init())) - void BootstrapRuntime.runPromise(Format.Service.use((svc) => svc.init())) - await LSP.init() - File.init() - void BootstrapRuntime.runPromise(FileWatcher.Service.use((svc) => svc.init())) - Vcs.init() - Snapshot.init() - - Bus.subscribe(Command.Event.Executed, async (payload) => { - if (payload.properties.name === Command.Default.INIT) { - Project.setInitialized(Instance.project.id) +export const layer = Layer.effect( + BootstrapService.Service, + Effect.gen(function* () { + const bus = yield* Bus.Service + const config = yield* Config.Service + const plugin = yield* Plugin.Service + const lsp = yield* LSP.Service + const shareNext = yield* ShareNext.Service + const format = yield* Format.Service + const file = yield* File.Service + const fileWatcher = yield* FileWatcher.Service + const vcs = yield* Vcs.Service + const snapshot = yield* Snapshot.Service + + return { + run: Effect.gen(function* () { + const ctx = yield* InstanceState.context + const boot = (init: Effect.Effect) => + init.pipe( + Effect.catchCause((cause) => + Effect.sync(() => { + log.error("bootstrap service failed", { cause }) + }), + ), + ) + + // Plugin and config failures are fatal: callers must not run instance code + // against an invalid project configuration. + yield* plugin.init() + const unsubscribe = yield* bus.subscribeCallback(Command.Event.Executed, (payload) => { + if (payload.properties.name === Command.Default.INIT) { + Project.setInitialized(ctx.project.id) + } + }) + yield* Effect.sync(() => { + let off = () => {} + off = registerDisposer(async (directory) => { + if (directory !== ctx.directory) return + unsubscribe() + off() + }) + }) + + yield* config.get().pipe(Effect.asVoid) + yield* Effect.forEach( + [ + shareNext.init(), + format.init(), + lsp.init(), + file.init(), + ], + boot, + { + concurrency: "unbounded", + discard: true, + }, + ) + yield* boot(fileWatcher.init()) + yield* boot(vcs.init()) + yield* boot(snapshot.init()) + }), } - }) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(Bus.defaultLayer), + Layer.provide(Config.defaultLayer), + Layer.provide(File.defaultLayer), + Layer.provide(FileWatcher.defaultLayer), + Layer.provide(Format.defaultLayer), + Layer.provide(LSP.defaultLayer), + Layer.provide(Plugin.defaultLayer), + Layer.provide(Project.defaultLayer), + Layer.provide(ShareNext.defaultLayer), + Layer.provide(Snapshot.defaultLayer), + Layer.provide(Vcs.defaultLayer), +) + +export const InstanceBootstrap = { + layer, + defaultLayer, } diff --git a/packages/opencode/src/project/instance-context.ts b/packages/opencode/src/project/instance-context.ts new file mode 100644 index 000000000..2e2e85186 --- /dev/null +++ b/packages/opencode/src/project/instance-context.ts @@ -0,0 +1,17 @@ +import { Filesystem } from "@/util/filesystem" +import { LocalContext } from "@/util/local-context" +import type { Project } from "./project" + +export interface InstanceContext { + directory: string + worktree: string + project: Project.Info +} + +export const context = LocalContext.create("instance") + +export function containsPath(filepath: string, ctx: InstanceContext) { + if (Filesystem.contains(ctx.directory, filepath)) return true + if (ctx.worktree === "/") return false + return Filesystem.contains(ctx.worktree, filepath) +} diff --git a/packages/opencode/src/project/instance-layer.ts b/packages/opencode/src/project/instance-layer.ts new file mode 100644 index 000000000..ec65113a3 --- /dev/null +++ b/packages/opencode/src/project/instance-layer.ts @@ -0,0 +1,13 @@ +import { Effect, Layer } from "effect" +import { InstanceStore } from "./instance-store" + +const bootstrapLayer = Layer.unwrap( + Effect.tryPromise({ + try: () => import("./bootstrap").then((bootstrap) => bootstrap.InstanceBootstrap.defaultLayer), + catch: (cause) => cause, + }), +) + +export const layer = InstanceStore.defaultLayer.pipe(Layer.provideMerge(bootstrapLayer)) + +export * as InstanceLayer from "./instance-layer" diff --git a/packages/opencode/src/project/instance-runtime.ts b/packages/opencode/src/project/instance-runtime.ts new file mode 100644 index 000000000..0f8e9257f --- /dev/null +++ b/packages/opencode/src/project/instance-runtime.ts @@ -0,0 +1,15 @@ +import { AppRuntime } from "@/effect/app-runtime" +import { InstanceStore, type LoadInput } from "./instance-store" + +export const load = (input: LoadInput) => AppRuntime.runPromise(InstanceStore.Service.use((store) => store.load(input))) + +export const reloadInstance = (input: LoadInput) => + AppRuntime.runPromise(InstanceStore.Service.use((store) => store.reload(input))) + +export const disposeInstance = (ctx: Parameters[0]) => + AppRuntime.runPromise(InstanceStore.Service.use((store) => store.dispose(ctx))) + +export const disposeAllInstances = () => + AppRuntime.runPromise(InstanceStore.Service.use((store) => store.disposeAll())) + +export * as InstanceRuntime from "./instance-runtime" diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts new file mode 100644 index 000000000..789adc027 --- /dev/null +++ b/packages/opencode/src/project/instance-store.ts @@ -0,0 +1,233 @@ +import { GlobalBus } from "@/bus/global" +import { WorkspaceContext } from "@/control-plane/workspace-context" +import { InstanceRef } from "@/effect/instance-ref" +import { disposeInstance as runDisposers } from "@/effect/instance-registry" +import { Filesystem } from "@/util/filesystem" +import { Context, Deferred, Effect, Exit, Layer, Scope } from "effect" +import { InstanceBootstrap } from "./bootstrap-service" +import { type InstanceContext } from "./instance-context" +import { Project } from "./project" +import { State } from "./state" + +export interface LoadInput { + directory: string + worktree?: string | undefined + project?: Project.Info | undefined +} + +export interface Interface { + readonly load: (input: LoadInput) => Effect.Effect + readonly reload: (input: LoadInput) => Effect.Effect + readonly dispose: (ctx: InstanceContext) => Effect.Effect + readonly disposeAll: () => Effect.Effect + readonly provide: (input: LoadInput, effect: Effect.Effect) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/InstanceStore") {} + +type Entry = { + readonly deferred: Deferred.Deferred +} + +const disposeLoadedInstances = new Set<() => Promise>() + +export async function disposeAllLoadedInstances() { + await Promise.all([...disposeLoadedInstances].map((dispose) => dispose())) +} + +function hasExplicitContext(input: LoadInput) { + return input.worktree !== undefined || input.project !== undefined +} + +function validateExplicitContext(input: LoadInput) { + if ((input.worktree === undefined) !== (input.project === undefined)) { + throw new Error("Instance worktree and project must be provided together") + } +} + +function contextMatches(ctx: InstanceContext, input: LoadInput) { + if (!hasExplicitContext(input)) return true + return ctx.worktree === input.worktree && ctx.project.id === input.project?.id +} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const project = yield* Project.Service + const bootstrap = yield* InstanceBootstrap.Service + const scope = yield* Scope.Scope + const entries = new Map() + + const boot = (input: LoadInput & { directory: string }): Effect.Effect => + Effect.gen(function* () { + validateExplicitContext(input) + + const ctx = + input.project && input.worktree + ? { + directory: input.directory, + worktree: input.worktree, + project: input.project, + } + : yield* project.fromDirectory(input.directory).pipe( + Effect.map(({ project, sandbox }) => ({ + directory: input.directory, + worktree: sandbox, + project, + })), + ) + + yield* bootstrap.run.pipe(Effect.provideService(InstanceRef, ctx)) + return ctx + }).pipe(Effect.withSpan("InstanceStore.boot")) + + const removeEntry = (directory: string, entry: Entry) => + Effect.sync(() => { + if (entries.get(directory) !== entry) return false + entries.delete(directory) + return true + }) + + const completeLoad = (directory: string, input: LoadInput, entry: Entry) => + Effect.gen(function* () { + const exit = yield* Effect.exit(boot({ ...input, directory })) + if (Exit.isFailure(exit)) yield* removeEntry(directory, entry) + yield* Deferred.done(entry.deferred, exit).pipe(Effect.asVoid) + }) + + const emitDisposed = (ctx: InstanceContext) => + Effect.sync(() => + GlobalBus.emit("event", { + directory: ctx.directory, + project: ctx.project.id, + workspace: WorkspaceContext.workspaceID, + payload: { + type: "server.instance.disposed", + properties: { + directory: ctx.directory, + }, + }, + }), + ) + + const disposeContext = (ctx: InstanceContext) => + Effect.gen(function* () { + yield* Effect.promise(async () => { + await State.dispose(ctx.directory) + await runDisposers(ctx.directory) + }) + yield* emitDisposed(ctx) + }) + + const disposeEntry = (directory: string, entry: Entry, ctx: InstanceContext) => + Effect.gen(function* () { + if (entries.get(directory) !== entry) return false + yield* disposeContext(ctx) + if (entries.get(directory) !== entry) return false + entries.delete(directory) + return true + }) + + const reload = (input: LoadInput): Effect.Effect => { + const directory = Filesystem.resolve(input.directory) + return Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + validateExplicitContext(input) + const previous = entries.get(directory) + const entry: Entry = { deferred: Deferred.makeUnsafe() } + entries.set(directory, entry) + yield* Effect.gen(function* () { + if (previous) { + const exit = yield* Deferred.await(previous.deferred).pipe(Effect.exit) + if (Exit.isSuccess(exit)) yield* disposeContext(exit.value) + else yield* removeEntry(directory, previous) + } + yield* completeLoad(directory, input, entry) + }).pipe(Effect.forkIn(scope, { startImmediately: true })) + return yield* restore(Deferred.await(entry.deferred)) + }), + ).pipe(Effect.withSpan("InstanceStore.reload")) + } + + const load = (input: LoadInput): Effect.Effect => { + const directory = Filesystem.resolve(input.directory) + return Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + validateExplicitContext(input) + const existing = entries.get(directory) + if (existing) { + const exit = yield* restore(Deferred.await(existing.deferred)).pipe(Effect.exit) + if (Exit.isSuccess(exit) && contextMatches(exit.value, input)) { + const row = yield* project.get(exit.value.project.id) + if (row) return exit.value + } + return yield* reload(input) + } + + const entry: Entry = { deferred: Deferred.makeUnsafe() } + entries.set(directory, entry) + yield* Effect.gen(function* () { + yield* completeLoad(directory, input, entry) + }).pipe(Effect.forkIn(scope, { startImmediately: true })) + return yield* restore(Deferred.await(entry.deferred)) + }), + ).pipe(Effect.withSpan("InstanceStore.load")) + } + + const dispose = (ctx: InstanceContext) => + Effect.gen(function* () { + const directory = Filesystem.resolve(ctx.directory) + const entry = entries.get(directory) + if (!entry) return yield* disposeContext(ctx) + + const exit = yield* Deferred.await(entry.deferred).pipe(Effect.exit) + if (Exit.isFailure(exit)) return yield* removeEntry(directory, entry).pipe(Effect.asVoid) + if (exit.value !== ctx) return + yield* disposeEntry(directory, entry, ctx).pipe(Effect.asVoid) + }) + + const disposeAllOnce = Effect.gen(function* () { + yield* Effect.forEach( + [...entries.entries()], + ([directory, entry]) => + Effect.gen(function* () { + const exit = yield* Deferred.await(entry.deferred).pipe(Effect.exit) + if (Exit.isFailure(exit)) { + yield* removeEntry(directory, entry) + return + } + yield* disposeEntry(directory, entry, exit.value) + }), + { discard: true }, + ) + }) + + const disposeAll = () => disposeAllOnce + + const disposeAllPromise = () => Effect.runPromise(disposeAll()) + yield* Effect.sync(() => { + disposeLoadedInstances.add(disposeAllPromise) + }) + yield* Effect.addFinalizer(() => + disposeAll().pipe( + Effect.andThen( + Effect.sync(() => { + disposeLoadedInstances.delete(disposeAllPromise) + }), + ), + ), + ) + + return { + load, + reload, + dispose, + disposeAll, + provide: (input, effect) => load(input).pipe(Effect.flatMap((ctx) => effect.pipe(Effect.provideService(InstanceRef, ctx)))), + } + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(Project.defaultLayer)) + +export * as InstanceStore from "./instance-store" diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 4c9a77e3e..50687e4b9 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -1,66 +1,20 @@ -import { GlobalBus } from "@/bus/global" -import { disposeInstance } from "@/effect/instance-registry" import { Filesystem } from "@/util/filesystem" -import { iife } from "@/util/iife" -import { Log } from "@opencode-ai/core/util/log" -import { LocalContext } from "../util/local-context" +import { context, containsPath as containsPathInContext, type InstanceContext } from "./instance-context" import { Project } from "./project" -import { WorkspaceContext } from "@/control-plane/workspace-context" import { State } from "./state" -export interface InstanceContext { - directory: string - worktree: string - project: Project.Info -} - -const context = LocalContext.create("instance") -const cache = new Map>() - -const disposal = { - all: undefined as Promise | undefined, -} - -function emitDisposed(directory: string) {} - -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.fromDirectory(input.directory).then(({ project, sandbox }) => ({ - directory: input.directory, - worktree: sandbox, - project, - })) - await context.provide(ctx, async () => { - await input.init?.() - }) - return ctx - }) -} +export type { InstanceContext } from "./instance-context" -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 directories = new Set() -function matchesOverride(ctx: InstanceContext, input: { worktree?: string; project?: Project.Info }) { - if (!input.worktree && !input.project) return true - return ctx.worktree === input.worktree && ctx.project.id === input.project?.id +async function runtime() { + return (await import("./instance-runtime")).InstanceRuntime } export const Instance = { async provide(input: { directory: string + // Legacy per-call hook. Instance bootstrap is owned by InstanceStore. init?: () => Promise worktree?: string project?: Project.Info @@ -71,34 +25,15 @@ export const Instance = { } const directory = Filesystem.resolve(input.directory) - let existing = cache.get(directory) - if (!existing) { - Log.Default.info("creating instance", { directory }) - existing = track( - directory, - boot({ - directory, - init: input.init, - worktree: input.worktree, - project: input.project, - }), - ) - } - let ctx = await existing - if (!matchesOverride(ctx, input)) { - Log.Default.info("recreating instance with explicit context", { directory }) - existing = track( - directory, - boot({ - directory, - init: input.init, - worktree: input.worktree, - project: input.project, - }), - ) - ctx = await existing - } + const instanceRuntime = await runtime() + const ctx = await instanceRuntime.load({ + directory, + worktree: input.worktree, + project: input.project, + }) + directories.add(ctx.directory) return context.provide(ctx, async () => { + await input.init?.() return input.fn() }) }, @@ -129,7 +64,7 @@ export const Instance = { return context.use() }, directories() { - return [...cache.keys()] + return [...directories] }, get directory() { return context.use().directory @@ -140,19 +75,8 @@ export const Instance = { get project() { return context.use().project }, - - /** - * Check if a path is within the project boundary. - * Returns true if path is inside Instance.directory OR Instance.worktree. - * Paths within the worktree but outside the working directory should not trigger external_directory permission. - */ containsPath(filepath: string, ctx?: InstanceContext) { - const instance = ctx ?? Instance - if (Filesystem.contains(instance.directory, filepath)) return true - // Non-git projects set worktree to "/" which would match ANY absolute path. - // Skip worktree check in this case to preserve external_directory permissions. - if (instance.worktree === "/") return false - return Filesystem.contains(instance.worktree, filepath) + return containsPathInContext(filepath, ctx ?? context.use()) }, /** * Captures the current instance ALS context and returns a wrapper that @@ -174,75 +98,36 @@ export const Instance = { state(init: () => S, dispose?: (state: Awaited) => Promise): () => S { return State.create(() => Instance.directory, init, dispose) }, - async reload(input: { directory: string; init?: () => Promise; project?: Project.Info; worktree?: string }) { + async reload(input: { + directory: string + // Legacy per-call hook. Instance bootstrap is owned by InstanceStore. + init?: () => Promise + project?: Project.Info + worktree?: string + }) { + if (!!input.worktree !== !!input.project) { + throw new Error("Instance.reload requires both worktree and project when overriding context") + } const directory = Filesystem.resolve(input.directory) - Log.Default.info("reloading instance", { directory }) - await Promise.all([State.dispose(directory), disposeInstance(directory)]) - cache.delete(directory) - const next = track(directory, boot({ ...input, directory })) - - GlobalBus.emit("event", { + const instanceRuntime = await runtime() + const ctx = await instanceRuntime.reloadInstance({ directory, - project: input.project?.id, - workspace: WorkspaceContext.workspaceID, - payload: { - type: "server.instance.disposed", - properties: { - directory, - }, - }, + worktree: input.worktree, + project: input.project, }) - - return await next + directories.add(ctx.directory) + await context.provide(ctx, () => input.init?.()) + return ctx }, async dispose() { - const directory = Instance.directory - const project = Instance.project - Log.Default.info("disposing instance", { directory }) - await Promise.all([State.dispose(directory), disposeInstance(directory)]) - cache.delete(directory) - - GlobalBus.emit("event", { - directory, - project: project.id, - workspace: WorkspaceContext.workspaceID, - payload: { - type: "server.instance.disposed", - properties: { - directory, - }, - }, - }) + const ctx = Instance.current + const instanceRuntime = await runtime() + await instanceRuntime.disposeInstance(ctx) + directories.delete(ctx.directory) }, 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 + const { disposeAllLoadedInstances } = await import("./instance-store") + await disposeAllLoadedInstances() + directories.clear() }, } diff --git a/packages/opencode/src/project/with-instance.ts b/packages/opencode/src/project/with-instance.ts new file mode 100644 index 000000000..088862fe6 --- /dev/null +++ b/packages/opencode/src/project/with-instance.ts @@ -0,0 +1,11 @@ +import { InstanceRuntime } from "./instance-runtime" +import { context } from "./instance-context" + +// Loads an instance from the shared InstanceStore; cleanup is owned by the +// store, so callers should not spawn unawaited work that outlives this callback. +export async function provide(input: { directory: string; fn: () => R | Promise }) { + const ctx = await InstanceRuntime.load({ directory: input.directory }) + return context.provide(ctx, input.fn) +} + +export * as WithInstance from "./with-instance" diff --git a/packages/opencode/src/server/instance/middleware.ts b/packages/opencode/src/server/instance/middleware.ts index 619c17a4e..26a7414dd 100644 --- a/packages/opencode/src/server/instance/middleware.ts +++ b/packages/opencode/src/server/instance/middleware.ts @@ -8,7 +8,6 @@ import { Workspace } from "@/control-plane/workspace" import { ServerProxy } from "../proxy" import { Filesystem } from "@/util/filesystem" import { Instance } from "@/project/instance" -import { InstanceBootstrap } from "@/project/bootstrap" import { Session } from "@/session" import { SessionID } from "@/session/schema" import { WorkspaceContext } from "@/control-plane/workspace-context" @@ -86,7 +85,6 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware return Instance.provide({ directory, - init: InstanceBootstrap, async fn() { return next() }, @@ -128,7 +126,6 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware fn: () => Instance.provide({ directory: target.directory, - init: InstanceBootstrap, async fn() { return next() }, diff --git a/packages/opencode/src/server/instance/project.ts b/packages/opencode/src/server/instance/project.ts index e5dd5782d..b77951c7c 100644 --- a/packages/opencode/src/server/instance/project.ts +++ b/packages/opencode/src/server/instance/project.ts @@ -7,7 +7,6 @@ import z from "zod" import { ProjectID } from "../../project/schema" import { errors } from "../error" import { lazy } from "../../util/lazy" -import { InstanceBootstrap } from "../../project/bootstrap" export const ProjectRoutes = lazy(() => new Hono() @@ -83,7 +82,6 @@ export const ProjectRoutes = lazy(() => directory: dir, worktree: dir, project: next, - init: InstanceBootstrap, }) return c.json(next) }, diff --git a/packages/opencode/src/server/routes/instance/middleware.ts b/packages/opencode/src/server/routes/instance/middleware.ts index 67c20833f..40d7e2ead 100644 --- a/packages/opencode/src/server/routes/instance/middleware.ts +++ b/packages/opencode/src/server/routes/instance/middleware.ts @@ -5,7 +5,6 @@ import path from "path" import { WorkspaceContext } from "@/control-plane/workspace-context" import type { WorkspaceID } from "@/control-plane/schema" import { Filesystem } from "@/util/filesystem" -import { InstanceBootstrap } from "@/project/bootstrap" import { Instance } from "@/project/instance" export function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler { @@ -33,7 +32,6 @@ export function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler const runInstance = () => Instance.provide({ directory, - init: InstanceBootstrap, fn: () => next(), }) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 749ce58cc..0e6d266b9 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -5,7 +5,7 @@ import * as Tool from "./tool" import path from "path" import DESCRIPTION from "./bash.txt" import { Log } from "../util" -import { Instance } from "../project/instance" +import { Instance, type InstanceContext } from "../project/instance" import { lazy } from "@/util/lazy" import { Language, type Node, type Tree } from "web-tree-sitter" @@ -24,6 +24,7 @@ import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner import { withoutInternalServerAuthEnv } from "@/util/env" import { Global } from "@opencode-ai/core/global" import { resolveExternalPathForPermission } from "./external-directory" +import { InstanceState } from "@/effect/instance-state" const MAX_METADATA_LENGTH = 30_000 const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 @@ -384,7 +385,13 @@ export const BashTool = Tool.define( return yield* resolvePermissionTarget(next, cwd, shell) }) - const collect = Effect.fn("BashTool.collect")(function* (root: Node, cwd: string, ps: boolean, shell: string) { + const collect = Effect.fn("BashTool.collect")(function* ( + root: Node, + cwd: string, + ps: boolean, + shell: string, + instance: InstanceContext, + ) { const scan: Scan = { dirs: new Set(), patterns: new Set(), @@ -402,7 +409,7 @@ export const BashTool = Tool.define( log.info("resolved path", { arg, resolved }) if (!resolved) continue const permissionPath = resolveExternalPathForPermission(resolved, cwd) - if (Instance.containsPath(permissionPath)) continue + if (Instance.containsPath(permissionPath, instance)) continue const dir = (yield* fs.isDir(permissionPath)) ? permissionPath : path.dirname(permissionPath) scan.dirs.add(dir) } @@ -601,7 +608,8 @@ export const BashTool = Tool.define( }) return () => - Effect.sync(() => { + Effect.gen(function* () { + const directory = (yield* InstanceState.context).directory const shell = Shell.acceptable() const name = Shell.name(shell) const chain = @@ -611,7 +619,7 @@ export const BashTool = Tool.define( log.info("bash tool using shell", { shell }) return { - description: DESCRIPTION.replaceAll("${directory}", Instance.directory) + description: DESCRIPTION.replaceAll("${directory}", directory) .replaceAll("${tmp}", Global.Path.tmp) .replaceAll("${os}", process.platform) .replaceAll("${shell}", name) @@ -621,10 +629,12 @@ export const BashTool = Tool.define( parameters: Parameters, execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { - const cwd = params.workdir ? yield* resolveExecutionPath(params.workdir, Instance.directory, shell) : Instance.directory + const instance = yield* InstanceState.context + const directory = instance.directory + const cwd = params.workdir ? yield* resolveExecutionPath(params.workdir, directory, shell) : directory const permissionCwdTarget = params.workdir - ? yield* resolvePermissionTarget(params.workdir, Instance.directory, shell) - : Instance.directory + ? yield* resolvePermissionTarget(params.workdir, directory, shell) + : directory if (params.timeout !== undefined && params.timeout <= 0) { throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) } @@ -636,9 +646,9 @@ export const BashTool = Tool.define( parse(params.command, ps), (tree: Tree) => Effect.sync(() => tree.delete()), ) - const scan = yield* collect(tree.rootNode, cwd, ps, shell) - const permissionCwd = resolveExternalPathForPermission(permissionCwdTarget, Instance.directory) - if (!Instance.containsPath(permissionCwd)) scan.dirs.add(permissionCwd) + const scan = yield* collect(tree.rootNode, cwd, ps, shell, instance) + const permissionCwd = resolveExternalPathForPermission(permissionCwdTarget, directory) + if (!Instance.containsPath(permissionCwd, instance)) scan.dirs.add(permissionCwd) yield* ask(ctx, scan) }), ) diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 3f4803390..84553d87c 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -1,7 +1,6 @@ import z from "zod" import { NamedError } from "@opencode-ai/util/error" import { Instance } from "../project/instance" -import { InstanceBootstrap } from "../project/bootstrap" import { Project } from "../project/project" import { Database, eq } from "../storage/db" import { ProjectTable } from "../project/project.sql" @@ -416,7 +415,6 @@ export namespace Worktree { const booted = yield* Effect.promise(() => Instance.provide({ directory: info.directory, - init: InstanceBootstrap, fn: () => undefined, }) .then(() => true) diff --git a/packages/opencode/test/cli/effect-cmd-instance-als.test.ts b/packages/opencode/test/cli/effect-cmd-instance-als.test.ts new file mode 100644 index 000000000..2d042a6af --- /dev/null +++ b/packages/opencode/test/cli/effect-cmd-instance-als.test.ts @@ -0,0 +1,35 @@ +import { afterEach, expect, test } from "bun:test" +import { Effect } from "effect" +import { effectCmd } from "../../src/cli/effect-cmd" +import { AppRuntime } from "../../src/effect/app-runtime" +import { Instance } from "../../src/project/instance" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" + +afterEach(async () => { + await disposeAllInstances() +}) + +test("effectCmd preserves Instance.current for nested runPromise inside async callbacks", async () => { + await using dir = await tmpdir() + const command = effectCmd<{ directory: string }, void>({ + command: "probe", + describe: false, + directory: (args) => args.directory, + handler: () => + Effect.promise(async () => { + await new Promise((resolve) => setTimeout(resolve, 5)) + const current = await AppRuntime.runPromise( + Effect.sync(() => { + try { + return Instance.current + } catch { + return undefined + } + }), + ) + expect(current?.directory).toBe(dir.path) + }), + }) + + await (command.handler as any)({ directory: dir.path }) +}) diff --git a/packages/opencode/test/cli/error.test.ts b/packages/opencode/test/cli/error.test.ts index adc609566..bc363bd22 100644 --- a/packages/opencode/test/cli/error.test.ts +++ b/packages/opencode/test/cli/error.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import { AccountTransportError } from "../../src/account/schema" +import { CliError } from "../../src/cli/effect-cmd" import { FormatError } from "../../src/cli/error" describe("cli.error", () => { @@ -31,4 +32,10 @@ describe("cli.error", () => { expect(formatted).toContain("opencode models") expect(formatted).toContain("pawwork.json") }) + + test("formats effectCmd CliError", () => { + const formatted = FormatError(new CliError({ message: "Provider not found: missing" })) + + expect(formatted).toBe("Provider not found: missing") + }) }) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index d23e47a43..d6ac65c01 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -8,6 +8,7 @@ import { configEntryNameFromPath } from "../../src/config/entry-name" import { ConfigModelID } from "../../src/config/model-id" import { Instance } from "../../src/project/instance" +import { Project } from "../../src/project/project" import { Auth } from "../../src/auth" import { Account } from "../../src/account" import { AccessToken, AccountID, OrgID } from "../../src/account/schema" @@ -104,6 +105,12 @@ async function writeConfig(dir: string, config: object, name = "opencode.json") await Filesystem.write(path.join(dir, name), JSON.stringify(config)) } +async function withRawInstance(directory: string, fn: () => R): Promise> { + const resolved = Filesystem.resolve(directory) + const { project, sandbox } = await Project.fromDirectory(resolved) + return await Instance.restore({ directory: resolved, worktree: sandbox, project }, fn) +} + async function check(map: (dir: string) => string) { if (process.platform !== "win32") return await using globalTmp = await tmpdir() @@ -838,13 +845,12 @@ test("validates config schema and throws on invalid fields", async () => { }) }, }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - // Strict schema should throw an error for invalid fields - await expect(load()).rejects.toThrow() - }, - }) + await expect( + Instance.provide({ + directory: tmp.path, + fn: load, + }), + ).rejects.toThrow() }) test("throws error for invalid JSON", async () => { @@ -853,12 +859,12 @@ test("throws error for invalid JSON", async () => { await Filesystem.write(path.join(dir, "opencode.json"), "{ invalid json }") }, }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await expect(load()).rejects.toThrow() - }, - }) + await expect( + Instance.provide({ + directory: tmp.path, + fn: load, + }), + ).rejects.toThrow() }) test("handles agent configuration", async () => { @@ -1565,13 +1571,10 @@ test("resolves scoped npm plugins in config", async () => { }, }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await load() - const pluginEntries = config.plugin ?? [] - expect(pluginEntries).toContain("@scope/plugin") - }, + await withRawInstance(tmp.path, async () => { + const config = await load() + const pluginEntries = config.plugin ?? [] + expect(pluginEntries).toContain("@scope/plugin") }) }) @@ -1603,21 +1606,18 @@ test("merges plugin arrays from global and local configs", async () => { }, }) - await Instance.provide({ - directory: path.join(tmp.path, "project"), - fn: async () => { - const config = await load() - const plugins = config.plugin ?? [] + await withRawInstance(path.join(tmp.path, "project"), async () => { + const config = await load() + const plugins = config.plugin ?? [] - // Should contain both global and local plugins - expect(plugins.some((p) => p.includes("global-plugin-1"))).toBe(true) - expect(plugins.some((p) => p.includes("global-plugin-2"))).toBe(true) - expect(plugins.some((p) => p.includes("local-plugin-1"))).toBe(true) + // Should contain both global and local plugins + expect(plugins.some((p) => p.includes("global-plugin-1"))).toBe(true) + expect(plugins.some((p) => p.includes("global-plugin-2"))).toBe(true) + expect(plugins.some((p) => p.includes("local-plugin-1"))).toBe(true) - // Should have all 3 plugins (not replaced, but merged) - const pluginNames = plugins.filter((p) => p.includes("global-plugin") || p.includes("local-plugin")) - expect(pluginNames.length).toBeGreaterThanOrEqual(3) - }, + // Should have all 3 plugins (not replaced, but merged) + const pluginNames = plugins.filter((p) => p.includes("global-plugin") || p.includes("local-plugin")) + expect(pluginNames.length).toBeGreaterThanOrEqual(3) }) }) @@ -1762,27 +1762,24 @@ test("deduplicates duplicate plugins from global and local configs", async () => }, }) - await Instance.provide({ - directory: path.join(tmp.path, "project"), - fn: async () => { - const config = await load() - const plugins = config.plugin ?? [] + await withRawInstance(path.join(tmp.path, "project"), async () => { + const config = await load() + const plugins = config.plugin ?? [] - // Should contain all unique plugins - expect(plugins.some((p) => p.includes("global-plugin-1"))).toBe(true) - expect(plugins.some((p) => p.includes("local-plugin-1"))).toBe(true) - expect(plugins.some((p) => p.includes("duplicate-plugin"))).toBe(true) + // Should contain all unique plugins + expect(plugins.some((p) => p.includes("global-plugin-1"))).toBe(true) + expect(plugins.some((p) => p.includes("local-plugin-1"))).toBe(true) + expect(plugins.some((p) => p.includes("duplicate-plugin"))).toBe(true) - // Should deduplicate the duplicate plugin - const duplicatePlugins = plugins.filter((p) => p.includes("duplicate-plugin")) - expect(duplicatePlugins.length).toBe(1) + // Should deduplicate the duplicate plugin + const duplicatePlugins = plugins.filter((p) => p.includes("duplicate-plugin")) + expect(duplicatePlugins.length).toBe(1) - // Should have exactly 3 unique plugins - const pluginNames = plugins.filter( - (p) => p.includes("global-plugin") || p.includes("local-plugin") || p.includes("duplicate-plugin"), - ) - expect(pluginNames.length).toBe(3) - }, + // Should have exactly 3 unique plugins + const pluginNames = plugins.filter( + (p) => p.includes("global-plugin") || p.includes("local-plugin") || p.includes("duplicate-plugin"), + ) + expect(pluginNames.length).toBe(3) }) }) @@ -1811,23 +1808,20 @@ test("keeps plugin origins aligned with merged plugin list", async () => { }, }) - await Instance.provide({ - directory: path.join(tmp.path, "project"), - fn: async () => { - const cfg = await load() - const plugins = cfg.plugin ?? [] - const origins = cfg.plugin_origins ?? [] - const names = plugins.map((item) => ConfigPlugin.pluginSpecifier(item)) + await withRawInstance(path.join(tmp.path, "project"), async () => { + const cfg = await load() + const plugins = cfg.plugin ?? [] + const origins = cfg.plugin_origins ?? [] + const names = plugins.map((item) => ConfigPlugin.pluginSpecifier(item)) - expect(names).toContain("shared-plugin@2.0.0") - expect(names).not.toContain("shared-plugin@1.0.0") - expect(names).toContain("global-only@1.0.0") - expect(names).toContain("local-only@1.0.0") + expect(names).toContain("shared-plugin@2.0.0") + expect(names).not.toContain("shared-plugin@1.0.0") + expect(names).toContain("global-only@1.0.0") + expect(names).toContain("local-only@1.0.0") - expect(origins.map((item) => item.spec)).toEqual(plugins) - const hit = origins.find((item) => ConfigPlugin.pluginSpecifier(item.spec) === "shared-plugin@2.0.0") - expect(hit?.scope).toBe("local") - }, + expect(origins.map((item) => item.spec)).toEqual(plugins) + const hit = origins.find((item) => ConfigPlugin.pluginSpecifier(item.spec) === "shared-plugin@2.0.0") + expect(hit?.scope).toBe("local") }) }) @@ -2384,12 +2378,14 @@ test("rejects invalid MCP timeout values", async () => { ) }, }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await expect(load()).rejects.toThrow() - }, - }) + await expect( + Instance.provide({ + directory: tmp.path, + fn: async () => { + await load() + }, + }), + ).rejects.toThrow() } }) @@ -2410,12 +2406,14 @@ test("rejects empty local MCP command arrays", async () => { ) }, }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await expect(load()).rejects.toThrow() - }, - }) + await expect( + Instance.provide({ + directory: tmp.path, + fn: async () => { + await load() + }, + }), + ).rejects.toThrow() }) test("rejects unknown nested server keys", async () => { @@ -2432,12 +2430,14 @@ test("rejects unknown nested server keys", async () => { ) }, }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await expect(load()).rejects.toThrow() - }, - }) + await expect( + Instance.provide({ + directory: tmp.path, + fn: async () => { + await load() + }, + }), + ).rejects.toThrow() }) test("local .opencode config can override MCP from project config", async () => { @@ -2739,15 +2739,12 @@ describe("deduplicatePluginOrigins", () => { }, }) - await Instance.provide({ - directory: path.join(tmp.path, "project"), - fn: async () => { - const config = await load() - const plugins = config.plugin ?? [] + await withRawInstance(path.join(tmp.path, "project"), async () => { + const config = await load() + const plugins = config.plugin ?? [] - expect(plugins.some((p) => ConfigPlugin.pluginSpecifier(p) === "my-plugin@1.0.0")).toBe(true) - expect(plugins.some((p) => ConfigPlugin.pluginSpecifier(p).startsWith("file://"))).toBe(true) - }, + expect(plugins.some((p) => ConfigPlugin.pluginSpecifier(p) === "my-plugin@1.0.0")).toBe(true) + expect(plugins.some((p) => ConfigPlugin.pluginSpecifier(p).startsWith("file://"))).toBe(true) }) }) }) @@ -3026,14 +3023,11 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => { }) }, }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await load() - expect(config.plugin?.map(ConfigPlugin.pluginSpecifier)).toContain( - pathToFileURL(path.join(tmp.path, "plugin.ts")).href, - ) - }, + await withRawInstance(tmp.path, async () => { + const config = await load() + expect(config.plugin?.map(ConfigPlugin.pluginSpecifier)).toContain( + pathToFileURL(path.join(tmp.path, "plugin.ts")).href, + ) }) } finally { if (originalEnv !== undefined) { diff --git a/packages/opencode/test/file/index.test.ts b/packages/opencode/test/file/index.test.ts index cd4f023cc..375342df9 100644 --- a/packages/opencode/test/file/index.test.ts +++ b/packages/opencode/test/file/index.test.ts @@ -2,8 +2,11 @@ import { afterEach, describe, test, expect } from "bun:test" import { $ } from "bun" import path from "path" import fs from "fs/promises" +import { Effect } from "effect" import { File } from "../../src/file" +import { InstanceRef } from "../../src/effect/instance-ref" import { Instance } from "../../src/project/instance" +import { Project } from "../../src/project/project" import { Filesystem } from "../../src/util/filesystem" import { tmpdir } from "../fixture/fixture" @@ -11,6 +14,34 @@ afterEach(async () => { await Instance.disposeAll() }) +test("File service init works with InstanceRef and no legacy ALS", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await fs.writeFile(path.join(dir, "visible.txt"), "hello", "utf-8") + }, + }) + const { project, sandbox } = await Project.fromDirectory(tmp.path) + + await Effect.runPromise( + Effect.scoped( + File.Service.use((svc) => + Effect.gen(function* () { + yield* svc.init() + const files = yield* svc.search({ query: "", type: "file" }) + expect(files).toContain("visible.txt") + }), + ).pipe( + Effect.provide(File.defaultLayer), + Effect.provideService(InstanceRef, { + directory: tmp.path, + worktree: sandbox, + project, + }), + ), + ), + ) +}) + describe("file/index Filesystem patterns", () => { describe("File.read() - text content", () => { test("reads text file via Filesystem.readText()", async () => { diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index 797054354..3251f342c 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -172,3 +172,7 @@ export function provideTmpdirServer( }) }) } + +export async function disposeAllInstances() { + await Instance.disposeAll() +} diff --git a/packages/opencode/test/plugin/workspace-adaptor.test.ts b/packages/opencode/test/plugin/workspace-adaptor.test.ts index 85fe3d9e5..f7c51980f 100644 --- a/packages/opencode/test/plugin/workspace-adaptor.test.ts +++ b/packages/opencode/test/plugin/workspace-adaptor.test.ts @@ -19,6 +19,7 @@ const { Flag } = await import("@opencode-ai/core/flag/flag") const { Plugin } = await import("../../src/plugin/index") const { Workspace } = await import("../../src/control-plane/workspace") const { Instance } = await import("../../src/project/instance") +const { getAdaptor, ownerKey } = await import("../../src/control-plane/adaptors") const experimental = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES // @ts-expect-error test-only flag override @@ -287,26 +288,29 @@ describe("plugin.workspace", () => { ) }) - test("plugin workspace adaptor registration does not survive instance disposal", async () => { + test("plugin workspace adaptor registration is removed on dispose and restored on bootstrap", async () => { await using source = await pluginProject() - await Instance.provide({ + const owner = await Instance.provide({ directory: source.path, - fn: async () => - Effect.gen(function* () { - const plugin = yield* Plugin.Service - yield* plugin.init() - return Workspace.create({ - type: source.extra.type, - branch: null, - extra: null, - projectID: Instance.project.id, - }) - }).pipe(Effect.provide(Plugin.defaultLayer), Effect.runPromise), + fn: async () => { + await Workspace.create({ + type: source.extra.type, + branch: null, + extra: null, + projectID: Instance.project.id, + }) + return { + projectID: Instance.project.id, + owner: ownerKey(Instance.directory, Instance.worktree), + } + }, }) await Instance.disposeAll() + await expect(getAdaptor(owner.projectID, source.extra.type, owner.owner)).rejects.toThrow(/workspace adaptor/i) + await expect( Instance.provide({ directory: source.path, @@ -318,7 +322,7 @@ describe("plugin.workspace", () => { projectID: Instance.project.id, }), }), - ).rejects.toThrow(/workspace adaptor/i) + ).resolves.toMatchObject({ directory: source.extra.space }) }) test("disposing one checkout restores the previous adaptor for the same project and type", async () => { diff --git a/packages/opencode/test/project/instance-bootstrap-regression.test.ts b/packages/opencode/test/project/instance-bootstrap-regression.test.ts new file mode 100644 index 000000000..bfe6b02f2 --- /dev/null +++ b/packages/opencode/test/project/instance-bootstrap-regression.test.ts @@ -0,0 +1,117 @@ +import { afterEach, expect, test } from "bun:test" +import { Effect } from "effect" +import { Hono } from "hono" +import { existsSync } from "node:fs" +import path from "node:path" +import { pathToFileURL } from "node:url" + +import { Bus } from "../../src/bus" +import { bootstrap as cliBootstrap } from "../../src/cli/bootstrap" +import { Command } from "../../src/command" +import { AppRuntime } from "../../src/effect/app-runtime" +import { InstanceRef } from "../../src/effect/instance-ref" +import { InstanceRuntime } from "../../src/project/instance-runtime" +import { Project } from "../../src/project/project" +import { WithInstance } from "../../src/project/with-instance" +import { InstanceMiddleware } from "../../src/server/routes/instance/middleware" +import { MessageID, SessionID } from "../../src/session/schema" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" + +afterEach(async () => { + await disposeAllInstances() +}) + +async function bootstrapFixture() { + return tmpdir({ + init: async (dir) => { + const marker = path.join(dir, "config-hook-fired") + const pluginFile = path.join(dir, "plugin.ts") + await Bun.write( + pluginFile, + [ + `const MARKER = ${JSON.stringify(marker)}`, + "export default async () => ({", + " config: async () => {", + ' await Bun.write(MARKER, "ran")', + " },", + "})", + "", + ].join("\n"), + ) + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(pluginFile).href], + }), + ) + return marker + }, + }) +} + +async function waitForInitialized(projectID: Project.Info["id"]) { + for (let i = 0; i < 20; i++) { + const project = Project.get(projectID) + if (project?.time.initialized) return + await Bun.sleep(10) + } + throw new Error("timed out waiting for project initialization marker") +} + +test("legacy instance boundary runs InstanceBootstrap before callback", async () => { + await using tmp = await bootstrapFixture() + + await WithInstance.provide({ + directory: tmp.path, + fn: async () => "ok", + }) + + expect(existsSync(tmp.extra)).toBe(true) +}) + +test("CLI bootstrap runs InstanceBootstrap before callback", async () => { + await using tmp = await bootstrapFixture() + + await cliBootstrap(tmp.path, async () => "ok") + + expect(existsSync(tmp.extra)).toBe(true) +}) + +test("legacy Hono instance middleware runs InstanceBootstrap before next handler", async () => { + await using tmp = await bootstrapFixture() + const app = new Hono().use(InstanceMiddleware()).get("/probe", (c) => c.text("ok")) + + const response = await app.request("/probe", { headers: { "x-opencode-directory": tmp.path } }) + + expect(response.status).toBe(200) + expect(existsSync(tmp.extra)).toBe(true) +}) + +test("InstanceRuntime.reloadInstance runs InstanceBootstrap", async () => { + await using tmp = await bootstrapFixture() + + await InstanceRuntime.reloadInstance({ directory: tmp.path }) + + expect(existsSync(tmp.extra)).toBe(true) +}) + +test("/init command event marks the bootstrapped project initialized", async () => { + await using tmp = await tmpdir({ git: true }) + const ctx = await InstanceRuntime.reloadInstance({ directory: tmp.path }) + + expect(Project.get(ctx.project.id)?.time.initialized).toBeUndefined() + + await AppRuntime.runPromise( + Bus.Service.use((bus) => + bus.publish(Command.Event.Executed, { + name: Command.Default.INIT, + sessionID: SessionID.descending(), + arguments: "", + messageID: MessageID.ascending(), + }), + ).pipe(Effect.provideService(InstanceRef, ctx)), + ) + + await waitForInitialized(ctx.project.id) +}) diff --git a/packages/opencode/test/project/instance-store.test.ts b/packages/opencode/test/project/instance-store.test.ts new file mode 100644 index 000000000..3cda2c7bb --- /dev/null +++ b/packages/opencode/test/project/instance-store.test.ts @@ -0,0 +1,169 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Effect, Exit, Fiber, Layer, ManagedRuntime } from "effect" +import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" + +import { InstanceRef } from "../../src/effect/instance-ref" +import { registerDisposer } from "../../src/effect/instance-registry" +import { InstanceBootstrap } from "../../src/project/bootstrap-service" +import { Instance } from "../../src/project/instance" +import { InstanceStore } from "../../src/project/instance-store" +import { Project } from "../../src/project/project" +import { Database } from "../../src/storage/db" +import { disposeAllInstances, tmpdir, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +let bootstrapRun: Effect.Effect = Effect.void +const noopBootstrap = Layer.succeed( + InstanceBootstrap.Service, + InstanceBootstrap.Service.of({ run: Effect.suspend(() => bootstrapRun) }), +) + +const it = testEffect( + Layer.mergeAll(InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)), CrossSpawnSpawner.defaultLayer), +) + +afterEach(async () => { + bootstrapRun = Effect.void + await disposeAllInstances() +}) + +describe("InstanceStore", () => { + it.live("runs bootstrap with InstanceRef provided", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + const store = yield* InstanceStore.Service + let initializedDirectory: string | undefined + + bootstrapRun = Effect.gen(function* () { + initializedDirectory = (yield* InstanceRef)?.directory + }) + yield* store.load({ directory: dir }) + + expect(initializedDirectory).toBe(dir) + expect(() => Instance.current).toThrow() + }), + ) + + it.live("dedupes concurrent loads while bootstrap is in flight", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + const store = yield* InstanceStore.Service + const started = Promise.withResolvers() + const release = Promise.withResolvers() + let initialized = 0 + + bootstrapRun = Effect.promise(async () => { + initialized++ + started.resolve() + await release.promise + }) + const first = yield* store.load({ directory: dir }).pipe(Effect.forkScoped) + + yield* Effect.promise(() => started.promise) + + bootstrapRun = Effect.sync(() => { + initialized++ + }) + const second = yield* store.load({ directory: dir }).pipe(Effect.forkScoped) + + expect(initialized).toBe(1) + release.resolve() + + const [firstCtx, secondCtx] = yield* Effect.all([Fiber.join(first), Fiber.join(second)]) + expect(secondCtx).toBe(firstCtx) + expect(initialized).toBe(1) + }), + ) + + it.live("reload replaces the cached context and disposes the previous one", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + const store = yield* InstanceStore.Service + const disposed: Array = [] + const off = registerDisposer(async (directory) => { + disposed.push(directory) + }) + yield* Effect.addFinalizer(() => Effect.sync(off)) + + const first = yield* store.load({ directory: dir }) + const second = yield* store.reload({ directory: dir }) + const cached = yield* store.load({ directory: dir }) + + expect(second).not.toBe(first) + expect(cached).toBe(second) + expect(disposed).toEqual([dir]) + }), + ) + + it.live("drops failed loads so the next attempt can boot again", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + const store = yield* InstanceStore.Service + let attempts = 0 + + bootstrapRun = Effect.sync(() => { + attempts++ + throw new Error("boom") + }) + const failed = yield* store.load({ directory: dir }).pipe(Effect.exit) + expect(Exit.isFailure(failed)).toBe(true) + + bootstrapRun = Effect.sync(() => { + attempts++ + }) + yield* store.load({ directory: dir }) + + expect(attempts).toBe(2) + }), + ) + + test("cached legacy instances rehydrate project rows after the database is reopened", async () => { + await using dir = await tmpdir({ git: true }) + let projectID: Project.Info["id"] | undefined + + await Instance.provide({ + directory: dir.path, + fn: () => { + projectID = Instance.project.id + expect(Project.get(projectID!)).toBeDefined() + }, + }) + + Database.close() + + await Instance.provide({ + directory: dir.path, + fn: () => { + expect(Instance.project.id).toBe(projectID!) + expect(Project.get(projectID!)).toBeDefined() + }, + }) + }) + + test("disposeAll covers instances from every active store runtime", async () => { + await using first = await tmpdir() + await using second = await tmpdir() + const disposed: string[] = [] + const off = registerDisposer(async (directory) => { + disposed.push(directory) + }) + const layer = Layer.mergeAll( + InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)), + CrossSpawnSpawner.defaultLayer, + ) + const firstRuntime = ManagedRuntime.make(layer) + const secondRuntime = ManagedRuntime.make(layer) + + try { + await firstRuntime.runPromise(InstanceStore.Service.use((store) => store.load({ directory: first.path }))) + await secondRuntime.runPromise(InstanceStore.Service.use((store) => store.load({ directory: second.path }))) + + await Instance.disposeAll() + + expect(new Set(disposed)).toEqual(new Set([first.path, second.path])) + } finally { + off() + await Promise.all([firstRuntime.dispose(), secondRuntime.dispose()]) + } + }) +})