Skip to content

Commit 7b58be1

Browse files
committed
fix: keep httpapi instance reloads in layer store
1 parent 81a352d commit 7b58be1

8 files changed

Lines changed: 205 additions & 189 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: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
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 * as Log from "@opencode-ai/core/util/log"
7+
import { Context, Effect, Layer } from "effect"
8+
import { iife } from "@/util/iife"
9+
import { context, type InstanceContext } from "./instance-context"
10+
import * as Project from "./project"
11+
12+
export interface LoadInput {
13+
directory: string
14+
init?: () => Promise<unknown>
15+
worktree?: string
16+
project?: Project.Info
17+
}
18+
19+
export interface Interface {
20+
readonly load: (input: LoadInput) => Effect.Effect<InstanceContext>
21+
readonly reload: (input: LoadInput) => Effect.Effect<InstanceContext>
22+
readonly dispose: (ctx: InstanceContext) => Effect.Effect<void>
23+
readonly disposeAll: () => Effect.Effect<void>
24+
}
25+
26+
export class Service extends Context.Service<Service, Interface>()("@opencode/InstanceStore") {}
27+
28+
export const layer: Layer.Layer<Service, never, Project.Service> = Layer.effect(
29+
Service,
30+
Effect.gen(function* () {
31+
const project = yield* Project.Service
32+
const cache = new Map<string, Promise<InstanceContext>>()
33+
const disposal = {
34+
all: undefined as Promise<void> | undefined,
35+
}
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+
function track(directory: string, next: Promise<InstanceContext>) {
58+
const task = next.catch((error) => {
59+
if (cache.get(directory) === task) cache.delete(directory)
60+
throw error
61+
})
62+
cache.set(directory, task)
63+
return task
64+
}
65+
66+
const load = Effect.fn("InstanceStore.load")(function* (input: LoadInput) {
67+
const directory = AppFileSystem.resolve(input.directory)
68+
const existing = cache.get(directory)
69+
if (existing) return yield* Effect.promise(() => existing)
70+
71+
Log.Default.info("creating instance", { directory })
72+
return yield* Effect.promise(() => track(directory, Effect.runPromise(boot({ ...input, directory }))))
73+
})
74+
75+
const reload = Effect.fn("InstanceStore.reload")(function* (input: LoadInput) {
76+
const directory = AppFileSystem.resolve(input.directory)
77+
Log.Default.info("reloading instance", { directory })
78+
yield* Effect.promise(() => disposeInstance(directory))
79+
cache.delete(directory)
80+
const next = track(directory, Effect.runPromise(boot({ ...input, directory })))
81+
82+
GlobalBus.emit("event", {
83+
directory,
84+
project: input.project?.id,
85+
workspace: WorkspaceContext.workspaceID,
86+
payload: {
87+
type: "server.instance.disposed",
88+
properties: {
89+
directory,
90+
},
91+
},
92+
})
93+
94+
return yield* Effect.promise(() => next)
95+
})
96+
97+
const dispose = Effect.fn("InstanceStore.dispose")(function* (ctx: InstanceContext) {
98+
Log.Default.info("disposing instance", { directory: ctx.directory })
99+
yield* Effect.promise(() => disposeInstance(ctx.directory))
100+
cache.delete(ctx.directory)
101+
102+
GlobalBus.emit("event", {
103+
directory: ctx.directory,
104+
project: ctx.project.id,
105+
workspace: WorkspaceContext.workspaceID,
106+
payload: {
107+
type: "server.instance.disposed",
108+
properties: {
109+
directory: ctx.directory,
110+
},
111+
},
112+
})
113+
})
114+
115+
const disposeAll = Effect.fn("InstanceStore.disposeAll")(function* () {
116+
if (disposal.all) return yield* Effect.promise(() => disposal.all!)
117+
118+
disposal.all = iife(async () => {
119+
Log.Default.info("disposing all instances")
120+
const entries = [...cache.entries()]
121+
for (const [key, value] of entries) {
122+
if (cache.get(key) !== value) continue
123+
124+
const ctx = await value.catch((error) => {
125+
Log.Default.warn("instance dispose failed", { key, error })
126+
return undefined
127+
})
128+
129+
if (!ctx) {
130+
if (cache.get(key) === value) cache.delete(key)
131+
continue
132+
}
133+
134+
if (cache.get(key) !== value) continue
135+
await Effect.runPromise(dispose(ctx))
136+
}
137+
}).finally(() => {
138+
disposal.all = undefined
139+
})
140+
141+
return yield* Effect.promise(() => disposal.all!)
142+
})
143+
144+
yield* Effect.addFinalizer(() => disposeAll().pipe(Effect.ignore))
145+
146+
return Service.of({
147+
load,
148+
reload,
149+
dispose,
150+
disposeAll,
151+
})
152+
}),
153+
)
154+
155+
export const defaultLayer = layer.pipe(Layer.provide(Project.defaultLayer))
156+
157+
export const runtime = makeRuntime(Service, defaultLayer)
158+
159+
export * as InstanceStore from "./instance-store"
Lines changed: 9 additions & 167 deletions
Original file line numberDiff line numberDiff line change
@@ -1,172 +1,14 @@
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"
10-
import { Context, Effect, Layer } from "effect"
3+
import { context, type InstanceContext } from "./instance-context"
4+
import { InstanceStore } from "./instance-store"
115

12-
export interface InstanceContext {
13-
directory: string
14-
worktree: string
15-
project: Project.Info
16-
}
17-
18-
const context = LocalContext.create<InstanceContext>("instance")
19-
20-
export interface LoadInput {
21-
directory: string
22-
init?: () => Promise<unknown>
23-
worktree?: string
24-
project?: Project.Info
25-
}
26-
27-
export interface Interface {
28-
readonly load: (input: LoadInput) => Effect.Effect<InstanceContext>
29-
readonly reload: (input: LoadInput) => Effect.Effect<InstanceContext>
30-
readonly dispose: (ctx: InstanceContext) => Effect.Effect<void>
31-
readonly disposeAll: () => Effect.Effect<void>
32-
}
33-
34-
export class InstanceStore extends Context.Service<InstanceStore, Interface>()("@opencode/InstanceStore") {}
35-
36-
export const instanceStoreLayer: Layer.Layer<InstanceStore, never, Project.Service> = Layer.effect(
37-
InstanceStore,
38-
Effect.gen(function* () {
39-
const project = yield* Project.Service
40-
const cache = new Map<string, Promise<InstanceContext>>()
41-
const disposal = {
42-
all: undefined as Promise<void> | undefined,
43-
}
44-
45-
const boot = Effect.fn("InstanceStore.boot")(function* (input: LoadInput & { directory: string }) {
46-
const ctx =
47-
input.project && input.worktree
48-
? {
49-
directory: input.directory,
50-
worktree: input.worktree,
51-
project: input.project,
52-
}
53-
: yield* project.fromDirectory(input.directory).pipe(
54-
Effect.map((result) => ({
55-
directory: input.directory,
56-
worktree: result.sandbox,
57-
project: result.project,
58-
})),
59-
)
60-
const init = input.init
61-
if (init) yield* Effect.promise(() => context.provide(ctx, init))
62-
return ctx
63-
})
64-
65-
function track(directory: string, next: Promise<InstanceContext>) {
66-
const task = next.catch((error) => {
67-
if (cache.get(directory) === task) cache.delete(directory)
68-
throw error
69-
})
70-
cache.set(directory, task)
71-
return task
72-
}
73-
74-
const load = Effect.fn("InstanceStore.load")(function* (input: LoadInput) {
75-
const directory = AppFileSystem.resolve(input.directory)
76-
const existing = cache.get(directory)
77-
if (existing) return yield* Effect.promise(() => existing)
78-
79-
Log.Default.info("creating instance", { directory })
80-
return yield* Effect.promise(() => track(directory, Effect.runPromise(boot({ ...input, directory }))))
81-
})
82-
83-
const reload = Effect.fn("InstanceStore.reload")(function* (input: LoadInput) {
84-
const directory = AppFileSystem.resolve(input.directory)
85-
Log.Default.info("reloading instance", { directory })
86-
yield* Effect.promise(() => disposeInstance(directory))
87-
cache.delete(directory)
88-
const next = track(directory, Effect.runPromise(boot({ ...input, directory })))
89-
90-
GlobalBus.emit("event", {
91-
directory,
92-
project: input.project?.id,
93-
workspace: WorkspaceContext.workspaceID,
94-
payload: {
95-
type: "server.instance.disposed",
96-
properties: {
97-
directory,
98-
},
99-
},
100-
})
101-
102-
return yield* Effect.promise(() => next)
103-
})
104-
105-
const dispose = Effect.fn("InstanceStore.dispose")(function* (ctx: InstanceContext) {
106-
Log.Default.info("disposing instance", { directory: ctx.directory })
107-
yield* Effect.promise(() => disposeInstance(ctx.directory))
108-
cache.delete(ctx.directory)
109-
110-
GlobalBus.emit("event", {
111-
directory: ctx.directory,
112-
project: ctx.project.id,
113-
workspace: WorkspaceContext.workspaceID,
114-
payload: {
115-
type: "server.instance.disposed",
116-
properties: {
117-
directory: ctx.directory,
118-
},
119-
},
120-
})
121-
})
122-
123-
const disposeAll = Effect.fn("InstanceStore.disposeAll")(function* () {
124-
if (disposal.all) return yield* Effect.promise(() => disposal.all!)
125-
126-
disposal.all = iife(async () => {
127-
Log.Default.info("disposing all instances")
128-
const entries = [...cache.entries()]
129-
for (const [key, value] of entries) {
130-
if (cache.get(key) !== value) continue
131-
132-
const ctx = await value.catch((error) => {
133-
Log.Default.warn("instance dispose failed", { key, error })
134-
return undefined
135-
})
136-
137-
if (!ctx) {
138-
if (cache.get(key) === value) cache.delete(key)
139-
continue
140-
}
141-
142-
if (cache.get(key) !== value) continue
143-
await Effect.runPromise(dispose(ctx))
144-
}
145-
}).finally(() => {
146-
disposal.all = undefined
147-
})
148-
149-
return yield* Effect.promise(() => disposal.all!)
150-
})
151-
152-
yield* Effect.addFinalizer(() => disposeAll().pipe(Effect.ignore))
153-
154-
return InstanceStore.of({
155-
load,
156-
reload,
157-
dispose,
158-
disposeAll,
159-
})
160-
}),
161-
)
162-
163-
export const instanceStoreDefaultLayer = instanceStoreLayer.pipe(Layer.provide(Project.defaultLayer))
164-
165-
const instanceStoreRuntime = makeRuntime(InstanceStore, instanceStoreDefaultLayer)
6+
export type { InstanceContext } from "./instance-context"
7+
export type { LoadInput } from "./instance-store"
1668

1679
export const Instance = {
168-
load(input: LoadInput): Promise<InstanceContext> {
169-
return instanceStoreRuntime.runPromise((store) => store.load(input))
10+
load(input: InstanceStore.LoadInput): Promise<InstanceContext> {
11+
return InstanceStore.runtime.runPromise((store) => store.load(input))
17012
},
17113
async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
17214
return context.provide(await Instance.load(input), async () => input.fn())
@@ -215,12 +57,12 @@ export const Instance = {
21557
return context.provide(ctx, fn)
21658
},
21759
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
218-
return instanceStoreRuntime.runPromise((store) => store.reload(input))
60+
return InstanceStore.runtime.runPromise((store) => store.reload(input))
21961
},
22062
async dispose() {
221-
return instanceStoreRuntime.runPromise((store) => store.dispose(Instance.current))
63+
return InstanceStore.runtime.runPromise((store) => store.dispose(Instance.current))
22264
},
22365
async disposeAll() {
224-
return instanceStoreRuntime.runPromise((store) => store.disposeAll())
66+
return InstanceStore.runtime.runPromise((store) => store.disposeAll())
22567
},
22668
}

0 commit comments

Comments
 (0)