Convert LoadInput.init to Effect + extract InstanceBootstrap as a Service (#25376)
This commit is contained in:
@@ -8,37 +8,71 @@ import * as Vcs from "./vcs"
|
||||
import { Bus } from "../bus"
|
||||
import { Command } from "../command"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
import * as Effect from "effect/Effect"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Config } from "@/config/config"
|
||||
|
||||
export const InstanceBootstrap = Effect.gen(function* () {
|
||||
const ctx = yield* InstanceState.context
|
||||
Log.Default.info("bootstrapping", { directory: ctx.directory })
|
||||
// everything depends on config so eager load it for nice traces
|
||||
yield* Config.Service.use((svc) => svc.get())
|
||||
// Plugin can mutate config so it has to be initialized before anything else.
|
||||
yield* Plugin.Service.use((svc) => svc.init())
|
||||
yield* Effect.all(
|
||||
[
|
||||
LSP.Service,
|
||||
ShareNext.Service,
|
||||
Format.Service,
|
||||
File.Service,
|
||||
FileWatcher.Service,
|
||||
Vcs.Service,
|
||||
Snapshot.Service,
|
||||
].map((s) => Effect.forkDetach(s.use((i) => i.init()))),
|
||||
).pipe(Effect.withSpan("InstanceBootstrap.init"))
|
||||
export interface Interface {
|
||||
readonly run: Effect.Effect<void>
|
||||
}
|
||||
|
||||
const projectID = ctx.project.id
|
||||
yield* Bus.Service.use((svc) =>
|
||||
svc.subscribeCallback(Command.Event.Executed, async (payload) => {
|
||||
if (payload.properties.name === Command.Default.INIT) {
|
||||
Project.setInitialized(projectID)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}).pipe(Effect.withSpan("InstanceBootstrap"))
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/InstanceBootstrap") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
// Yield each bootstrap dep at layer init so `run` itself has R = never.
|
||||
// This breaks the circular declaration loop through Config → Instance → InstanceStore
|
||||
// (instance-store.ts only yields this Service tag, never the impl-side services).
|
||||
const bus = yield* Bus.Service
|
||||
const config = yield* Config.Service
|
||||
const file = yield* File.Service
|
||||
const fileWatcher = yield* FileWatcher.Service
|
||||
const format = yield* Format.Service
|
||||
const lsp = yield* LSP.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
const shareNext = yield* ShareNext.Service
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const vcs = yield* Vcs.Service
|
||||
|
||||
const run = Effect.gen(function* () {
|
||||
const ctx = yield* InstanceState.context
|
||||
yield* Effect.logInfo("bootstrapping", { directory: ctx.directory })
|
||||
// everything depends on config so eager load it for nice traces
|
||||
yield* config.get()
|
||||
// Plugin can mutate config so it has to be initialized before anything else.
|
||||
yield* plugin.init()
|
||||
yield* Effect.all(
|
||||
[lsp, shareNext, format, file, fileWatcher, vcs, snapshot].map((s) => Effect.forkDetach(s.init())),
|
||||
).pipe(Effect.withSpan("InstanceBootstrap.init"))
|
||||
|
||||
const projectID = ctx.project.id
|
||||
yield* bus.subscribeCallback(Command.Event.Executed, async (payload) => {
|
||||
if (payload.properties.name === Command.Default.INIT) {
|
||||
Project.setInitialized(projectID)
|
||||
}
|
||||
})
|
||||
}).pipe(Effect.withSpan("InstanceBootstrap"))
|
||||
|
||||
return Service.of({ run })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
|
||||
Layer.provide([
|
||||
Bus.layer,
|
||||
Config.defaultLayer,
|
||||
File.defaultLayer,
|
||||
FileWatcher.defaultLayer,
|
||||
Format.defaultLayer,
|
||||
LSP.defaultLayer,
|
||||
Plugin.defaultLayer,
|
||||
Project.defaultLayer,
|
||||
ShareNext.defaultLayer,
|
||||
Snapshot.defaultLayer,
|
||||
Vcs.defaultLayer,
|
||||
]),
|
||||
)
|
||||
|
||||
export * as InstanceBootstrap from "./bootstrap"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { LocalContext } from "@/util/local-context"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import type * as Project from "./project"
|
||||
|
||||
export interface InstanceContext {
|
||||
@@ -8,3 +9,16 @@ export interface InstanceContext {
|
||||
}
|
||||
|
||||
export const context = LocalContext.create<InstanceContext>("instance")
|
||||
|
||||
/**
|
||||
* Check if a path is within the project boundary.
|
||||
* Returns true if path is inside ctx.directory OR ctx.worktree.
|
||||
* Paths within the worktree but outside the working directory should not trigger external_directory permission.
|
||||
*/
|
||||
export function containsPath(filepath: string, ctx: InstanceContext): boolean {
|
||||
if (AppFileSystem.contains(ctx.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 (ctx.worktree === "/") return false
|
||||
return AppFileSystem.contains(ctx.worktree, filepath)
|
||||
}
|
||||
|
||||
@@ -5,22 +5,29 @@ import { disposeInstance } from "@/effect/instance-registry"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Context, Deferred, Duration, Effect, Exit, Layer, Scope } from "effect"
|
||||
import { context, type InstanceContext } from "./instance-context"
|
||||
import { type InstanceContext } from "./instance-context"
|
||||
import * as Project from "./project"
|
||||
|
||||
export interface LoadInput {
|
||||
export interface LoadInput<R = never> {
|
||||
directory: string
|
||||
init?: () => Promise<unknown>
|
||||
/**
|
||||
* Additional setup to run after the default InstanceBootstrap.
|
||||
* Mainly used by tests for env-var setup or file writes that need the instance ALS context.
|
||||
*/
|
||||
init?: Effect.Effect<void, never, R>
|
||||
worktree?: string
|
||||
project?: Project.Info
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly load: (input: LoadInput) => Effect.Effect<InstanceContext>
|
||||
readonly reload: (input: LoadInput) => Effect.Effect<InstanceContext>
|
||||
readonly load: <R = never>(input: LoadInput<R>) => Effect.Effect<InstanceContext, never, R>
|
||||
readonly reload: <R = never>(input: LoadInput<R>) => Effect.Effect<InstanceContext, never, R>
|
||||
readonly dispose: (ctx: InstanceContext) => Effect.Effect<void>
|
||||
readonly disposeAll: () => Effect.Effect<void>
|
||||
readonly provide: <A, E, R>(input: LoadInput, effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
|
||||
readonly provide: <A, E, R, R2 = never>(
|
||||
input: LoadInput<R2>,
|
||||
effect: Effect.Effect<A, E, R>,
|
||||
) => Effect.Effect<A, E, R | R2>
|
||||
}
|
||||
|
||||
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(
|
||||
const scope = yield* Scope.Scope
|
||||
const cache = new Map<string, Entry>()
|
||||
|
||||
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) => ({
|
||||
const boot = <R>(input: LoadInput<R> & { directory: string }) =>
|
||||
Effect.gen(function* () {
|
||||
const ctx: InstanceContext =
|
||||
input.project && input.worktree
|
||||
? {
|
||||
directory: input.directory,
|
||||
worktree: result.sandbox,
|
||||
project: result.project,
|
||||
})),
|
||||
)
|
||||
const init = input.init
|
||||
if (init) yield* Effect.promise(() => context.provide(ctx, init))
|
||||
return ctx
|
||||
})
|
||||
worktree: input.worktree,
|
||||
project: input.project,
|
||||
}
|
||||
: yield* project.fromDirectory(input.directory).pipe(
|
||||
Effect.map((result) => ({
|
||||
directory: input.directory,
|
||||
worktree: result.sandbox,
|
||||
project: result.project,
|
||||
})),
|
||||
)
|
||||
if (input.init) yield* input.init.pipe(Effect.provideService(InstanceRef, ctx))
|
||||
return ctx
|
||||
}).pipe(Effect.withSpan("InstanceStore.boot"))
|
||||
|
||||
const removeEntry = (directory: string, entry: Entry) =>
|
||||
Effect.sync(() => {
|
||||
@@ -63,11 +70,12 @@ export const layer: Layer.Layer<Service, never, Project.Service> = Layer.effect(
|
||||
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 completeLoad = <R>(directory: string, input: LoadInput<R>, entry: Entry) =>
|
||||
Effect.gen(function* () {
|
||||
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(() =>
|
||||
@@ -98,9 +106,9 @@ export const layer: Layer.Layer<Service, never, Project.Service> = Layer.effect(
|
||||
return true
|
||||
})
|
||||
|
||||
const load = Effect.fn("InstanceStore.load")(function* (input: LoadInput) {
|
||||
const load = <R>(input: LoadInput<R>): Effect.Effect<InstanceContext, never, R> => {
|
||||
const directory = AppFileSystem.resolve(input.directory)
|
||||
return yield* Effect.uninterruptibleMask((restore) =>
|
||||
return Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const existing = cache.get(directory)
|
||||
if (existing) return yield* restore(Deferred.await(existing.deferred))
|
||||
@@ -113,12 +121,12 @@ export const layer: Layer.Layer<Service, never, Project.Service> = Layer.effect(
|
||||
}).pipe(Effect.forkIn(scope, { startImmediately: true }))
|
||||
return yield* restore(Deferred.await(entry.deferred))
|
||||
}),
|
||||
)
|
||||
})
|
||||
).pipe(Effect.withSpan("InstanceStore.load"))
|
||||
}
|
||||
|
||||
const reload = Effect.fn("InstanceStore.reload")(function* (input: LoadInput) {
|
||||
const reload = <R>(input: LoadInput<R>): Effect.Effect<InstanceContext, never, R> => {
|
||||
const directory = AppFileSystem.resolve(input.directory)
|
||||
return yield* Effect.uninterruptibleMask((restore) =>
|
||||
return Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const previous = cache.get(directory)
|
||||
const entry: Entry = { deferred: Deferred.makeUnsafe<InstanceContext>() }
|
||||
@@ -134,8 +142,8 @@ export const layer: Layer.Layer<Service, never, Project.Service> = Layer.effect(
|
||||
}).pipe(Effect.forkIn(scope, { startImmediately: true }))
|
||||
return yield* restore(Deferred.await(entry.deferred))
|
||||
}),
|
||||
)
|
||||
})
|
||||
).pipe(Effect.withSpan("InstanceStore.reload"))
|
||||
}
|
||||
|
||||
const dispose = Effect.fn("InstanceStore.dispose")(function* (ctx: InstanceContext) {
|
||||
const entry = cache.get(ctx.directory)
|
||||
@@ -170,7 +178,10 @@ export const layer: Layer.Layer<Service, never, Project.Service> = Layer.effect(
|
||||
return yield* cachedDisposeAll
|
||||
})
|
||||
|
||||
const provide = <A, E, R>(input: LoadInput, effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
|
||||
const provide = <A, E, R, R2>(
|
||||
input: LoadInput<R2>,
|
||||
effect: Effect.Effect<A, E, R>,
|
||||
): Effect.Effect<A, E, R | R2> =>
|
||||
load(input).pipe(Effect.flatMap((ctx) => effect.pipe(Effect.provideService(InstanceRef, ctx))))
|
||||
|
||||
yield* Effect.addFinalizer(() => disposeAll().pipe(Effect.ignore))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Effect } from "effect"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import * as Project from "./project"
|
||||
import { context, type InstanceContext } from "./instance-context"
|
||||
import { InstanceStore } from "./instance-store"
|
||||
@@ -6,13 +7,37 @@ import { InstanceStore } from "./instance-store"
|
||||
export type { InstanceContext } from "./instance-context"
|
||||
export type { LoadInput } from "./instance-store"
|
||||
|
||||
type LegacyLoadInput = {
|
||||
directory: string
|
||||
init?: () => Promise<unknown>
|
||||
project?: Project.Info
|
||||
worktree?: string
|
||||
}
|
||||
|
||||
// Promise-style legacy inits often read Instance.directory etc. from the ALS context.
|
||||
// The new Effect-typed init path doesn't bind ALS — it provides InstanceRef. To keep
|
||||
// legacy inits working without forcing every test to convert, bind ALS around the
|
||||
// Promise call here using the instance ctx that the store provides via InstanceRef.
|
||||
const liftLegacyInput = (input: LegacyLoadInput): InstanceStore.LoadInput => {
|
||||
const { init, ...rest } = input
|
||||
if (!init) return rest
|
||||
return {
|
||||
...rest,
|
||||
init: Effect.gen(function* () {
|
||||
const ctx = yield* InstanceRef
|
||||
yield* Effect.promise(() => (ctx ? context.provide(ctx, init) : init()))
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export const Instance = {
|
||||
load(input: InstanceStore.LoadInput): Promise<InstanceContext> {
|
||||
return InstanceStore.runtime.runPromise((store) => store.load(input))
|
||||
load(input: LegacyLoadInput): Promise<InstanceContext> {
|
||||
return InstanceStore.runtime.runPromise((store) => store.load(liftLegacyInput(input)))
|
||||
},
|
||||
async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
|
||||
return context.provide(await Instance.load({ directory: input.directory, init: input.init }), async () =>
|
||||
input.fn(),
|
||||
async provide<R>(input: { directory: string; init?: () => Promise<unknown>; fn: () => R }): Promise<R> {
|
||||
return context.provide(
|
||||
await Instance.load({ directory: input.directory, init: input.init }),
|
||||
async () => input.fn(),
|
||||
)
|
||||
},
|
||||
get current() {
|
||||
@@ -28,18 +53,6 @@ export const Instance = {
|
||||
return context.use().project
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a path is within the project boundary.
|
||||
* Returns true if path is inside ctx.directory OR ctx.worktree.
|
||||
* Paths within the worktree but outside the working directory should not trigger external_directory permission.
|
||||
*/
|
||||
containsPath(filepath: string, ctx: InstanceContext) {
|
||||
if (AppFileSystem.contains(ctx.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 (ctx.worktree === "/") return false
|
||||
return AppFileSystem.contains(ctx.worktree, filepath)
|
||||
},
|
||||
/**
|
||||
* Captures the current instance ALS context and returns a wrapper that
|
||||
* restores it when called. Use this for callbacks that fire outside the
|
||||
@@ -57,8 +70,8 @@ export const Instance = {
|
||||
restore<R>(ctx: InstanceContext, fn: () => R): R {
|
||||
return context.provide(ctx, fn)
|
||||
},
|
||||
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
|
||||
return InstanceStore.runtime.runPromise((store) => store.reload(input))
|
||||
async reload(input: LegacyLoadInput) {
|
||||
return InstanceStore.runtime.runPromise((store) => store.reload(liftLegacyInput(input)))
|
||||
},
|
||||
async dispose() {
|
||||
return InstanceStore.runtime.runPromise((store) => store.dispose(Instance.current))
|
||||
|
||||
Reference in New Issue
Block a user