Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/opencode/src/project/instance-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { LocalContext } from "@/util/local-context"
import type * as Project from "./project"

export interface InstanceContext {
directory: string
worktree: string
project: Project.Info
}

export const context = LocalContext.create<InstanceContext>("instance")
199 changes: 199 additions & 0 deletions packages/opencode/src/project/instance-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import { GlobalBus } from "@/bus/global"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { disposeInstance } from "@/effect/instance-registry"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Context, Deferred, Effect, Exit, Layer, Scope } from "effect"
import { context, type InstanceContext } from "./instance-context"
import * as Project from "./project"

export interface LoadInput {
directory: string
init?: () => Promise<unknown>
worktree?: string
project?: Project.Info
}

export interface Interface {
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 Service extends Context.Service<Service, Interface>()("@opencode/InstanceStore") {}

interface Entry {
readonly deferred: Deferred.Deferred<InstanceContext>
Comment thread
kitlangton marked this conversation as resolved.
}

export const layer: Layer.Layer<Service, never, Project.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const project = yield* Project.Service
const scope = yield* Scope.Scope
const cache = new Map<string, Entry>()
const disposal = {
all: undefined as Deferred.Deferred<void> | undefined,
}

const boot = Effect.fn("InstanceStore.boot")(function* (input: LoadInput & { directory: string }) {
const ctx =
input.project && input.worktree
? {
directory: input.directory,
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
})

const removeEntry = (directory: string, entry: Entry) =>
Effect.sync(() => {
if (cache.get(directory) !== entry) return false
cache.delete(directory)
return true
})

const completeLoad = Effect.fnUntraced(function* (directory: string, input: LoadInput, entry: Entry) {
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 = (input: { directory: string; project?: string }) =>
Effect.sync(() =>
GlobalBus.emit("event", {
directory: input.directory,
project: input.project,
workspace: WorkspaceContext.workspaceID,
payload: {
type: "server.instance.disposed",
properties: {
Comment thread
kitlangton marked this conversation as resolved.
directory: input.directory,
},
},
}),
)

const disposeContext = Effect.fn("InstanceStore.disposeContext")(function* (ctx: InstanceContext) {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test

yield* Effect.logInfo("disposing instance", { directory: ctx.directory })
yield* Effect.promise(() => disposeInstance(ctx.directory))
yield* emitDisposed({ directory: ctx.directory, project: ctx.project.id })
})

const disposeEntry = Effect.fnUntraced(function* (directory: string, entry: Entry, ctx: InstanceContext) {
if (cache.get(directory) !== entry) return false
yield* disposeContext(ctx)
if (cache.get(directory) !== entry) return false
cache.delete(directory)
return true
})

const load = Effect.fn("InstanceStore.load")(function* (input: LoadInput) {
const directory = AppFileSystem.resolve(input.directory)
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const existing = cache.get(directory)
if (existing) return yield* restore(Deferred.await(existing.deferred))

const entry: Entry = { deferred: Deferred.makeUnsafe<InstanceContext>() }
cache.set(directory, entry)
yield* Effect.gen(function* () {
yield* Effect.logInfo("creating instance", { directory })
yield* completeLoad(directory, input, entry)
}).pipe(Effect.forkIn(scope, { startImmediately: true }))
return yield* restore(Deferred.await(entry.deferred))
}),
)
})

const reload = Effect.fn("InstanceStore.reload")(function* (input: LoadInput) {
const directory = AppFileSystem.resolve(input.directory)
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const previous = cache.get(directory)
const entry: Entry = { deferred: Deferred.makeUnsafe<InstanceContext>() }
cache.set(directory, entry)
yield* Effect.gen(function* () {
yield* Effect.logInfo("reloading instance", { directory })
if (previous) yield* Deferred.await(previous.deferred).pipe(Effect.exit, Effect.asVoid)
yield* Effect.promise(() => disposeInstance(directory))
yield* emitDisposed({ directory, project: input.project?.id })
yield* completeLoad(directory, input, entry)
}).pipe(Effect.forkIn(scope, { startImmediately: true }))
return yield* restore(Deferred.await(entry.deferred))
}),
)
})

const dispose = Effect.fn("InstanceStore.dispose")(function* (ctx: InstanceContext) {
const entry = cache.get(ctx.directory)
if (!entry) return yield* disposeContext(ctx)

const exit = yield* Deferred.await(entry.deferred).pipe(Effect.exit)
if (Exit.isFailure(exit)) return yield* removeEntry(ctx.directory, entry).pipe(Effect.asVoid)
if (exit.value !== ctx) return
yield* disposeEntry(ctx.directory, entry, ctx).pipe(Effect.asVoid)
})

const disposeAll = Effect.fn("InstanceStore.disposeAll")(function* () {
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const existing = disposal.all
if (existing) return yield* restore(Deferred.await(existing))

const done = Deferred.makeUnsafe<void>()
const entries = [...cache.entries()]
disposal.all = done
const exit = yield* Effect.gen(function* () {
yield* Effect.logInfo("disposing all instances")
yield* Effect.forEach(
entries,
(item) =>
Effect.gen(function* () {
const exit = yield* Deferred.await(item[1].deferred).pipe(Effect.exit)
if (Exit.isFailure(exit)) {
yield* Effect.logWarning("instance dispose failed", { key: item[0], cause: exit.cause })
yield* removeEntry(item[0], item[1])
return
}
yield* disposeEntry(item[0], item[1], exit.value)
}),
{ discard: true },
)
}).pipe(Effect.exit)
yield* Deferred.done(done, exit).pipe(Effect.asVoid)
if (disposal.all === done) {
disposal.all = undefined
}
return yield* restore(Deferred.await(done))
}),
)
})

yield* Effect.addFinalizer(() => disposeAll().pipe(Effect.ignore))

return Service.of({
load,
reload,
dispose,
disposeAll,
})
}),
)

export const defaultLayer = layer.pipe(Layer.provide(Project.defaultLayer))

export const runtime = makeRuntime(Service, defaultLayer)

export * as InstanceStore from "./instance-store"
147 changes: 14 additions & 133 deletions packages/opencode/src/project/instance.ts
Original file line number Diff line number Diff line change
@@ -1,77 +1,20 @@
import { GlobalBus } from "@/bus/global"
import { disposeInstance } from "@/effect/instance-registry"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { iife } from "@/util/iife"
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, type InstanceContext } from "./instance-context"
import { InstanceStore } from "./instance-store"

export interface InstanceContext {
directory: string
worktree: string
project: Project.Info
}

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,
}

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 }) => ({
directory: input.directory,
worktree: sandbox,
project,
}))
await context.provide(ctx, async () => {
await input.init?.()
})
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
}
export type { InstanceContext } from "./instance-context"
export type { LoadInput } from "./instance-store"

export const Instance = {
load(input: InstanceStore.LoadInput): Promise<InstanceContext> {
return InstanceStore.runtime.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({ directory: input.directory, init: input.init }),
async () => input.fn(),
)
},
get current() {
return context.use()
Expand Down Expand Up @@ -117,74 +60,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 InstanceStore.runtime.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 InstanceStore.runtime.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 InstanceStore.runtime.runPromise((store) => store.disposeAll())
},
}
Loading
Loading