Skip to content

Commit f33aec1

Browse files
authored
Convert LoadInput.init to Effect + extract InstanceBootstrap as a Service (#25376)
1 parent 1571933 commit f33aec1

21 files changed

Lines changed: 224 additions & 154 deletions

File tree

packages/opencode/src/cli/bootstrap.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
import { AppRuntime } from "@/effect/app-runtime"
2-
import { InstanceBootstrap } from "../project/bootstrap"
32
import { Instance } from "../project/instance"
43

54
export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
65
return Instance.provide({
76
directory,
8-
init: () => AppRuntime.runPromise(InstanceBootstrap),
97
fn: async () => {
108
try {
119
const result = await cb()

packages/opencode/src/cli/cmd/tui/worker.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { Installation } from "@/installation"
22
import { Server } from "@/server/server"
33
import * as Log from "@opencode-ai/core/util/log"
44
import { Instance } from "@/project/instance"
5-
import { InstanceBootstrap } from "@/project/bootstrap"
65
import { Rpc } from "@/util/rpc"
76
import { upgrade } from "@/cli/upgrade"
87
import { Config } from "@/config/config"
@@ -77,7 +76,6 @@ export const rpc = {
7776
async checkUpgrade(input: { directory: string }) {
7877
await Instance.provide({
7978
directory: input.directory,
80-
init: () => AppRuntime.runPromise(InstanceBootstrap),
8179
fn: async () => {
8280
await upgrade().catch(() => {})
8381
},

packages/opencode/src/config/config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
2323
import { InstanceState } from "@/effect/instance-state"
2424
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
2525
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
26+
import { containsPath } from "../project/instance-context"
2627
import { zod } from "@/util/effect-zod"
2728
import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@/util/schema"
2829
import { ConfigAgent } from "./agent"
@@ -458,7 +459,7 @@ export const layer = Layer.effect(
458459
const pluginScopeForSource = Effect.fnUntraced(function* (source: string) {
459460
if (source.startsWith("http://") || source.startsWith("https://")) return "global"
460461
if (source === "OPENCODE_CONFIG_CONTENT") return "local"
461-
if (Instance.containsPath(source, ctx)) return "local"
462+
if (containsPath(source, ctx)) return "local"
462463
return "global"
463464
})
464465

packages/opencode/src/file/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import fuzzysort from "fuzzysort"
1010
import ignore from "ignore"
1111
import path from "path"
1212
import { Global } from "@opencode-ai/core/global"
13-
import { Instance } from "../project/instance"
13+
import { containsPath } from "../project/instance-context"
1414
import * as Log from "@opencode-ai/core/util/log"
1515
import { Protected } from "./protected"
1616
import { Ripgrep } from "./ripgrep"
@@ -507,7 +507,7 @@ export const layer = Layer.effect(
507507
const ctx = yield* InstanceState.context
508508
const full = path.join(ctx.directory, file)
509509

510-
if (!Instance.containsPath(full, ctx)) {
510+
if (!containsPath(full, ctx)) {
511511
throw new Error("Access denied: path escapes project directory")
512512
}
513513

@@ -587,7 +587,7 @@ export const layer = Layer.effect(
587587
}
588588

589589
const resolved = dir ? path.join(ctx.directory, dir) : ctx.directory
590-
if (!Instance.containsPath(resolved, ctx)) {
590+
if (!containsPath(resolved, ctx)) {
591591
throw new Error("Access denied: path escapes project directory")
592592
}
593593

packages/opencode/src/lsp/lsp.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { Process } from "@/util/process"
1212
import { spawn as lspspawn } from "./launch"
1313
import { Effect, Layer, Context, Schema } from "effect"
1414
import { InstanceState } from "@/effect/instance-state"
15-
import { AppFileSystem } from "@opencode-ai/core/filesystem"
15+
import { containsPath } from "@/project/instance-context"
1616
import { NonNegativeInt, withStatics } from "@/util/schema"
1717
import { zod, ZodOverride } from "@/util/effect-zod"
1818

@@ -221,12 +221,7 @@ export const layer = Layer.effect(
221221

222222
const getClients = Effect.fnUntraced(function* (file: string) {
223223
const ctx = yield* InstanceState.context
224-
if (
225-
!AppFileSystem.contains(ctx.directory, file) &&
226-
(ctx.worktree === "/" || !AppFileSystem.contains(ctx.worktree, file))
227-
) {
228-
return [] as LSPClient.Info[]
229-
}
224+
if (!containsPath(file, ctx)) return [] as LSPClient.Info[]
230225
const s = yield* InstanceState.get(state)
231226
return yield* Effect.promise(async () => {
232227
const extension = path.parse(file).ext || file

packages/opencode/src/project/bootstrap.ts

Lines changed: 64 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -8,37 +8,71 @@ import * as Vcs from "./vcs"
88
import { Bus } from "../bus"
99
import { Command } from "../command"
1010
import { InstanceState } from "@/effect/instance-state"
11-
import * as Log from "@opencode-ai/core/util/log"
1211
import { FileWatcher } from "@/file/watcher"
1312
import { ShareNext } from "@/share/share-next"
14-
import * as Effect from "effect/Effect"
13+
import { Context, Effect, Layer } from "effect"
1514
import { Config } from "@/config/config"
1615

17-
export const InstanceBootstrap = Effect.gen(function* () {
18-
const ctx = yield* InstanceState.context
19-
Log.Default.info("bootstrapping", { directory: ctx.directory })
20-
// everything depends on config so eager load it for nice traces
21-
yield* Config.Service.use((svc) => svc.get())
22-
// Plugin can mutate config so it has to be initialized before anything else.
23-
yield* Plugin.Service.use((svc) => svc.init())
24-
yield* Effect.all(
25-
[
26-
LSP.Service,
27-
ShareNext.Service,
28-
Format.Service,
29-
File.Service,
30-
FileWatcher.Service,
31-
Vcs.Service,
32-
Snapshot.Service,
33-
].map((s) => Effect.forkDetach(s.use((i) => i.init()))),
34-
).pipe(Effect.withSpan("InstanceBootstrap.init"))
35-
36-
const projectID = ctx.project.id
37-
yield* Bus.Service.use((svc) =>
38-
svc.subscribeCallback(Command.Event.Executed, async (payload) => {
39-
if (payload.properties.name === Command.Default.INIT) {
40-
Project.setInitialized(projectID)
41-
}
42-
}),
43-
)
44-
}).pipe(Effect.withSpan("InstanceBootstrap"))
16+
export interface Interface {
17+
readonly run: Effect.Effect<void>
18+
}
19+
20+
export class Service extends Context.Service<Service, Interface>()("@opencode/InstanceBootstrap") {}
21+
22+
export const layer = Layer.effect(
23+
Service,
24+
Effect.gen(function* () {
25+
// Yield each bootstrap dep at layer init so `run` itself has R = never.
26+
// This breaks the circular declaration loop through Config → Instance → InstanceStore
27+
// (instance-store.ts only yields this Service tag, never the impl-side services).
28+
const bus = yield* Bus.Service
29+
const config = yield* Config.Service
30+
const file = yield* File.Service
31+
const fileWatcher = yield* FileWatcher.Service
32+
const format = yield* Format.Service
33+
const lsp = yield* LSP.Service
34+
const plugin = yield* Plugin.Service
35+
const shareNext = yield* ShareNext.Service
36+
const snapshot = yield* Snapshot.Service
37+
const vcs = yield* Vcs.Service
38+
39+
const run = Effect.gen(function* () {
40+
const ctx = yield* InstanceState.context
41+
yield* Effect.logInfo("bootstrapping", { directory: ctx.directory })
42+
// everything depends on config so eager load it for nice traces
43+
yield* config.get()
44+
// Plugin can mutate config so it has to be initialized before anything else.
45+
yield* plugin.init()
46+
yield* Effect.all(
47+
[lsp, shareNext, format, file, fileWatcher, vcs, snapshot].map((s) => Effect.forkDetach(s.init())),
48+
).pipe(Effect.withSpan("InstanceBootstrap.init"))
49+
50+
const projectID = ctx.project.id
51+
yield* bus.subscribeCallback(Command.Event.Executed, async (payload) => {
52+
if (payload.properties.name === Command.Default.INIT) {
53+
Project.setInitialized(projectID)
54+
}
55+
})
56+
}).pipe(Effect.withSpan("InstanceBootstrap"))
57+
58+
return Service.of({ run })
59+
}),
60+
)
61+
62+
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
63+
Layer.provide([
64+
Bus.layer,
65+
Config.defaultLayer,
66+
File.defaultLayer,
67+
FileWatcher.defaultLayer,
68+
Format.defaultLayer,
69+
LSP.defaultLayer,
70+
Plugin.defaultLayer,
71+
Project.defaultLayer,
72+
ShareNext.defaultLayer,
73+
Snapshot.defaultLayer,
74+
Vcs.defaultLayer,
75+
]),
76+
)
77+
78+
export * as InstanceBootstrap from "./bootstrap"

packages/opencode/src/project/instance-context.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { LocalContext } from "@/util/local-context"
2+
import { AppFileSystem } from "@opencode-ai/core/filesystem"
23
import type * as Project from "./project"
34

45
export interface InstanceContext {
@@ -8,3 +9,16 @@ export interface InstanceContext {
89
}
910

1011
export const context = LocalContext.create<InstanceContext>("instance")
12+
13+
/**
14+
* Check if a path is within the project boundary.
15+
* Returns true if path is inside ctx.directory OR ctx.worktree.
16+
* Paths within the worktree but outside the working directory should not trigger external_directory permission.
17+
*/
18+
export function containsPath(filepath: string, ctx: InstanceContext): boolean {
19+
if (AppFileSystem.contains(ctx.directory, filepath)) return true
20+
// Non-git projects set worktree to "/" which would match ANY absolute path.
21+
// Skip worktree check in this case to preserve external_directory permissions.
22+
if (ctx.worktree === "/") return false
23+
return AppFileSystem.contains(ctx.worktree, filepath)
24+
}

packages/opencode/src/project/instance-store.ts

Lines changed: 49 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,29 @@ import { disposeInstance } from "@/effect/instance-registry"
55
import { makeRuntime } from "@/effect/run-service"
66
import { AppFileSystem } from "@opencode-ai/core/filesystem"
77
import { Context, Deferred, Duration, Effect, Exit, Layer, Scope } from "effect"
8-
import { context, type InstanceContext } from "./instance-context"
8+
import { type InstanceContext } from "./instance-context"
99
import * as Project from "./project"
1010

11-
export interface LoadInput {
11+
export interface LoadInput<R = never> {
1212
directory: string
13-
init?: () => Promise<unknown>
13+
/**
14+
* Additional setup to run after the default InstanceBootstrap.
15+
* Mainly used by tests for env-var setup or file writes that need the instance ALS context.
16+
*/
17+
init?: Effect.Effect<void, never, R>
1418
worktree?: string
1519
project?: Project.Info
1620
}
1721

1822
export interface Interface {
19-
readonly load: (input: LoadInput) => Effect.Effect<InstanceContext>
20-
readonly reload: (input: LoadInput) => Effect.Effect<InstanceContext>
23+
readonly load: <R = never>(input: LoadInput<R>) => Effect.Effect<InstanceContext, never, R>
24+
readonly reload: <R = never>(input: LoadInput<R>) => Effect.Effect<InstanceContext, never, R>
2125
readonly dispose: (ctx: InstanceContext) => Effect.Effect<void>
2226
readonly disposeAll: () => Effect.Effect<void>
23-
readonly provide: <A, E, R>(input: LoadInput, effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
27+
readonly provide: <A, E, R, R2 = never>(
28+
input: LoadInput<R2>,
29+
effect: Effect.Effect<A, E, R>,
30+
) => Effect.Effect<A, E, R | R2>
2431
}
2532

2633
export class Service extends Context.Service<Service, Interface>()("@opencode/InstanceStore") {}
@@ -36,25 +43,25 @@ export const layer: Layer.Layer<Service, never, Project.Service> = Layer.effect(
3643
const scope = yield* Scope.Scope
3744
const cache = new Map<string, Entry>()
3845

39-
const boot = Effect.fn("InstanceStore.boot")(function* (input: LoadInput & { directory: string }) {
40-
const ctx =
41-
input.project && input.worktree
42-
? {
43-
directory: input.directory,
44-
worktree: input.worktree,
45-
project: input.project,
46-
}
47-
: yield* project.fromDirectory(input.directory).pipe(
48-
Effect.map((result) => ({
46+
const boot = <R>(input: LoadInput<R> & { directory: string }) =>
47+
Effect.gen(function* () {
48+
const ctx: InstanceContext =
49+
input.project && input.worktree
50+
? {
4951
directory: input.directory,
50-
worktree: result.sandbox,
51-
project: result.project,
52-
})),
53-
)
54-
const init = input.init
55-
if (init) yield* Effect.promise(() => context.provide(ctx, init))
56-
return ctx
57-
})
52+
worktree: input.worktree,
53+
project: input.project,
54+
}
55+
: yield* project.fromDirectory(input.directory).pipe(
56+
Effect.map((result) => ({
57+
directory: input.directory,
58+
worktree: result.sandbox,
59+
project: result.project,
60+
})),
61+
)
62+
if (input.init) yield* input.init.pipe(Effect.provideService(InstanceRef, ctx))
63+
return ctx
64+
}).pipe(Effect.withSpan("InstanceStore.boot"))
5865

5966
const removeEntry = (directory: string, entry: Entry) =>
6067
Effect.sync(() => {
@@ -63,11 +70,12 @@ export const layer: Layer.Layer<Service, never, Project.Service> = Layer.effect(
6370
return true
6471
})
6572

66-
const completeLoad = Effect.fnUntraced(function* (directory: string, input: LoadInput, entry: Entry) {
67-
const exit = yield* Effect.exit(boot({ ...input, directory }))
68-
if (Exit.isFailure(exit)) yield* removeEntry(directory, entry)
69-
yield* Deferred.done(entry.deferred, exit).pipe(Effect.asVoid)
70-
})
73+
const completeLoad = <R>(directory: string, input: LoadInput<R>, entry: Entry) =>
74+
Effect.gen(function* () {
75+
const exit = yield* Effect.exit(boot({ ...input, directory }))
76+
if (Exit.isFailure(exit)) yield* removeEntry(directory, entry)
77+
yield* Deferred.done(entry.deferred, exit).pipe(Effect.asVoid)
78+
})
7179

7280
const emitDisposed = (input: { directory: string; project?: string }) =>
7381
Effect.sync(() =>
@@ -98,9 +106,9 @@ export const layer: Layer.Layer<Service, never, Project.Service> = Layer.effect(
98106
return true
99107
})
100108

101-
const load = Effect.fn("InstanceStore.load")(function* (input: LoadInput) {
109+
const load = <R>(input: LoadInput<R>): Effect.Effect<InstanceContext, never, R> => {
102110
const directory = AppFileSystem.resolve(input.directory)
103-
return yield* Effect.uninterruptibleMask((restore) =>
111+
return Effect.uninterruptibleMask((restore) =>
104112
Effect.gen(function* () {
105113
const existing = cache.get(directory)
106114
if (existing) return yield* restore(Deferred.await(existing.deferred))
@@ -113,12 +121,12 @@ export const layer: Layer.Layer<Service, never, Project.Service> = Layer.effect(
113121
}).pipe(Effect.forkIn(scope, { startImmediately: true }))
114122
return yield* restore(Deferred.await(entry.deferred))
115123
}),
116-
)
117-
})
124+
).pipe(Effect.withSpan("InstanceStore.load"))
125+
}
118126

119-
const reload = Effect.fn("InstanceStore.reload")(function* (input: LoadInput) {
127+
const reload = <R>(input: LoadInput<R>): Effect.Effect<InstanceContext, never, R> => {
120128
const directory = AppFileSystem.resolve(input.directory)
121-
return yield* Effect.uninterruptibleMask((restore) =>
129+
return Effect.uninterruptibleMask((restore) =>
122130
Effect.gen(function* () {
123131
const previous = cache.get(directory)
124132
const entry: Entry = { deferred: Deferred.makeUnsafe<InstanceContext>() }
@@ -134,8 +142,8 @@ export const layer: Layer.Layer<Service, never, Project.Service> = Layer.effect(
134142
}).pipe(Effect.forkIn(scope, { startImmediately: true }))
135143
return yield* restore(Deferred.await(entry.deferred))
136144
}),
137-
)
138-
})
145+
).pipe(Effect.withSpan("InstanceStore.reload"))
146+
}
139147

140148
const dispose = Effect.fn("InstanceStore.dispose")(function* (ctx: InstanceContext) {
141149
const entry = cache.get(ctx.directory)
@@ -170,7 +178,10 @@ export const layer: Layer.Layer<Service, never, Project.Service> = Layer.effect(
170178
return yield* cachedDisposeAll
171179
})
172180

173-
const provide = <A, E, R>(input: LoadInput, effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
181+
const provide = <A, E, R, R2>(
182+
input: LoadInput<R2>,
183+
effect: Effect.Effect<A, E, R>,
184+
): Effect.Effect<A, E, R | R2> =>
174185
load(input).pipe(Effect.flatMap((ctx) => effect.pipe(Effect.provideService(InstanceRef, ctx))))
175186

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

0 commit comments

Comments
 (0)