mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-06 08:21:50 +00:00
fix(instance): run bootstrap from instance store (#25475)
This commit is contained in:
parent
36007aecf4
commit
f98053c34e
42 changed files with 540 additions and 249 deletions
|
|
@ -1,17 +1,15 @@
|
|||
import { Instance } from "../project/instance"
|
||||
import { InstanceStore } from "../project/instance-store"
|
||||
import { getBootstrapRunEffect } from "../effect/app-runtime"
|
||||
import { InstanceRuntime } from "../project/instance-runtime"
|
||||
|
||||
export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
|
||||
return Instance.provide({
|
||||
directory,
|
||||
init: await getBootstrapRunEffect(),
|
||||
fn: async () => {
|
||||
try {
|
||||
const result = await cb()
|
||||
return result
|
||||
} finally {
|
||||
await InstanceStore.disposeInstance(Instance.current)
|
||||
await InstanceRuntime.disposeInstance(Instance.current)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Installation } from "@/installation"
|
|||
import { Server } from "@/server/server"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { InstanceRuntime } from "@/project/instance-runtime"
|
||||
import { Rpc } from "@/util/rpc"
|
||||
import { upgrade } from "@/cli/upgrade"
|
||||
import { Config } from "@/config/config"
|
||||
|
|
@ -10,8 +10,10 @@ import { GlobalBus } from "@/bus/global"
|
|||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { writeHeapSnapshot } from "node:v8"
|
||||
import { Heap } from "@/cli/heap"
|
||||
import { AppRuntime, getBootstrapRunEffect } from "@/effect/app-runtime"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process"
|
||||
import { Effect } from "effect"
|
||||
import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle"
|
||||
|
||||
ensureProcessMetadata("worker")
|
||||
|
||||
|
|
@ -77,19 +79,24 @@ export const rpc = {
|
|||
async checkUpgrade(input: { directory: string }) {
|
||||
await Instance.provide({
|
||||
directory: input.directory,
|
||||
init: await getBootstrapRunEffect(),
|
||||
fn: async () => {
|
||||
await upgrade().catch(() => {})
|
||||
},
|
||||
})
|
||||
},
|
||||
async reload() {
|
||||
await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.invalidate(true)))
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const cfg = yield* Config.Service
|
||||
yield* cfg.invalidate()
|
||||
yield* disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true })
|
||||
}),
|
||||
)
|
||||
},
|
||||
async shutdown() {
|
||||
Log.Default.info("worker shutting down")
|
||||
|
||||
await InstanceStore.disposeAllInstances()
|
||||
await InstanceRuntime.disposeAllInstances()
|
||||
if (server) await server.stop(true)
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,11 +12,8 @@ import { Auth } from "../auth"
|
|||
import { Env } from "../env"
|
||||
import { applyEdits, modify } from "jsonc-parser"
|
||||
import { type InstanceContext } from "../project/instance"
|
||||
import { InstanceStore } from "../project/instance-store"
|
||||
import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { existsSync } from "fs"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Event } from "../server/event"
|
||||
import { Account } from "@/account/account"
|
||||
import { isRecord } from "@/util/record"
|
||||
import type { ConsoleState } from "./console-state"
|
||||
|
|
@ -289,9 +286,9 @@ export interface Interface {
|
|||
readonly get: () => Effect.Effect<Info>
|
||||
readonly getGlobal: () => Effect.Effect<Info>
|
||||
readonly getConsoleState: () => Effect.Effect<ConsoleState>
|
||||
readonly update: (config: Info, options?: { dispose?: boolean }) => Effect.Effect<void>
|
||||
readonly updateGlobal: (config: Info) => Effect.Effect<Info>
|
||||
readonly invalidate: (wait?: boolean) => Effect.Effect<void>
|
||||
readonly update: (config: Info) => Effect.Effect<void>
|
||||
readonly updateGlobal: (config: Info) => Effect.Effect<{ info: Info; changed: boolean }>
|
||||
readonly invalidate: () => Effect.Effect<void>
|
||||
readonly directories: () => Effect.Effect<string[]>
|
||||
readonly waitForDependencies: () => Effect.Effect<void>
|
||||
}
|
||||
|
|
@ -730,37 +727,17 @@ export const layer = Layer.effect(
|
|||
)
|
||||
})
|
||||
|
||||
const update = Effect.fn("Config.update")(function* (config: Info, options?: { dispose?: boolean }) {
|
||||
const update = Effect.fn("Config.update")(function* (config: Info) {
|
||||
const dir = yield* InstanceState.directory
|
||||
const file = path.join(dir, "config.json")
|
||||
const existing = yield* loadFile(file)
|
||||
yield* fs
|
||||
.writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2))
|
||||
.pipe(Effect.orDie)
|
||||
if (options?.dispose !== false) {
|
||||
// Fail loudly if no instance is bound — silently skipping would
|
||||
// mask "config update without an active instance" bugs. The throw
|
||||
// comes from `Instance.current` inside `InstanceState.context`.
|
||||
const ctx = yield* InstanceState.context
|
||||
yield* Effect.promise(() => InstanceStore.disposeInstance(ctx))
|
||||
}
|
||||
})
|
||||
|
||||
const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) {
|
||||
const invalidate = Effect.fn("Config.invalidate")(function* () {
|
||||
yield* invalidateGlobal
|
||||
const task = InstanceStore.disposeAllInstances()
|
||||
.catch(() => undefined)
|
||||
.finally(() =>
|
||||
GlobalBus.emit("event", {
|
||||
directory: "global",
|
||||
payload: {
|
||||
type: Event.Disposed.type,
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
)
|
||||
if (wait) yield* Effect.promise(() => task)
|
||||
else void task
|
||||
})
|
||||
|
||||
const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
|
||||
|
|
@ -784,9 +761,8 @@ export const layer = Layer.effect(
|
|||
if (changed) yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
// Only tear down running instances if the config actually changed.
|
||||
if (changed) yield* invalidate()
|
||||
return next
|
||||
return { info: next, changed }
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||
import { Layer, ManagedRuntime } from "effect"
|
||||
import { attach } from "./run-service"
|
||||
import * as Observability from "@opencode-ai/core/effect/observability"
|
||||
|
||||
|
|
@ -40,8 +40,7 @@ import { Command } from "@/command"
|
|||
import { Truncate } from "@/tool/truncate"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import { Format } from "@/format"
|
||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { InstanceRuntime } from "@/project/instance-runtime"
|
||||
import { Project } from "@/project/project"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
|
|
@ -94,8 +93,7 @@ export const AppLayer = Layer.mergeAll(
|
|||
Truncate.defaultLayer,
|
||||
ToolRegistry.defaultLayer,
|
||||
Format.defaultLayer,
|
||||
InstanceBootstrap.defaultLayer,
|
||||
InstanceStore.defaultLayer,
|
||||
InstanceRuntime.layer,
|
||||
Project.defaultLayer,
|
||||
Vcs.defaultLayer,
|
||||
Workspace.defaultLayer,
|
||||
|
|
@ -132,15 +130,3 @@ export const AppRuntime: Runtime = {
|
|||
},
|
||||
dispose: () => rt.dispose(),
|
||||
}
|
||||
|
||||
let bootstrapRun: Promise<Effect.Effect<void>>
|
||||
export function getBootstrapRunEffect(): Promise<Effect.Effect<void>> {
|
||||
if (!bootstrapRun) {
|
||||
bootstrapRun = AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
return (yield* InstanceBootstrap.Service).run
|
||||
}),
|
||||
)
|
||||
}
|
||||
return bootstrapRun
|
||||
}
|
||||
|
|
|
|||
9
packages/opencode/src/project/bootstrap-service.ts
Normal file
9
packages/opencode/src/project/bootstrap-service.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Context, Effect } from "effect"
|
||||
|
||||
export interface Interface {
|
||||
readonly run: Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/InstanceBootstrap") {}
|
||||
|
||||
export * as InstanceBootstrap from "./bootstrap-service"
|
||||
|
|
@ -10,21 +10,19 @@ import { Command } from "../command"
|
|||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Config } from "@/config/config"
|
||||
import { Service } from "./bootstrap-service"
|
||||
|
||||
export interface Interface {
|
||||
readonly run: Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/InstanceBootstrap") {}
|
||||
export { Service } from "./bootstrap-service"
|
||||
export type { Interface } from "./bootstrap-service"
|
||||
|
||||
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).
|
||||
// InstanceStore imports only the lightweight tag from bootstrap-service.ts,
|
||||
// so it can depend on bootstrap without importing this implementation graph.
|
||||
const bus = yield* Bus.Service
|
||||
const config = yield* Config.Service
|
||||
const file = yield* File.Service
|
||||
|
|
|
|||
27
packages/opencode/src/project/instance-runtime.ts
Normal file
27
packages/opencode/src/project/instance-runtime.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { type InstanceContext } from "./instance-context"
|
||||
import { InstanceStore, type LoadInput } from "./instance-store"
|
||||
import { Effect, Layer } from "effect"
|
||||
|
||||
// Production InstanceStore wiring plus a bridge for Promise/ALS callers that
|
||||
// cannot yet yield InstanceStore.Service. This keeps InstanceStore itself
|
||||
// low-level while still giving legacy Hono and CLI paths the production
|
||||
// bootstrap implementation. Delete the Promise helpers once those callers are
|
||||
// migrated to Effect boundaries that provide InstanceStore directly.
|
||||
// Keep the bootstrap implementation import lazy: Instance is imported broadly,
|
||||
// and importing the app bootstrap graph at module load can trigger ESM cycles.
|
||||
export const layer = Layer.unwrap(
|
||||
Effect.promise(async () => {
|
||||
const { InstanceBootstrap } = await import("./bootstrap")
|
||||
return InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer))
|
||||
}),
|
||||
)
|
||||
|
||||
const runtime = makeRuntime(InstanceStore.Service, layer)
|
||||
|
||||
export const load = (input: LoadInput) => runtime.runPromise((store) => store.load(input))
|
||||
export const disposeInstance = (ctx: InstanceContext) => runtime.runPromise((store) => store.dispose(ctx))
|
||||
export const disposeAllInstances = () => runtime.runPromise((store) => store.disposeAll())
|
||||
export const reloadInstance = (input: LoadInput) => runtime.runPromise((store) => store.reload(input))
|
||||
|
||||
export * as InstanceRuntime from "./instance-runtime"
|
||||
|
|
@ -2,10 +2,10 @@ import { GlobalBus } from "@/bus/global"
|
|||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { disposeInstance as runDisposers } 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 { type InstanceContext } from "./instance-context"
|
||||
import { InstanceBootstrap } from "./bootstrap-service"
|
||||
import * as Project from "./project"
|
||||
|
||||
export interface LoadInput<R = never> {
|
||||
|
|
@ -36,10 +36,11 @@ interface Entry {
|
|||
readonly deferred: Deferred.Deferred<InstanceContext>
|
||||
}
|
||||
|
||||
export const layer: Layer.Layer<Service, never, Project.Service> = Layer.effect(
|
||||
export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootstrap.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const project = yield* Project.Service
|
||||
const bootstrap = yield* InstanceBootstrap.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const cache = new Map<string, Entry>()
|
||||
|
||||
|
|
@ -59,6 +60,7 @@ export const layer: Layer.Layer<Service, never, Project.Service> = Layer.effect(
|
|||
project: result.project,
|
||||
})),
|
||||
)
|
||||
yield* bootstrap.run.pipe(Effect.provideService(InstanceRef, ctx))
|
||||
if (input.init) yield* input.init.pipe(Effect.provideService(InstanceRef, ctx))
|
||||
return ctx
|
||||
}).pipe(Effect.withSpan("InstanceStore.boot"))
|
||||
|
|
@ -195,13 +197,4 @@ export const layer: Layer.Layer<Service, never, Project.Service> = Layer.effect(
|
|||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Project.defaultLayer))
|
||||
|
||||
export const runtime = makeRuntime(Service, defaultLayer)
|
||||
|
||||
// Promise-returning helpers for callers without an Effect runtime in scope.
|
||||
// They route through `runtime` (not a yielded Service from a fresh runtime)
|
||||
// so they share the cache that `Instance.provide` populates.
|
||||
export const disposeInstance = (ctx: InstanceContext) => runtime.runPromise((store) => store.dispose(ctx))
|
||||
export const disposeAllInstances = () => runtime.runPromise((store) => store.disposeAll())
|
||||
export const reloadInstance = (input: LoadInput) => runtime.runPromise((store) => store.reload(input))
|
||||
|
||||
export * as InstanceStore from "./instance-store"
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
import { Effect } from "effect"
|
||||
import { context, type InstanceContext } from "./instance-context"
|
||||
import { InstanceStore } from "./instance-store"
|
||||
import { InstanceRuntime } from "./instance-runtime"
|
||||
|
||||
export type { InstanceContext } from "./instance-context"
|
||||
export type { LoadInput } from "./instance-store"
|
||||
|
||||
export const Instance = {
|
||||
async provide<R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }): Promise<R> {
|
||||
const ctx = await InstanceStore.runtime.runPromise((store) =>
|
||||
store.load({ directory: input.directory, init: input.init }),
|
||||
)
|
||||
const ctx = await InstanceRuntime.load({ directory: input.directory, init: input.init })
|
||||
return context.provide(ctx, async () => input.fn())
|
||||
},
|
||||
get current() {
|
||||
|
|
|
|||
37
packages/opencode/src/server/global-lifecycle.ts
Normal file
37
packages/opencode/src/server/global-lifecycle.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { GlobalBus } from "@/bus/global"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Effect } from "effect"
|
||||
import { Event } from "./event"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
export const emitGlobalDisposed = Effect.sync(() =>
|
||||
GlobalBus.emit("event", {
|
||||
directory: "global",
|
||||
payload: {
|
||||
type: Event.Disposed.type,
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
export const disposeAllInstancesAndEmitGlobalDisposed = Effect.fn(
|
||||
"Server.disposeAllInstancesAndEmitGlobalDisposed",
|
||||
)(function* (options?: { swallowErrors?: boolean }) {
|
||||
const store = yield* InstanceStore.Service
|
||||
yield* Effect.gen(function* () {
|
||||
yield* (options?.swallowErrors
|
||||
? store.disposeAll().pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.sync(() => {
|
||||
log.warn("global disposal failed", { cause })
|
||||
}),
|
||||
),
|
||||
)
|
||||
: store.disposeAll())
|
||||
yield* emitGlobalDisposed
|
||||
}).pipe(Effect.uninterruptible)
|
||||
})
|
||||
|
||||
export * as GlobalLifecycle from "./global-lifecycle"
|
||||
|
|
@ -1,25 +1,23 @@
|
|||
import { Hono, type Context } from "hono"
|
||||
import { describeRoute, resolver, validator } from "hono-openapi"
|
||||
import { streamSSE } from "hono/streaming"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import z from "zod"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { AsyncQueue } from "@/util/queue"
|
||||
import { InstanceStore } from "../../project/instance-store"
|
||||
import { Installation } from "@/installation"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { lazy } from "../../util/lazy"
|
||||
import { Config } from "@/config/config"
|
||||
import { errors } from "../error"
|
||||
import { disposeAllInstancesAndEmitGlobalDisposed } from "../global-lifecycle"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
export const GlobalDisposedEvent = BusEvent.define("global.disposed", Schema.Struct({}))
|
||||
|
||||
async function streamEvents(c: Context, subscribe: (q: AsyncQueue<string | null>) => () => void) {
|
||||
return streamSSE(c, async (stream) => {
|
||||
const q = new AsyncQueue<string | null>()
|
||||
|
|
@ -178,8 +176,13 @@ export const GlobalRoutes = lazy(() =>
|
|||
validator("json", Config.Info.zod),
|
||||
async (c) => {
|
||||
const config = c.req.valid("json")
|
||||
const next = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.updateGlobal(config)))
|
||||
return c.json(next)
|
||||
const result = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.updateGlobal(config)))
|
||||
if (result.changed) {
|
||||
void AppRuntime.runPromise(disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true })).catch(
|
||||
() => undefined,
|
||||
)
|
||||
}
|
||||
return c.json(result.info)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
|
|
@ -200,14 +203,7 @@ export const GlobalRoutes = lazy(() =>
|
|||
},
|
||||
}),
|
||||
async (c) => {
|
||||
await InstanceStore.disposeAllInstances()
|
||||
GlobalBus.emit("event", {
|
||||
directory: "global",
|
||||
payload: {
|
||||
type: GlobalDisposedEvent.type,
|
||||
properties: {},
|
||||
},
|
||||
})
|
||||
await AppRuntime.runPromise(disposeAllInstancesAndEmitGlobalDisposed())
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { Config } from "@/config/config"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
|
|
@ -55,7 +56,9 @@ export const ConfigRoutes = lazy(() =>
|
|||
jsonRequest("ConfigRoutes.update", c, function* () {
|
||||
const config = c.req.valid("json")
|
||||
const cfg = yield* Config.Service
|
||||
const store = yield* InstanceStore.Service
|
||||
yield* cfg.update(config)
|
||||
yield* store.dispose(yield* InstanceState.context)
|
||||
return config
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Config } from "@/config/config"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import "@/server/event"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { described } from "./metadata"
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export const configHandlers = HttpApiBuilder.group(InstanceHttpApi, "config", (h
|
|||
})
|
||||
|
||||
const update = Effect.fn("ConfigHttpApi.update")(function* (ctx) {
|
||||
yield* configSvc.update(ctx.payload, { dispose: false })
|
||||
yield* configSvc.update(ctx.payload)
|
||||
yield* markInstanceForDisposal(yield* InstanceState.context)
|
||||
return ctx.payload
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { Config } from "@/config/config"
|
||||
import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { Installation } from "@/installation"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Effect, Queue, Schema } from "effect"
|
||||
|
|
@ -68,7 +69,7 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl
|
|||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const installation = yield* Installation.Service
|
||||
const store = yield* InstanceStore.Service
|
||||
const bridge = yield* EffectBridge.make()
|
||||
|
||||
const health = Effect.fn("GlobalHttpApi.health")(function* () {
|
||||
return { healthy: true as const, version: InstallationVersion }
|
||||
|
|
@ -83,15 +84,13 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl
|
|||
})
|
||||
|
||||
const configUpdate = Effect.fn("GlobalHttpApi.configUpdate")(function* (ctx) {
|
||||
return yield* config.updateGlobal(ctx.payload)
|
||||
const result = yield* config.updateGlobal(ctx.payload)
|
||||
if (result.changed) bridge.fork(disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true }))
|
||||
return result.info
|
||||
})
|
||||
|
||||
const dispose = Effect.fn("GlobalHttpApi.dispose")(function* () {
|
||||
yield* store.disposeAll()
|
||||
GlobalBus.emit("event", {
|
||||
directory: "global",
|
||||
payload: { type: "global.disposed", properties: {} },
|
||||
})
|
||||
yield* disposeAllInstancesAndEmitGlobalDisposed()
|
||||
return true
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { WorkspaceRef } from "@/effect/instance-ref"
|
||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpRouter, HttpServerResponse } from "effect/unstable/http"
|
||||
|
|
@ -24,12 +23,11 @@ function decode(input: string): string {
|
|||
function provideInstanceContext<E>(
|
||||
effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E>,
|
||||
store: InstanceStore.Interface,
|
||||
bootstrap: InstanceBootstrap.Interface,
|
||||
): Effect.Effect<HttpServerResponse.HttpServerResponse, E, WorkspaceRouteContext> {
|
||||
return Effect.gen(function* () {
|
||||
const route = yield* WorkspaceRouteContext
|
||||
return yield* store.provide(
|
||||
{ directory: decode(route.directory), init: bootstrap.run },
|
||||
{ directory: decode(route.directory) },
|
||||
effect.pipe(Effect.provideService(WorkspaceRef, route.workspaceID)),
|
||||
)
|
||||
})
|
||||
|
|
@ -39,15 +37,13 @@ export const instanceContextLayer = Layer.effect(
|
|||
InstanceContextMiddleware,
|
||||
Effect.gen(function* () {
|
||||
const store = yield* InstanceStore.Service
|
||||
const bootstrap = yield* InstanceBootstrap.Service
|
||||
return InstanceContextMiddleware.of((effect) => provideInstanceContext(effect, store, bootstrap))
|
||||
return InstanceContextMiddleware.of((effect) => provideInstanceContext(effect, store))
|
||||
}),
|
||||
)
|
||||
|
||||
export const instanceRouterMiddleware = HttpRouter.middleware()(
|
||||
Effect.gen(function* () {
|
||||
const store = yield* InstanceStore.Service
|
||||
const bootstrap = yield* InstanceBootstrap.Service
|
||||
return (effect) => provideInstanceContext(effect, store, bootstrap)
|
||||
return (effect) => provideInstanceContext(effect, store)
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -18,8 +18,7 @@ import { LSP } from "@/lsp/lsp"
|
|||
import { MCP } from "@/mcp"
|
||||
import { Permission } from "@/permission"
|
||||
import { Installation } from "@/installation"
|
||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { InstanceRuntime } from "@/project/instance-runtime"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Project } from "@/project/project"
|
||||
import { ProviderAuth } from "@/provider/auth"
|
||||
|
|
@ -153,8 +152,7 @@ export function createRoutes(corsOptions?: CorsOptions) {
|
|||
Format.defaultLayer,
|
||||
LSP.defaultLayer,
|
||||
Installation.defaultLayer,
|
||||
InstanceBootstrap.defaultLayer,
|
||||
InstanceStore.defaultLayer,
|
||||
InstanceRuntime.layer,
|
||||
MCP.defaultLayer,
|
||||
ModelsDev.defaultLayer,
|
||||
Permission.defaultLayer,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import z from "zod"
|
|||
import { Format } from "@/format"
|
||||
import { TuiRoutes } from "./tui"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { InstanceRuntime } from "@/project/instance-runtime"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Skill } from "@/skill"
|
||||
|
|
@ -25,7 +25,6 @@ import { ExperimentalRoutes } from "./experimental"
|
|||
import { ProviderRoutes } from "./provider"
|
||||
import { EventRoutes } from "./event"
|
||||
import { SyncRoutes } from "./sync"
|
||||
import { InstanceMiddleware } from "./middleware"
|
||||
import { jsonRequest } from "./trace"
|
||||
|
||||
export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
|
||||
|
|
@ -63,7 +62,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
|
|||
},
|
||||
}),
|
||||
async (c) => {
|
||||
await InstanceStore.disposeInstance(Instance.current)
|
||||
await InstanceRuntime.disposeInstance(Instance.current)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import type { MiddlewareHandler } from "hono"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { getBootstrapRunEffect } from "@/effect/app-runtime"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
|
|
@ -23,7 +22,6 @@ export function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler
|
|||
async fn() {
|
||||
return Instance.provide({
|
||||
directory,
|
||||
init: await getBootstrapRunEffect(),
|
||||
async fn() {
|
||||
return next()
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,13 +2,12 @@ import { Hono } from "hono"
|
|||
import { describeRoute, validator } from "hono-openapi"
|
||||
import { resolver } from "hono-openapi"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { InstanceRuntime } from "@/project/instance-runtime"
|
||||
import { Project } from "@/project/project"
|
||||
import z from "zod"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { getBootstrapRunEffect } from "@/effect/app-runtime"
|
||||
import { jsonRequest, runRequest } from "./trace"
|
||||
|
||||
export const ProjectRoutes = lazy(() =>
|
||||
|
|
@ -82,12 +81,7 @@ export const ProjectRoutes = lazy(() =>
|
|||
Project.Service.use((svc) => svc.initGit({ directory: dir, project: prev })),
|
||||
)
|
||||
if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next)
|
||||
await InstanceStore.reloadInstance({
|
||||
directory: dir,
|
||||
worktree: dir,
|
||||
project: next,
|
||||
init: await getBootstrapRunEffect(),
|
||||
})
|
||||
await InstanceRuntime.reloadInstance({ directory: dir, worktree: dir, project: next })
|
||||
return c.json(next)
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { WorkspaceID } from "@/control-plane/schema"
|
|||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { getBootstrapRunEffect, AppRuntime } from "@/effect/app-runtime"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Session } from "@/session/session"
|
||||
import { SessionID } from "@/session/schema"
|
||||
|
|
@ -94,13 +94,11 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
|||
const target = await adapter.target(workspace)
|
||||
|
||||
if (target.type === "local") {
|
||||
const init = await getBootstrapRunEffect()
|
||||
return WorkspaceContext.provide({
|
||||
workspaceID: WorkspaceID.make(workspaceID),
|
||||
fn: () =>
|
||||
Instance.provide({
|
||||
directory: target.directory,
|
||||
init,
|
||||
async fn() {
|
||||
return next()
|
||||
},
|
||||
|
|
|
|||
51
packages/opencode/test/agent/plugin-agent-regression.test.ts
Normal file
51
packages/opencode/test/agent/plugin-agent-regression.test.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { afterEach, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
})
|
||||
|
||||
test("plugin-registered agents appear in Agent.list", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const pluginFile = path.join(dir, "plugin.ts")
|
||||
await Bun.write(
|
||||
pluginFile,
|
||||
[
|
||||
"export default async () => ({",
|
||||
" config: async (cfg) => {",
|
||||
" cfg.agent = cfg.agent ?? {}",
|
||||
" cfg.agent.plugin_added = {",
|
||||
' description: "Added by a plugin via the config hook",',
|
||||
' mode: "subagent",',
|
||||
" }",
|
||||
" },",
|
||||
"})",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
plugin: [pathToFileURL(pluginFile).href],
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const agents = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list()))
|
||||
const added = agents.find((agent) => agent.name === "plugin_added")
|
||||
expect(added?.description).toBe("Added by a plugin via the config hook")
|
||||
expect(added?.mode).toBe("subagent")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
@ -12,8 +12,9 @@ import { Account } from "../../src/account/account"
|
|||
import { AccessToken, AccountID, OrgID } from "../../src/account/schema"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Env } from "../../src/env"
|
||||
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { provideTestInstance, provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { InstanceRuntime } from "@/project/instance-runtime"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
|
|
@ -41,6 +42,12 @@ const emptyAuth = Layer.mock(Auth.Service)({
|
|||
|
||||
const testFlock = EffectFlock.defaultLayer
|
||||
|
||||
const noopNpm = Layer.mock(Npm.Service)({
|
||||
install: () => Effect.void,
|
||||
add: () => Effect.die("not implemented"),
|
||||
which: () => Effect.succeed(Option.none()),
|
||||
})
|
||||
|
||||
const layer = Config.layer.pipe(
|
||||
Layer.provide(testFlock),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
|
|
@ -48,7 +55,7 @@ const layer = Config.layer.pipe(
|
|||
Layer.provide(emptyAuth),
|
||||
Layer.provide(emptyAccount),
|
||||
Layer.provideMerge(infra),
|
||||
Layer.provide(Npm.defaultLayer),
|
||||
Layer.provide(noopNpm),
|
||||
)
|
||||
|
||||
const it = testEffect(layer)
|
||||
|
|
@ -57,9 +64,17 @@ const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe
|
|||
const save = (config: Config.Info) =>
|
||||
Effect.runPromise(Config.Service.use((svc) => svc.update(config)).pipe(Effect.scoped, Effect.provide(layer)))
|
||||
const saveGlobal = (config: Config.Info) =>
|
||||
Effect.runPromise(Config.Service.use((svc) => svc.updateGlobal(config)).pipe(Effect.scoped, Effect.provide(layer)))
|
||||
const clear = (wait = false) =>
|
||||
Effect.runPromise(Config.Service.use((svc) => svc.invalidate(wait)).pipe(Effect.scoped, Effect.provide(layer)))
|
||||
Effect.runPromise(
|
||||
Config.Service.use((svc) => svc.updateGlobal(config)).pipe(
|
||||
Effect.map((result) => result.info),
|
||||
Effect.scoped,
|
||||
Effect.provide(layer),
|
||||
),
|
||||
)
|
||||
const clear = async (wait = false) => {
|
||||
await Effect.runPromise(Config.Service.use((svc) => svc.invalidate()).pipe(Effect.scoped, Effect.provide(layer)))
|
||||
if (wait) await InstanceRuntime.disposeAllInstances()
|
||||
}
|
||||
const listDirs = () =>
|
||||
Effect.runPromise(Config.Service.use((svc) => svc.directories()).pipe(Effect.scoped, Effect.provide(layer)))
|
||||
const ready = () =>
|
||||
|
|
@ -108,7 +123,7 @@ async function check(map: (dir: string) => string) {
|
|||
},
|
||||
})
|
||||
} finally {
|
||||
await disposeAllInstances()
|
||||
await InstanceRuntime.disposeAllInstances()
|
||||
;(Global.Path as { config: string }).config = prev
|
||||
await clear()
|
||||
}
|
||||
|
|
@ -483,6 +498,7 @@ test("resolves env templates in account config with account token", async () =>
|
|||
Layer.provide(emptyAuth),
|
||||
Layer.provide(fakeAccount),
|
||||
Layer.provideMerge(infra),
|
||||
Layer.provide(noopNpm),
|
||||
)
|
||||
|
||||
try {
|
||||
|
|
@ -493,7 +509,7 @@ test("resolves env templates in account config with account token", async () =>
|
|||
expect(config.provider?.["opencode"]?.options?.apiKey).toBe("st_test_token")
|
||||
}),
|
||||
),
|
||||
).pipe(Effect.scoped, Effect.provide(layer), Effect.provide(Npm.defaultLayer), Effect.runPromise)
|
||||
).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
|
||||
} finally {
|
||||
if (originalControlToken !== undefined) {
|
||||
process.env["OPENCODE_CONSOLE_TOKEN"] = originalControlToken
|
||||
|
|
@ -550,7 +566,7 @@ test("validates config schema and throws on invalid fields", async () => {
|
|||
})
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await provideTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// Strict schema should throw an error for invalid fields
|
||||
|
|
@ -565,7 +581,7 @@ test("throws error for invalid JSON", async () => {
|
|||
await Filesystem.write(path.join(dir, "opencode.json"), "{ invalid json }")
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
await provideTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await expect(load()).rejects.toThrow()
|
||||
|
|
@ -986,11 +1002,6 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
|
|||
const prev = process.env.OPENCODE_CONFIG_DIR
|
||||
process.env.OPENCODE_CONFIG_DIR = tmp.extra
|
||||
|
||||
const noopNpm = Layer.mock(Npm.Service)({
|
||||
install: () => Effect.void,
|
||||
add: () => Effect.die("not implemented"),
|
||||
which: () => Effect.succeed(Option.none()),
|
||||
})
|
||||
const testLayer = Config.layer.pipe(
|
||||
Layer.provide(testFlock),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
|
|
@ -1061,7 +1072,7 @@ test("resolves scoped npm plugins in config", async () => {
|
|||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await provideTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
|
|
@ -1099,7 +1110,7 @@ test("merges plugin arrays from global and local configs", async () => {
|
|||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await provideTestInstance({
|
||||
directory: path.join(tmp.path, "project"),
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
|
|
@ -1258,7 +1269,7 @@ test("deduplicates duplicate plugins from global and local configs", async () =>
|
|||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await provideTestInstance({
|
||||
directory: path.join(tmp.path, "project"),
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
|
|
@ -1307,7 +1318,7 @@ test("keeps plugin origins aligned with merged plugin list", async () => {
|
|||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await provideTestInstance({
|
||||
directory: path.join(tmp.path, "project"),
|
||||
fn: async () => {
|
||||
const cfg = await load()
|
||||
|
|
@ -1883,7 +1894,7 @@ test("project config overrides remote well-known config", async () => {
|
|||
Layer.provide(fakeAuth),
|
||||
Layer.provide(emptyAccount),
|
||||
Layer.provideMerge(infra),
|
||||
Layer.provide(Npm.defaultLayer),
|
||||
Layer.provide(noopNpm),
|
||||
)
|
||||
|
||||
try {
|
||||
|
|
@ -1941,7 +1952,7 @@ test("wellknown URL with trailing slash is normalized", async () => {
|
|||
Layer.provide(fakeAuth),
|
||||
Layer.provide(emptyAccount),
|
||||
Layer.provideMerge(infra),
|
||||
Layer.provide(Npm.defaultLayer),
|
||||
Layer.provide(noopNpm),
|
||||
)
|
||||
|
||||
try {
|
||||
|
|
@ -2096,7 +2107,7 @@ describe("deduplicatePluginOrigins", () => {
|
|||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await provideTestInstance({
|
||||
directory: path.join(tmp.path, "project"),
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { afterEach, beforeEach, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { provideTestInstance, tmpdir } from "../fixture/fixture"
|
||||
import { InstanceRuntime } from "@/project/instance-runtime"
|
||||
import { TuiConfig } from "../../src/cli/cmd/tui/config/tui"
|
||||
import { Config } from "@/config/config"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
|
|
@ -13,7 +13,10 @@ import { CurrentWorkingDirectory } from "@/cli/cmd/tui/config/cwd"
|
|||
import { ConfigPlugin } from "@/config/plugin"
|
||||
|
||||
const wintest = process.platform === "win32" ? test : test.skip
|
||||
const clear = (wait = false) => AppRuntime.runPromise(Config.Service.use((svc) => svc.invalidate(wait)))
|
||||
const clear = async (wait = false) => {
|
||||
await AppRuntime.runPromise(Config.Service.use((svc) => svc.invalidate()))
|
||||
if (wait) await InstanceRuntime.disposeAllInstances()
|
||||
}
|
||||
const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get()))
|
||||
|
||||
beforeEach(async () => {
|
||||
|
|
@ -87,7 +90,7 @@ test("keeps server and tui plugin merge semantics aligned", async () => {
|
|||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
await provideTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const server = await load()
|
||||
|
|
|
|||
|
|
@ -3,9 +3,8 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
|||
import { $ } from "bun"
|
||||
import { Context, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { InstanceStore } from "../../src/project/instance-store"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { disposeAllInstances, provideInstance, reloadTestInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(CrossSpawnSpawner.defaultLayer)
|
||||
|
|
@ -70,7 +69,7 @@ it.live("InstanceState invalidates on reload", () =>
|
|||
)
|
||||
|
||||
const a = yield* access(state, dir)
|
||||
yield* Effect.promise(() => InstanceStore.reloadInstance({ directory: dir }))
|
||||
yield* Effect.promise(() => reloadTestInstance({ directory: dir }))
|
||||
const b = yield* access(state, dir)
|
||||
|
||||
expect(a).not.toBe(b)
|
||||
|
|
@ -270,7 +269,7 @@ it.live("InstanceState correct after interleaved init and dispose", () =>
|
|||
|
||||
const [, b] = yield* Effect.all(
|
||||
[
|
||||
Effect.promise(() => InstanceStore.reloadInstance({ directory: one })),
|
||||
Effect.promise(() => reloadTestInstance({ directory: one })),
|
||||
Test.use((svc) => svc.get()).pipe(provideInstance(two)),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
|
|
|
|||
23
packages/opencode/test/fixture/config.ts
Normal file
23
packages/opencode/test/fixture/config.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { Config } from "@/config/config"
|
||||
import { emptyConsoleState } from "@/config/console-state"
|
||||
import { Effect, Layer } from "effect"
|
||||
|
||||
export function make(overrides: Partial<Config.Interface> = {}) {
|
||||
return Config.Service.of({
|
||||
get: () => Effect.succeed({}),
|
||||
getGlobal: () => Effect.succeed({}),
|
||||
getConsoleState: () => Effect.succeed(emptyConsoleState),
|
||||
update: () => Effect.void,
|
||||
updateGlobal: (config) => Effect.succeed({ info: config, changed: false }),
|
||||
invalidate: () => Effect.void,
|
||||
directories: () => Effect.succeed([]),
|
||||
waitForDependencies: () => Effect.void,
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
export function layer(overrides?: Partial<Config.Interface>) {
|
||||
return Layer.succeed(Config.Service, make(overrides))
|
||||
}
|
||||
|
||||
export * as TestConfig from "./config"
|
||||
|
|
@ -1,20 +1,44 @@
|
|||
import { $ } from "bun"
|
||||
import * as Observability from "@opencode-ai/core/effect/observability"
|
||||
import * as fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Effect, Context } from "effect"
|
||||
import { Effect, Context, Layer, ManagedRuntime } from "effect"
|
||||
import type * as PlatformError from "effect/PlatformError"
|
||||
import type * as Scope from "effect/Scope"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import type { Config } from "@/config/config"
|
||||
import { InstanceRef } from "../../src/effect/instance-ref"
|
||||
import { InstanceBootstrap } from "../../src/project/bootstrap-service"
|
||||
import { InstanceRuntime } from "../../src/project/instance-runtime"
|
||||
import { InstanceStore } from "../../src/project/instance-store"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { TestLLMServer } from "../lib/llm-server"
|
||||
|
||||
// Re-export for test ergonomics. The implementation lives next to the runtime
|
||||
// it consumes; see `InstanceStore.disposeAllInstances` for the rationale.
|
||||
export { disposeAllInstances } from "../../src/project/instance-store"
|
||||
const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
|
||||
const testInstanceRuntime = ManagedRuntime.make(
|
||||
InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap), Layer.provideMerge(Observability.layer)),
|
||||
)
|
||||
|
||||
const runTestInstanceStore = <A>(fn: (store: InstanceStore.Interface) => Effect.Effect<A>) =>
|
||||
testInstanceRuntime.runPromise(InstanceStore.Service.use(fn))
|
||||
|
||||
export async function provideTestInstance<R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) {
|
||||
const ctx = await runTestInstanceStore((store) => store.load({ directory: input.directory, init: input.init }))
|
||||
try {
|
||||
return await Instance.restore(ctx, () => input.fn())
|
||||
} finally {
|
||||
await runTestInstanceStore((store) => store.dispose(ctx))
|
||||
}
|
||||
}
|
||||
|
||||
export async function reloadTestInstance(input: { directory: string }) {
|
||||
return runTestInstanceStore((store) => store.reload(input))
|
||||
}
|
||||
|
||||
export async function disposeAllInstances() {
|
||||
await Promise.all([InstanceRuntime.disposeAllInstances(), runTestInstanceStore((store) => store.disposeAll())])
|
||||
}
|
||||
|
||||
// Strip null bytes from paths (defensive fix for CI environment issues)
|
||||
function sanitizePath(p: string): string {
|
||||
|
|
@ -129,12 +153,10 @@ export const provideInstance =
|
|||
(directory: string) =>
|
||||
<A, E, R>(self: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
|
||||
Effect.contextWith((services: Context.Context<R>) =>
|
||||
Effect.promise<A>(async () =>
|
||||
Instance.provide({
|
||||
directory,
|
||||
fn: () => Effect.runPromiseWith(services)(self.pipe(Effect.provideService(InstanceRef, Instance.current))),
|
||||
}),
|
||||
),
|
||||
Effect.promise<A>(async () => {
|
||||
const ctx = await runTestInstanceStore((store) => store.load({ directory }))
|
||||
return Instance.restore(ctx, () => Effect.runPromiseWith(services)(self.pipe(Effect.provideService(InstanceRef, ctx))))
|
||||
}),
|
||||
)
|
||||
|
||||
export function provideTmpdirInstance<A, E, R>(
|
||||
|
|
@ -148,10 +170,7 @@ export function provideTmpdirInstance<A, E, R>(
|
|||
yield* Effect.addFinalizer(() =>
|
||||
provided
|
||||
? Effect.promise(() =>
|
||||
Instance.provide({
|
||||
directory: path,
|
||||
fn: () => InstanceStore.disposeInstance(Instance.current),
|
||||
}),
|
||||
runTestInstanceStore((store) => store.load({ directory: path }).pipe(Effect.flatMap((ctx) => store.dispose(ctx)))),
|
||||
).pipe(Effect.ignore)
|
||||
: Effect.void,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { test, expect, mock, beforeEach } from "bun:test"
|
||||
import { InstanceStore } from "../../src/project/instance-store"
|
||||
import { InstanceRuntime } from "../../src/project/instance-runtime"
|
||||
import { Effect } from "effect"
|
||||
import type { MCP as MCPNS } from "../../src/mcp/index"
|
||||
|
||||
|
|
@ -198,7 +198,7 @@ function withInstance(
|
|||
fn: async () => {
|
||||
await Effect.runPromise(MCP.Service.use(fn).pipe(Effect.provide(MCP.defaultLayer)))
|
||||
// dispose instance to clean up state between tests
|
||||
await InstanceStore.disposeInstance(Instance.current)
|
||||
await InstanceRuntime.disposeInstance(Instance.current)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,14 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
|||
import { Permission } from "../../src/permission"
|
||||
import { PermissionID } from "../../src/permission/schema"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { InstanceStore } from "../../src/project/instance-store"
|
||||
import { disposeAllInstances, provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { InstanceRuntime } from "../../src/project/instance-runtime"
|
||||
import {
|
||||
disposeAllInstances,
|
||||
provideInstance,
|
||||
provideTmpdirInstance,
|
||||
reloadTestInstance,
|
||||
tmpdirScoped,
|
||||
} from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { MessageID, SessionID } from "../../src/session/schema"
|
||||
|
||||
|
|
@ -1000,7 +1006,7 @@ it.live("pending permission rejects on instance dispose", () =>
|
|||
|
||||
expect(yield* waitForPending(1).pipe(run)).toHaveLength(1)
|
||||
yield* Effect.promise(() =>
|
||||
Instance.provide({ directory: dir, fn: () => void InstanceStore.disposeInstance(Instance.current) }),
|
||||
Instance.provide({ directory: dir, fn: () => void InstanceRuntime.disposeInstance(Instance.current) }),
|
||||
)
|
||||
|
||||
const exit = yield* Fiber.await(fiber)
|
||||
|
|
@ -1024,7 +1030,7 @@ it.live("pending permission rejects on instance reload", () =>
|
|||
}).pipe(run, Effect.forkScoped)
|
||||
|
||||
expect(yield* waitForPending(1).pipe(run)).toHaveLength(1)
|
||||
yield* Effect.promise(() => InstanceStore.reloadInstance({ directory: dir }))
|
||||
yield* Effect.promise(() => reloadTestInstance({ directory: dir }))
|
||||
|
||||
const exit = yield* Fiber.await(fiber)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
|
|
@ -1118,7 +1124,7 @@ it.live("ask - abort should clear pending request", () =>
|
|||
|
||||
const pending = yield* waitForPending(1).pipe(run)
|
||||
expect(pending).toHaveLength(1)
|
||||
yield* Effect.promise(() => InstanceStore.reloadInstance({ directory: dir }))
|
||||
yield* Effect.promise(() => reloadTestInstance({ directory: dir }))
|
||||
|
||||
const exit = yield* Fiber.await(fiber)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,40 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Effect } from "effect"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { provideTestInstance, tmpdir } from "../fixture/fixture"
|
||||
import { ProviderAuth } from "@/provider/auth"
|
||||
import { ProviderID } from "../../src/provider/schema"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Auth } from "@/auth"
|
||||
import { Bus } from "@/bus"
|
||||
import { TestConfig } from "../fixture/config"
|
||||
|
||||
function layer(directory: string, plugins: string[]) {
|
||||
return ProviderAuth.layer.pipe(
|
||||
Layer.provide(Auth.defaultLayer),
|
||||
Layer.provide(
|
||||
Plugin.layer.pipe(
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(
|
||||
TestConfig.layer({
|
||||
get: () =>
|
||||
Effect.succeed({
|
||||
plugin: plugins,
|
||||
plugin_origins: plugins.map((plugin) => ({
|
||||
spec: plugin,
|
||||
source: path.join(directory, "opencode.json"),
|
||||
scope: "local" as const,
|
||||
})),
|
||||
}),
|
||||
directories: () => Effect.succeed([directory]),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
describe("plugin.auth-override", () => {
|
||||
test("user plugin overrides built-in github-copilot auth", async () => {
|
||||
|
|
@ -37,30 +66,32 @@ describe("plugin.auth-override", () => {
|
|||
|
||||
await using plain = await tmpdir()
|
||||
|
||||
const methods = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
return Effect.runPromise(
|
||||
ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(ProviderAuth.defaultLayer)),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const plainMethods = await Instance.provide({
|
||||
directory: plain.path,
|
||||
fn: async () => {
|
||||
return Effect.runPromise(
|
||||
ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(ProviderAuth.defaultLayer)),
|
||||
)
|
||||
},
|
||||
})
|
||||
const plugin = pathToFileURL(path.join(tmp.path, ".opencode", "plugin", "custom-copilot-auth.ts")).href
|
||||
const [methods, plainMethods] = await Promise.all([
|
||||
provideTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
return Effect.runPromise(
|
||||
ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(layer(tmp.path, [plugin]))),
|
||||
)
|
||||
},
|
||||
}),
|
||||
provideTestInstance({
|
||||
directory: plain.path,
|
||||
fn: async () => {
|
||||
return Effect.runPromise(
|
||||
ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(layer(plain.path, []))),
|
||||
)
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
const copilot = methods[ProviderID.make("github-copilot")]
|
||||
expect(copilot).toBeDefined()
|
||||
expect(copilot.length).toBe(1)
|
||||
expect(copilot[0].label).toBe("Test Override Auth")
|
||||
expect(plainMethods[ProviderID.make("github-copilot")][0].label).not.toBe("Test Override Auth")
|
||||
}, 30000) // Increased timeout for plugin installation
|
||||
}, 30000)
|
||||
})
|
||||
|
||||
const file = path.join(import.meta.dir, "../../src/plugin/index.ts")
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { afterAll, afterEach, describe, expect, spyOn, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
|
||||
const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
|
||||
|
|
@ -12,8 +12,9 @@ process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
|
|||
const { Plugin } = await import("../../src/plugin/index")
|
||||
const { PluginLoader } = await import("../../src/plugin/loader")
|
||||
const { readPackageThemes } = await import("../../src/plugin/shared")
|
||||
const { Instance } = await import("../../src/project/instance")
|
||||
const { Bus } = await import("../../src/bus")
|
||||
const { Npm } = await import("@opencode-ai/core/npm")
|
||||
const { TestConfig } = await import("../fixture/config")
|
||||
|
||||
afterAll(() => {
|
||||
if (disableDefault === undefined) {
|
||||
|
|
@ -28,14 +29,31 @@ afterEach(async () => {
|
|||
})
|
||||
|
||||
async function load(dir: string) {
|
||||
return Instance.provide({
|
||||
directory: dir,
|
||||
fn: async () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
yield* plugin.list()
|
||||
}).pipe(Effect.provide(Plugin.defaultLayer), Effect.runPromise),
|
||||
})
|
||||
const source = path.join(dir, "opencode.json")
|
||||
const config = (await Bun.file(source).json()) as { plugin?: Array<string | [string, Record<string, unknown>]> }
|
||||
const plugins = config.plugin ?? []
|
||||
return Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
yield* plugin.list()
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
Plugin.layer.pipe(
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(
|
||||
TestConfig.layer({
|
||||
get: () =>
|
||||
Effect.succeed({
|
||||
plugin: plugins,
|
||||
plugin_origins: plugins.map((plugin) => ({ spec: plugin, source, scope: "local" as const })),
|
||||
}),
|
||||
directories: () => Effect.succeed([dir]),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
provideInstance(dir),
|
||||
Effect.runPromise,
|
||||
)
|
||||
}
|
||||
|
||||
describe("plugin.loader.shared", () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
import { afterEach, expect, test } from "bun:test"
|
||||
import { Hono } from "hono"
|
||||
import { existsSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { bootstrap as cliBootstrap } from "../../src/cli/bootstrap"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { InstanceRuntime } from "../../src/project/instance-runtime"
|
||||
import { InstanceMiddleware } from "../../src/server/routes/instance/middleware"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
|
||||
// These regressions cover the legacy instance-loading paths fixed by PRs
|
||||
// #25389 and #25449. The plugin config hook writes a marker file, and the test
|
||||
// bodies deliberately avoid touching Plugin or config directly. The marker only
|
||||
// exists if InstanceBootstrap ran at the instance boundary.
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
})
|
||||
|
||||
async function bootstrapFixture() {
|
||||
return tmpdir({
|
||||
init: async (dir) => {
|
||||
const marker = path.join(dir, "config-hook-fired")
|
||||
const pluginFile = path.join(dir, "plugin.ts")
|
||||
await Bun.write(
|
||||
pluginFile,
|
||||
[
|
||||
`const MARKER = ${JSON.stringify(marker)}`,
|
||||
"export default async () => ({",
|
||||
" config: async () => {",
|
||||
' await Bun.write(MARKER, "ran")',
|
||||
" },",
|
||||
"})",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
plugin: [pathToFileURL(pluginFile).href],
|
||||
}),
|
||||
)
|
||||
return marker
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
test("Instance.provide runs InstanceBootstrap before fn (boundary invariant)", async () => {
|
||||
await using tmp = await bootstrapFixture()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => "ok",
|
||||
})
|
||||
|
||||
expect(existsSync(tmp.extra)).toBe(true)
|
||||
})
|
||||
|
||||
test("CLI bootstrap runs InstanceBootstrap before callback", async () => {
|
||||
await using tmp = await bootstrapFixture()
|
||||
|
||||
await cliBootstrap(tmp.path, async () => "ok")
|
||||
|
||||
expect(existsSync(tmp.extra)).toBe(true)
|
||||
})
|
||||
|
||||
test("legacy Hono instance middleware runs InstanceBootstrap before next handler", async () => {
|
||||
await using tmp = await bootstrapFixture()
|
||||
const app = new Hono().use(InstanceMiddleware()).get("/probe", (c) => c.text("ok"))
|
||||
|
||||
const response = await app.request("/probe", { headers: { "x-opencode-directory": tmp.path } })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(existsSync(tmp.extra)).toBe(true)
|
||||
})
|
||||
|
||||
test("InstanceRuntime.reloadInstance runs InstanceBootstrap", async () => {
|
||||
await using tmp = await bootstrapFixture()
|
||||
|
||||
await InstanceRuntime.reloadInstance({ directory: tmp.path })
|
||||
|
||||
expect(existsSync(tmp.extra)).toBe(true)
|
||||
})
|
||||
|
|
@ -3,12 +3,17 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
|||
import { Effect, Fiber, Layer } from "effect"
|
||||
import { InstanceRef } from "../../src/effect/instance-ref"
|
||||
import { registerDisposer } from "../../src/effect/instance-registry"
|
||||
import { InstanceBootstrap } from "../../src/project/bootstrap-service"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { InstanceStore } from "../../src/project/instance-store"
|
||||
import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(InstanceStore.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
||||
const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(InstanceStore.defaultLayer, CrossSpawnSpawner.defaultLayer).pipe(Layer.provide(noopBootstrap)),
|
||||
)
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import path from "path"
|
|||
import { Cause, Effect, Exit, Layer } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { InstanceStore } from "../../src/project/instance-store"
|
||||
import { InstanceRuntime } from "../../src/project/instance-runtime"
|
||||
import { Worktree } from "../../src/worktree"
|
||||
import { disposeAllInstances, provideInstance, provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
|
@ -138,9 +138,10 @@ describe("Worktree", () => {
|
|||
expect(props.branch).toBe(info.branch)
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
InstanceStore.runtime.runPromise((s) =>
|
||||
s.load({ directory: info.directory }).pipe(Effect.flatMap(s.dispose)),
|
||||
),
|
||||
Instance.provide({
|
||||
directory: info.directory,
|
||||
fn: () => InstanceRuntime.disposeInstance(Instance.current),
|
||||
}),
|
||||
)
|
||||
yield* Effect.promise(() => Bun.sleep(100))
|
||||
yield* svc.remove({ directory: info.directory })
|
||||
|
|
@ -162,9 +163,10 @@ describe("Worktree", () => {
|
|||
|
||||
yield* Effect.promise(() => ready)
|
||||
yield* Effect.promise(() =>
|
||||
InstanceStore.runtime.runPromise((s) =>
|
||||
s.load({ directory: info.directory }).pipe(Effect.flatMap(s.dispose)),
|
||||
),
|
||||
Instance.provide({
|
||||
directory: info.directory,
|
||||
fn: () => InstanceRuntime.disposeInstance(Instance.current),
|
||||
}),
|
||||
)
|
||||
yield* Effect.promise(() => Bun.sleep(100))
|
||||
yield* svc.remove({ directory: info.directory })
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { afterEach, test, expect } from "bun:test"
|
||||
import { Question } from "../../src/question"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { InstanceStore } from "../../src/project/instance-store"
|
||||
import { InstanceRuntime } from "../../src/project/instance-runtime"
|
||||
import { QuestionID } from "../../src/question/schema"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { SessionID } from "../../src/session/schema"
|
||||
|
|
@ -422,7 +422,7 @@ test("pending question rejects on instance dispose", async () => {
|
|||
fn: async () => {
|
||||
const items = await list()
|
||||
expect(items).toHaveLength(1)
|
||||
await InstanceStore.disposeInstance(Instance.current)
|
||||
await InstanceRuntime.disposeInstance(Instance.current)
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -457,7 +457,7 @@ test("pending question rejects on instance reload", async () => {
|
|||
fn: async () => {
|
||||
const items = await list()
|
||||
expect(items).toHaveLength(1)
|
||||
await InstanceStore.reloadInstance({ directory: tmp.path })
|
||||
await InstanceRuntime.reloadInstance({ directory: tmp.path })
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -11,9 +11,8 @@ import { registerAdapter } from "../../src/control-plane/adapters"
|
|||
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
||||
import { Workspace } from "../../src/control-plane/workspace"
|
||||
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
|
||||
import { InstanceBootstrap } from "../../src/project/bootstrap"
|
||||
import { InstanceRuntime } from "../../src/project/instance-runtime"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { InstanceStore } from "../../src/project/instance-store"
|
||||
import { Project } from "../../src/project/project"
|
||||
import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle"
|
||||
import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
|
||||
|
|
@ -42,8 +41,7 @@ const it = testEffect(
|
|||
testStateLayer,
|
||||
NodeHttpServer.layerTest,
|
||||
NodeServices.layer,
|
||||
InstanceBootstrap.defaultLayer,
|
||||
InstanceStore.defaultLayer,
|
||||
InstanceRuntime.layer,
|
||||
Project.defaultLayer,
|
||||
Workspace.defaultLayer,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { Flag } from "@opencode-ai/core/flag/flag"
|
|||
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { InstanceStore } from "../../src/project/instance-store"
|
||||
import { InstanceRuntime } from "../../src/project/instance-runtime"
|
||||
import { Server } from "../../src/server/server"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
|
|
@ -59,7 +59,7 @@ function withMcpProject<A, E, R>(self: (dir: string) => Effect.Effect<A, E, R>)
|
|||
)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() =>
|
||||
Instance.provide({ directory: dir, fn: () => InstanceStore.disposeInstance(Instance.current) }),
|
||||
Instance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }),
|
||||
).pipe(Effect.ignore),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Effect, FileSystem, Layer, Path } from "effect"
|
|||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { InstanceStore } from "../../src/project/instance-store"
|
||||
import { InstanceRuntime } from "../../src/project/instance-runtime"
|
||||
import { Server } from "../../src/server/server"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
|
|
@ -91,7 +91,7 @@ function withProviderProject<A, E, R>(self: (dir: string) => Effect.Effect<A, E,
|
|||
yield* writeProviderAuthPlugin(dir)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() =>
|
||||
Instance.provide({ directory: dir, fn: () => InstanceStore.disposeInstance(Instance.current) }),
|
||||
Instance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }),
|
||||
).pipe(Effect.ignore),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { Snapshot } from "../../src/snapshot"
|
|||
import { ProviderTest } from "../fake/provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { TestConfig } from "../fixture/config"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
|
|
@ -208,7 +209,7 @@ function layer(result: "continue" | "compact") {
|
|||
|
||||
function cfg(compaction?: Config.Info["compaction"]) {
|
||||
const base = Config.Info.zod.parse({})
|
||||
return Layer.mock(Config.Service)({
|
||||
return TestConfig.layer({
|
||||
get: () => Effect.succeed({ ...base, compaction }),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@ import { FetchHttpClient } from "effect/unstable/http"
|
|||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Config } from "@/config/config"
|
||||
import { emptyConsoleState } from "@/config/console-state"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { Instruction } from "../../src/session/instruction"
|
||||
import type { MessageV2 } from "../../src/session/message-v2"
|
||||
|
|
@ -14,22 +12,11 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
|||
import { Global } from "@opencode-ai/core/global"
|
||||
import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { TestConfig } from "../fixture/config"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer))
|
||||
|
||||
const configLayer = Layer.succeed(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
get: () => Effect.succeed({}),
|
||||
getGlobal: () => Effect.succeed({}),
|
||||
getConsoleState: () => Effect.succeed(emptyConsoleState),
|
||||
update: () => Effect.void,
|
||||
updateGlobal: (config) => Effect.succeed(config),
|
||||
invalidate: () => Effect.void,
|
||||
directories: () => Effect.succeed([]),
|
||||
waitForDependencies: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
const configLayer = TestConfig.layer()
|
||||
|
||||
const instructionLayer = (global: Partial<Global.Interface>) =>
|
||||
Instruction.layer.pipe(
|
||||
|
|
|
|||
|
|
@ -7,10 +7,50 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
|||
import { ToolRegistry } from "@/tool/registry"
|
||||
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { TestConfig } from "../fixture/config"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Question } from "@/question"
|
||||
import { Todo } from "@/session/todo"
|
||||
import { Skill } from "@/skill"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Session } from "@/session/session"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import { Instruction } from "@/session/instruction"
|
||||
import { Bus } from "@/bus"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { Format } from "@/format"
|
||||
import { Ripgrep } from "@/file/ripgrep"
|
||||
import * as Truncate from "@/tool/truncate"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
|
||||
const node = CrossSpawnSpawner.defaultLayer
|
||||
const configLayer = TestConfig.layer({
|
||||
directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])),
|
||||
})
|
||||
|
||||
const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node))
|
||||
const registryLayer = ToolRegistry.layer.pipe(
|
||||
Layer.provide(configLayer),
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(Question.defaultLayer),
|
||||
Layer.provide(Todo.defaultLayer),
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
Layer.provide(Agent.defaultLayer),
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(LSP.defaultLayer),
|
||||
Layer.provide(Instruction.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(Format.defaultLayer),
|
||||
Layer.provide(node),
|
||||
Layer.provide(Ripgrep.defaultLayer),
|
||||
Layer.provide(Truncate.defaultLayer),
|
||||
)
|
||||
|
||||
const it = testEffect(Layer.mergeAll(registryLayer, node))
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { Filesystem } from "@/util/filesystem"
|
|||
import path from "path"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { writeFileStringScoped } from "../lib/filesystem"
|
||||
import { TestConfig } from "../fixture/config"
|
||||
|
||||
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
|
||||
const ROOT = path.resolve(import.meta.dir, "..", "..")
|
||||
|
|
@ -19,7 +20,7 @@ const configuredLayer = (cfg: Config.Info) =>
|
|||
Layer.mergeAll(
|
||||
Truncate.defaultLayer,
|
||||
NodeFileSystem.layer,
|
||||
Layer.mock(Config.Service)({ get: () => Effect.succeed(cfg) }),
|
||||
TestConfig.layer({ get: () => Effect.succeed(cfg) }),
|
||||
)
|
||||
const configuredIt = (cfg: Config.Info) => testEffect(configuredLayer(cfg))
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue