Skip to content

Commit cec9c61

Browse files
authored
Move instance loading into Effect service (#25277)
1 parent 51e310c commit cec9c61

9 files changed

Lines changed: 502 additions & 169 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { LocalContext } from "@/util/local-context"
2+
import type * as Project from "./project"
3+
4+
export interface InstanceContext {
5+
directory: string
6+
worktree: string
7+
project: Project.Info
8+
}
9+
10+
export const context = LocalContext.create<InstanceContext>("instance")
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import { GlobalBus } from "@/bus/global"
2+
import { WorkspaceContext } from "@/control-plane/workspace-context"
3+
import { disposeInstance } from "@/effect/instance-registry"
4+
import { makeRuntime } from "@/effect/run-service"
5+
import { AppFileSystem } from "@opencode-ai/core/filesystem"
6+
import { Context, Deferred, Duration, Effect, Exit, Layer, Scope } from "effect"
7+
import { context, type InstanceContext } from "./instance-context"
8+
import * as Project from "./project"
9+
10+
export interface LoadInput {
11+
directory: string
12+
init?: () => Promise<unknown>
13+
worktree?: string
14+
project?: Project.Info
15+
}
16+
17+
export interface Interface {
18+
readonly load: (input: LoadInput) => Effect.Effect<InstanceContext>
19+
readonly reload: (input: LoadInput) => Effect.Effect<InstanceContext>
20+
readonly dispose: (ctx: InstanceContext) => Effect.Effect<void>
21+
readonly disposeAll: () => Effect.Effect<void>
22+
}
23+
24+
export class Service extends Context.Service<Service, Interface>()("@opencode/InstanceStore") {}
25+
26+
interface Entry {
27+
readonly deferred: Deferred.Deferred<InstanceContext>
28+
}
29+
30+
export const layer: Layer.Layer<Service, never, Project.Service> = Layer.effect(
31+
Service,
32+
Effect.gen(function* () {
33+
const project = yield* Project.Service
34+
const scope = yield* Scope.Scope
35+
const cache = new Map<string, Entry>()
36+
37+
const boot = Effect.fn("InstanceStore.boot")(function* (input: LoadInput & { directory: string }) {
38+
const ctx =
39+
input.project && input.worktree
40+
? {
41+
directory: input.directory,
42+
worktree: input.worktree,
43+
project: input.project,
44+
}
45+
: yield* project.fromDirectory(input.directory).pipe(
46+
Effect.map((result) => ({
47+
directory: input.directory,
48+
worktree: result.sandbox,
49+
project: result.project,
50+
})),
51+
)
52+
const init = input.init
53+
if (init) yield* Effect.promise(() => context.provide(ctx, init))
54+
return ctx
55+
})
56+
57+
const removeEntry = (directory: string, entry: Entry) =>
58+
Effect.sync(() => {
59+
if (cache.get(directory) !== entry) return false
60+
cache.delete(directory)
61+
return true
62+
})
63+
64+
const completeLoad = Effect.fnUntraced(function* (directory: string, input: LoadInput, entry: Entry) {
65+
const exit = yield* Effect.exit(boot({ ...input, directory }))
66+
if (Exit.isFailure(exit)) yield* removeEntry(directory, entry)
67+
yield* Deferred.done(entry.deferred, exit).pipe(Effect.asVoid)
68+
})
69+
70+
const emitDisposed = (input: { directory: string; project?: string }) =>
71+
Effect.sync(() =>
72+
GlobalBus.emit("event", {
73+
directory: input.directory,
74+
project: input.project,
75+
workspace: WorkspaceContext.workspaceID,
76+
payload: {
77+
type: "server.instance.disposed",
78+
properties: {
79+
directory: input.directory,
80+
},
81+
},
82+
}),
83+
)
84+
85+
const disposeContext = Effect.fn("InstanceStore.disposeContext")(function* (ctx: InstanceContext) {
86+
yield* Effect.logInfo("disposing instance", { directory: ctx.directory })
87+
yield* Effect.promise(() => disposeInstance(ctx.directory))
88+
yield* emitDisposed({ directory: ctx.directory, project: ctx.project.id })
89+
})
90+
91+
const disposeEntry = Effect.fnUntraced(function* (directory: string, entry: Entry, ctx: InstanceContext) {
92+
if (cache.get(directory) !== entry) return false
93+
yield* disposeContext(ctx)
94+
if (cache.get(directory) !== entry) return false
95+
cache.delete(directory)
96+
return true
97+
})
98+
99+
const load = Effect.fn("InstanceStore.load")(function* (input: LoadInput) {
100+
const directory = AppFileSystem.resolve(input.directory)
101+
return yield* Effect.uninterruptibleMask((restore) =>
102+
Effect.gen(function* () {
103+
const existing = cache.get(directory)
104+
if (existing) return yield* restore(Deferred.await(existing.deferred))
105+
106+
const entry: Entry = { deferred: Deferred.makeUnsafe<InstanceContext>() }
107+
cache.set(directory, entry)
108+
yield* Effect.gen(function* () {
109+
yield* Effect.logInfo("creating instance", { directory })
110+
yield* completeLoad(directory, input, entry)
111+
}).pipe(Effect.forkIn(scope, { startImmediately: true }))
112+
return yield* restore(Deferred.await(entry.deferred))
113+
}),
114+
)
115+
})
116+
117+
const reload = Effect.fn("InstanceStore.reload")(function* (input: LoadInput) {
118+
const directory = AppFileSystem.resolve(input.directory)
119+
return yield* Effect.uninterruptibleMask((restore) =>
120+
Effect.gen(function* () {
121+
const previous = cache.get(directory)
122+
const entry: Entry = { deferred: Deferred.makeUnsafe<InstanceContext>() }
123+
cache.set(directory, entry)
124+
yield* Effect.gen(function* () {
125+
yield* Effect.logInfo("reloading instance", { directory })
126+
if (previous) {
127+
yield* Deferred.await(previous.deferred).pipe(Effect.ignore)
128+
yield* Effect.promise(() => disposeInstance(directory))
129+
yield* emitDisposed({ directory, project: input.project?.id })
130+
}
131+
yield* completeLoad(directory, input, entry)
132+
}).pipe(Effect.forkIn(scope, { startImmediately: true }))
133+
return yield* restore(Deferred.await(entry.deferred))
134+
}),
135+
)
136+
})
137+
138+
const dispose = Effect.fn("InstanceStore.dispose")(function* (ctx: InstanceContext) {
139+
const entry = cache.get(ctx.directory)
140+
if (!entry) return yield* disposeContext(ctx)
141+
142+
const exit = yield* Deferred.await(entry.deferred).pipe(Effect.exit)
143+
if (Exit.isFailure(exit)) return yield* removeEntry(ctx.directory, entry).pipe(Effect.asVoid)
144+
if (exit.value !== ctx) return
145+
yield* disposeEntry(ctx.directory, entry, ctx).pipe(Effect.asVoid)
146+
})
147+
148+
const disposeAllOnce = Effect.fnUntraced(function* () {
149+
yield* Effect.logInfo("disposing all instances")
150+
yield* Effect.forEach(
151+
[...cache.entries()],
152+
(item) =>
153+
Effect.gen(function* () {
154+
const exit = yield* Deferred.await(item[1].deferred).pipe(Effect.exit)
155+
if (Exit.isFailure(exit)) {
156+
yield* Effect.logWarning("instance dispose failed", { key: item[0], cause: exit.cause })
157+
yield* removeEntry(item[0], item[1])
158+
return
159+
}
160+
yield* disposeEntry(item[0], item[1], exit.value)
161+
}),
162+
{ discard: true },
163+
)
164+
})
165+
166+
const cachedDisposeAll = yield* Effect.cachedWithTTL(disposeAllOnce(), Duration.zero)
167+
const disposeAll = Effect.fn("InstanceStore.disposeAll")(function* () {
168+
return yield* cachedDisposeAll
169+
})
170+
171+
yield* Effect.addFinalizer(() => disposeAll().pipe(Effect.ignore))
172+
173+
return Service.of({
174+
load,
175+
reload,
176+
dispose,
177+
disposeAll,
178+
})
179+
}),
180+
)
181+
182+
export const defaultLayer = layer.pipe(Layer.provide(Project.defaultLayer))
183+
184+
export const runtime = makeRuntime(Service, defaultLayer)
185+
186+
export * as InstanceStore from "./instance-store"
Lines changed: 14 additions & 133 deletions
Original file line numberDiff line numberDiff line change
@@ -1,77 +1,20 @@
1-
import { GlobalBus } from "@/bus/global"
2-
import { disposeInstance } from "@/effect/instance-registry"
3-
import { makeRuntime } from "@/effect/run-service"
41
import { AppFileSystem } from "@opencode-ai/core/filesystem"
5-
import { iife } from "@/util/iife"
6-
import * as Log from "@opencode-ai/core/util/log"
7-
import { LocalContext } from "@/util/local-context"
82
import * as Project from "./project"
9-
import { WorkspaceContext } from "@/control-plane/workspace-context"
3+
import { context, type InstanceContext } from "./instance-context"
4+
import { InstanceStore } from "./instance-store"
105

11-
export interface InstanceContext {
12-
directory: string
13-
worktree: string
14-
project: Project.Info
15-
}
16-
17-
const context = LocalContext.create<InstanceContext>("instance")
18-
const cache = new Map<string, Promise<InstanceContext>>()
19-
const project = makeRuntime(Project.Service, Project.defaultLayer)
20-
21-
const disposal = {
22-
all: undefined as Promise<void> | undefined,
23-
}
24-
25-
function boot(input: { directory: string; init?: () => Promise<any>; worktree?: string; project?: Project.Info }) {
26-
return iife(async () => {
27-
const ctx =
28-
input.project && input.worktree
29-
? {
30-
directory: input.directory,
31-
worktree: input.worktree,
32-
project: input.project,
33-
}
34-
: await project
35-
.runPromise((svc) => svc.fromDirectory(input.directory))
36-
.then(({ project, sandbox }) => ({
37-
directory: input.directory,
38-
worktree: sandbox,
39-
project,
40-
}))
41-
await context.provide(ctx, async () => {
42-
await input.init?.()
43-
})
44-
return ctx
45-
})
46-
}
47-
48-
function track(directory: string, next: Promise<InstanceContext>) {
49-
const task = next.catch((error) => {
50-
if (cache.get(directory) === task) cache.delete(directory)
51-
throw error
52-
})
53-
cache.set(directory, task)
54-
return task
55-
}
6+
export type { InstanceContext } from "./instance-context"
7+
export type { LoadInput } from "./instance-store"
568

579
export const Instance = {
10+
load(input: InstanceStore.LoadInput): Promise<InstanceContext> {
11+
return InstanceStore.runtime.runPromise((store) => store.load(input))
12+
},
5813
async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
59-
const directory = AppFileSystem.resolve(input.directory)
60-
let existing = cache.get(directory)
61-
if (!existing) {
62-
Log.Default.info("creating instance", { directory })
63-
existing = track(
64-
directory,
65-
boot({
66-
directory,
67-
init: input.init,
68-
}),
69-
)
70-
}
71-
const ctx = await existing
72-
return context.provide(ctx, async () => {
73-
return input.fn()
74-
})
14+
return context.provide(
15+
await Instance.load({ directory: input.directory, init: input.init }),
16+
async () => input.fn(),
17+
)
7518
},
7619
get current() {
7720
return context.use()
@@ -117,74 +60,12 @@ export const Instance = {
11760
return context.provide(ctx, fn)
11861
},
11962
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
120-
const directory = AppFileSystem.resolve(input.directory)
121-
Log.Default.info("reloading instance", { directory })
122-
await disposeInstance(directory)
123-
cache.delete(directory)
124-
const next = track(directory, boot({ ...input, directory }))
125-
126-
GlobalBus.emit("event", {
127-
directory,
128-
project: input.project?.id,
129-
workspace: WorkspaceContext.workspaceID,
130-
payload: {
131-
type: "server.instance.disposed",
132-
properties: {
133-
directory,
134-
},
135-
},
136-
})
137-
138-
return await next
63+
return InstanceStore.runtime.runPromise((store) => store.reload(input))
13964
},
14065
async dispose() {
141-
const directory = Instance.directory
142-
const project = Instance.project
143-
Log.Default.info("disposing instance", { directory })
144-
await disposeInstance(directory)
145-
cache.delete(directory)
146-
147-
GlobalBus.emit("event", {
148-
directory,
149-
project: project.id,
150-
workspace: WorkspaceContext.workspaceID,
151-
payload: {
152-
type: "server.instance.disposed",
153-
properties: {
154-
directory,
155-
},
156-
},
157-
})
66+
return InstanceStore.runtime.runPromise((store) => store.dispose(Instance.current))
15867
},
15968
async disposeAll() {
160-
if (disposal.all) return disposal.all
161-
162-
disposal.all = iife(async () => {
163-
Log.Default.info("disposing all instances")
164-
const entries = [...cache.entries()]
165-
for (const [key, value] of entries) {
166-
if (cache.get(key) !== value) continue
167-
168-
const ctx = await value.catch((error) => {
169-
Log.Default.warn("instance dispose failed", { key, error })
170-
return undefined
171-
})
172-
173-
if (!ctx) {
174-
if (cache.get(key) === value) cache.delete(key)
175-
continue
176-
}
177-
178-
if (cache.get(key) !== value) continue
179-
180-
await context.provide(ctx, async () => {
181-
await Instance.dispose()
182-
})
183-
}
184-
}).finally(() => {
185-
disposal.all = undefined
186-
})
187-
188-
return disposal.all
69+
return InstanceStore.runtime.runPromise((store) => store.disposeAll())
18970
},
19071
}

packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Config } from "@/config/config"
22
import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global"
33
import { Installation } from "@/installation"
4-
import { Instance } from "@/project/instance"
4+
import { InstanceStore } from "@/project/instance-store"
55
import { InstallationVersion } from "@opencode-ai/core/installation/version"
66
import * as Log from "@opencode-ai/core/util/log"
77
import { Effect, Queue, Schema } from "effect"
@@ -68,6 +68,7 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl
6868
Effect.gen(function* () {
6969
const config = yield* Config.Service
7070
const installation = yield* Installation.Service
71+
const store = yield* InstanceStore.Service
7172

7273
const health = Effect.fn("GlobalHttpApi.health")(function* () {
7374
return { healthy: true as const, version: InstallationVersion }
@@ -86,7 +87,7 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl
8687
})
8788

8889
const dispose = Effect.fn("GlobalHttpApi.dispose")(function* () {
89-
yield* Effect.promise(() => Instance.disposeAll())
90+
yield* store.disposeAll()
9091
GlobalBus.emit("event", {
9192
directory: "global",
9293
payload: { type: "global.disposed", properties: {} },

0 commit comments

Comments
 (0)