import { Log } from "@/util/log" import { Context } from "../util/context" import { Project } from "./project" import { State } from "./state" import { iife } from "@/util/iife" import { GlobalBus } from "@/bus/global" import { Filesystem } from "@/util/filesystem" import { withTimeout } from "@/util/timeout" interface Context { directory: string worktree: string project: Project.Info } const context = Context.create("instance") const cache = new Map>() const DISPOSE_TIMEOUT_MS = 10_000 const disposal = { all: undefined as Promise | undefined, } export const Instance = { async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { let existing = cache.get(input.directory) if (!existing) { Log.Default.info("creating instance", { directory: input.directory }) existing = iife(async () => { const { project, sandbox } = await Project.fromDirectory(input.directory) const ctx = { directory: input.directory, worktree: sandbox, project, } await context.provide(ctx, async () => { await input.init?.() }) return ctx }) cache.set(input.directory, existing) } const ctx = await existing return context.provide(ctx, async () => { return input.fn() }) }, get directory() { return context.use().directory }, get worktree() { return context.use().worktree }, 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) { 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) }, state(init: () => S, dispose?: (state: Awaited) => Promise): () => S { return State.create(() => Instance.directory, init, dispose) }, async dispose() { Log.Default.info("disposing instance", { directory: Instance.directory }) await State.dispose(Instance.directory) cache.delete(Instance.directory) GlobalBus.emit("event", { directory: Instance.directory, payload: { type: "server.instance.disposed", properties: { directory: Instance.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 withTimeout(value, DISPOSE_TIMEOUT_MS).catch((error) => { Log.Default.warn("instance dispose timed out", { 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 }, }