Compare commits

...
8 changed files with 295 additions and 31 deletions
+18 -1
View File
@@ -69,4 +69,21 @@ const opencode = yield * OpenCode.create()
const session = yield * opencode.sessions.get({ sessionID })
```
The Effect Workerd entrypoint is `@opencode-ai/sdk/workerd/effect`.
`OpenCode.create()` and `OpenCode.layer()` start recovery automatically. Use `OpenCode.layerWith()` when plugins come from registration Layers so recovery starts only after those Layers succeed:
```ts
import { OpenCode } from "@opencode-ai/sdk/effect"
import { Effect, Layer } from "effect"
import myPlugin from "./my-plugin"
const PluginLive = Layer.effectDiscard(
Effect.gen(function* () {
const opencode = yield* OpenCode.Service
yield* opencode.plugin(myPlugin)
}),
)
const OpenCodeLive = OpenCode.layerWith(PluginLive)
```
The Effect Workerd entrypoint is `@opencode-ai/sdk/workerd/effect`, with a matching `layerWith` export.
+41 -15
View File
@@ -25,13 +25,7 @@ export type Interface = Omit<OpenCodeClient, "plugin" | "workspace"> & {
readonly plugin: EmbeddedHost.Interface["plugins"]["register"] & OpenCodeClient["plugin"]
}
export const create: (
options?: CreateOptions,
embed?: EmbedOptions,
) => Effect.Effect<Interface, Config.ConfigError | Error, Scope.Scope> = Effect.fn("OpenCode.create")(function* (
options: CreateOptions = {},
embed: EmbedOptions = {},
) {
const make = Effect.fn("OpenCode.make")(function* (options: CreateOptions = {}, embed: EmbedOptions = {}) {
const host = yield* Effect.acquireRelease(EmbeddedHost.create(options, embed), (host) => Effect.promise(host.close))
const client = yield* OpenCode.make({ baseUrl: "http://opencode.local" }).pipe(
Effect.provide(
@@ -40,19 +34,51 @@ export const create: (
)
return {
...client,
sessions: client.session,
events: client.event,
workspace: {
create: ({ provider }: { readonly provider: string }) => host.workspace.create(provider),
provision: ({ workspaceID }: { readonly workspaceID: Workspace.ID }) => host.workspace.provision(workspaceID),
destroy: ({ workspaceID }: { readonly workspaceID: Workspace.ID }) => host.workspace.destroy(workspaceID),
opencode: {
...client,
sessions: client.session,
events: client.event,
workspace: {
create: ({ provider }: { readonly provider: string }) => host.workspace.create(provider),
provision: ({ workspaceID }: { readonly workspaceID: Workspace.ID }) => host.workspace.provision(workspaceID),
destroy: ({ workspaceID }: { readonly workspaceID: Workspace.ID }) => host.workspace.destroy(workspaceID),
},
plugin: Object.assign(host.plugins.register, client.plugin),
},
plugin: Object.assign(host.plugins.register, client.plugin),
start: host.start,
}
})
export const create: (
options?: CreateOptions,
embed?: EmbedOptions,
) => Effect.Effect<Interface, Config.ConfigError | Error, Scope.Scope> = Effect.fn("OpenCode.create")(function* (
options: CreateOptions = {},
embed: EmbedOptions = {},
) {
const host = yield* make(options, embed)
yield* host.start
return host.opencode
})
export class Service extends Context.Service<Service, Interface>()("@opencode-ai/sdk/OpenCode") {}
export const layer = (options: CreateOptions = {}): Layer.Layer<Service, Config.ConfigError | Error> =>
Layer.effect(Service, create(options))
/** Builds a registration layer before starting suspended-session recovery. */
export const layerWith = <E, R>(
registration: Layer.Layer<never, E, R>,
options: CreateOptions = {},
embed: EmbedOptions = {},
): Layer.Layer<Service, Config.ConfigError | Error | E, Exclude<R, Service>> =>
Layer.unwrap(
make(options, embed).pipe(
Effect.map((host) =>
registration.pipe(
Layer.provideMerge(Layer.succeed(Service, host.opencode)),
Layer.tap(() => host.start),
),
),
),
)
+21 -3
View File
@@ -5,6 +5,8 @@ import type { Config, Scope } from "effect"
import { WorkerdProfile } from "../internal/workerd"
import { OpenCode } from "./opencode"
export { Service } from "./opencode"
export type Configuration = WorkerdProfile.Configuration
export interface CreateOptions extends WorkerdProfile.Options {
@@ -12,13 +14,29 @@ export interface CreateOptions extends WorkerdProfile.Options {
readonly workspaceProviders?: OpenCode.CreateOptions["workspaceProviders"]
}
export const create = ({ log, workspaceProviders, ...options }: CreateOptions) => {
const profile = WorkerdProfile.make(options)
return OpenCode.create({ ...profile.options, log, workspaceProviders }, { overrides: profile.replacements })
export const create = (options: CreateOptions) => {
const host = make(options)
return OpenCode.create(host.options, host.embed)
}
export const layer = (options: CreateOptions): Layer.Layer<OpenCode.Service, Config.ConfigError | Error> =>
Layer.effect(OpenCode.Service, create(options))
export const layerWith = <E, R>(
registration: Layer.Layer<never, E, R>,
options: CreateOptions,
): Layer.Layer<OpenCode.Service, Config.ConfigError | Error | E, Exclude<R, OpenCode.Service>> => {
const host = make(options)
return OpenCode.layerWith(registration, host.options, host.embed)
}
export type Interface = OpenCode.Interface
export type Requirements = Scope.Scope
function make({ log, workspaceProviders, ...options }: CreateOptions) {
const profile = WorkerdProfile.make(options)
return {
options: { ...profile.options, log, workspaceProviders },
embed: { overrides: profile.replacements },
}
}
+13 -4
View File
@@ -4,6 +4,7 @@ import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { Workspace } from "@opencode-ai/core/workspace"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
import { createEmbeddedRoutes } from "@opencode-ai/server/routes"
import type { ServerOptions } from "@opencode-ai/server/options"
import type { LayerNode } from "@opencode-ai/util/effect/layer-node"
@@ -25,6 +26,7 @@ export interface EmbedOptions {
export const create = Effect.fn("EmbeddedHost.create")(function* (
options: CreateOptions = {},
embed: EmbedOptions = {},
initialPlugins: ReadonlyArray<Plugin> = [],
) {
const { log, workspaceProviders, ...server } = options
const runtime = ManagedRuntime.make(
@@ -42,9 +44,13 @@ export const create = Effect.fn("EmbeddedHost.create")(function* (
return yield* Effect.gen(function* () {
const services = yield* runtime.contextEffect
// The sweep is a no-op when nothing is suspended. ManagedRuntime owns the
// fiber so recovery never delays startup but still stops with the host.
runtime.runFork(Context.get(services, SessionRestart.Service).resumeSuspendedSessions)
const plugins = Context.get(services, SdkPlugins.Service)
yield* Effect.forEach(initialPlugins, (plugin) => plugins.register(plugin), { discard: true })
const start = yield* Effect.cached(
Effect.sync(() => {
runtime.runFork(Context.get(services, SessionRestart.Service).resumeSuspendedSessions)
}),
)
const handler = HttpEffect.toWebHandlerWith<never, HttpServerRequest.HttpServerRequest | Scope.Scope>(
context(services),
)(Context.get(services, HttpRouter.HttpRouter).asHttpEffect())
@@ -53,8 +59,11 @@ export const create = Effect.fn("EmbeddedHost.create")(function* (
return {
runtime,
fetch: transport.fetch,
plugins: Context.get(services, SdkPlugins.Service),
plugins,
workspace: Context.get(services, Workspace.Service),
// The sweep is a no-op when nothing is suspended. ManagedRuntime owns
// the fiber so recovery never delays startup but still stops with the host.
start,
close: transport.close,
}
}).pipe(Effect.onError(() => runtime.disposeEffect))
+11 -4
View File
@@ -19,13 +19,15 @@ 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 initialPlugins = plugins?.length ? plugins.map(await loadAdapter()) : []
const host = await Effect.runPromise(
EmbeddedHost.create(hostOptions, embed, initialPlugins).pipe(Effect.tap((host) => host.start)),
)
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")
return host.runtime.runPromise(host.plugins.register(PluginPromise.fromPromise(plugin)))
const fromPromise = await loadAdapter()
return host.runtime.runPromise(host.plugins.register(fromPromise(plugin)))
}
for (const plugin of plugins ?? []) await register(plugin)
return {
...client,
@@ -36,3 +38,8 @@ export async function create(options: CreateOptions = {}, embed: EmbeddedHost.Em
[Symbol.asyncDispose]: host.close,
}
}
async function loadAdapter() {
const { PluginPromise } = await import("@opencode-ai/core/plugin/promise")
return PluginPromise.fromPromise
}
+78
View File
@@ -6,6 +6,7 @@ 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 { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { Deferred, Effect, Fiber, Latch, Layer, Option, Ref, Schema, Stream } from "effect"
@@ -442,6 +443,83 @@ it.live("embedded client is available as a Layer service", () =>
}),
)
it.live("starts recovery after Effect registration layers finish", () =>
withEmbedded("opencode-embedded-start-", (fixture) =>
Effect.gen(function* () {
const order: string[] = []
const recovered = yield* Deferred.make<void>()
const restart = Layer.mock(SessionRestart.Service, {
resumeSuspendedSessions: Effect.sync(() => {
order.push("recovery")
}).pipe(Effect.andThen(Deferred.succeed(recovered, undefined))),
})
const registration = Layer.effectDiscard(
Effect.gen(function* () {
const opencode = yield* fixture.sdk.OpenCode.Service
yield* Effect.sleep("50 millis")
yield* opencode.plugin({ id: "startup", effect: () => Effect.void })
order.push("plugin")
}),
)
const application = fixture.sdk.OpenCode.layerWith(
registration,
{},
{
overrides: [[SessionRestart.node, restart]],
},
)
// Reusing a memoized application layer must not launch another recovery sweep.
yield* Layer.build(Layer.merge(application, application))
yield* Deferred.await(recovered).pipe(Effect.timeout("2 seconds"))
expect(order).toEqual(["plugin", "recovery"])
}),
),
)
it.live("disposes the Effect host when a registration layer fails", () =>
withEmbedded("opencode-embedded-start-failure-", (fixture) =>
Effect.gen(function* () {
let disposed = false
let recovered = false
const restart = Layer.effect(
SessionRestart.Service,
Effect.acquireRelease(
Effect.succeed(
SessionRestart.Service.of({
resumeSuspendedSessions: Effect.sync(() => {
recovered = true
}),
}),
),
() =>
Effect.sync(() => {
disposed = true
}),
),
)
const registration = Layer.effectDiscard(
Effect.gen(function* () {
yield* fixture.sdk.OpenCode.Service
yield* Effect.fail(new Error("registration failed"))
}),
)
const application = fixture.sdk.OpenCode.layerWith(
registration,
{},
{
overrides: [[SessionRestart.node, restart]],
},
)
const exit = yield* Layer.build(application).pipe(Effect.scoped, Effect.exit)
expect(exit._tag).toBe("Failure")
expect(disposed).toBe(true)
expect(recovered).toBe(false)
}),
),
)
it.live("configures workspace providers through the SDK facade", () =>
withEmbedded("opencode-embedded-workspace-", (fixture) =>
Effect.gen(function* () {
+89
View File
@@ -1,8 +1,97 @@
import { expect, test } from "bun:test"
import { mkdir } from "node:fs/promises"
import { join } from "node:path"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { Effect, Layer } from "effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { OpenCode, Session } from "../src"
import { PromiseSdk } from "../src/promise"
test("registers every initial Promise plugin before recovery starts", async () => {
const registered: string[] = []
const recovered = Promise.withResolvers<readonly string[]>()
const opencode = await PromiseSdk.create(
{
plugins: [
{ id: "first", setup() {} },
{ id: "second", setup() {} },
],
},
{
overrides: [
[
SdkPlugins.node,
Layer.mock(SdkPlugins.Service, {
register: (plugin) =>
Effect.sync(() => {
registered.push(plugin.id)
}),
all: () => [],
}),
],
[
SessionRestart.node,
Layer.mock(SessionRestart.Service, {
resumeSuspendedSessions: Effect.sync(() => {
recovered.resolve([...registered])
}),
}),
],
],
},
)
try {
expect(await recovered.promise).toEqual(["first", "second"])
} finally {
await opencode.close()
}
})
test("disposes the host when initial Promise plugin registration fails", async () => {
const failure = new Error("plugin registration failed")
let disposed = false
let recovered = false
const error = await PromiseSdk.create(
{ plugins: [{ id: "broken", setup() {} }] },
{
overrides: [
[
SdkPlugins.node,
Layer.effect(
SdkPlugins.Service,
Effect.acquireRelease(
Effect.succeed(
SdkPlugins.Service.of({
register: () => Effect.die(failure),
all: () => [],
}),
),
() =>
Effect.sync(() => {
disposed = true
}),
),
),
],
[
SessionRestart.node,
Layer.mock(SessionRestart.Service, {
resumeSuspendedSessions: Effect.sync(() => {
recovered = true
}),
}),
],
],
},
).catch((error: unknown) => error)
expect(error).toBeInstanceOf(Error)
expect(String(error)).toContain(failure.message)
expect(disposed).toBe(true)
expect(recovered).toBe(false)
})
test("Promise host uses the embedded router and releases plugins", async () => {
await using directory = await tmpdir("opencode-promise-sdk-")
+24 -4
View File
@@ -141,8 +141,28 @@ const session = await Effect.runPromise(program)
Effect applications can contribute plugins from ordinary registration layers.
The registration layer may depend on `OpenCode.Service` and any other services
needed to construct the plugin; `OpenCode.layer()` remains unaware of those
features.
needed to construct the plugin; the host layer remains unaware of those features:
Use `OpenCode.layer()` for dependency injection. The Effect-native Workerd
entrypoint is `@opencode-ai/sdk/workerd/effect`.
```ts
import { OpenCode } from "@opencode-ai/sdk/effect"
import { Effect, Layer } from "effect"
import myPlugin from "./my-plugin"
const PluginLive = Layer.effectDiscard(
Effect.gen(function* () {
const opencode = yield* OpenCode.Service
yield* opencode.plugin(myPlugin)
}),
)
const OpenCodeLive = OpenCode.layerWith(PluginLive)
```
`OpenCode.layerWith` supplies `OpenCode.Service` to the registration layer and
starts recovery only after that layer succeeds. If registration fails, the host
is released without starting recovery. Direct `OpenCode.create()` and
`OpenCode.layer()` calls start recovery automatically.
The Effect-native Workerd entrypoint is `@opencode-ai/sdk/workerd/effect`; it
exports matching `Service` and `layerWith` values for the same composition
pattern.