mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-01 15:54:32 +00:00
feat(sdk): configure session-selected instances
This commit is contained in:
parent
cc0cc59700
commit
cd4be9646f
16 changed files with 952 additions and 35 deletions
|
|
@ -1,6 +1,8 @@
|
|||
import { Context, Effect, Layer, LayerMap } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Node } from "@opencode-ai/util/effect/app-node"
|
||||
import { AbsolutePath } from "@opencode-ai/schema/schema"
|
||||
import path from "path"
|
||||
import { Location } from "./location.js"
|
||||
import type { Instance } from "./instance.js"
|
||||
|
||||
|
|
@ -15,4 +17,12 @@ export class Service extends Context.Service<
|
|||
|
||||
export const node = LayerNode.unbound(Service, Node.tags.values.global)
|
||||
|
||||
/** Normalize equivalent placements before they become resource-cache keys. */
|
||||
export function canonical(ref: Location.Ref) {
|
||||
return Location.Ref.make({
|
||||
directory: AbsolutePath.make(process.platform === "win32" ? path.normalize(ref.directory) : ref.directory),
|
||||
workspaceID: ref.workspaceID,
|
||||
})
|
||||
}
|
||||
|
||||
export * as LocationServiceMap from "./location-service-map.js"
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
import { Duration, Effect, Layer, LayerMap } from "effect"
|
||||
import { existsSync } from "fs"
|
||||
import path from "path"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Instance } from "./instance.js"
|
||||
import { Location } from "./location.js"
|
||||
import { LocationServiceMap } from "./location-service-map.js"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
|
||||
export { LocationServiceMap } from "./location-service-map.js"
|
||||
|
||||
|
|
@ -15,13 +13,6 @@ export type LocationError = Instance.Error
|
|||
export function buildLocationServiceMap(
|
||||
replacements: LayerNode.Replacements = [],
|
||||
): Layer.Layer<LocationServiceMap.Service> {
|
||||
// Structural Equal distinguishes optional-key shape and Windows separator style.
|
||||
// The RcMap caches the raw key before the build callback, so normalize both here.
|
||||
const canonical = (ref: Location.Ref) =>
|
||||
Location.Ref.make({
|
||||
directory: AbsolutePath.make(process.platform === "win32" ? path.normalize(ref.directory) : ref.directory),
|
||||
workspaceID: ref.workspaceID,
|
||||
})
|
||||
return Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
Effect.map(
|
||||
|
|
@ -35,10 +26,10 @@ export function buildLocationServiceMap(
|
|||
}),
|
||||
(inner) => ({
|
||||
...inner,
|
||||
get: (ref: Location.Ref) => inner.get(canonical(ref)),
|
||||
contextEffect: (ref: Location.Ref) => inner.contextEffect(canonical(ref)),
|
||||
contextEffectOption: (ref: Location.Ref) => inner.contextEffectOption(canonical(ref)),
|
||||
invalidate: (ref: Location.Ref) => inner.invalidate(canonical(ref)),
|
||||
get: (ref: Location.Ref) => inner.get(LocationServiceMap.canonical(ref)),
|
||||
contextEffect: (ref: Location.Ref) => inner.contextEffect(LocationServiceMap.canonical(ref)),
|
||||
contextEffectOption: (ref: Location.Ref) => inner.contextEffectOption(LocationServiceMap.canonical(ref)),
|
||||
invalidate: (ref: Location.Ref) => inner.invalidate(LocationServiceMap.canonical(ref)),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ export * as PluginSupervisor from "./supervisor.js"
|
|||
export { Service, type Interface } from "./supervisor-service.js"
|
||||
|
||||
import { Event } from "@opencode-ai/schema/config"
|
||||
import { Cause, Effect, Latch, Layer, Stream } from "effect"
|
||||
import { Cause, Effect, Exit, Latch, Layer, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { ConfigPluginSource } from "../config/plugin/source.js"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
|
|
@ -86,9 +86,8 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
|||
}
|
||||
})
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
function make(failOnError = false) {
|
||||
return Effect.gen(function* () {
|
||||
const registry = yield* Plugin.Service
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
const instance = yield* InstancePlugins.Service
|
||||
|
|
@ -97,6 +96,7 @@ export const layer = Layer.effect(
|
|||
const npm = yield* Npm.Service
|
||||
const ready = yield* Latch.make()
|
||||
let observed = 0
|
||||
let activation = Exit.void
|
||||
|
||||
const activate = Effect.fn("PluginSupervisor.activate")(function* () {
|
||||
// Resolve OpenCode's internal plugins with their privileged Location services.
|
||||
|
|
@ -148,15 +148,19 @@ export const layer = Layer.effect(
|
|||
Stream.debounce("100 millis"),
|
||||
Stream.runForEach((target) =>
|
||||
Effect.gen(function* () {
|
||||
yield* activate().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause })))
|
||||
activation = yield* Effect.exit(activate())
|
||||
if (Exit.isFailure(activation))
|
||||
yield* Effect.logError("failed to reload plugins", { cause: activation.cause })
|
||||
if (observed === target) yield* ready.open
|
||||
}),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
return Service.of({ flush: ready.await })
|
||||
}),
|
||||
)
|
||||
return Service.of({ flush: failOnError ? ready.await.pipe(Effect.andThen(() => activation)) : ready.await })
|
||||
})
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(Service, make())
|
||||
|
||||
const nodeDeps = [
|
||||
Plugin.node,
|
||||
|
|
@ -174,3 +178,8 @@ function pluginSource(target: string): Plugin.Source {
|
|||
}
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: nodeDeps })
|
||||
|
||||
/** Opt into propagating failed plugin generations through flush instead of only logging them. */
|
||||
export function configured(options: { readonly failOnError?: boolean } = {}) {
|
||||
return makeLocationNode({ service: Service, layer: Layer.effect(Service, make(options.failOnError)), deps: nodeDeps })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,53 @@ await using opencode = await OpenCode.create({
|
|||
|
||||
`close()` and `Symbol.asyncDispose` release router resources, Location services, fibers, and scoped plugin registrations.
|
||||
|
||||
## Session-Selected Plugins
|
||||
|
||||
Use `instances` when Sessions in the same directory need different application plugins. The application selects a stable key from Session metadata; the SDK constructs and caches an instance for that key and the Session's current Location.
|
||||
|
||||
```ts
|
||||
import { OpenCode } from "@opencode-ai/sdk"
|
||||
import { threads } from "./threads"
|
||||
import { slackPlugin } from "./slack-plugin"
|
||||
|
||||
await using opencode = await OpenCode.create({
|
||||
database: { path: "./sessions.db" },
|
||||
instances: {
|
||||
key(session) {
|
||||
const threadID = session.metadata?.threadID
|
||||
if (typeof threadID !== "string") throw new Error("Session has no thread ID")
|
||||
return threadID
|
||||
},
|
||||
configure: async (threadID) => ({
|
||||
plugins: [slackPlugin(await threads.get(threadID))],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const session = await opencode.sessions.create({
|
||||
location: { directory: "/workspace" },
|
||||
metadata: { threadID: "thread-42" },
|
||||
})
|
||||
await opencode.sessions.prompt({ sessionID: session.id, text: "Review the changes" })
|
||||
```
|
||||
|
||||
`threads` and `slackPlugin` are application-owned modules. `key` is synchronous and should only select identity, not initialize plugins. `configure` returns plugin definitions; their setup receives the selected instance's `ctx.location`.
|
||||
|
||||
- The same key and Location share one live instance. Different directories or workspace IDs always select separate instances, even with the same application key.
|
||||
- `configure` runs on a cache miss, not on each prompt. Loaded instances live until the host closes; change the application key or restart the host to reconstruct their birth configuration. Plugin transforms and reloads remain available within that lifetime.
|
||||
- Session reads and unloaded permission/form lists do not initialize plugins. Configuration or initial supplied-plugin setup failure prevents capability acquisition, without falling back to another instance. A subsequent request can retry a failed construction.
|
||||
- Existing HTTP prompt middleware also acquires capabilities for an idempotent retry. After restart, that retry can reconstruct plugins before returning the original admission; prompt preparation and hooks do not rerun. Configuration failure can therefore block the retry even when its input was already saved.
|
||||
- Instance selection is not an authorization or storage-isolation boundary. Plugin Session APIs and the existing plugin-ID-based durable storage retain their normal scope.
|
||||
- Omitting `instances` preserves default Location sharing. Host-wide plugins remain separate from Session-selected configuration; retain host-wide catalog policy when locationless generation needs it.
|
||||
|
||||
### Restart and Lifetime
|
||||
|
||||
The selector is installed before automatic recovery starts. Its callbacks must be able to load application data without depending on the returned `opencode` handle or a later registration call. Functions are reconstructed, not serialized.
|
||||
|
||||
Use a persistent `database.path` to recover Sessions after restart; the default database is in memory. Workerd uses its injected Durable Object storage. After restart, the next capability-dependent operation or recovery drain rebuilds the selected instance from saved Session metadata and application data.
|
||||
|
||||
Promise plugin resources should be acquired in `setup` and released by its cleanup function. Effect configuration can acquire resources in its supplied instance Scope; capture application services before creating the SDK host.
|
||||
|
||||
## Workerd
|
||||
|
||||
Use the Workerd entrypoint inside a Cloudflare Durable Object. Hold one host for the lifetime of the object instance rather than creating one per request.
|
||||
|
|
@ -70,3 +117,25 @@ const session = yield * opencode.sessions.get({ sessionID })
|
|||
```
|
||||
|
||||
The Effect Workerd entrypoint is `@opencode-ai/sdk/workerd/effect`.
|
||||
|
||||
Effect configuration uses the same keys and lifetime rules, with canonical `Session.Info` values and an Effect-returning factory:
|
||||
|
||||
```ts
|
||||
import { OpenCode } from "@opencode-ai/sdk/effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { threads } from "./threads-effect"
|
||||
import { slackPlugin } from "./slack-plugin-effect"
|
||||
|
||||
const threadMetadata = Schema.decodeUnknownSync(Schema.Struct({ threadID: Schema.String }))
|
||||
const opencode =
|
||||
yield *
|
||||
OpenCode.create({
|
||||
database: { path: "./sessions.db" },
|
||||
instances: {
|
||||
key: (session) => threadMetadata(session.metadata).threadID,
|
||||
configure: (threadID) => threads.get(threadID).pipe(Effect.map((thread) => ({ plugins: [slackPlugin(thread)] }))),
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Both Workerd entrypoints also accept `instances`. The public `OpenCode.InstanceOptions` and `OpenCode.InstanceConfiguration` types describe the corresponding Promise or Effect callbacks.
|
||||
|
|
|
|||
|
|
@ -105,9 +105,30 @@ import { OpenCodeWorkerd } from "@opencode-ai/sdk/workerd"
|
|||
|
||||
export class OpenCodeDO {
|
||||
constructor(state) {
|
||||
this.configurations = 0
|
||||
this.opencode = state.blockConcurrencyWhile(() => OpenCodeWorkerd.create({
|
||||
storage: state.storage,
|
||||
app: { version: "packed-workerd" },
|
||||
models: { fetch: false },
|
||||
instances: {
|
||||
key: session => String(session.metadata.thread),
|
||||
configure: key => {
|
||||
this.configurations++
|
||||
return {
|
||||
plugins: [{
|
||||
id: "packed-instance",
|
||||
async setup(ctx) {
|
||||
if (ctx.app.version !== "packed-workerd" || ctx.location.directory !== "/workspace") {
|
||||
throw new Error("Selected instance did not inherit the host configuration")
|
||||
}
|
||||
await ctx.session.hook("prompt", event => {
|
||||
event.prompt.text += ":" + key
|
||||
})
|
||||
},
|
||||
}],
|
||||
}
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
@ -116,6 +137,18 @@ export class OpenCodeDO {
|
|||
throw new Error("Packed workerd SHA-256 mismatch")
|
||||
}
|
||||
const opencode = await this.opencode
|
||||
const sessions = await Promise.all([1, 2].map(() => opencode.sessions.create({
|
||||
location: { directory: "/workspace" },
|
||||
metadata: { thread: "packed-thread" },
|
||||
})))
|
||||
const admitted = await Promise.all(sessions.map(session => opencode.sessions.prompt({
|
||||
sessionID: session.id,
|
||||
text: "Packed prompt",
|
||||
resume: false,
|
||||
})))
|
||||
if (this.configurations !== 1 || admitted.some(item => item.payload.text !== "Packed prompt:packed-thread")) {
|
||||
throw new Error("Packed instance configuration did not share or prepare prompts correctly")
|
||||
}
|
||||
return Response.json(await opencode.health.get())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,11 +6,14 @@ import { Context, Effect, Layer } from "effect"
|
|||
import type { Config, Scope } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { EmbeddedHost } from "../internal/host"
|
||||
import type { SdkInstances } from "../internal/instances"
|
||||
|
||||
export type { LogEntry, LogLevel, LogOptions, LogWriter } from "../logging"
|
||||
|
||||
export type CreateOptions = EmbeddedHost.CreateOptions
|
||||
export type EmbedOptions = EmbeddedHost.EmbedOptions
|
||||
export type InstanceOptions = SdkInstances.Options
|
||||
export type InstanceConfiguration = SdkInstances.Configuration
|
||||
|
||||
export type Interface = Omit<OpenCodeClient, "plugin" | "workspace"> & {
|
||||
readonly sessions: OpenCodeClient["session"]
|
||||
|
|
|
|||
|
|
@ -10,11 +10,15 @@ export type Configuration = WorkerdProfile.Configuration
|
|||
export interface CreateOptions extends WorkerdProfile.Options {
|
||||
readonly log?: OpenCode.CreateOptions["log"]
|
||||
readonly workspaceProviders?: OpenCode.CreateOptions["workspaceProviders"]
|
||||
readonly instances?: OpenCode.CreateOptions["instances"]
|
||||
}
|
||||
|
||||
export const create = ({ log, workspaceProviders, ...options }: CreateOptions) => {
|
||||
export const create = ({ log, workspaceProviders, instances, ...options }: CreateOptions) => {
|
||||
const profile = WorkerdProfile.make(options)
|
||||
return OpenCode.create({ ...profile.options, log, workspaceProviders }, { overrides: profile.replacements })
|
||||
return OpenCode.create(
|
||||
{ ...profile.options, log, workspaceProviders, instances },
|
||||
{ overrides: profile.replacements },
|
||||
)
|
||||
}
|
||||
|
||||
export const layer = (options: CreateOptions): Layer.Layer<OpenCode.Service, Config.ConfigError | Error> =>
|
||||
|
|
|
|||
|
|
@ -11,10 +11,12 @@ import { Context, Effect, Layer, ManagedRuntime, Scope } from "effect"
|
|||
import { HttpEffect, HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http"
|
||||
import { context, layer, type LogOptions } from "../logging"
|
||||
import { OwnedFetch } from "./fetch"
|
||||
import { SdkInstances } from "./instances"
|
||||
|
||||
export interface CreateOptions extends Omit<ServerOptions, "hostname" | "port" | "password"> {
|
||||
readonly log?: LogOptions
|
||||
readonly workspaceProviders?: Readonly<Record<string, WorkspaceDriver.Interface>>
|
||||
readonly instances?: SdkInstances.Options
|
||||
}
|
||||
|
||||
/** Host hooks for embedding opencode on a non-default runtime profile. */
|
||||
|
|
@ -26,7 +28,7 @@ export const create = Effect.fn("EmbeddedHost.create")(function* (
|
|||
options: CreateOptions = {},
|
||||
embed: EmbedOptions = {},
|
||||
) {
|
||||
const { log, workspaceProviders, ...server } = options
|
||||
const { log, workspaceProviders, instances, ...server } = options
|
||||
const runtime = ManagedRuntime.make(
|
||||
createEmbeddedRoutes(
|
||||
{
|
||||
|
|
@ -37,6 +39,7 @@ export const create = Effect.fn("EmbeddedHost.create")(function* (
|
|||
workspaceProviders
|
||||
? [...(embed.overrides ?? []), WorkspaceDriver.node.replace(WorkspaceDriver.registryNode(workspaceProviders))]
|
||||
: embed.overrides,
|
||||
instances ? (replacements) => SdkInstances.layer(instances, replacements) : undefined,
|
||||
).pipe(Layer.provide(HttpServer.layerServices), Layer.provideMerge(layer(log))),
|
||||
)
|
||||
|
||||
|
|
|
|||
84
packages/sdk/src/internal/instances.ts
Normal file
84
packages/sdk/src/internal/instances.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
export * as SdkInstances from "./instances"
|
||||
|
||||
import { Instance } from "@opencode-ai/core/instance"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import type { InstancePlugins } from "@opencode-ai/core/plugin/instance"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import type { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Duration, Effect, Layer, LayerMap, Option, Scope } from "effect"
|
||||
|
||||
export interface Configuration {
|
||||
readonly plugins: InstancePlugins.List
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
/** Select a sharing key within the Session's current Location. Must not initialize plugins. */
|
||||
readonly key: (session: Session.Info) => string
|
||||
/** Reconstruct configuration on a cache miss. Resources belong to the instance Scope. */
|
||||
readonly configure: (key: string) => Effect.Effect<Configuration, unknown, Scope.Scope>
|
||||
}
|
||||
|
||||
export function layer(options: Options, replacements: LayerNode.Replacements) {
|
||||
return Layer.effect(
|
||||
Instance.Service,
|
||||
Effect.gen(function* () {
|
||||
const scope = yield* Effect.scope
|
||||
const key = (session: Session.Info) => ({
|
||||
key: options.key(session),
|
||||
...LocationServiceMap.canonical(session.location),
|
||||
})
|
||||
const instances: LayerMap.LayerMap<ReturnType<typeof key>, Instance.Services> = yield* LayerMap.make(
|
||||
(input: ReturnType<typeof key>) =>
|
||||
Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const configuration = yield* options.configure(input.key).pipe(Effect.orDie)
|
||||
return Instance.layer(Location.Ref.make({ directory: input.directory, workspaceID: input.workspaceID }), {
|
||||
plugins: configuration.plugins,
|
||||
replacements: [
|
||||
PluginSupervisor.node.replace(PluginSupervisor.configured({ failOnError: true })),
|
||||
...replacements,
|
||||
],
|
||||
}).pipe(
|
||||
Layer.tap((context) =>
|
||||
Effect.gen(function* () {
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
const plugins = yield* Plugin.Service
|
||||
yield* supervisor.flush
|
||||
const failed = (yield* plugins.list()).filter(
|
||||
(plugin) =>
|
||||
plugin.state.status === "failed" &&
|
||||
configuration.plugins.some((configured) => configured.id === plugin.id),
|
||||
)
|
||||
if (failed.length > 0)
|
||||
yield* Effect.die(
|
||||
new Error(`Instance plugin setup failed: ${failed.map((plugin) => plugin.id).join(", ")}`),
|
||||
)
|
||||
}).pipe(Effect.provide(context)),
|
||||
),
|
||||
)
|
||||
}),
|
||||
).pipe(
|
||||
// Eviction can close the lookup's scope; do not make that fiber wait on itself.
|
||||
Layer.tapCause(() =>
|
||||
instances.invalidate(input).pipe(Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid),
|
||||
),
|
||||
),
|
||||
{ idleTimeToLive: Duration.infinity },
|
||||
)
|
||||
return Instance.Service.of({
|
||||
provide: (session) => Effect.provide(instances.get(key(session))),
|
||||
provideIfLoaded: (session) => (effect) =>
|
||||
Effect.scopedWith((scope) =>
|
||||
Effect.gen(function* () {
|
||||
const context = yield* instances.contextEffectOption(key(session)).pipe(Scope.provide(scope))
|
||||
if (Option.isNone(context)) return Option.none()
|
||||
return Option.some(yield* effect.pipe(Effect.provide(context.value)))
|
||||
}),
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
@ -3,6 +3,8 @@ import { PromiseSdk } from "./promise"
|
|||
export type { LogEntry, LogLevel, LogOptions, LogWriter } from "./logging"
|
||||
|
||||
export type CreateOptions = PromiseSdk.CreateOptions
|
||||
export type InstanceOptions = PromiseSdk.InstanceOptions
|
||||
export type InstanceConfiguration = PromiseSdk.InstanceConfiguration
|
||||
export type Interface = PromiseSdk.Interface
|
||||
|
||||
export const create = (options: CreateOptions = {}) => PromiseSdk.create(options)
|
||||
|
|
|
|||
|
|
@ -2,11 +2,24 @@ export * as PromiseSdk from "./promise"
|
|||
|
||||
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client"
|
||||
import type { Plugin } from "@opencode-ai/plugin"
|
||||
import { Effect } from "effect"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { EmbeddedHost } from "./internal/host"
|
||||
|
||||
export interface CreateOptions extends Omit<EmbeddedHost.CreateOptions, "workspaceProviders"> {
|
||||
export interface InstanceConfiguration {
|
||||
readonly plugins: ReadonlyArray<Plugin.Plugin>
|
||||
}
|
||||
|
||||
export interface InstanceOptions {
|
||||
/** Select a sharing key within the Session's current Location. Must not initialize plugins. */
|
||||
readonly key: (session: typeof Session.Info.Encoded) => string
|
||||
/** Reconstruct configuration on a cache miss, including after a host restart. */
|
||||
readonly configure: (key: string) => InstanceConfiguration | Promise<InstanceConfiguration>
|
||||
}
|
||||
|
||||
export interface CreateOptions extends Omit<EmbeddedHost.CreateOptions, "workspaceProviders" | "instances"> {
|
||||
readonly plugins?: ReadonlyArray<Plugin.Plugin>
|
||||
readonly instances?: InstanceOptions
|
||||
}
|
||||
|
||||
export type Interface = Omit<OpenCodeClient, "plugin"> & {
|
||||
|
|
@ -18,8 +31,26 @@ export type Interface = Omit<OpenCodeClient, "plugin"> & {
|
|||
}
|
||||
|
||||
export async function create(options: CreateOptions = {}, embed: EmbeddedHost.EmbedOptions = {}): Promise<Interface> {
|
||||
const { plugins, ...hostOptions } = options
|
||||
const host = await Effect.runPromise(EmbeddedHost.create(hostOptions, embed))
|
||||
const { plugins, instances, ...hostOptions } = options
|
||||
const host = await Effect.runPromise(
|
||||
EmbeddedHost.create(
|
||||
{
|
||||
...hostOptions,
|
||||
instances: instances
|
||||
? {
|
||||
key: (session) => instances.key(Schema.encodeSync(Session.Info)(session)),
|
||||
configure: (key) =>
|
||||
Effect.gen(function* () {
|
||||
const { PluginPromise } = yield* Effect.promise(() => import("@opencode-ai/core/plugin/promise"))
|
||||
const configuration = yield* Effect.tryPromise(async () => instances.configure(key))
|
||||
return { plugins: configuration.plugins.map(PluginPromise.fromPromise) }
|
||||
}),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
embed,
|
||||
),
|
||||
)
|
||||
const client = OpenCode.make({ baseUrl: "http://opencode.local", fetch: host.fetch })
|
||||
const register = async (plugin: Plugin.Plugin) => {
|
||||
const { PluginPromise } = await import("@opencode-ai/core/plugin/promise")
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export type Configuration = WorkerdProfile.Configuration
|
|||
export interface CreateOptions extends WorkerdProfile.Options {
|
||||
readonly log?: LogOptions
|
||||
readonly plugins?: PromiseSdk.CreateOptions["plugins"]
|
||||
readonly instances?: PromiseSdk.CreateOptions["instances"]
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -25,9 +26,9 @@ export interface CreateOptions extends WorkerdProfile.Options {
|
|||
* session operations plus the live `events.subscribe()` stream — served over
|
||||
* an in-process fetch transport, so no request leaves the isolate.
|
||||
*/
|
||||
export const create = ({ log, plugins, ...options }: CreateOptions) => {
|
||||
export const create = ({ log, plugins, instances, ...options }: CreateOptions) => {
|
||||
const profile = WorkerdProfile.make(options)
|
||||
return PromiseSdk.create({ ...profile.options, log, plugins }, { overrides: profile.replacements })
|
||||
return PromiseSdk.create({ ...profile.options, log, plugins, instances }, { overrides: profile.replacements })
|
||||
}
|
||||
|
||||
export type Interface = PromiseSdk.Interface
|
||||
|
|
|
|||
236
packages/sdk/test/instances-effect.test.ts
Normal file
236
packages/sdk/test/instances-effect.test.ts
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import { expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { LanguageModel, LLMClient } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
import { TestLLM } from "@opencode-ai/ai/testing"
|
||||
import { llmClient } from "@opencode-ai/core/effect/app-node-platform"
|
||||
import { makeMemoryDriver } from "@opencode-ai/core/environment/index"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
|
||||
import { Plugin } from "@opencode-ai/plugin/effect"
|
||||
import { Deferred, Effect, Exit, Layer, Schema, Scope } from "effect"
|
||||
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
|
||||
import { testEffect } from "../../core/test/lib/effect"
|
||||
import { AbsolutePath, Agent, Location, OpenCode } from "../src/effect"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
const metadata = Schema.decodeUnknownSync(Schema.Struct({ threadID: Schema.String }))
|
||||
const model = SessionRunnerModel.resolved(
|
||||
LanguageModel.make({ id: "instance-model", provider: "test", route: OpenAIChat.route }),
|
||||
{
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
cost: [],
|
||||
limit: { context: 200_000, output: 8_192 },
|
||||
},
|
||||
)
|
||||
|
||||
it.live(
|
||||
"reconstructs configured instances before automatic recovery executes tools",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped()
|
||||
const scope = yield* Effect.scope
|
||||
const firstScope = yield* Scope.fork(scope)
|
||||
const secondScope = yield* Scope.fork(scope)
|
||||
const started = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const llm = yield* TestLLM.Test.pipe(Effect.provide(TestLLM.testLayer()))
|
||||
const configured: string[] = []
|
||||
const closed: number[] = []
|
||||
const executed: number[] = []
|
||||
const options: OpenCode.CreateOptions = {
|
||||
database: { path: path.join(directory.path, "sessions.db") },
|
||||
app: { name: "instance-test", version: "1.2.3" },
|
||||
events: { persist: true },
|
||||
config: { directory: directory.path, project: false, content: "{}" },
|
||||
models: { fetch: false },
|
||||
fs: { filewatcher: false },
|
||||
instances: {
|
||||
key: (session) => metadata(session.metadata).threadID,
|
||||
configure: (key) =>
|
||||
Effect.gen(function* () {
|
||||
const generation = configured.push(key)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => closed.push(generation)))
|
||||
if (generation === 2) {
|
||||
yield* Deferred.succeed(started, undefined)
|
||||
yield* Deferred.await(release)
|
||||
}
|
||||
return {
|
||||
plugins: [
|
||||
Plugin.define({
|
||||
id: "thread-tools",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
expect(ctx.app).toMatchObject({ name: "instance-test", version: "1.2.3" })
|
||||
yield* ctx.agent.transform((draft) =>
|
||||
draft.update(Agent.ID.make("build"), (agent) => {
|
||||
agent.permissions = [{ action: "*", resource: "*", effect: "allow" }]
|
||||
}),
|
||||
)
|
||||
yield* ctx.session.hook("context", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.generation.temperature = 0.25
|
||||
}),
|
||||
)
|
||||
yield* ctx.tool.transform((draft) =>
|
||||
draft.add({
|
||||
name: "thread_echo",
|
||||
description: "Report the configured thread",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
options: { codemode: false },
|
||||
execute: (_, tool) =>
|
||||
Effect.gen(function* () {
|
||||
executed.push(generation)
|
||||
yield* ctx.session
|
||||
.rename({ sessionID: tool.sessionID, title: `${key}:${generation}` })
|
||||
.pipe(Effect.orDie)
|
||||
return { output: key, content: key }
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}
|
||||
}),
|
||||
},
|
||||
}
|
||||
const embed = {
|
||||
overrides: [
|
||||
llmClient.replace(Layer.succeed(LLMClient.Service, llm)),
|
||||
SessionRunnerModel.node.replace(
|
||||
Layer.succeed(SessionRunnerModel.Service, { resolve: () => Effect.succeed(model) }),
|
||||
),
|
||||
],
|
||||
}
|
||||
yield* llm.push(TestLLM.hangAfter())
|
||||
const first = yield* OpenCode.create(options, embed).pipe(Scope.provide(firstScope))
|
||||
const session = yield* first.sessions.create({
|
||||
title: "Recovery fixture",
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make(directory.path) }),
|
||||
model: model.ref,
|
||||
metadata: { threadID: "thread-recovery" },
|
||||
})
|
||||
expect(configured).toEqual([])
|
||||
yield* first.sessions.prompt({ sessionID: session.id, text: "Use the thread tool" })
|
||||
yield* llm.wait(1).pipe(Effect.timeout("5 seconds"))
|
||||
expect(configured).toEqual(["thread-recovery"])
|
||||
|
||||
// Closing the host interrupts active execution while preserving its durable recovery claim.
|
||||
yield* Scope.close(firstScope, Exit.void)
|
||||
expect(closed).toEqual([1])
|
||||
yield* llm.push(TestLLM.tool("recovered-tool", "thread_echo", {}), TestLLM.text("Recovered", "answer"))
|
||||
const second = yield* OpenCode.create(options, embed).pipe(Scope.provide(secondScope))
|
||||
yield* Deferred.await(started).pipe(Effect.timeout("5 seconds"))
|
||||
expect((yield* llm.requests()).length).toBe(1)
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* llm.wait(3).pipe(Effect.timeout("5 seconds"))
|
||||
yield* second.sessions.wait({ sessionID: session.id })
|
||||
|
||||
expect(configured).toEqual(["thread-recovery", "thread-recovery"])
|
||||
expect(executed).toEqual([2])
|
||||
expect((yield* second.sessions.get({ sessionID: session.id })).title).toBe("thread-recovery:2")
|
||||
expect(
|
||||
(yield* llm.requests()).map((request) => ({
|
||||
temperature: request.generation?.temperature,
|
||||
tools: request.tools?.filter((tool) => tool.name.startsWith("thread_")).map((tool) => tool.name),
|
||||
})),
|
||||
).toEqual(Array.from({ length: 3 }, () => ({ temperature: 0.25, tools: ["thread_echo"] })))
|
||||
yield* Scope.close(secondScope, Exit.void)
|
||||
expect(closed).toEqual([1, 2])
|
||||
}),
|
||||
15_000,
|
||||
)
|
||||
|
||||
it.live(
|
||||
"qualifies application keys by workspace and reselects a moved Session",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped()
|
||||
const llm = yield* TestLLM.Test.pipe(
|
||||
Effect.provide(TestLLM.testLayer({ fallback: TestLLM.text("Ready", "answer") })),
|
||||
)
|
||||
const configured: string[] = []
|
||||
const placements: Location.Ref[] = []
|
||||
const driver = WorkspaceDriver.make({
|
||||
create: ({ workspaceID }) => Effect.succeed({ binding: { workspaceID } }),
|
||||
connect: () => Effect.succeed(makeMemoryDriver()),
|
||||
suspendForIdle: () => Effect.void,
|
||||
destroy: () => Effect.void,
|
||||
})
|
||||
const opencode = yield* OpenCode.create(
|
||||
{
|
||||
config: { directory: directory.path, project: false, content: "{}" },
|
||||
models: { fetch: false },
|
||||
fs: { filewatcher: false },
|
||||
workspaceProviders: { memory: driver },
|
||||
instances: {
|
||||
key: (session) => metadata(session.metadata).threadID,
|
||||
configure: (key) =>
|
||||
Effect.sync(() => {
|
||||
configured.push(key)
|
||||
return {
|
||||
plugins: [
|
||||
Plugin.define({
|
||||
id: "placement-hook",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
placements.push(Location.Ref.make(ctx.location))
|
||||
yield* ctx.session.hook("prompt", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.prompt.text += `:${ctx.location.workspaceID}`
|
||||
}),
|
||||
)
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
overrides: [
|
||||
llmClient.replace(Layer.succeed(LLMClient.Service, llm)),
|
||||
SessionRunnerModel.node.replace(
|
||||
Layer.succeed(SessionRunnerModel.Service, { resolve: () => Effect.succeed(model) }),
|
||||
),
|
||||
],
|
||||
},
|
||||
)
|
||||
const firstWorkspace = yield* opencode.workspace.create({ provider: "memory" })
|
||||
const secondWorkspace = yield* opencode.workspace.create({ provider: "memory" })
|
||||
const first = yield* opencode.sessions.create({
|
||||
title: "First placement",
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make(directory.path), workspaceID: firstWorkspace }),
|
||||
model: model.ref,
|
||||
metadata: { threadID: "same-thread" },
|
||||
})
|
||||
const second = yield* opencode.sessions.create({
|
||||
title: "Second placement",
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make(directory.path), workspaceID: secondWorkspace }),
|
||||
model: model.ref,
|
||||
metadata: { threadID: "same-thread" },
|
||||
})
|
||||
for (const session of [first, second]) {
|
||||
const admitted = yield* opencode.sessions.prompt({ sessionID: session.id, text: "Hello" })
|
||||
expect(admitted.payload.text).toBe(`Hello:${session.location.workspaceID}`)
|
||||
yield* opencode.sessions.wait({ sessionID: session.id })
|
||||
}
|
||||
expect(configured).toEqual(["same-thread", "same-thread"])
|
||||
expect(placements.map((location) => location.workspaceID)).toEqual([firstWorkspace, secondWorkspace])
|
||||
|
||||
yield* opencode.sessions.move({
|
||||
sessionID: first.id,
|
||||
directory: AbsolutePath.make(directory.path),
|
||||
workspaceID: secondWorkspace,
|
||||
})
|
||||
yield* opencode.sessions.wait({ sessionID: first.id })
|
||||
const moved = yield* opencode.sessions.get({ sessionID: first.id })
|
||||
expect(moved.location.workspaceID).toBe(secondWorkspace)
|
||||
const admitted = yield* opencode.sessions.prompt({ sessionID: first.id, text: "Moved", resume: false })
|
||||
expect(admitted.payload.text).toBe(`Moved:${secondWorkspace}`)
|
||||
expect(configured).toEqual(["same-thread", "same-thread"])
|
||||
}),
|
||||
15_000,
|
||||
)
|
||||
73
packages/sdk/test/instances-lifecycle.test.ts
Normal file
73
packages/sdk/test/instances-lifecycle.test.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { expect } from "bun:test"
|
||||
import { Instance } from "@opencode-ai/core/instance/service"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { AbsolutePath } from "@opencode-ai/schema/schema"
|
||||
import { Context, Deferred, Effect, Exit, Fiber, Layer, Option } from "effect"
|
||||
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
|
||||
import { testEffect } from "../../core/test/lib/effect"
|
||||
import { EmbeddedHost } from "../src/internal/host"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
it.live("a cancelled borrower cannot strand a later failed instance construction", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped()
|
||||
const started = yield* Deferred.make<Fiber.Fiber<unknown, unknown>>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const acquired: number[] = []
|
||||
const released: number[] = []
|
||||
const host = yield* Effect.acquireRelease(
|
||||
EmbeddedHost.create({
|
||||
config: { directory: directory.path, project: false, content: "{}" },
|
||||
models: { fetch: false },
|
||||
fs: { filewatcher: false },
|
||||
instances: {
|
||||
key: () => "shared",
|
||||
configure: () =>
|
||||
Effect.gen(function* () {
|
||||
const attempt = acquired.length + 1
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() => acquired.push(attempt)),
|
||||
() => Effect.sync(() => released.push(attempt)),
|
||||
)
|
||||
if (attempt !== 1) return { plugins: [] }
|
||||
yield* Effect.withFiber((fiber) => Deferred.succeed(started, fiber))
|
||||
yield* Deferred.await(release)
|
||||
return yield* Effect.fail(new Error("Configuration unavailable"))
|
||||
}),
|
||||
},
|
||||
}),
|
||||
(host) => Effect.promise(host.close),
|
||||
)
|
||||
const services = yield* host.runtime.contextEffect
|
||||
const instances = Context.get(services, Instance.Service)
|
||||
const sessions = Context.get(services, Session.Service)
|
||||
const session = yield* sessions.create({
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make(directory.path) }),
|
||||
})
|
||||
expect(yield* Effect.void.pipe(instances.provideIfLoaded(session))).toEqual(Option.none())
|
||||
expect(acquired).toEqual([])
|
||||
|
||||
const borrower = yield* Effect.void.pipe(instances.provide(session), Effect.forkScoped)
|
||||
const lookup = yield* Deferred.await(started)
|
||||
yield* Fiber.interrupt(borrower)
|
||||
expect(released).toEqual([])
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
expect(Exit.isFailure(yield* Fiber.await(lookup).pipe(Effect.timeout("1 second")))).toBe(true)
|
||||
expect(released).toEqual([1])
|
||||
|
||||
yield* Effect.void.pipe(instances.provide(session))
|
||||
expect(acquired).toEqual([1, 2])
|
||||
expect(released).toEqual([1])
|
||||
const callerScope = yield* Effect.scope
|
||||
expect(Option.getOrThrow(yield* Effect.scope.pipe(instances.provideIfLoaded(session)))).toBe(callerScope)
|
||||
expect(yield* Effect.void.pipe(instances.provideIfLoaded(session))).toEqual(Option.some(undefined))
|
||||
const error = new Error("Operation failed")
|
||||
expect(yield* Effect.fail(error).pipe(instances.provideIfLoaded(session), Effect.flip)).toBe(error)
|
||||
|
||||
yield* Effect.promise(host.close)
|
||||
expect(released).toEqual([1, 2])
|
||||
expect(yield* Effect.void.pipe(instances.provideIfLoaded(session))).toEqual(Option.none())
|
||||
}),
|
||||
)
|
||||
353
packages/sdk/test/instances.test.ts
Normal file
353
packages/sdk/test/instances.test.ts
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import { join } from "node:path"
|
||||
import { Schema } from "effect"
|
||||
import { tmpdir } from "../../core/test/fixture/tmpdir"
|
||||
import { OpenCode, Session, SessionMessage } from "../src"
|
||||
|
||||
const metadata = Schema.decodeUnknownSync(Schema.Struct({ account: Schema.String }))
|
||||
const hostOptions = (directory: string) => ({
|
||||
database: { path: join(directory, "opencode.sqlite") },
|
||||
config: { directory, project: false, content: "{}" },
|
||||
models: { fetch: false },
|
||||
fs: { filewatcher: false },
|
||||
})
|
||||
|
||||
test("Promise instances are lazy, share by key and Location, and stay isolated between hosts", async () => {
|
||||
await using directory = await tmpdir("opencode-promise-instances-")
|
||||
const otherDirectory = join(directory.path, "other")
|
||||
await mkdir(otherDirectory)
|
||||
const configured: string[] = []
|
||||
const setups: string[] = []
|
||||
const cleanups: string[] = []
|
||||
const createHost = (name: string) => {
|
||||
const instances: OpenCode.InstanceOptions = {
|
||||
key(session) {
|
||||
expect(typeof session.time.created).toBe("number")
|
||||
return metadata(session.metadata).account
|
||||
},
|
||||
configure(key): OpenCode.InstanceConfiguration {
|
||||
configured.push(`${name}:${key}`)
|
||||
return {
|
||||
plugins: [
|
||||
{
|
||||
id: "account-prompts",
|
||||
async setup(ctx) {
|
||||
const activation = `${name}:${key}@${ctx.location.directory}`
|
||||
setups.push(activation)
|
||||
await ctx.session.hook("prompt", (event) => {
|
||||
event.prompt.text = `${activation}: ${event.prompt.text}`
|
||||
})
|
||||
return () => {
|
||||
cleanups.push(activation)
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
}
|
||||
return OpenCode.create({
|
||||
...hostOptions(directory.path),
|
||||
database: { path: join(directory.path, `${name}.sqlite`) },
|
||||
instances,
|
||||
})
|
||||
}
|
||||
|
||||
await using first = await createHost("first")
|
||||
await using second = await createHost("second")
|
||||
const sessionID = Session.ID.create()
|
||||
const original = await first.sessions.create({
|
||||
id: sessionID,
|
||||
location: { directory: directory.path },
|
||||
metadata: { account: "alpha", labels: ["review", 2] },
|
||||
})
|
||||
const sameKey = await first.sessions.create({
|
||||
location: { directory: directory.path },
|
||||
metadata: original.metadata,
|
||||
})
|
||||
const separateKey = await first.sessions.create({
|
||||
location: { directory: directory.path },
|
||||
metadata: { account: "beta" },
|
||||
})
|
||||
const separateLocation = await first.sessions.create({
|
||||
location: { directory: otherDirectory },
|
||||
metadata: { account: "alpha" },
|
||||
})
|
||||
const separateHost = await second.sessions.create({
|
||||
id: sessionID,
|
||||
location: { directory: directory.path },
|
||||
metadata: original.metadata,
|
||||
})
|
||||
|
||||
expect(await first.sessions.get({ sessionID })).toEqual(original)
|
||||
expect((await first.sessions.list()).data).toHaveLength(4)
|
||||
expect((await first.message.list({ sessionID })).data).toEqual([])
|
||||
expect(await first.sessions.context({ sessionID })).toEqual([])
|
||||
expect(await first.sessions.inbox.list({ sessionID })).toEqual([])
|
||||
expect(await first.permission.list({ sessionID })).toEqual([])
|
||||
expect(await first.form.list({ sessionID })).toEqual([])
|
||||
expect(await second.sessions.get({ sessionID })).toEqual(separateHost)
|
||||
expect(configured).toEqual([])
|
||||
expect(setups).toEqual([])
|
||||
|
||||
await Promise.all(
|
||||
[
|
||||
{ host: first, session: original, prefix: `first:alpha@${directory.path}` },
|
||||
{ host: first, session: sameKey, prefix: `first:alpha@${directory.path}` },
|
||||
{ host: first, session: separateKey, prefix: `first:beta@${directory.path}` },
|
||||
{ host: first, session: separateLocation, prefix: `first:alpha@${otherDirectory}` },
|
||||
{ host: second, session: separateHost, prefix: `second:alpha@${directory.path}` },
|
||||
].map(async (input) => {
|
||||
const admitted = await input.host.sessions.prompt({
|
||||
sessionID: input.session.id,
|
||||
text: "Review this change",
|
||||
resume: false,
|
||||
})
|
||||
expect(admitted.payload.text).toBe(`${input.prefix}: Review this change`)
|
||||
expect(await input.host.sessions.inbox.list({ sessionID: input.session.id })).toEqual([admitted])
|
||||
}),
|
||||
)
|
||||
|
||||
await first.sessions.switchAgent({ sessionID, agent: "plan" })
|
||||
const fork = await first.sessions.fork({ sessionID, boundary: { type: "through" } })
|
||||
expect(fork.metadata).toEqual(original.metadata)
|
||||
expect(fork.location).toEqual(original.location)
|
||||
expect(fork.fork?.sessionID).toBe(sessionID)
|
||||
const inherited = await first.sessions.prompt({ sessionID: fork.id, text: "Review the fork", resume: false })
|
||||
expect(inherited.payload.text).toBe(`first:alpha@${directory.path}: Review the fork`)
|
||||
expect(await first.sessions.inbox.list({ sessionID: fork.id })).toEqual([inherited])
|
||||
|
||||
expect(configured.toSorted()).toEqual(["first:alpha", "first:alpha", "first:beta", "second:alpha"])
|
||||
expect(setups.toSorted()).toEqual(
|
||||
[
|
||||
`first:alpha@${directory.path}`,
|
||||
`first:alpha@${otherDirectory}`,
|
||||
`first:beta@${directory.path}`,
|
||||
`second:alpha@${directory.path}`,
|
||||
].toSorted(),
|
||||
)
|
||||
expect(cleanups).toEqual([])
|
||||
expect(await first.sessions.active()).toEqual({})
|
||||
await first.close()
|
||||
expect(cleanups.toSorted()).toEqual(setups.filter((activation) => activation.startsWith("first:")).toSorted())
|
||||
|
||||
const continued = await second.sessions.prompt({ sessionID, text: "Keep working", resume: false })
|
||||
expect(continued.payload.text).toBe(`second:alpha@${directory.path}: Keep working`)
|
||||
expect(await second.sessions.inbox.list({ sessionID })).toHaveLength(2)
|
||||
expect(configured).toHaveLength(4)
|
||||
await second.close()
|
||||
expect(cleanups.toSorted()).toEqual(setups.toSorted())
|
||||
}, 20_000)
|
||||
|
||||
test.each(["configuration", "plugin setup"])(
|
||||
"Promise instance %s failure admits nothing, preserves healthy instances, and can be retried",
|
||||
async (failure) => {
|
||||
await using directory = await tmpdir("opencode-promise-instance-failure-")
|
||||
const configured: string[] = []
|
||||
const setups: string[] = []
|
||||
const cleanups: string[] = []
|
||||
await using opencode = await OpenCode.create({
|
||||
...hostOptions(directory.path),
|
||||
instances: {
|
||||
key: (session) => metadata(session.metadata).account,
|
||||
async configure(key) {
|
||||
configured.push(key)
|
||||
if (key === "retry" && failure === "configuration" && configured.filter((item) => item === key).length === 1)
|
||||
throw new Error("Account configuration unavailable")
|
||||
return {
|
||||
plugins: [
|
||||
{
|
||||
id: "account-prompts",
|
||||
async setup(ctx) {
|
||||
setups.push(key)
|
||||
await ctx.session.hook("prompt", (event) => {
|
||||
event.prompt.text = `${key}: ${event.prompt.text}`
|
||||
})
|
||||
if (
|
||||
key === "retry" &&
|
||||
failure === "plugin setup" &&
|
||||
setups.filter((item) => item === key).length === 1
|
||||
)
|
||||
throw new Error("Account plugin unavailable")
|
||||
return () => {
|
||||
cleanups.push(key)
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
const healthy = await opencode.sessions.create({
|
||||
location: { directory: directory.path },
|
||||
metadata: { account: "healthy" },
|
||||
})
|
||||
const retry = await opencode.sessions.create({
|
||||
location: { directory: directory.path },
|
||||
metadata: { account: "retry" },
|
||||
})
|
||||
const before = await opencode.sessions.prompt({ sessionID: healthy.id, text: "Before failure", resume: false })
|
||||
expect(before.payload.text).toBe("healthy: Before failure")
|
||||
const input = { sessionID: retry.id, id: SessionMessage.ID.create(), text: "Retry this input", resume: false }
|
||||
|
||||
expect(await opencode.sessions.prompt(input).catch((error: unknown) => error)).toMatchObject({
|
||||
name: "ClientError",
|
||||
reason: "UnexpectedStatus",
|
||||
})
|
||||
expect(await opencode.sessions.inbox.list({ sessionID: retry.id })).toEqual([])
|
||||
expect((await opencode.message.list({ sessionID: retry.id })).data).toEqual([])
|
||||
expect(await opencode.sessions.get({ sessionID: retry.id })).toEqual(retry)
|
||||
expect(configured).toEqual(["healthy", "retry"])
|
||||
expect(cleanups).toEqual([])
|
||||
|
||||
const after = await opencode.sessions.prompt({ sessionID: healthy.id, text: "After failure", resume: false })
|
||||
expect(after.payload.text).toBe("healthy: After failure")
|
||||
expect(await opencode.sessions.inbox.list({ sessionID: healthy.id })).toEqual([before, after])
|
||||
const admitted = await opencode.sessions.prompt(input)
|
||||
expect(admitted.id).toBe(input.id)
|
||||
expect(admitted.payload.text).toBe("retry: Retry this input")
|
||||
expect(await opencode.sessions.inbox.list({ sessionID: retry.id })).toEqual([admitted])
|
||||
expect(configured).toEqual(["healthy", "retry", "retry"])
|
||||
expect(setups).toEqual(failure === "configuration" ? ["healthy", "retry"] : ["healthy", "retry", "retry"])
|
||||
expect(cleanups).toEqual([])
|
||||
await opencode.close()
|
||||
expect(cleanups.toSorted()).toEqual(["healthy", "retry"])
|
||||
},
|
||||
20_000,
|
||||
)
|
||||
|
||||
test("Promise instances reconstruct callbacks for persisted Sessions only on a cold prompt", async () => {
|
||||
await using directory = await tmpdir("opencode-promise-instance-restart-")
|
||||
const sessionID = Session.ID.create()
|
||||
const configured: string[] = []
|
||||
const setups: number[] = []
|
||||
const cleanups: number[] = []
|
||||
const options: OpenCode.CreateOptions = {
|
||||
...hostOptions(directory.path),
|
||||
instances: {
|
||||
key: (session) => metadata(session.metadata).account,
|
||||
configure(key) {
|
||||
configured.push(key)
|
||||
const generation = configured.length
|
||||
return {
|
||||
plugins: [
|
||||
{
|
||||
id: "account-prompts",
|
||||
async setup(ctx) {
|
||||
setups.push(generation)
|
||||
await ctx.session.hook("prompt", (event) => {
|
||||
event.prompt.text = `${key}/${generation}: ${event.prompt.text}`
|
||||
})
|
||||
return () => {
|
||||
cleanups.push(generation)
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
await using first = await OpenCode.create(options)
|
||||
const created = await first.sessions.create({
|
||||
id: sessionID,
|
||||
location: { directory: directory.path },
|
||||
metadata: { account: "alpha", labels: ["review"] },
|
||||
})
|
||||
const before = await first.sessions.prompt({ sessionID, text: "Before restart", resume: false })
|
||||
expect(before.payload.text).toBe("alpha/1: Before restart")
|
||||
await first.close()
|
||||
expect(cleanups).toEqual([1])
|
||||
|
||||
await using second = await OpenCode.create(options)
|
||||
expect(await second.sessions.get({ sessionID })).toMatchObject({
|
||||
id: sessionID,
|
||||
location: created.location,
|
||||
metadata: created.metadata,
|
||||
time: { created: created.time.created },
|
||||
})
|
||||
expect((await second.sessions.list()).data.map((session) => session.id)).toEqual([sessionID])
|
||||
expect(await second.sessions.inbox.list({ sessionID })).toEqual([before])
|
||||
expect((await second.message.list({ sessionID })).data).toEqual([])
|
||||
expect(await second.permission.list({ sessionID })).toEqual([])
|
||||
expect(await second.form.list({ sessionID })).toEqual([])
|
||||
expect(configured).toEqual(["alpha"])
|
||||
expect(setups).toEqual([1])
|
||||
|
||||
// The HTTP prompt boundary still acquires capabilities, even when Core reconciles an existing admission.
|
||||
expect(await second.sessions.prompt({ sessionID, id: before.id, text: "Already admitted", resume: false })).toEqual(
|
||||
before,
|
||||
)
|
||||
expect(configured).toEqual(["alpha", "alpha"])
|
||||
expect(setups).toEqual([1, 2])
|
||||
expect(await second.sessions.active()).toEqual({})
|
||||
|
||||
const after = await second.sessions.prompt({ sessionID, text: "After restart", resume: false })
|
||||
expect(after.payload.text).toBe("alpha/2: After restart")
|
||||
expect(await second.sessions.inbox.list({ sessionID })).toEqual([before, after])
|
||||
expect(configured).toEqual(["alpha", "alpha"])
|
||||
expect(setups).toEqual([1, 2])
|
||||
expect(cleanups).toEqual([1])
|
||||
await second.close()
|
||||
expect(cleanups).toEqual([1, 2])
|
||||
}, 20_000)
|
||||
|
||||
test("Promise instance plugin ID collisions reject admission and reconstruct on retry", async () => {
|
||||
await using directory = await tmpdir("opencode-promise-instance-collision-")
|
||||
const configured: string[] = []
|
||||
const setups: string[] = []
|
||||
await using opencode = await OpenCode.create({
|
||||
...hostOptions(directory.path),
|
||||
plugins: [
|
||||
{
|
||||
id: "account-prompts",
|
||||
setup() {
|
||||
setups.push("host")
|
||||
},
|
||||
},
|
||||
],
|
||||
instances: {
|
||||
key: (session) => metadata(session.metadata).account,
|
||||
configure(key) {
|
||||
configured.push(key)
|
||||
return {
|
||||
plugins: [
|
||||
{
|
||||
id: configured.length === 1 ? "account-prompts" : "instance-prompts",
|
||||
async setup(ctx) {
|
||||
setups.push(key)
|
||||
await ctx.session.hook("prompt", (event) => {
|
||||
event.prompt.text = `${key}: ${event.prompt.text}`
|
||||
})
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
const session = await opencode.sessions.create({
|
||||
location: { directory: directory.path },
|
||||
metadata: { account: "alpha" },
|
||||
})
|
||||
const input = { sessionID: session.id, id: SessionMessage.ID.create(), text: "Retry this input", resume: false }
|
||||
expect(configured).toEqual([])
|
||||
|
||||
expect(await opencode.sessions.prompt(input).catch((error: unknown) => error)).toMatchObject({
|
||||
name: "ClientError",
|
||||
reason: "UnexpectedStatus",
|
||||
})
|
||||
expect(await opencode.sessions.inbox.list({ sessionID: session.id })).toEqual([])
|
||||
expect((await opencode.message.list({ sessionID: session.id })).data).toEqual([])
|
||||
expect(configured).toEqual(["alpha"])
|
||||
expect(setups).toEqual([])
|
||||
|
||||
const admitted = await opencode.sessions.prompt(input)
|
||||
expect(admitted.id).toBe(input.id)
|
||||
expect(admitted.payload.text).toBe("alpha: Retry this input")
|
||||
expect(await opencode.sessions.inbox.list({ sessionID: session.id })).toEqual([admitted])
|
||||
expect(configured).toEqual(["alpha", "alpha"])
|
||||
expect(setups).toEqual(["host", "alpha"])
|
||||
}, 20_000)
|
||||
|
|
@ -89,8 +89,14 @@ export function createRoutes(
|
|||
)
|
||||
}
|
||||
|
||||
export function createEmbeddedRoutes(options: ServerOptions = {}, overrides: LayerNode.Replacements = []) {
|
||||
return makeRoutes(ServerAuth.Config.configLayer({ password: Option.none() }), options, () => [], overrides)
|
||||
type InstanceLayer = (replacements: LayerNode.Replacements) => Layer.Layer<Instance.Service>
|
||||
|
||||
export function createEmbeddedRoutes(
|
||||
options: ServerOptions = {},
|
||||
overrides: LayerNode.Replacements = [],
|
||||
instances?: InstanceLayer,
|
||||
) {
|
||||
return makeRoutes(ServerAuth.Config.configLayer({ password: Option.none() }), options, () => [], overrides, instances)
|
||||
}
|
||||
|
||||
function makeRoutes<AuthError, AuthServices>(
|
||||
|
|
@ -99,6 +105,7 @@ function makeRoutes<AuthError, AuthServices>(
|
|||
serviceURLs: () => ReadonlyArray<string>,
|
||||
// Runtime-profile replacements (e.g. workerd) applied after the standard set, so later entries win.
|
||||
overrides: LayerNode.Replacements,
|
||||
instances?: InstanceLayer,
|
||||
) {
|
||||
const pluginRuntimeCell = PluginRuntime.makeCell()
|
||||
const standard: LayerNode.Replacements = [
|
||||
|
|
@ -130,16 +137,24 @@ function makeRoutes<AuthError, AuthServices>(
|
|||
PluginRuntime.node.replace(PluginRuntime.layerWithCell(pluginRuntimeCell)),
|
||||
PluginRuntime.providerNode.replace(PluginRuntime.providerNodeWithCell(pluginRuntimeCell)),
|
||||
]
|
||||
const replacements: LayerNode.Replacements = [...standard, ...overrides]
|
||||
const build = (overrides: LayerNode.Replacements) => {
|
||||
const replacements: LayerNode.Replacements = [
|
||||
...standard,
|
||||
// Resolve lazily so private instances inherit the complete host graph, including this selector.
|
||||
...(instances ? [Instance.byLocationNode.replace(Layer.suspend(() => instances(replacements)))] : []),
|
||||
...overrides,
|
||||
]
|
||||
return AppNodeBuilder.build(applicationServices, replacements)
|
||||
}
|
||||
const serviceLayer = options.simulation
|
||||
? Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const { simulationReplacements } = yield* Effect.promise(() => import("@opencode-ai/simulation/backend"))
|
||||
const simulation = yield* simulationReplacements({ version: App.make(options.app).version })
|
||||
return AppNodeBuilder.build(applicationServices, [...replacements, ...simulation])
|
||||
return build([...overrides, ...simulation])
|
||||
}),
|
||||
)
|
||||
: AppNodeBuilder.build(applicationServices, replacements)
|
||||
: build(overrides)
|
||||
return serviceLayer.pipe(
|
||||
Layer.flatMap((context) => {
|
||||
const services = Layer.succeedContext(context)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue