mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-29 13:06:13 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c158eee846 | ||
|
|
2bba909432 | ||
|
|
18a3cfe787 | ||
|
|
459ea834e7 | ||
|
|
4867facad2 |
+18
-1
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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-")
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user