Convert LoadInput.init to Effect + extract InstanceBootstrap as a Service (#25376)
This commit is contained in:
parent
1571933096
commit
f33aec1139
21 changed files with 224 additions and 154 deletions
|
|
@ -1,11 +1,9 @@
|
||||||
import { AppRuntime } from "@/effect/app-runtime"
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
import { InstanceBootstrap } from "../project/bootstrap"
|
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
|
|
||||||
export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
|
export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
|
||||||
return Instance.provide({
|
return Instance.provide({
|
||||||
directory,
|
directory,
|
||||||
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
try {
|
try {
|
||||||
const result = await cb()
|
const result = await cb()
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { Installation } from "@/installation"
|
||||||
import { Server } from "@/server/server"
|
import { Server } from "@/server/server"
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
|
||||||
import { Rpc } from "@/util/rpc"
|
import { Rpc } from "@/util/rpc"
|
||||||
import { upgrade } from "@/cli/upgrade"
|
import { upgrade } from "@/cli/upgrade"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
|
|
@ -77,7 +76,6 @@ export const rpc = {
|
||||||
async checkUpgrade(input: { directory: string }) {
|
async checkUpgrade(input: { directory: string }) {
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: input.directory,
|
directory: input.directory,
|
||||||
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
await upgrade().catch(() => {})
|
await upgrade().catch(() => {})
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
|
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
|
||||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||||
|
import { containsPath } from "../project/instance-context"
|
||||||
import { zod } from "@/util/effect-zod"
|
import { zod } from "@/util/effect-zod"
|
||||||
import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@/util/schema"
|
import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@/util/schema"
|
||||||
import { ConfigAgent } from "./agent"
|
import { ConfigAgent } from "./agent"
|
||||||
|
|
@ -458,7 +459,7 @@ export const layer = Layer.effect(
|
||||||
const pluginScopeForSource = Effect.fnUntraced(function* (source: string) {
|
const pluginScopeForSource = Effect.fnUntraced(function* (source: string) {
|
||||||
if (source.startsWith("http://") || source.startsWith("https://")) return "global"
|
if (source.startsWith("http://") || source.startsWith("https://")) return "global"
|
||||||
if (source === "OPENCODE_CONFIG_CONTENT") return "local"
|
if (source === "OPENCODE_CONFIG_CONTENT") return "local"
|
||||||
if (Instance.containsPath(source, ctx)) return "local"
|
if (containsPath(source, ctx)) return "local"
|
||||||
return "global"
|
return "global"
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import fuzzysort from "fuzzysort"
|
||||||
import ignore from "ignore"
|
import ignore from "ignore"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { Instance } from "../project/instance"
|
import { containsPath } from "../project/instance-context"
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
import { Protected } from "./protected"
|
import { Protected } from "./protected"
|
||||||
import { Ripgrep } from "./ripgrep"
|
import { Ripgrep } from "./ripgrep"
|
||||||
|
|
@ -507,7 +507,7 @@ export const layer = Layer.effect(
|
||||||
const ctx = yield* InstanceState.context
|
const ctx = yield* InstanceState.context
|
||||||
const full = path.join(ctx.directory, file)
|
const full = path.join(ctx.directory, file)
|
||||||
|
|
||||||
if (!Instance.containsPath(full, ctx)) {
|
if (!containsPath(full, ctx)) {
|
||||||
throw new Error("Access denied: path escapes project directory")
|
throw new Error("Access denied: path escapes project directory")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -587,7 +587,7 @@ export const layer = Layer.effect(
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolved = dir ? path.join(ctx.directory, dir) : ctx.directory
|
const resolved = dir ? path.join(ctx.directory, dir) : ctx.directory
|
||||||
if (!Instance.containsPath(resolved, ctx)) {
|
if (!containsPath(resolved, ctx)) {
|
||||||
throw new Error("Access denied: path escapes project directory")
|
throw new Error("Access denied: path escapes project directory")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import { Process } from "@/util/process"
|
||||||
import { spawn as lspspawn } from "./launch"
|
import { spawn as lspspawn } from "./launch"
|
||||||
import { Effect, Layer, Context, Schema } from "effect"
|
import { Effect, Layer, Context, Schema } from "effect"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { containsPath } from "@/project/instance-context"
|
||||||
import { NonNegativeInt, withStatics } from "@/util/schema"
|
import { NonNegativeInt, withStatics } from "@/util/schema"
|
||||||
import { zod, ZodOverride } from "@/util/effect-zod"
|
import { zod, ZodOverride } from "@/util/effect-zod"
|
||||||
|
|
||||||
|
|
@ -221,12 +221,7 @@ export const layer = Layer.effect(
|
||||||
|
|
||||||
const getClients = Effect.fnUntraced(function* (file: string) {
|
const getClients = Effect.fnUntraced(function* (file: string) {
|
||||||
const ctx = yield* InstanceState.context
|
const ctx = yield* InstanceState.context
|
||||||
if (
|
if (!containsPath(file, ctx)) return [] as LSPClient.Info[]
|
||||||
!AppFileSystem.contains(ctx.directory, file) &&
|
|
||||||
(ctx.worktree === "/" || !AppFileSystem.contains(ctx.worktree, file))
|
|
||||||
) {
|
|
||||||
return [] as LSPClient.Info[]
|
|
||||||
}
|
|
||||||
const s = yield* InstanceState.get(state)
|
const s = yield* InstanceState.get(state)
|
||||||
return yield* Effect.promise(async () => {
|
return yield* Effect.promise(async () => {
|
||||||
const extension = path.parse(file).ext || file
|
const extension = path.parse(file).ext || file
|
||||||
|
|
|
||||||
|
|
@ -8,37 +8,71 @@ import * as Vcs from "./vcs"
|
||||||
import { Bus } from "../bus"
|
import { Bus } from "../bus"
|
||||||
import { Command } from "../command"
|
import { Command } from "../command"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
|
||||||
import { FileWatcher } from "@/file/watcher"
|
import { FileWatcher } from "@/file/watcher"
|
||||||
import { ShareNext } from "@/share/share-next"
|
import { ShareNext } from "@/share/share-next"
|
||||||
import * as Effect from "effect/Effect"
|
import { Context, Effect, Layer } from "effect"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
|
|
||||||
export const InstanceBootstrap = Effect.gen(function* () {
|
export interface Interface {
|
||||||
const ctx = yield* InstanceState.context
|
readonly run: Effect.Effect<void>
|
||||||
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"))
|
|
||||||
|
|
||||||
const projectID = ctx.project.id
|
export class Service extends Context.Service<Service, Interface>()("@opencode/InstanceBootstrap") {}
|
||||||
yield* Bus.Service.use((svc) =>
|
|
||||||
svc.subscribeCallback(Command.Event.Executed, async (payload) => {
|
export const layer = Layer.effect(
|
||||||
if (payload.properties.name === Command.Default.INIT) {
|
Service,
|
||||||
Project.setInitialized(projectID)
|
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).
|
||||||
}).pipe(Effect.withSpan("InstanceBootstrap"))
|
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 { LocalContext } from "@/util/local-context"
|
||||||
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import type * as Project from "./project"
|
import type * as Project from "./project"
|
||||||
|
|
||||||
export interface InstanceContext {
|
export interface InstanceContext {
|
||||||
|
|
@ -8,3 +9,16 @@ export interface InstanceContext {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const context = LocalContext.create<InstanceContext>("instance")
|
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 { makeRuntime } from "@/effect/run-service"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { Context, Deferred, Duration, Effect, Exit, Layer, Scope } from "effect"
|
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"
|
import * as Project from "./project"
|
||||||
|
|
||||||
export interface LoadInput {
|
export interface LoadInput<R = never> {
|
||||||
directory: string
|
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
|
worktree?: string
|
||||||
project?: Project.Info
|
project?: Project.Info
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly load: (input: LoadInput) => Effect.Effect<InstanceContext>
|
readonly load: <R = never>(input: LoadInput<R>) => Effect.Effect<InstanceContext, never, R>
|
||||||
readonly reload: (input: LoadInput) => Effect.Effect<InstanceContext>
|
readonly reload: <R = never>(input: LoadInput<R>) => Effect.Effect<InstanceContext, never, R>
|
||||||
readonly dispose: (ctx: InstanceContext) => Effect.Effect<void>
|
readonly dispose: (ctx: InstanceContext) => Effect.Effect<void>
|
||||||
readonly disposeAll: () => 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") {}
|
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 scope = yield* Scope.Scope
|
||||||
const cache = new Map<string, Entry>()
|
const cache = new Map<string, Entry>()
|
||||||
|
|
||||||
const boot = Effect.fn("InstanceStore.boot")(function* (input: LoadInput & { directory: string }) {
|
const boot = <R>(input: LoadInput<R> & { directory: string }) =>
|
||||||
const ctx =
|
Effect.gen(function* () {
|
||||||
input.project && input.worktree
|
const ctx: InstanceContext =
|
||||||
? {
|
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,
|
directory: input.directory,
|
||||||
worktree: result.sandbox,
|
worktree: input.worktree,
|
||||||
project: result.project,
|
project: input.project,
|
||||||
})),
|
}
|
||||||
)
|
: yield* project.fromDirectory(input.directory).pipe(
|
||||||
const init = input.init
|
Effect.map((result) => ({
|
||||||
if (init) yield* Effect.promise(() => context.provide(ctx, init))
|
directory: input.directory,
|
||||||
return ctx
|
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) =>
|
const removeEntry = (directory: string, entry: Entry) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
|
|
@ -63,11 +70,12 @@ export const layer: Layer.Layer<Service, never, Project.Service> = Layer.effect(
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
const completeLoad = Effect.fnUntraced(function* (directory: string, input: LoadInput, entry: Entry) {
|
const completeLoad = <R>(directory: string, input: LoadInput<R>, entry: Entry) =>
|
||||||
const exit = yield* Effect.exit(boot({ ...input, directory }))
|
Effect.gen(function* () {
|
||||||
if (Exit.isFailure(exit)) yield* removeEntry(directory, entry)
|
const exit = yield* Effect.exit(boot({ ...input, directory }))
|
||||||
yield* Deferred.done(entry.deferred, exit).pipe(Effect.asVoid)
|
if (Exit.isFailure(exit)) yield* removeEntry(directory, entry)
|
||||||
})
|
yield* Deferred.done(entry.deferred, exit).pipe(Effect.asVoid)
|
||||||
|
})
|
||||||
|
|
||||||
const emitDisposed = (input: { directory: string; project?: string }) =>
|
const emitDisposed = (input: { directory: string; project?: string }) =>
|
||||||
Effect.sync(() =>
|
Effect.sync(() =>
|
||||||
|
|
@ -98,9 +106,9 @@ export const layer: Layer.Layer<Service, never, Project.Service> = Layer.effect(
|
||||||
return true
|
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)
|
const directory = AppFileSystem.resolve(input.directory)
|
||||||
return yield* Effect.uninterruptibleMask((restore) =>
|
return Effect.uninterruptibleMask((restore) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const existing = cache.get(directory)
|
const existing = cache.get(directory)
|
||||||
if (existing) return yield* restore(Deferred.await(existing.deferred))
|
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 }))
|
}).pipe(Effect.forkIn(scope, { startImmediately: true }))
|
||||||
return yield* restore(Deferred.await(entry.deferred))
|
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)
|
const directory = AppFileSystem.resolve(input.directory)
|
||||||
return yield* Effect.uninterruptibleMask((restore) =>
|
return Effect.uninterruptibleMask((restore) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const previous = cache.get(directory)
|
const previous = cache.get(directory)
|
||||||
const entry: Entry = { deferred: Deferred.makeUnsafe<InstanceContext>() }
|
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 }))
|
}).pipe(Effect.forkIn(scope, { startImmediately: true }))
|
||||||
return yield* restore(Deferred.await(entry.deferred))
|
return yield* restore(Deferred.await(entry.deferred))
|
||||||
}),
|
}),
|
||||||
)
|
).pipe(Effect.withSpan("InstanceStore.reload"))
|
||||||
})
|
}
|
||||||
|
|
||||||
const dispose = Effect.fn("InstanceStore.dispose")(function* (ctx: InstanceContext) {
|
const dispose = Effect.fn("InstanceStore.dispose")(function* (ctx: InstanceContext) {
|
||||||
const entry = cache.get(ctx.directory)
|
const entry = cache.get(ctx.directory)
|
||||||
|
|
@ -170,7 +178,10 @@ export const layer: Layer.Layer<Service, never, Project.Service> = Layer.effect(
|
||||||
return yield* cachedDisposeAll
|
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))))
|
load(input).pipe(Effect.flatMap((ctx) => effect.pipe(Effect.provideService(InstanceRef, ctx))))
|
||||||
|
|
||||||
yield* Effect.addFinalizer(() => disposeAll().pipe(Effect.ignore))
|
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 * as Project from "./project"
|
||||||
import { context, type InstanceContext } from "./instance-context"
|
import { context, type InstanceContext } from "./instance-context"
|
||||||
import { InstanceStore } from "./instance-store"
|
import { InstanceStore } from "./instance-store"
|
||||||
|
|
@ -6,13 +7,37 @@ import { InstanceStore } from "./instance-store"
|
||||||
export type { InstanceContext } from "./instance-context"
|
export type { InstanceContext } from "./instance-context"
|
||||||
export type { LoadInput } from "./instance-store"
|
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 = {
|
export const Instance = {
|
||||||
load(input: InstanceStore.LoadInput): Promise<InstanceContext> {
|
load(input: LegacyLoadInput): Promise<InstanceContext> {
|
||||||
return InstanceStore.runtime.runPromise((store) => store.load(input))
|
return InstanceStore.runtime.runPromise((store) => store.load(liftLegacyInput(input)))
|
||||||
},
|
},
|
||||||
async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
|
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 () =>
|
return context.provide(
|
||||||
input.fn(),
|
await Instance.load({ directory: input.directory, init: input.init }),
|
||||||
|
async () => input.fn(),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
get current() {
|
get current() {
|
||||||
|
|
@ -28,18 +53,6 @@ export const Instance = {
|
||||||
return context.use().project
|
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
|
* Captures the current instance ALS context and returns a wrapper that
|
||||||
* restores it when called. Use this for callbacks that fire outside the
|
* 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 {
|
restore<R>(ctx: InstanceContext, fn: () => R): R {
|
||||||
return context.provide(ctx, fn)
|
return context.provide(ctx, fn)
|
||||||
},
|
},
|
||||||
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
|
async reload(input: LegacyLoadInput) {
|
||||||
return InstanceStore.runtime.runPromise((store) => store.reload(input))
|
return InstanceStore.runtime.runPromise((store) => store.reload(liftLegacyInput(input)))
|
||||||
},
|
},
|
||||||
async dispose() {
|
async dispose() {
|
||||||
return InstanceStore.runtime.runPromise((store) => store.dispose(Instance.current))
|
return InstanceStore.runtime.runPromise((store) => store.dispose(Instance.current))
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { AppRuntime } from "@/effect/app-runtime"
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
import * as InstanceState from "@/effect/instance-state"
|
import * as InstanceState from "@/effect/instance-state"
|
||||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
|
||||||
import { Project } from "@/project/project"
|
import { Project } from "@/project/project"
|
||||||
import { ProjectID } from "@/project/schema"
|
import { ProjectID } from "@/project/schema"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
|
|
@ -29,7 +28,6 @@ export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project",
|
||||||
directory: ctx.directory,
|
directory: ctx.directory,
|
||||||
worktree: ctx.directory,
|
worktree: ctx.directory,
|
||||||
project: next,
|
project: next,
|
||||||
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
|
||||||
})
|
})
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { WorkspaceRef } from "@/effect/instance-ref"
|
import { WorkspaceRef } from "@/effect/instance-ref"
|
||||||
import { AppRuntime } from "@/effect/app-runtime"
|
|
||||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||||
import { InstanceStore } from "@/project/instance-store"
|
import { InstanceStore } from "@/project/instance-store"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
|
|
@ -25,11 +24,12 @@ function decode(input: string): string {
|
||||||
function provideInstanceContext<E>(
|
function provideInstanceContext<E>(
|
||||||
effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E>,
|
effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E>,
|
||||||
store: InstanceStore.Interface,
|
store: InstanceStore.Interface,
|
||||||
|
bootstrap: InstanceBootstrap.Interface,
|
||||||
): Effect.Effect<HttpServerResponse.HttpServerResponse, E, WorkspaceRouteContext> {
|
): Effect.Effect<HttpServerResponse.HttpServerResponse, E, WorkspaceRouteContext> {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const route = yield* WorkspaceRouteContext
|
const route = yield* WorkspaceRouteContext
|
||||||
return yield* store.provide(
|
return yield* store.provide(
|
||||||
{ directory: decode(route.directory), init: () => AppRuntime.runPromise(InstanceBootstrap) },
|
{ directory: decode(route.directory), init: bootstrap.run },
|
||||||
effect.pipe(Effect.provideService(WorkspaceRef, route.workspaceID)),
|
effect.pipe(Effect.provideService(WorkspaceRef, route.workspaceID)),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
@ -39,13 +39,15 @@ export const instanceContextLayer = Layer.effect(
|
||||||
InstanceContextMiddleware,
|
InstanceContextMiddleware,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const store = yield* InstanceStore.Service
|
const store = yield* InstanceStore.Service
|
||||||
return InstanceContextMiddleware.of((effect) => provideInstanceContext(effect, store))
|
const bootstrap = yield* InstanceBootstrap.Service
|
||||||
|
return InstanceContextMiddleware.of((effect) => provideInstanceContext(effect, store, bootstrap))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const instanceRouterMiddleware = HttpRouter.middleware()(
|
export const instanceRouterMiddleware = HttpRouter.middleware()(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const store = yield* InstanceStore.Service
|
const store = yield* InstanceStore.Service
|
||||||
return (effect) => provideInstanceContext(effect, store)
|
const bootstrap = yield* InstanceBootstrap.Service
|
||||||
|
return (effect) => provideInstanceContext(effect, store, bootstrap)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,16 @@ import { Config } from "@/config/config"
|
||||||
import { Command } from "@/command"
|
import { Command } from "@/command"
|
||||||
import * as Observability from "@opencode-ai/core/effect/observability"
|
import * as Observability from "@opencode-ai/core/effect/observability"
|
||||||
import { File } from "@/file"
|
import { File } from "@/file"
|
||||||
|
import { FileWatcher } from "@/file/watcher"
|
||||||
import { Ripgrep } from "@/file/ripgrep"
|
import { Ripgrep } from "@/file/ripgrep"
|
||||||
import { Format } from "@/format"
|
import { Format } from "@/format"
|
||||||
import { LSP } from "@/lsp/lsp"
|
import { LSP } from "@/lsp/lsp"
|
||||||
import { MCP } from "@/mcp"
|
import { MCP } from "@/mcp"
|
||||||
import { Permission } from "@/permission"
|
import { Permission } from "@/permission"
|
||||||
import { Installation } from "@/installation"
|
import { Installation } from "@/installation"
|
||||||
|
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||||
import { InstanceStore } from "@/project/instance-store"
|
import { InstanceStore } from "@/project/instance-store"
|
||||||
|
import { Plugin } from "@/plugin"
|
||||||
import { Project } from "@/project/project"
|
import { Project } from "@/project/project"
|
||||||
import { ProviderAuth } from "@/provider/auth"
|
import { ProviderAuth } from "@/provider/auth"
|
||||||
import { Provider } from "@/provider/provider"
|
import { Provider } from "@/provider/provider"
|
||||||
|
|
@ -32,7 +35,9 @@ import { SessionStatus } from "@/session/status"
|
||||||
import { SessionSummary } from "@/session/summary"
|
import { SessionSummary } from "@/session/summary"
|
||||||
import { Todo } from "@/session/todo"
|
import { Todo } from "@/session/todo"
|
||||||
import { SessionShare } from "@/share/session"
|
import { SessionShare } from "@/share/session"
|
||||||
|
import { ShareNext } from "@/share/share-next"
|
||||||
import { Skill } from "@/skill"
|
import { Skill } from "@/skill"
|
||||||
|
import { Snapshot } from "@/snapshot"
|
||||||
import { SyncEvent } from "@/sync"
|
import { SyncEvent } from "@/sync"
|
||||||
import { ToolRegistry } from "@/tool/registry"
|
import { ToolRegistry } from "@/tool/registry"
|
||||||
import { lazy } from "@/util/lazy"
|
import { lazy } from "@/util/lazy"
|
||||||
|
|
@ -143,12 +148,15 @@ export function createRoutes(corsOptions?: CorsOptions) {
|
||||||
Command.defaultLayer,
|
Command.defaultLayer,
|
||||||
Config.defaultLayer,
|
Config.defaultLayer,
|
||||||
File.defaultLayer,
|
File.defaultLayer,
|
||||||
|
FileWatcher.defaultLayer,
|
||||||
Format.defaultLayer,
|
Format.defaultLayer,
|
||||||
LSP.defaultLayer,
|
LSP.defaultLayer,
|
||||||
Installation.defaultLayer,
|
Installation.defaultLayer,
|
||||||
|
InstanceBootstrap.defaultLayer,
|
||||||
InstanceStore.defaultLayer,
|
InstanceStore.defaultLayer,
|
||||||
MCP.defaultLayer,
|
MCP.defaultLayer,
|
||||||
Permission.defaultLayer,
|
Permission.defaultLayer,
|
||||||
|
Plugin.defaultLayer,
|
||||||
Project.defaultLayer,
|
Project.defaultLayer,
|
||||||
ProviderAuth.defaultLayer,
|
ProviderAuth.defaultLayer,
|
||||||
Provider.defaultLayer,
|
Provider.defaultLayer,
|
||||||
|
|
@ -163,6 +171,8 @@ export function createRoutes(corsOptions?: CorsOptions) {
|
||||||
SessionRunState.defaultLayer,
|
SessionRunState.defaultLayer,
|
||||||
SessionStatus.defaultLayer,
|
SessionStatus.defaultLayer,
|
||||||
SessionSummary.defaultLayer,
|
SessionSummary.defaultLayer,
|
||||||
|
ShareNext.defaultLayer,
|
||||||
|
Snapshot.defaultLayer,
|
||||||
SyncEvent.defaultLayer,
|
SyncEvent.defaultLayer,
|
||||||
Skill.defaultLayer,
|
Skill.defaultLayer,
|
||||||
Todo.defaultLayer,
|
Todo.defaultLayer,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import type { MiddlewareHandler } from "hono"
|
import type { MiddlewareHandler } from "hono"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
|
||||||
import { AppRuntime } from "@/effect/app-runtime"
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||||
|
|
@ -24,7 +23,6 @@ export function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler
|
||||||
async fn() {
|
async fn() {
|
||||||
return Instance.provide({
|
return Instance.provide({
|
||||||
directory,
|
directory,
|
||||||
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
|
||||||
async fn() {
|
async fn() {
|
||||||
return next()
|
return next()
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import z from "zod"
|
||||||
import { ProjectID } from "@/project/schema"
|
import { ProjectID } from "@/project/schema"
|
||||||
import { errors } from "../../error"
|
import { errors } from "../../error"
|
||||||
import { lazy } from "@/util/lazy"
|
import { lazy } from "@/util/lazy"
|
||||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
|
||||||
import { AppRuntime } from "@/effect/app-runtime"
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
import { jsonRequest, runRequest } from "./trace"
|
import { jsonRequest, runRequest } from "./trace"
|
||||||
|
|
||||||
|
|
@ -86,7 +85,6 @@ export const ProjectRoutes = lazy(() =>
|
||||||
directory: dir,
|
directory: dir,
|
||||||
worktree: dir,
|
worktree: dir,
|
||||||
project: next,
|
project: next,
|
||||||
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
|
||||||
})
|
})
|
||||||
return c.json(next)
|
return c.json(next)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import { WorkspaceID } from "@/control-plane/schema"
|
||||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||||
import { Workspace } from "@/control-plane/workspace"
|
import { Workspace } from "@/control-plane/workspace"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
import { Session } from "@/session/session"
|
import { Session } from "@/session/session"
|
||||||
import { SessionID } from "@/session/schema"
|
import { SessionID } from "@/session/schema"
|
||||||
|
|
@ -100,7 +99,6 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
||||||
fn: () =>
|
fn: () =>
|
||||||
Instance.provide({
|
Instance.provide({
|
||||||
directory: target.directory,
|
directory: target.directory,
|
||||||
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
|
||||||
async fn() {
|
async fn() {
|
||||||
return next()
|
return next()
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import * as Tool from "./tool"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import DESCRIPTION from "./bash.txt"
|
import DESCRIPTION from "./bash.txt"
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
import { Instance, type InstanceContext } from "../project/instance"
|
import { containsPath, type InstanceContext } from "../project/instance-context"
|
||||||
import { lazy } from "@/util/lazy"
|
import { lazy } from "@/util/lazy"
|
||||||
import { Language, type Node } from "web-tree-sitter"
|
import { Language, type Node } from "web-tree-sitter"
|
||||||
|
|
||||||
|
|
@ -386,7 +386,7 @@ export const BashTool = Tool.define(
|
||||||
for (const arg of pathArgs(command, ps)) {
|
for (const arg of pathArgs(command, ps)) {
|
||||||
const resolved = yield* argPath(arg, cwd, ps, shell)
|
const resolved = yield* argPath(arg, cwd, ps, shell)
|
||||||
log.info("resolved path", { arg, resolved })
|
log.info("resolved path", { arg, resolved })
|
||||||
if (!resolved || Instance.containsPath(resolved, instance)) continue
|
if (!resolved || containsPath(resolved, instance)) continue
|
||||||
const dir = (yield* fs.isDir(resolved)) ? resolved : path.dirname(resolved)
|
const dir = (yield* fs.isDir(resolved)) ? resolved : path.dirname(resolved)
|
||||||
scan.dirs.add(dir)
|
scan.dirs.add(dir)
|
||||||
}
|
}
|
||||||
|
|
@ -612,7 +612,7 @@ export const BashTool = Tool.define(
|
||||||
Effect.sync(() => tree.delete()),
|
Effect.sync(() => tree.delete()),
|
||||||
)
|
)
|
||||||
const scan = yield* collect(tree.rootNode, cwd, ps, shell, executeInstance)
|
const scan = yield* collect(tree.rootNode, cwd, ps, shell, executeInstance)
|
||||||
if (!Instance.containsPath(cwd, executeInstance)) scan.dirs.add(cwd)
|
if (!containsPath(cwd, executeInstance)) scan.dirs.add(cwd)
|
||||||
yield* ask(ctx, scan)
|
yield* ask(ctx, scan)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { Effect } from "effect"
|
||||||
import * as EffectLogger from "@opencode-ai/core/effect/logger"
|
import * as EffectLogger from "@opencode-ai/core/effect/logger"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import type * as Tool from "./tool"
|
import type * as Tool from "./tool"
|
||||||
import { Instance } from "../project/instance"
|
import { containsPath } from "../project/instance-context"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
|
|
||||||
type Kind = "file" | "directory"
|
type Kind = "file" | "directory"
|
||||||
|
|
@ -24,7 +24,7 @@ export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirec
|
||||||
|
|
||||||
const ins = yield* InstanceState.context
|
const ins = yield* InstanceState.context
|
||||||
const full = process.platform === "win32" ? AppFileSystem.normalizePath(target) : target
|
const full = process.platform === "win32" ? AppFileSystem.normalizePath(target) : target
|
||||||
if (Instance.containsPath(full, ins)) return
|
if (containsPath(full, ins)) return
|
||||||
|
|
||||||
const kind = options?.kind ?? "file"
|
const kind = options?.kind ?? "file"
|
||||||
const dir = kind === "directory" ? full : path.dirname(full)
|
const dir = kind === "directory" ? full : path.dirname(full)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import z from "zod"
|
||||||
import { NamedError } from "@opencode-ai/core/util/error"
|
import { NamedError } from "@opencode-ai/core/util/error"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { InstanceBootstrap } from "../project/bootstrap"
|
|
||||||
import { Project } from "@/project/project"
|
import { Project } from "@/project/project"
|
||||||
import { Database } from "@/storage/db"
|
import { Database } from "@/storage/db"
|
||||||
import { eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
|
|
@ -255,7 +254,6 @@ export const layer: Layer.Layer<
|
||||||
const booted = yield* Effect.promise(() =>
|
const booted = yield* Effect.promise(() =>
|
||||||
Instance.provide({
|
Instance.provide({
|
||||||
directory: info.directory,
|
directory: info.directory,
|
||||||
init: () => BootstrapRuntime.runPromise(InstanceBootstrap),
|
|
||||||
fn: () => undefined,
|
fn: () => undefined,
|
||||||
})
|
})
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import fs from "fs/promises"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
import { File } from "../../src/file"
|
import { File } from "../../src/file"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
|
import { containsPath } from "../../src/project/instance-context"
|
||||||
import { provideInstance, tmpdir } from "../fixture/fixture"
|
import { provideInstance, tmpdir } from "../fixture/fixture"
|
||||||
|
|
||||||
const run = <A, E>(eff: Effect.Effect<A, E, File.Service>) =>
|
const run = <A, E>(eff: Effect.Effect<A, E, File.Service>) =>
|
||||||
|
|
@ -121,15 +122,15 @@ describe("File.list path traversal protection", () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("Instance.containsPath", () => {
|
describe("containsPath", () => {
|
||||||
test("returns true for path inside directory", async () => {
|
test("returns true for path inside directory", async () => {
|
||||||
await using tmp = await tmpdir({ git: true })
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: () => {
|
fn: () => {
|
||||||
expect(Instance.containsPath(path.join(tmp.path, "foo.txt"), Instance.current)).toBe(true)
|
expect(containsPath(path.join(tmp.path, "foo.txt"), Instance.current)).toBe(true)
|
||||||
expect(Instance.containsPath(path.join(tmp.path, "src", "file.ts"), Instance.current)).toBe(true)
|
expect(containsPath(path.join(tmp.path, "src", "file.ts"), Instance.current)).toBe(true)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -143,11 +144,11 @@ describe("Instance.containsPath", () => {
|
||||||
directory: subdir,
|
directory: subdir,
|
||||||
fn: () => {
|
fn: () => {
|
||||||
// .opencode at worktree root, but we're running from packages/lib
|
// .opencode at worktree root, but we're running from packages/lib
|
||||||
expect(Instance.containsPath(path.join(tmp.path, ".opencode", "state"), Instance.current)).toBe(true)
|
expect(containsPath(path.join(tmp.path, ".opencode", "state"), Instance.current)).toBe(true)
|
||||||
// sibling package should also be accessible
|
// sibling package should also be accessible
|
||||||
expect(Instance.containsPath(path.join(tmp.path, "packages", "other", "file.ts"), Instance.current)).toBe(true)
|
expect(containsPath(path.join(tmp.path, "packages", "other", "file.ts"), Instance.current)).toBe(true)
|
||||||
// worktree root itself
|
// worktree root itself
|
||||||
expect(Instance.containsPath(tmp.path, Instance.current)).toBe(true)
|
expect(containsPath(tmp.path, Instance.current)).toBe(true)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -158,8 +159,8 @@ describe("Instance.containsPath", () => {
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: () => {
|
fn: () => {
|
||||||
expect(Instance.containsPath("/etc/passwd", Instance.current)).toBe(false)
|
expect(containsPath("/etc/passwd", Instance.current)).toBe(false)
|
||||||
expect(Instance.containsPath("/tmp/other-project", Instance.current)).toBe(false)
|
expect(containsPath("/tmp/other-project", Instance.current)).toBe(false)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -170,7 +171,7 @@ describe("Instance.containsPath", () => {
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: () => {
|
fn: () => {
|
||||||
expect(Instance.containsPath(path.join(tmp.path, "..", "escape.txt"), Instance.current)).toBe(false)
|
expect(containsPath(path.join(tmp.path, "..", "escape.txt"), Instance.current)).toBe(false)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -182,8 +183,8 @@ describe("Instance.containsPath", () => {
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: () => {
|
fn: () => {
|
||||||
expect(Instance.directory).toBe(Instance.worktree)
|
expect(Instance.directory).toBe(Instance.worktree)
|
||||||
expect(Instance.containsPath(path.join(tmp.path, "file.txt"), Instance.current)).toBe(true)
|
expect(containsPath(path.join(tmp.path, "file.txt"), Instance.current)).toBe(true)
|
||||||
expect(Instance.containsPath("/etc/passwd", Instance.current)).toBe(false)
|
expect(containsPath("/etc/passwd", Instance.current)).toBe(false)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -195,9 +196,9 @@ describe("Instance.containsPath", () => {
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: () => {
|
fn: () => {
|
||||||
// worktree is "/" for non-git projects, but containsPath should NOT allow all paths
|
// worktree is "/" for non-git projects, but containsPath should NOT allow all paths
|
||||||
expect(Instance.containsPath(path.join(tmp.path, "file.txt"), Instance.current)).toBe(true)
|
expect(containsPath(path.join(tmp.path, "file.txt"), Instance.current)).toBe(true)
|
||||||
expect(Instance.containsPath("/etc/passwd", Instance.current)).toBe(false)
|
expect(containsPath("/etc/passwd", Instance.current)).toBe(false)
|
||||||
expect(Instance.containsPath("/tmp/other", Instance.current)).toBe(false)
|
expect(containsPath("/tmp/other", Instance.current)).toBe(false)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { afterEach, describe, expect } from "bun:test"
|
import { afterEach, describe, expect } from "bun:test"
|
||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
import { Effect, Fiber, Layer } from "effect"
|
import { Effect, Fiber, Layer } from "effect"
|
||||||
|
import { InstanceRef } from "../../src/effect/instance-ref"
|
||||||
import { registerDisposer } from "../../src/effect/instance-registry"
|
import { registerDisposer } from "../../src/effect/instance-registry"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { InstanceStore } from "../../src/project/instance-store"
|
import { InstanceStore } from "../../src/project/instance-store"
|
||||||
|
|
@ -26,7 +27,7 @@ describe("InstanceStore", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("runs load init inside the loaded legacy instance context", () =>
|
it.live("runs load init with InstanceRef provided", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const dir = yield* tmpdirScoped({ git: true })
|
const dir = yield* tmpdirScoped({ git: true })
|
||||||
const store = yield* InstanceStore.Service
|
const store = yield* InstanceStore.Service
|
||||||
|
|
@ -34,9 +35,9 @@ describe("InstanceStore", () => {
|
||||||
|
|
||||||
yield* store.load({
|
yield* store.load({
|
||||||
directory: dir,
|
directory: dir,
|
||||||
init: async () => {
|
init: Effect.gen(function* () {
|
||||||
initializedDirectory = Instance.directory
|
initializedDirectory = (yield* InstanceRef)?.directory
|
||||||
},
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(initializedDirectory).toBe(dir)
|
expect(initializedDirectory).toBe(dir)
|
||||||
|
|
@ -52,15 +53,15 @@ describe("InstanceStore", () => {
|
||||||
|
|
||||||
const first = yield* store.load({
|
const first = yield* store.load({
|
||||||
directory: dir,
|
directory: dir,
|
||||||
init: async () => {
|
init: Effect.sync(() => {
|
||||||
initialized++
|
initialized++
|
||||||
},
|
}),
|
||||||
})
|
})
|
||||||
const second = yield* store.load({
|
const second = yield* store.load({
|
||||||
directory: dir,
|
directory: dir,
|
||||||
init: async () => {
|
init: Effect.sync(() => {
|
||||||
initialized++
|
initialized++
|
||||||
},
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(second).toBe(first)
|
expect(second).toBe(first)
|
||||||
|
|
@ -79,11 +80,11 @@ describe("InstanceStore", () => {
|
||||||
const first = yield* store
|
const first = yield* store
|
||||||
.load({
|
.load({
|
||||||
directory: dir,
|
directory: dir,
|
||||||
init: async () => {
|
init: Effect.promise(async () => {
|
||||||
initialized++
|
initialized++
|
||||||
started.resolve()
|
started.resolve()
|
||||||
await release.promise
|
await release.promise
|
||||||
},
|
}),
|
||||||
})
|
})
|
||||||
.pipe(Effect.forkScoped)
|
.pipe(Effect.forkScoped)
|
||||||
|
|
||||||
|
|
@ -92,9 +93,9 @@ describe("InstanceStore", () => {
|
||||||
const second = yield* store
|
const second = yield* store
|
||||||
.load({
|
.load({
|
||||||
directory: dir,
|
directory: dir,
|
||||||
init: async () => {
|
init: Effect.sync(() => {
|
||||||
initialized++
|
initialized++
|
||||||
},
|
}),
|
||||||
})
|
})
|
||||||
.pipe(Effect.forkScoped)
|
.pipe(Effect.forkScoped)
|
||||||
|
|
||||||
|
|
@ -116,10 +117,10 @@ describe("InstanceStore", () => {
|
||||||
const failed = yield* store
|
const failed = yield* store
|
||||||
.load({
|
.load({
|
||||||
directory: dir,
|
directory: dir,
|
||||||
init: async () => {
|
init: Effect.sync(() => {
|
||||||
attempts++
|
attempts++
|
||||||
throw new Error("init failed")
|
throw new Error("init failed")
|
||||||
},
|
}),
|
||||||
})
|
})
|
||||||
.pipe(
|
.pipe(
|
||||||
Effect.as(false),
|
Effect.as(false),
|
||||||
|
|
@ -130,9 +131,9 @@ describe("InstanceStore", () => {
|
||||||
|
|
||||||
const ctx = yield* store.load({
|
const ctx = yield* store.load({
|
||||||
directory: dir,
|
directory: dir,
|
||||||
init: async () => {
|
init: Effect.sync(() => {
|
||||||
attempts++
|
attempts++
|
||||||
},
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(ctx.directory).toBe(dir)
|
expect(ctx.directory).toBe(dir)
|
||||||
|
|
@ -170,10 +171,10 @@ describe("InstanceStore", () => {
|
||||||
const reload = yield* store
|
const reload = yield* store
|
||||||
.reload({
|
.reload({
|
||||||
directory: dir,
|
directory: dir,
|
||||||
init: async () => {
|
init: Effect.promise(async () => {
|
||||||
reloading.resolve()
|
reloading.resolve()
|
||||||
await releaseReload.promise
|
await releaseReload.promise
|
||||||
},
|
}),
|
||||||
})
|
})
|
||||||
.pipe(Effect.forkScoped)
|
.pipe(Effect.forkScoped)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { registerAdapter } from "../../src/control-plane/adapters"
|
||||||
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
||||||
import { Workspace } from "../../src/control-plane/workspace"
|
import { Workspace } from "../../src/control-plane/workspace"
|
||||||
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
|
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
|
||||||
|
import { InstanceBootstrap } from "../../src/project/bootstrap"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { InstanceStore } from "../../src/project/instance-store"
|
import { InstanceStore } from "../../src/project/instance-store"
|
||||||
import { Project } from "../../src/project/project"
|
import { Project } from "../../src/project/project"
|
||||||
|
|
@ -41,6 +42,7 @@ const it = testEffect(
|
||||||
testStateLayer,
|
testStateLayer,
|
||||||
NodeHttpServer.layerTest,
|
NodeHttpServer.layerTest,
|
||||||
NodeServices.layer,
|
NodeServices.layer,
|
||||||
|
InstanceBootstrap.defaultLayer,
|
||||||
InstanceStore.defaultLayer,
|
InstanceStore.defaultLayer,
|
||||||
Project.defaultLayer,
|
Project.defaultLayer,
|
||||||
Workspace.defaultLayer,
|
Workspace.defaultLayer,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue