refactor(client): simplify local service lifecycle

This commit is contained in:
Dax Raad
2026-07-15 13:19:50 -04:00
parent 4f1298063b
commit 75bc611ef1
51 changed files with 635 additions and 534 deletions
+6 -15
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import { Service } from "@opencode-ai/client/effect" import { Service } from "@opencode-ai/client/effect/service"
import { ServiceStatus } from "@opencode-ai/protocol/groups/health" import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
import { Schema } from "effect" import { Schema } from "effect"
import fs from "node:fs/promises" import fs from "node:fs/promises"
@@ -37,8 +37,7 @@ try {
const headers = { authorization: "Basic " + credential } const headers = { authorization: "Basic " + credential }
const token = encodeURIComponent(credential) const token = encodeURIComponent(credential)
const health = await waitForReady(info.url, headers) const health = await waitForReady(info.url, headers)
if (health.pid !== info.pid || health.instanceID !== info.id) if (health.pid !== info.pid) throw new Error("Health process does not match registration")
throw new Error("Health identity does not match registration")
const tokenHealth = await fetch( const tokenHealth = await fetch(
new URL(`/api/health?auth_token=${token}`, info.url), new URL(`/api/health?auth_token=${token}`, info.url),
{ signal: AbortSignal.timeout(5_000) }, { signal: AbortSignal.timeout(5_000) },
@@ -75,7 +74,7 @@ try {
await fetch(new URL("/api/service/stop", info.url), { await fetch(new URL("/api/service/stop", info.url), {
method: "POST", method: "POST",
headers: { ...headers, "content-type": "application/json" }, headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ instanceID: info.id, targetVersion: "smoke-next" }), body: JSON.stringify({ instanceID: info.id }),
signal: AbortSignal.timeout(5_000), signal: AbortSignal.timeout(5_000),
}).then((response) => response.json()), }).then((response) => response.json()),
) )
@@ -120,19 +119,11 @@ async function waitForRegistration() {
async function waitForReady(url: string, headers: HeadersInit) { async function waitForReady(url: string, headers: HeadersInit) {
const deadline = Date.now() + 20_000 const deadline = Date.now() + 20_000
while (Date.now() < deadline) { while (Date.now() < deadline) {
const health = await fetch(new URL("/api/health", url), { const response = await fetch(new URL("/api/health", url), {
headers, headers,
signal: AbortSignal.timeout(1_000), signal: AbortSignal.timeout(1_000),
}) }).catch(() => undefined)
.then((response) => response.json()) if (response?.ok) return Schema.decodeUnknownPromise(ServiceStatus.Health)(await response.json())
.then(Schema.decodeUnknownPromise(ServiceStatus.Health))
.catch(() => undefined)
if (health === undefined) {
await Bun.sleep(25)
continue
}
if (health.status.type === "ready") return health
if (health.status.type === "failed") throw new Error(health.status.message)
await Bun.sleep(25) await Bun.sleep(25)
} }
throw new Error("Compiled service did not become ready") throw new Error("Compiled service did not become ready")
+2 -2
View File
@@ -2,7 +2,7 @@ import { EOL } from "node:os"
import { Effect, Option } from "effect" import { Effect, Option } from "effect"
import { Commands } from "../commands" import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime" import { Runtime } from "../../framework/runtime"
import { Service } from "@opencode-ai/client/effect" import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
import { ServerConnection } from "../../services/server-connection" import { ServerConnection } from "../../services/server-connection"
const methods = new Set(["delete", "get", "head", "options", "patch", "post", "put"]) const methods = new Set(["delete", "get", "head", "options", "patch", "post", "put"])
@@ -62,7 +62,7 @@ export function rawRequest(input: readonly string[]) {
return { method: input[0].toUpperCase(), path: input[1] } return { method: input[0].toUpperCase(), path: input[1] }
} }
function resolveRequest(endpoint: Service.Endpoint, input: readonly string[], params: Record<string, string>) { function resolveRequest(endpoint: Endpoint, input: readonly string[], params: Record<string, string>) {
const raw = rawRequest(input) const raw = rawRequest(input)
if (raw) return Effect.succeed(raw) if (raw) return Effect.succeed(raw)
if (input.length !== 1) return Effect.fail(new Error("Expected an operation name or an HTTP method and path")) if (input.length !== 1) return Effect.fail(new Error("Expected an operation name or an HTTP method and path"))
@@ -1,5 +1,5 @@
import { Cause, Effect, Exit, Option } from "effect" import { Cause, Effect, Exit, Option } from "effect"
import { Service } from "@opencode-ai/client/effect" import { Service } from "@opencode-ai/client/effect/service"
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import { AppProcess } from "@opencode-ai/core/process" import { AppProcess } from "@opencode-ai/core/process"
import { Commands } from "../../commands" import { Commands } from "../../commands"
@@ -3,7 +3,7 @@ import { Effect } from "effect"
import { OpenCode } from "@opencode-ai/client" import { OpenCode } from "@opencode-ai/client"
import { Commands } from "../../commands" import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime" import { Runtime } from "../../../framework/runtime"
import { Service } from "@opencode-ai/client/effect" import { Service } from "@opencode-ai/client/effect/service"
import { ServiceConfig } from "../../../services/service-config" import { ServiceConfig } from "../../../services/service-config"
export default Runtime.handler( export default Runtime.handler(
@@ -21,8 +21,8 @@ export default Runtime.handler(Commands, (input) =>
const server = yield* ServerConnection.resolve({ const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server), server: Option.getOrUndefined(input.server),
standalone: input.standalone, standalone: input.standalone,
onStart: (reason, existing) => { onStart: (reason, previousVersion) => {
if (reason === "version-mismatch" && preflight.begin(existing?.version)) return if (reason === "version-mismatch" && preflight.begin(previousVersion)) return
process.stderr.write( process.stderr.write(
reason === "version-mismatch" reason === "version-mismatch"
? "Restarting background server (version mismatch)...\n" ? "Restarting background server (version mismatch)...\n"
@@ -48,7 +48,7 @@ export default Runtime.handler(Commands, (input) =>
endpoint: server.endpoint, endpoint: server.endpoint,
service: service service: service
? { ? {
reconnect: (onStatus, signal) => runServicePromise(service.reconnect(onStatus), { signal }), reconnect: (signal) => runServicePromise(service.reconnect(), { signal }),
restart: () => runServicePromise(service.restart()), restart: () => runServicePromise(service.restart()),
} }
: undefined, : undefined,
@@ -8,7 +8,7 @@ import {
} from "@opencode-ai/client" } from "@opencode-ai/client"
import { Commands } from "../../commands" import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime" import { Runtime } from "../../../framework/runtime"
import { Service } from "@opencode-ai/client/effect" import { Service } from "@opencode-ai/client/effect/service"
import { ServiceConfig } from "../../../services/service-config" import { ServiceConfig } from "../../../services/service-config"
import { resolveIntegration } from "./resolve" import { resolveIntegration } from "./resolve"
@@ -3,7 +3,7 @@ import { Effect } from "effect"
import { OpenCode, type McpServer } from "@opencode-ai/client" import { OpenCode, type McpServer } from "@opencode-ai/client"
import { Commands } from "../../commands" import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime" import { Runtime } from "../../../framework/runtime"
import { Service } from "@opencode-ai/client/effect" import { Service } from "@opencode-ai/client/effect/service"
import { ServiceConfig } from "../../../services/service-config" import { ServiceConfig } from "../../../services/service-config"
export default Runtime.handler( export default Runtime.handler(
@@ -3,7 +3,7 @@ import { Effect } from "effect"
import { OpenCode } from "@opencode-ai/client" import { OpenCode } from "@opencode-ai/client"
import { Commands } from "../../commands" import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime" import { Runtime } from "../../../framework/runtime"
import { Service } from "@opencode-ai/client/effect" import { Service } from "@opencode-ai/client/effect/service"
import { ServiceConfig } from "../../../services/service-config" import { ServiceConfig } from "../../../services/service-config"
import { resolveIntegration } from "./resolve" import { resolveIntegration } from "./resolve"
+1 -1
View File
@@ -1,6 +1,6 @@
import { EOL } from "os" import { EOL } from "os"
import { Effect } from "effect" import { Effect } from "effect"
import { Service } from "@opencode-ai/client/effect" import { Service } from "@opencode-ai/client/effect/service"
import { OpenCode } from "@opencode-ai/client/promise" import { OpenCode } from "@opencode-ai/client/promise"
import { renderUnicodeCompact } from "uqr" import { renderUnicodeCompact } from "uqr"
import { Commands } from "../commands" import { Commands } from "../commands"
@@ -1,6 +1,6 @@
import { EOL } from "os" import { EOL } from "os"
import { Effect } from "effect" import { Effect } from "effect"
import { Service } from "@opencode-ai/client/effect" import { Service } from "@opencode-ai/client/effect/service"
import { Commands } from "../../commands" import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime" import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config" import { ServiceConfig } from "../../../services/service-config"
@@ -9,7 +9,7 @@ export default Runtime.handler(
Commands.commands.service.commands.restart, Commands.commands.service.commands.restart,
Effect.fn("cli.service.restart")(function* () { Effect.fn("cli.service.restart")(function* () {
const options = yield* ServiceConfig.options() const options = yield* ServiceConfig.options()
yield* Service.stop(options, { targetVersion: options.version }) yield* Service.stop(options)
const transport = yield* Service.start(options) const transport = yield* Service.start(options)
process.stdout.write(transport.url + EOL) process.stdout.write(transport.url + EOL)
}), }),
@@ -1,6 +1,6 @@
import { EOL } from "os" import { EOL } from "os"
import { Effect } from "effect" import { Effect } from "effect"
import { Service } from "@opencode-ai/client/effect" import { Service } from "@opencode-ai/client/effect/service"
import { Commands } from "../../commands" import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime" import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config" import { ServiceConfig } from "../../../services/service-config"
@@ -1,6 +1,6 @@
import { EOL } from "os" import { EOL } from "os"
import { Effect } from "effect" import { Effect } from "effect"
import { Service } from "@opencode-ai/client/effect" import { Service } from "@opencode-ai/client/effect/service"
import { Commands } from "../../commands" import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime" import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config" import { ServiceConfig } from "../../../services/service-config"
@@ -9,12 +9,7 @@ export default Runtime.handler(
Commands.commands.service.commands.status, Commands.commands.service.commands.status,
Effect.fn("cli.service.status")(function* () { Effect.fn("cli.service.status")(function* () {
const options = yield* ServiceConfig.options() const options = yield* ServiceConfig.options()
const status = yield* Service.status(options)
if (status.type !== "ready") {
process.stdout.write(status.type + EOL)
return
}
const found = yield* Service.discover({ ...options, version: undefined }) const found = yield* Service.discover({ ...options, version: undefined })
process.stdout.write((found?.url ?? status.type) + EOL) process.stdout.write((found?.url ?? "stopped") + EOL)
}), }),
) )
@@ -1,5 +1,5 @@
import { Effect } from "effect" import { Effect } from "effect"
import { Service } from "@opencode-ai/client/effect" import { Service } from "@opencode-ai/client/effect/service"
import { Commands } from "../../commands" import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime" import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config" import { ServiceConfig } from "../../../services/service-config"
+1 -1
View File
@@ -1,4 +1,4 @@
import { Service } from "@opencode-ai/client/effect" import { Service } from "@opencode-ai/client/effect/service"
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import { ServerConnection } from "../services/server-connection" import { ServerConnection } from "../services/server-connection"
import { waitForCatalogReady } from "./catalog.shared" import { waitForCatalogReady } from "./catalog.shared"
+2 -2
View File
@@ -1,4 +1,4 @@
import { Service } from "@opencode-ai/client/effect" import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { Model } from "@opencode-ai/schema/model" import { Model } from "@opencode-ai/schema/model"
@@ -55,7 +55,7 @@ async function run(input: RunCommandInput) {
return execute(input, prepared, input.server.endpoint) return execute(input, prepared, input.server.endpoint)
} }
async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Service.Endpoint) { async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Endpoint) {
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const requestedDirectory = prepared.directory ?? (await client.location.get()).directory const requestedDirectory = prepared.directory ?? (await client.location.get()).directory
if (!requestedDirectory) fail("Failed to resolve server directory") if (!requestedDirectory) fail("Failed to resolve server directory")
+1 -1
View File
@@ -1,7 +1,7 @@
export * as ServerProcess from "./server-process" export * as ServerProcess from "./server-process"
import { NodeServices } from "@effect/platform-node" import { NodeServices } from "@effect/platform-node"
import { Service } from "@opencode-ai/client/effect" import { Service } from "@opencode-ai/client/effect/service"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
@@ -1,4 +1,4 @@
import { Service } from "@opencode-ai/client/effect" import { Service, type Endpoint, type StartOptions } from "@opencode-ai/client/effect/service"
import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise" import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise"
import { InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Effect, Redacted } from "effect" import { Effect, Redacted } from "effect"
@@ -10,11 +10,11 @@ export type Args = {
readonly server?: string readonly server?: string
readonly standalone?: boolean readonly standalone?: boolean
readonly mismatch?: "replace" | "ignore" | "error" readonly mismatch?: "replace" | "ignore" | "error"
readonly onStart?: Service.StartOptions["onStart"] readonly onStart?: StartOptions["onStart"]
} }
export type Resolved = { export type Resolved = {
readonly endpoint: Service.Endpoint readonly endpoint: Endpoint
readonly service?: ReturnType<typeof managedService> readonly service?: ReturnType<typeof managedService>
} }
@@ -26,7 +26,7 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg
const endpoint = { const endpoint = {
url: args.server, url: args.server,
auth: password ? { type: "basic" as const, username: "opencode", password: Redacted.value(password) } : undefined, auth: password ? { type: "basic" as const, username: "opencode", password: Redacted.value(password) } : undefined,
} satisfies Service.Endpoint } satisfies Endpoint
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const health = yield* Effect.tryPromise({ const health = yield* Effect.tryPromise({
try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }), try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }),
@@ -49,20 +49,20 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg
} satisfies Resolved } satisfies Resolved
}) })
function managedService(options: Service.StartOptions) { function managedService(options: StartOptions) {
const reconnectOptions = { ...options, version: undefined } const reconnectOptions = { ...options, version: undefined }
return { return {
reconnect: (onStatus: (status: Service.Status) => void) => Service.start({ ...reconnectOptions, onStatus }), reconnect: () => Service.start(reconnectOptions),
restart: () => restart: () =>
Effect.gen(function* () { Effect.gen(function* () {
yield* Service.stop(options, { targetVersion: options.version }) yield* Service.stop(options)
yield* Service.start(options) yield* Service.start(options)
}), }),
} }
} }
const resolveManaged = Effect.fnUntraced(function* ( const resolveManaged = Effect.fnUntraced(function* (
options: Service.StartOptions, options: StartOptions,
mismatch: NonNullable<Args["mismatch"]>, mismatch: NonNullable<Args["mismatch"]>,
) { ) {
if (mismatch === "replace") return yield* Service.start(options) if (mismatch === "replace") return yield* Service.start(options)
@@ -76,7 +76,7 @@ const resolveManaged = Effect.fnUntraced(function* (
return yield* Service.start(options) return yield* Service.start(options)
}) })
function connectError(endpoint: Service.Endpoint, cause: unknown) { function connectError(endpoint: Endpoint, cause: unknown) {
if (isUnauthorizedError(cause)) { if (isUnauthorizedError(cause)) {
return new Error( return new Error(
endpoint.auth === undefined endpoint.auth === undefined
+2 -2
View File
@@ -1,12 +1,12 @@
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
import { Hash } from "@opencode-ai/core/util/hash" import { Hash } from "@opencode-ai/core/util/hash"
import { Service } from "@opencode-ai/client/effect" import { Service } from "@opencode-ai/client/effect/service"
import { Effect, FileSystem, Option, Schema } from "effect" import { Effect, FileSystem, Option, Schema } from "effect"
import { randomBytes } from "crypto" import { randomBytes } from "crypto"
import path from "path" import path from "path"
// The CLI's service configuration file, plus the Service.Options binding that // The CLI's service configuration file, plus the Service.StartOptions binding that
// points the client package's service operations at this CLI: which // points the client package's service operations at this CLI: which
// registration file (by channel), which version, and how to spawn opencode. // registration file (by channel), which version, and how to spawn opencode.
+2 -2
View File
@@ -1,4 +1,4 @@
import { Service } from "@opencode-ai/client/effect" import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Effect, Schema, Stream } from "effect" import { Effect, Schema, Stream } from "effect"
@@ -46,7 +46,7 @@ const makeEndpoint = Effect.fn("cli.standalone.endpoint")(
url: ready.url, url: ready.url,
auth: { type: "basic" as const, username: "opencode", password }, auth: { type: "basic" as const, username: "opencode", password },
pid: proc.pid, pid: proc.pid,
} satisfies Service.Endpoint & { readonly pid: number } } satisfies Endpoint & { readonly pid: number }
}, },
Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node)), Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node)),
) )
@@ -1,5 +1,5 @@
import { Effect } from "effect" import { Effect } from "effect"
import { Service } from "@opencode-ai/client/effect" import { Service } from "@opencode-ai/client/effect/service"
import path from "node:path" import path from "node:path"
import { Standalone } from "../../src/services/standalone" import { Standalone } from "../../src/services/standalone"
+2 -4
View File
@@ -19,8 +19,6 @@ test("resolution groups Effect-native lifecycle operations only for the managed
healthy: true, healthy: true,
version: InstallationVersion, version: InstallationVersion,
pid: process.pid, pid: process.pid,
instanceID: id,
status: { type: "ready" },
}) })
}, },
}) })
@@ -45,9 +43,9 @@ test("resolution groups Effect-native lifecycle operations only for the managed
expect(resolved.endpoint.url).toBe(server.url.toString()) expect(resolved.endpoint.url).toBe(server.url.toString())
expect(resolved.service).toBeDefined() expect(resolved.service).toBeDefined()
if (!resolved.service) throw new Error("Expected managed service capabilities") if (!resolved.service) throw new Error("Expected managed service capabilities")
expect(Effect.isEffect(resolved.service.reconnect(() => {}))).toBe(true) expect(Effect.isEffect(resolved.service.reconnect())).toBe(true)
expect(Effect.isEffect(resolved.service.restart())).toBe(true) expect(Effect.isEffect(resolved.service.restart())).toBe(true)
expect(await runPromise(resolved.service.reconnect(() => {}))).toEqual(resolved.endpoint) expect(await runPromise(resolved.service.reconnect())).toEqual(resolved.endpoint)
const explicit = await runPromise(ServerConnection.resolve({ server: server.url.toString() })) const explicit = await runPromise(ServerConnection.resolve({ server: server.url.toString() }))
expect(explicit.endpoint.url).toBe(server.url.toString()) expect(explicit.endpoint.url).toBe(server.url.toString())
+4 -18
View File
@@ -1,5 +1,5 @@
import { NodeFileSystem } from "@effect/platform-node" import { NodeFileSystem } from "@effect/platform-node"
import { Service } from "@opencode-ai/client/effect" import { Service } from "@opencode-ai/client/effect/service"
import { Database } from "@opencode-ai/core/database/database" import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { EventTable } from "@opencode-ai/core/event/sql" import { EventTable } from "@opencode-ai/core/event/sql"
@@ -146,11 +146,10 @@ test("concurrent service processes elect one server", async () => {
await fetch(new URL("/api/health", info.url), { await fetch(new URL("/api/health", info.url), {
headers: { authorization: "Basic " + btoa(`opencode:${info.password}`) }, headers: { authorization: "Basic " + btoa(`opencode:${info.password}`) },
}).then((response) => response.json()), }).then((response) => response.json()),
).toMatchObject({ ).toEqual({
healthy: true, healthy: true,
version: info.version,
pid: info.pid, pid: info.pid,
instanceID: info.id,
status: { type: "ready" },
}) })
const blockedTemp = registration + "." + info.id + ".tmp" const blockedTemp = registration + "." + info.id + ".tmp"
await fs.mkdir(blockedTemp) await fs.mkdir(blockedTemp)
@@ -193,7 +192,7 @@ test("concurrent service processes elect one server", async () => {
).toEqual({ timeSuspended: null }) ).toEqual({ timeSuspended: null })
expect(await waitForExecutionStart(database, sessionID)).toBe(1) expect(await waitForExecutionStart(database, sessionID)).toBe(1)
await Effect.runPromise( await Effect.runPromise(
Service.stop({ file: registration }, { targetVersion: "next" }).pipe(Effect.provide(NodeFileSystem.layer)), Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)),
) )
await winner?.exited await winner?.exited
} finally { } finally {
@@ -227,19 +226,6 @@ test("a failed service stays registered and owns the lock until stopped", async
try { try {
const info = await waitForInfo(registration) const info = await waitForInfo(registration)
const status = await Effect.runPromise(
Service.status({ file: registration }).pipe(
Effect.filterOrFail((status) => status.type === "failed"),
Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(200)))),
Effect.provide(NodeFileSystem.layer),
),
)
expect(status).toEqual({
type: "failed",
version: info.version,
message: "The background service could not start.",
action: "Run `opencode service restart` after checking the service logs.",
})
expect(owner.exitCode).toBe(null) expect(owner.exitCode).toBe(null)
const contender = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" }) const contender = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
+3 -1
View File
@@ -19,8 +19,10 @@
".": "./src/promise/index.ts", ".": "./src/promise/index.ts",
"./promise": "./src/promise/index.ts", "./promise": "./src/promise/index.ts",
"./promise/api": "./src/promise/api.ts", "./promise/api": "./src/promise/api.ts",
"./service": "./src/promise/service.ts",
"./effect": "./src/effect/index.ts", "./effect": "./src/effect/index.ts",
"./effect/api": "./src/effect/api.ts" "./effect/api": "./src/effect/api.ts",
"./effect/service": "./src/effect/service.ts"
}, },
"scripts": { "scripts": {
"build": "bun run script/build-package.ts", "build": "bun run script/build-package.ts",
+1 -4
View File
@@ -11,10 +11,7 @@ export type Endpoint0_0Output = EffectValue<ReturnType<RawClient["server.health"
export type HealthGetOperation<E = never> = () => Effect.Effect<Endpoint0_0Output, E> export type HealthGetOperation<E = never> = () => Effect.Effect<Endpoint0_0Output, E>
type Endpoint0_1Request = Parameters<RawClient["server.health"]["health.stop"]>[0] type Endpoint0_1Request = Parameters<RawClient["server.health"]["health.stop"]>[0]
export type Endpoint0_1Input = { export type Endpoint0_1Input = { readonly instanceID: Endpoint0_1Request["payload"]["instanceID"] }
readonly instanceID: Endpoint0_1Request["payload"]["instanceID"]
readonly targetVersion?: Endpoint0_1Request["payload"]["targetVersion"]
}
export type Endpoint0_1Output = EffectValue<ReturnType<RawClient["server.health"]["health.stop"]>> export type Endpoint0_1Output = EffectValue<ReturnType<RawClient["server.health"]["health.stop"]>>
export type HealthStopOperation<E = never> = (input: Endpoint0_1Input) => Effect.Effect<Endpoint0_1Output, E> export type HealthStopOperation<E = never> = (input: Endpoint0_1Input) => Effect.Effect<Endpoint0_1Output, E>
@@ -17,14 +17,9 @@ const Endpoint0_0 = (raw: RawClient["server.health"]) => () =>
raw["health.get"]({}).pipe(Effect.mapError(mapClientError)) raw["health.get"]({}).pipe(Effect.mapError(mapClientError))
type Endpoint0_1Request = Parameters<RawClient["server.health"]["health.stop"]>[0] type Endpoint0_1Request = Parameters<RawClient["server.health"]["health.stop"]>[0]
type Endpoint0_1Input = { type Endpoint0_1Input = { readonly instanceID: Endpoint0_1Request["payload"]["instanceID"] }
readonly instanceID: Endpoint0_1Request["payload"]["instanceID"]
readonly targetVersion?: Endpoint0_1Request["payload"]["targetVersion"]
}
const Endpoint0_1 = (raw: RawClient["server.health"]) => (input: Endpoint0_1Input) => const Endpoint0_1 = (raw: RawClient["server.health"]) => (input: Endpoint0_1Input) =>
raw["health.stop"]({ payload: { instanceID: input["instanceID"], targetVersion: input["targetVersion"] } }).pipe( raw["health.stop"]({ payload: { instanceID: input["instanceID"] } }).pipe(Effect.mapError(mapClientError))
Effect.mapError(mapClientError),
)
const adaptGroup0 = (raw: RawClient["server.health"]) => ({ get: Endpoint0_0(raw), stop: Endpoint0_1(raw) }) const adaptGroup0 = (raw: RawClient["server.health"]) => ({ get: Endpoint0_0(raw), stop: Endpoint0_1(raw) })
-1
View File
@@ -17,7 +17,6 @@ export type {
SessionApi, SessionApi,
SkillApi, SkillApi,
} from "./api.js" } from "./api.js"
export { Service } from "./service.js"
export { Agent } from "@opencode-ai/schema/agent" export { Agent } from "@opencode-ai/schema/agent"
export { Command } from "@opencode-ai/schema/command" export { Command } from "@opencode-ai/schema/command"
export { Credential } from "@opencode-ai/schema/credential" export { Credential } from "@opencode-ai/schema/credential"
+41 -108
View File
@@ -3,6 +3,16 @@ import { Effect, FileSystem, Option, Schedule, Schema } from "effect"
import { spawn, type ChildProcess } from "node:child_process" import { spawn, type ChildProcess } from "node:child_process"
import { homedir } from "node:os" import { homedir } from "node:os"
import { join } from "node:path" import { join } from "node:path"
import type {
DiscoverOptions,
Endpoint,
StartOptions,
StopOptions,
} from "../service.js"
export * from "../service.js"
/** Contents of the local service registration file. */
export type Info = import("../service.js").Info
// Find, start, and stop the local opencode background service. // Find, start, and stop the local opencode background service.
// //
@@ -12,48 +22,6 @@ import { join } from "node:path"
// is all a client needs to connect. The daemon's own configuration (port, // is all a client needs to connect. The daemon's own configuration (port,
// persisted password) is CLI-owned and never read here. // persisted password) is CLI-owned and never read here.
export type Endpoint = {
readonly url: string
readonly auth?: {
readonly type: "basic"
readonly username: string
readonly password: string
}
}
export type Options = {
// Absolute path to the service registration file. Defaults to
// opencode/service.json in the XDG state directory.
readonly file?: string
// When set, discovery only returns a server reporting this exact version,
// and start() replaces a healthy server whose version differs.
readonly version?: string
// Argv used to spawn the service. Defaults to ["opencode", "serve",
// "--service"] resolved from PATH.
readonly command?: ReadonlyArray<string>
}
export type StartReason = "missing" | "version-mismatch"
export type StartOptions = Options & {
// Called once when start() decides it must spawn: either no service was
// found, or a healthy service with a different version is being replaced.
// `existing` carries the registration of the service being replaced.
readonly onStart?: (reason: StartReason, existing?: Info) => void
readonly onStatus?: (status: Status) => void
}
export type Status =
| { readonly type: "missing" }
| { readonly type: "unreachable" }
| { readonly type: "unresponsive" }
| (ServiceStatus.State & { readonly version?: string })
export class FailedError extends Schema.TaggedErrorClass<FailedError>()("ServiceFailedError", {
message: Schema.String,
action: Schema.String,
}) {}
type Contender = { type Contender = {
readonly child: ChildProcess readonly child: ChildProcess
readonly error: () => Error | undefined readonly error: () => Error | undefined
@@ -61,24 +29,14 @@ type Contender = {
// Read-only lookup: registration file plus health check and version gate. // Read-only lookup: registration file plus health check and version gate.
// Never spawns; escalation to start() is the caller's policy. // Never spawns; escalation to start() is the caller's policy.
export const discover = Effect.fn("service.discover")(function* (options: Options = {}) { /** Discover a healthy, compatible local service without starting one. */
export const discover = Effect.fn("service.discover")(function* (options: DiscoverOptions = {}) {
return (yield* discoverLocal(options))?.endpoint return (yield* discoverLocal(options))?.endpoint
}) })
export const status = Effect.fn("service.status")(function* (options: Options = {}) { const discoverLocal = Effect.fnUntraced(function* (options: DiscoverOptions) {
const result = yield* registered(options.file, true)
if (result.info === undefined) return { type: "missing" } satisfies Status
if (result.service === undefined) return { type: "unreachable" } satisfies Status
return publicStatus(result.service)
})
function publicStatus(service: LocalService): Status {
return { ...service.status, version: service.version }
}
const discoverLocal = Effect.fnUntraced(function* (options: Options) {
const found = (yield* registered(options.file)).service const found = (yield* registered(options.file)).service
if (found?.status.type !== "ready") return undefined if (found?.state !== "ready") return undefined
if (options.version !== undefined && found.version !== options.version) return undefined if (options.version !== undefined && found.version !== options.version) return undefined
return found return found
}) })
@@ -86,18 +44,18 @@ const discoverLocal = Effect.fnUntraced(function* (options: Options) {
// Idempotent ensure-running: reuses a healthy compatible server, replaces a // Idempotent ensure-running: reuses a healthy compatible server, replaces a
// version-mismatched one, and otherwise spawns small contenders until a server // version-mismatched one, and otherwise spawns small contenders until a server
// becomes discoverable. A contender is never killed merely for slow startup. // becomes discoverable. A contender is never killed merely for slow startup.
/** Ensure a healthy, compatible local service is running. */
export const start = Effect.fn("service.start")(function* (options: StartOptions = {}) { export const start = Effect.fn("service.start")(function* (options: StartOptions = {}) {
const contenders = new Set<Contender>() const contenders = new Set<Contender>()
let announced = false let announced = false
let reported: Status | undefined
let lastSpawn = 0 let lastSpawn = 0
let spawnDelay = 5_000 let spawnDelay = 5_000
let ownerHeld = false let ownerHeld = false
const announce = (reason: StartReason, existing?: Info) => const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) =>
Effect.sync(() => { Effect.sync(() => {
if (announced) return if (announced) return
announced = true announced = true
options.onStart?.(reason, existing) options.onStart?.(reason, previousVersion)
}) })
const spawnContender = Effect.gen(function* () { const spawnContender = Effect.gen(function* () {
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"] const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
@@ -119,24 +77,15 @@ export const start = Effect.fn("service.start")(function* (options: StartOptions
const registration = yield* registered(options.file, true) const registration = yield* registered(options.file, true)
const info = registration.info const info = registration.info
const service = registration.service const service = registration.service
const current: Status =
service === undefined ? { type: info === undefined ? "missing" : "unreachable" } : publicStatus(service)
const next = ownerHeld && service === undefined ? ({ type: "unresponsive" } satisfies Status) : current
yield* Effect.sync(() => {
if (sameStatus(reported, next)) return
reported = next
options.onStatus?.(next)
})
if (service !== undefined) { if (service !== undefined) {
ownerHeld = false ownerHeld = false
spawnDelay = 5_000 spawnDelay = 5_000
const compatible = !service.legacy && (options.version === undefined || service.version === options.version) const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
if (compatible && service.status.type === "ready") return Option.some(service) if (compatible && service.state === "ready") return Option.some(service)
if (compatible && service.status.type === "failed") if (compatible && service.state === "failed") return yield* Effect.fail(new Error("Background service failed to start"))
return yield* new FailedError({ message: service.status.message, action: service.status.action }) if (compatible) return Option.none<LocalService>()
if (compatible || service.status.type === "stopping") return Option.none<LocalService>() yield* announce("version-mismatch", service.version)
yield* announce("version-mismatch", service.info) yield* kill(service, options).pipe(Effect.ignore)
yield* kill(service, options, options.version).pipe(Effect.ignore)
lastSpawn = 0 lastSpawn = 0
return Option.none<LocalService>() return Option.none<LocalService>()
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now() } else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
@@ -160,22 +109,6 @@ export const start = Effect.fn("service.start")(function* (options: StartOptions
return Option.getOrThrow(found).endpoint return Option.getOrThrow(found).endpoint
}) })
function sameStatus(left: Status | undefined, right: Status) {
if (left?.type !== right.type) return false
if (right.type === "failed")
return (
left.type === "failed" &&
left.version === right.version &&
left.message === right.message &&
left.action === right.action
)
if (right.type === "stopping")
return left.type === "stopping" && left.version === right.version && left.targetVersion === right.targetVersion
if (right.type === "starting" || right.type === "ready")
return left.type === right.type && left.version === right.version
return true
}
function contenderFailure(contender: Contender) { function contenderFailure(contender: Contender) {
const error = contender.error() const error = contender.error()
if (error !== undefined) return error if (error !== undefined) return error
@@ -190,13 +123,10 @@ function contenderFinished(contender: Contender) {
return contender.error() !== undefined || contender.child.exitCode !== null || contender.child.signalCode !== null return contender.error() !== undefined || contender.child.exitCode !== null || contender.child.signalCode !== null
} }
export type StopMetadata = { /** Stop the registered local service. */
readonly targetVersion?: string export const stop = Effect.fn("service.stop")(function* (options: StopOptions = {}) {
}
export const stop = Effect.fn("service.stop")(function* (options: Options = {}, metadata: StopMetadata = {}) {
const existing = yield* find(options) const existing = yield* find(options)
if (existing !== undefined) yield* kill(existing, options, metadata.targetVersion) if (existing !== undefined) yield* kill(existing, options)
}) })
function fallback() { function fallback() {
@@ -204,11 +134,13 @@ function fallback() {
return join(state, "opencode", "service.json") return join(state, "opencode", "service.json")
} }
/** Create HTTP authentication headers for a service endpoint. */
export function headers(endpoint: Endpoint) { export function headers(endpoint: Endpoint) {
if (endpoint.auth === undefined) return undefined if (endpoint.auth === undefined) return undefined
return { authorization: "Basic " + btoa(endpoint.auth.username + ":" + endpoint.auth.password) } return { authorization: "Basic " + btoa(endpoint.auth.username + ":" + endpoint.auth.password) }
} }
/** Schema for the local service registration file. */
export const Info = Schema.Struct({ export const Info = Schema.Struct({
id: Schema.optional(Schema.String), id: Schema.optional(Schema.String),
version: Schema.optional(Schema.String), version: Schema.optional(Schema.String),
@@ -216,7 +148,6 @@ export const Info = Schema.Struct({
pid: Schema.Int.check(Schema.isGreaterThan(0)), pid: Schema.Int.check(Schema.isGreaterThan(0)),
password: Schema.optional(Schema.String), password: Schema.optional(Schema.String),
}) })
export type Info = typeof Info.Type
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Info)) const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
const decodeHealth = Schema.decodeUnknownOption(ServiceStatus.Health) const decodeHealth = Schema.decodeUnknownOption(ServiceStatus.Health)
@@ -235,7 +166,7 @@ type LocalService = {
readonly info: Info readonly info: Info
readonly endpoint: Endpoint readonly endpoint: Endpoint
readonly version?: string readonly version?: string
readonly status: ServiceStatus.State readonly state: "ready" | "waiting" | "failed"
readonly legacy: boolean readonly legacy: boolean
} }
@@ -259,13 +190,11 @@ const probe = Effect.fnUntraced(function* (info: Info, allowLegacy = false) {
if (Option.isSome(health)) { if (Option.isSome(health)) {
if (health.value.pid !== info.pid) return undefined if (health.value.pid !== info.pid) return undefined
if (info.version !== undefined && health.value.version !== info.version) return undefined if (info.version !== undefined && health.value.version !== info.version) return undefined
if (info.id !== undefined && health.value.instanceID !== undefined && health.value.instanceID !== info.id)
return undefined
return { return {
info, info,
endpoint, endpoint,
version: health.value.version, version: health.value.version,
status: health.value.status, state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
legacy: false, legacy: false,
} satisfies LocalService } satisfies LocalService
} }
@@ -275,7 +204,7 @@ const probe = Effect.fnUntraced(function* (info: Info, allowLegacy = false) {
(typeof body === "object" && body !== null && ("version" in body || "pid" in body)) (typeof body === "object" && body !== null && ("version" in body || "pid" in body))
) )
return undefined return undefined
return { info, endpoint, status: { type: "ready" }, legacy: true } satisfies LocalService return { info, endpoint, state: "ready", legacy: true } satisfies LocalService
}) })
const registered = Effect.fnUntraced(function* (file?: string, allowLegacy = false) { const registered = Effect.fnUntraced(function* (file?: string, allowLegacy = false) {
@@ -284,9 +213,9 @@ const registered = Effect.fnUntraced(function* (file?: string, allowLegacy = fal
return { info, service: yield* probe(info, allowLegacy) } return { info, service: yield* probe(info, allowLegacy) }
}) })
// Health-checked lookup without the version gate: status operations must be // Health-checked lookup without the version gate: lifecycle operations must be
// able to see (and replace or stop) a server from a different version. // able to see (and replace or stop) a server from a different version.
const find = Effect.fnUntraced(function* (options: Options) { const find = Effect.fnUntraced(function* (options: { readonly file?: string }) {
return (yield* registered(options.file, true)).service return (yield* registered(options.file, true)).service
}) })
@@ -309,8 +238,11 @@ function same(left: Info, right: Info) {
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
} }
const kill = Effect.fnUntraced(function* (service: LocalService, options: Options, targetVersion?: string) { const kill = Effect.fnUntraced(function* (
const requested = yield* requestStop(service, targetVersion) service: LocalService,
options: { readonly file?: string },
) {
const requested = yield* requestStop(service)
if (requested === "rejected") return if (requested === "rejected") return
if (requested === "unsupported") { if (requested === "unsupported") {
// A stale registration may point at a reused PID. Authenticate again // A stale registration may point at a reused PID. Authenticate again
@@ -330,13 +262,13 @@ const kill = Effect.fnUntraced(function* (service: LocalService, options: Option
const decodeStopResponse = Schema.decodeUnknownOption(ServiceStatus.StopResponse) const decodeStopResponse = Schema.decodeUnknownOption(ServiceStatus.StopResponse)
const requestStop = Effect.fnUntraced(function* (service: LocalService, targetVersion?: string) { const requestStop = Effect.fnUntraced(function* (service: LocalService) {
if (service.info.id === undefined || service.legacy) return "unsupported" as const if (service.info.id === undefined || service.legacy) return "unsupported" as const
const response = yield* Effect.tryPromise(() => const response = yield* Effect.tryPromise(() =>
fetch(new URL("/api/service/stop", service.info.url), { fetch(new URL("/api/service/stop", service.info.url), {
method: "POST", method: "POST",
headers: { ...headers(service.endpoint), "content-type": "application/json" }, headers: { ...headers(service.endpoint), "content-type": "application/json" },
body: JSON.stringify({ instanceID: service.info.id, targetVersion }), body: JSON.stringify({ instanceID: service.info.id }),
signal: AbortSignal.timeout(2_000), signal: AbortSignal.timeout(2_000),
}), }),
).pipe(Effect.option, Effect.map(Option.getOrUndefined)) ).pipe(Effect.option, Effect.map(Option.getOrUndefined))
@@ -347,4 +279,5 @@ const requestStop = Effect.fnUntraced(function* (service: LocalService, targetVe
return "accepted" as const return "accepted" as const
}) })
export * as Service from "./service.js" /** Effect-based local service lifecycle operations. */
export const Service = { discover, start, stop, headers, Info }
@@ -339,7 +339,7 @@ export function make(options: ClientOptions) {
{ {
method: "POST", method: "POST",
path: `/api/service/stop`, path: `/api/service/stop`,
body: { instanceID: input["instanceID"], targetVersion: input["targetVersion"] }, body: { instanceID: input["instanceID"] },
successStatus: 200, successStatus: 200,
declaredStatuses: [401, 400], declaredStatuses: [401, 400],
empty: false, empty: false,
+2 -17
View File
@@ -1,10 +1,6 @@
export type JsonValue = null | boolean | number | string | Array<JsonValue> | { [key: string]: JsonValue } export type JsonValue = null | boolean | number | string | Array<JsonValue> | { [key: string]: JsonValue }
export type ServiceStatus = export type ServiceHealth = { healthy: true; version: string; pid: number }
| { type: "starting" }
| { type: "ready" }
| { type: "stopping"; targetVersion?: string | null }
| { type: "failed"; message: string; action: string }
export type ServiceStopResponse = { accepted: boolean } export type ServiceStopResponse = { accepted: boolean }
@@ -506,14 +502,6 @@ export type VcsFileStatus = {
status: "added" | "deleted" | "modified" status: "added" | "deleted" | "modified"
} }
export type ServiceHealth = {
healthy: true
version: string
pid: number
instanceID?: string | null
status?: ServiceStatus
}
export type SessionMessageModelSelected = { export type SessionMessageModelSelected = {
id: string id: string
metadata?: { [x: string]: JsonValue } metadata?: { [x: string]: JsonValue }
@@ -2501,10 +2489,7 @@ export const isProjectCopyError = (value: unknown): value is ProjectCopyError =>
export type HealthGetOutput = ServiceHealth export type HealthGetOutput = ServiceHealth
export type HealthStopInput = { export type HealthStopInput = { readonly instanceID: { readonly instanceID: string }["instanceID"] }
readonly instanceID: { readonly instanceID: string; readonly targetVersion?: string | undefined }["instanceID"]
readonly targetVersion?: { readonly instanceID: string; readonly targetVersion?: string | undefined }["targetVersion"]
}
export type HealthStopOutput = ServiceStopResponse export type HealthStopOutput = ServiceStopResponse
+252
View File
@@ -0,0 +1,252 @@
import { readFile } from "node:fs/promises"
import { spawn, type ChildProcess } from "node:child_process"
import { homedir } from "node:os"
import { join } from "node:path"
import type {
DiscoverOptions,
Endpoint,
Info,
StartOptions,
StopOptions,
} from "../service.js"
import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js"
export * from "../service.js"
// Find, start, and stop the local opencode background service.
//
// The registration file is the complete discovery contract. This module is
// intentionally implemented with Node APIs so Promise clients do not need
// Effect or @effect/platform-node at runtime.
type Contender = {
readonly child: ChildProcess
readonly error: () => Error | undefined
}
/** Discover a healthy, compatible local service without starting one. */
export async function discover(options: DiscoverOptions = {}) {
return (await discoverLocal(options))?.endpoint
}
async function discoverLocal(options: DiscoverOptions) {
const found = (await registered(options.file)).service
if (found?.state !== "ready") return undefined
if (options.version !== undefined && found.version !== options.version) return undefined
return found
}
/** Ensure a healthy, compatible local service is running. */
export async function start(options: StartOptions = {}): Promise<Endpoint> {
const contenders = new Set<Contender>()
let announced = false
let lastSpawn = 0
let spawnDelay = 5_000
let ownerHeld = false
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) => {
if (announced) return
announced = true
options.onStart?.(reason, previousVersion)
}
const spawnContender = () => {
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
if (command === undefined) throw new Error("Missing service command")
try {
const child = spawn(command, args, { detached: true, stdio: "ignore" })
let error: Error | undefined
child.once("error", (cause) => {
error = new Error("Failed to start server", { cause })
})
child.unref()
return { child, error: () => error }
} catch (cause) {
throw new Error("Failed to start server", { cause })
}
}
while (true) {
const registration = await registered(options.file, true)
if (registration.service !== undefined) {
ownerHeld = false
spawnDelay = 5_000
const service = registration.service
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
if (compatible && service.state === "ready") return service.endpoint
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
if (!compatible) {
announce("version-mismatch", service.version)
await kill(service, options).catch(() => undefined)
lastSpawn = 0
}
} else {
if (lastSpawn === 0 && registration.info !== undefined) lastSpawn = Date.now()
const failure = [...contenders].map(contenderFailure).find((error) => error !== undefined)
if (failure !== undefined) throw failure
const finished = [...contenders].filter(contenderFinished)
if (finished.some((item) => item.child.exitCode === 0)) {
ownerHeld = true
spawnDelay = Math.min(spawnDelay * 2, 30_000)
}
finished.forEach((item) => contenders.delete(item))
// Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
announce("missing")
contenders.add(spawnContender())
lastSpawn = Date.now()
}
}
await delay(1_000)
}
}
function contenderFailure(contender: Contender) {
const error = contender.error()
if (error !== undefined) return error
if (contender.child.exitCode !== null && contender.child.exitCode !== 0)
return new Error(`Server process exited with code ${contender.child.exitCode}`)
if (contender.child.signalCode !== null)
return new Error(`Server process terminated by ${contender.child.signalCode}`)
return undefined
}
function contenderFinished(contender: Contender) {
return contender.error() !== undefined || contender.child.exitCode !== null || contender.child.signalCode !== null
}
/** Stop the registered local service. */
export async function stop(options: StopOptions = {}) {
const existing = await find(options)
if (existing !== undefined) await kill(existing, options)
}
function fallback() {
return join(process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state"), "opencode", "service.json")
}
/** Create HTTP authentication headers for a service endpoint. */
export function headers(endpoint: Endpoint) {
if (endpoint.auth === undefined) return undefined
return { authorization: "Basic " + Buffer.from(endpoint.auth.username + ":" + endpoint.auth.password).toString("base64") }
}
async function read(file?: string) {
const text = await readFile(file ?? fallback(), "utf8").catch(() => undefined)
if (text === undefined) return undefined
try {
return JSON.parse(text) as Info
} catch {
return undefined
}
}
type LocalService = {
readonly info: Info
readonly endpoint: Endpoint
readonly version?: string
readonly state: "ready" | "waiting" | "failed"
readonly legacy: boolean
}
async function probe(info: Info, allowLegacy = false): Promise<LocalService | undefined> {
const endpoint = {
url: info.url,
auth:
info.password === undefined
? undefined
: { type: "basic" as const, username: "opencode", password: info.password },
} satisfies Endpoint
const response = await fetch(new URL("/api/health", info.url), {
headers: headers(endpoint),
signal: AbortSignal.timeout(2_000),
}).catch(() => undefined)
const body = (await response?.json().catch(() => undefined)) as ServiceHealth | { readonly healthy: true } | undefined
if (body !== undefined && "version" in body && "pid" in body) {
if (body.pid !== info.pid) return undefined
if (info.version !== undefined && body.version !== info.version) return undefined
return {
info,
endpoint,
version: body.version,
state: response?.ok ? "ready" : response?.status === 500 ? "failed" : "waiting",
legacy: false,
}
}
if (!allowLegacy || body?.healthy !== true) return undefined
return { info, endpoint, state: "ready", legacy: true }
}
async function registered(file?: string, allowLegacy = false) {
const info = await read(file)
if (info === undefined) return { info: undefined, service: undefined }
return { info, service: await probe(info, allowLegacy) }
}
async function find(options: { readonly file?: string }) {
return (await registered(options.file, true)).service
}
function signal(pid: number, name: NodeJS.Signals) {
try {
process.kill(pid, name)
} catch {}
}
function stopped(pid: number) {
try {
process.kill(pid, 0)
return false
} catch {
return true
}
}
async function waitUntilStopped(pid: number) {
for (let attempt = 0; attempt <= 100; attempt++) {
if (stopped(pid)) return true
if (attempt < 100) await delay(50)
}
return false
}
function same(left: Info, right: Info) {
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
}
async function kill(service: LocalService, options: { readonly file?: string }) {
const requested = await requestStop(service)
if (requested === "rejected") return
if (requested === "unsupported") {
const current = await find(options)
if (current === undefined || !same(current.info, service.info)) return
signal(service.info.pid, "SIGTERM")
}
if (await waitUntilStopped(service.info.pid)) return
const latest = await find(options)
if (latest === undefined || !same(latest.info, service.info)) return
signal(service.info.pid, "SIGKILL")
if (!(await waitUntilStopped(service.info.pid))) throw new Error(`Server process ${service.info.pid} is still running`)
}
async function requestStop(service: LocalService) {
if (service.info.id === undefined || service.legacy) return "unsupported" as const
const response = await fetch(new URL("/api/service/stop", service.info.url), {
method: "POST",
headers: { ...headers(service.endpoint), "content-type": "application/json" },
body: JSON.stringify({ instanceID: service.info.id }),
signal: AbortSignal.timeout(2_000),
}).catch(() => undefined)
if (response === undefined || response.status === 404 || response.status === 405) return "unsupported" as const
const body = (await response.json().catch(() => undefined)) as ServiceStopResponse | undefined
if (!response.ok || body?.accepted !== true) return "rejected" as const
return "accepted" as const
}
function delay(milliseconds: number) {
return new Promise<void>((resolve) => setTimeout(resolve, milliseconds))
}
/** Promise-based local service lifecycle operations. */
export const Service = { discover, start, stop, headers }
+53
View File
@@ -0,0 +1,53 @@
/** Connection details for a local OpenCode service. */
export type Endpoint = {
/** Base URL of the service. */
readonly url: string
/** Authentication required by the service, when configured. */
readonly auth?: {
/** HTTP authentication scheme. */
readonly type: "basic"
/** Basic authentication username. */
readonly username: string
/** Basic authentication password. */
readonly password: string
}
}
/** Options used to discover the local OpenCode service. */
export type DiscoverOptions = {
/** Absolute registration file path. Defaults to the XDG state directory. */
readonly file?: string
/** Required service version. */
readonly version?: string
}
/** Reason a new service process must be started. */
export type StartReason = "missing" | "version-mismatch"
/** Options used to ensure the local OpenCode service is running. */
export type StartOptions = DiscoverOptions & {
/** Service command and arguments. Defaults to `opencode serve --service`. */
readonly command?: ReadonlyArray<string>
/** Called once before spawning a new service process. */
readonly onStart?: (reason: StartReason, previousVersion?: string) => void
}
/** Options used to stop the local OpenCode service. */
export type StopOptions = {
/** Absolute registration file path. Defaults to the XDG state directory. */
readonly file?: string
}
/** Contents of the local service registration file. */
export type Info = {
/** Unique service instance identifier. */
readonly id?: string
/** OpenCode version served by the process. */
readonly version?: string
/** Base URL advertised by the service. */
readonly url: string
/** Operating system process identifier. */
readonly pid: number
/** Private service password, when authentication is enabled. */
readonly password?: string
}
+2 -2
View File
@@ -15,7 +15,7 @@ import {
const synced = { type: "log.synced" as const, aggregateID: "ses_test", seq: Event.Seq.make(1) } const synced = { type: "log.synced" as const, aggregateID: "ses_test", seq: Event.Seq.make(1) }
test("health.get treats an old server as ready", async () => { test("health.get decodes the readiness response", async () => {
const httpClient = HttpClient.make((request) => const httpClient = HttpClient.make((request) =>
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ healthy: true, version: "old", pid: 123 }))), Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ healthy: true, version: "old", pid: 123 }))),
) )
@@ -24,7 +24,7 @@ test("health.get treats an old server as ready", async () => {
return yield* client.health.get() return yield* client.health.get()
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(result.status).toEqual({ type: "ready" }) expect(result).toEqual({ healthy: true, version: "old", pid: 123 })
}) })
test("session.get returns the decoded Effect projection", async () => { test("session.get returns the decoded Effect projection", async () => {
+3 -15
View File
@@ -48,23 +48,11 @@ const server = Bun.serve({
} }
if (mode === "legacy") return Response.json({ healthy: true }) if (mode === "legacy") return Response.json({ healthy: true })
if (mode === "starting" && !(await Bun.file(registration + ".release").exists())) if (mode === "starting" && !(await Bun.file(registration + ".release").exists()))
return Response.json( return Response.json({ healthy: true, version, pid: process.pid }, { status: 503 })
{ healthy: true, version, pid: process.pid, instanceID: id, status: { type: "starting" } },
{ status: 503 },
)
if (mode === "failed-owner") if (mode === "failed-owner")
return Response.json( return Response.json({ healthy: true, version, pid: process.pid }, { status: 500 })
{
healthy: true,
version,
pid: process.pid,
instanceID: id,
status: { type: "failed", message: "Could not open the database.", action: "Check the service logs." },
},
{ status: 503 },
)
if (mode === "starting" || mode === "graceful" || mode === "reject-stop") if (mode === "starting" || mode === "graceful" || mode === "reject-stop")
return Response.json({ healthy: true, version, pid: process.pid, instanceID: id, status: { type: "ready" } }) return Response.json({ healthy: true, version, pid: process.pid })
return Response.json({ healthy: true, version, pid: process.pid }) return Response.json({ healthy: true, version, pid: process.pid })
}, },
}) })
+16 -3
View File
@@ -20,15 +20,28 @@ describe("public import boundaries", () => {
expect(within(root, core)).toEqual([]) expect(within(root, core)).toEqual([])
expect(within(root, server)).toEqual([]) expect(within(root, server)).toEqual([])
// The effect entry includes local service lifecycle (node spawn/fs), so it const network = await bundleInputs("@opencode-ai/client/effect", "browser")
// bundles for bun; the boundary assertions below are what matter.
const network = await bundleInputs("@opencode-ai/client/effect", "bun")
expect(within(network, effect).length).toBeGreaterThan(0) expect(within(network, effect).length).toBeGreaterThan(0)
expect(within(network, schema).length).toBeGreaterThan(0) expect(within(network, schema).length).toBeGreaterThan(0)
expect(within(network, protocol).length).toBeGreaterThan(0) expect(within(network, protocol).length).toBeGreaterThan(0)
expect(within(network, core)).toEqual([]) expect(within(network, core)).toEqual([])
expect(within(network, server)).toEqual([]) expect(within(network, server)).toEqual([])
const promiseService = await bundleInputs("@opencode-ai/client/service", "bun")
expect(within(promiseService, effect)).toEqual([])
expect(within(promiseService, schema)).toEqual([])
expect(within(promiseService, protocol)).toEqual([])
expect(within(promiseService, core)).toEqual([])
expect(within(promiseService, server)).toEqual([])
const effectService = await bundleInputs("@opencode-ai/client/effect/service", "bun")
expect(within(effectService, effect).length).toBeGreaterThan(0)
expect(within(effectService, protocol).length).toBeGreaterThan(0)
expect(within(effectService, core)).toEqual([])
expect(within(effectService, server)).toEqual([])
}) })
}) })
@@ -0,0 +1,96 @@
import { afterEach, expect, test } from "bun:test"
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { Service, type StartReason } from "../src/promise/service"
const fixture = join(import.meta.dir, "fixture/service.ts")
const processes: Bun.Subprocess[] = []
const directories: string[] = []
afterEach(async () => {
processes.forEach((process) => process.kill("SIGTERM"))
await Promise.all(processes.splice(0).map((process) => process.exited))
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))
})
test("discovers a registered service", async () => {
const registration = await setup("graceful")
expect(await Service.discover({ file: registration, version: "test" })).toEqual(
expect.objectContaining({ url: expect.stringMatching(/^http:\/\//) }),
)
expect(await Service.discover({ file: registration, version: "other" })).toBeUndefined()
})
test("starts a missing service with native promises", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const starts: StartReason[] = []
const endpoint = await Service.start({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "coordinated"],
onStart: (reason) => starts.push(reason),
})
const info = await Bun.file(registration).json()
try {
expect(endpoint.url).toBe(info.url)
expect(starts).toEqual(["missing"])
} finally {
process.kill(info.pid, "SIGTERM")
await waitForExit(info.pid)
}
}, 15_000)
test("reports a failed registered service", async () => {
const registration = await setup("failed-owner")
await expect(Service.start({ file: registration, version: "test", command: [] })).rejects.toThrow(
"Background service failed to start",
)
})
test("requests graceful stop of the exact service instance", async () => {
const registration = await setup("graceful")
const info = await Bun.file(registration).json()
await Service.stop({ file: registration })
expect(await Bun.file(registration + ".stop").json()).toEqual({ instanceID: info.id })
})
async function setup(mode: string) {
const directory = await temp()
const registration = join(directory, "service.json")
processes.push(Bun.spawn([process.execPath, fixture, registration, mode], { stdout: "ignore", stderr: "inherit" }))
await waitForFile(registration)
return registration
}
async function temp() {
const directory = await mkdtemp(join(tmpdir(), "opencode-promise-service-"))
directories.push(directory)
return directory
}
async function waitForFile(file: string) {
for (let attempt = 0; attempt < 600; attempt++) {
if (await Bun.file(file).exists()) return
await Bun.sleep(5)
}
throw new Error(`Timed out waiting for ${file}`)
}
async function waitForExit(pid: number) {
for (let attempt = 0; attempt < 600; attempt++) {
try {
process.kill(pid, 0)
} catch {
return
}
await Bun.sleep(5)
}
throw new Error(`Timed out waiting for process ${pid}`)
}
+2 -2
View File
@@ -71,10 +71,10 @@ test("health.stop sends exact replacement identity", async () => {
}, },
}) })
expect(await client.health.stop({ instanceID: "instance", targetVersion: "next" })).toEqual({ accepted: true }) expect(await client.health.stop({ instanceID: "instance" })).toEqual({ accepted: true })
expect(request?.method).toBe("POST") expect(request?.method).toBe("POST")
expect(request?.url).toBe("http://localhost:3000/api/service/stop") expect(request?.url).toBe("http://localhost:3000/api/service/stop")
expect(await request?.json()).toEqual({ instanceID: "instance", targetVersion: "next" }) expect(await request?.json()).toEqual({ instanceID: "instance" })
}) })
test("MCP resource catalog uses the public HTTP contract", async () => { test("MCP resource catalog uses the public HTTP contract", async () => {
+10 -17
View File
@@ -4,7 +4,7 @@ import { Effect } from "effect"
import { mkdtemp, rm, writeFile } from "node:fs/promises" import { mkdtemp, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os" import { tmpdir } from "node:os"
import { join } from "node:path" import { join } from "node:path"
import { Service } from "../src/effect/index" import { Service, type StartReason } from "../src/effect/service"
const fixture = join(import.meta.dir, "fixture/service.ts") const fixture = join(import.meta.dir, "fixture/service.ts")
const processes: Bun.Subprocess[] = [] const processes: Bun.Subprocess[] = []
@@ -23,7 +23,7 @@ test("a concurrent same-version start cannot invalidate a resolved endpoint", as
await waitForFile(registration) await waitForFile(registration)
const original = await Bun.file(registration).json() const original = await Bun.file(registration).json()
const starts: Service.StartReason[] = [] const starts: StartReason[] = []
const first = run( const first = run(
Service.start({ Service.start({
file: registration, file: registration,
@@ -43,7 +43,6 @@ test("a concurrent same-version start cannot invalidate a resolved endpoint", as
expect(starts).toEqual([]) expect(starts).toEqual([])
expect(await Bun.file(registration).json()).toEqual(original) expect(await Bun.file(registration).json()).toEqual(original)
expect(await health(resolved.url)).toEqual({ healthy: true, version: "test", pid: original.pid }) expect(await health(resolved.url)).toEqual({ healthy: true, version: "test", pid: original.pid })
expect(await run(Service.status({ file: registration }))).toEqual({ type: "ready", version: "test" })
}) })
test("waits for a registered service to finish starting", async () => { test("waits for a registered service to finish starting", async () => {
@@ -51,15 +50,10 @@ test("waits for a registered service to finish starting", async () => {
const registration = join(directory, "service.json") const registration = join(directory, "service.json")
const process = spawn(registration, "starting") const process = spawn(registration, "starting")
await waitForFile(registration) await waitForFile(registration)
const statuses: Service.Status[] = [] const result = run(Service.start({ file: registration, version: "test", command: [] }))
const result = run(
Service.start({ file: registration, version: "test", command: [], onStatus: (status) => statuses.push(status) }),
)
await Bun.sleep(500) await Bun.sleep(500)
expect(process.exitCode).toBe(null) expect(process.exitCode).toBe(null)
expect(statuses).toContainEqual({ type: "starting", version: "test" })
expect(statuses.filter((status) => status.type === "starting")).toHaveLength(1)
await writeFile(registration + ".release", "") await writeFile(registration + ".release", "")
expect((await result).url).toBe((await Bun.file(registration).json()).url) expect((await result).url).toBe((await Bun.file(registration).json()).url)
}) })
@@ -70,23 +64,22 @@ test("reports a failed registered service without spawning", async () => {
const process = spawn(registration, "failed-owner") const process = spawn(registration, "failed-owner")
await waitForFile(registration) await waitForFile(registration)
await expect(run(Service.start({ file: registration, version: "test", command: [] }))).rejects.toMatchObject({ await expect(run(Service.start({ file: registration, version: "test", command: [] }))).rejects.toThrow(
message: "Could not open the database.", "Background service failed to start",
action: "Check the service logs.", )
})
expect(process.exitCode).toBe(null) expect(process.exitCode).toBe(null)
}) })
test("requests graceful replacement of the exact service instance", async () => { test("requests graceful stop of the exact service instance", async () => {
const directory = await temp() const directory = await temp()
const registration = join(directory, "service.json") const registration = join(directory, "service.json")
const process = spawn(registration, "graceful") const process = spawn(registration, "graceful")
await waitForFile(registration) await waitForFile(registration)
const info = await Bun.file(registration).json() const info = await Bun.file(registration).json()
await run(Service.stop({ file: registration }, { targetVersion: "next" })) await run(Service.stop({ file: registration }))
await process.exited await process.exited
expect(await Bun.file(registration + ".stop").json()).toEqual({ instanceID: info.id, targetVersion: "next" }) expect(await Bun.file(registration + ".stop").json()).toEqual({ instanceID: info.id })
}) })
test("does not spawn contenders while an incompatible service rejects replacement", async () => { test("does not spawn contenders while an incompatible service rejects replacement", async () => {
@@ -120,7 +113,7 @@ test("a legacy health response is still replaced", async () => {
const existing = spawn(registration, "legacy") const existing = spawn(registration, "legacy")
await waitForFile(registration) await waitForFile(registration)
const starts: Service.StartReason[] = [] const starts: StartReason[] = []
const result = run(Service.start({ file: registration, command: [], onStart: (reason) => starts.push(reason) })) const result = run(Service.start({ file: registration, command: [], onStart: (reason) => starts.push(reason) }))
await expect(result).rejects.toThrow("Missing service command") await expect(result).rejects.toThrow("Missing service command")
+33 -10
View File
@@ -70,6 +70,33 @@ for await (const event of client.event.subscribe()) {
} }
``` ```
## Local service
`Service` discovers and manages the local OpenCode background service from a
Node application. The Promise API uses Node APIs directly and does not require
Effect or `@effect/platform-node`.
- `Service.discover()` returns a healthy registered endpoint without starting
a process.
- `Service.start()` reuses a compatible service or starts one when needed.
- `Service.stop()` stops the registered service.
- `Service.headers(endpoint)` creates the authentication headers for a client.
```ts
import { OpenCode } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/service"
const endpoint = await Service.start()
const client = OpenCode.make({
baseUrl: endpoint.url,
headers: Service.headers(endpoint),
})
const health = await client.health.get()
```
Import the native Promise service API from `@opencode-ai/client/service`.
## Effect ## Effect
OpenCode provides a first-class Effect client through the OpenCode provides a first-class Effect client through the
@@ -106,16 +133,11 @@ const session = await Effect.runPromise(
Streaming operations, including `client.event.subscribe()` and Streaming operations, including `client.event.subscribe()` and
`client.session.log(...)`, return Effect `Stream` values. `client.session.log(...)`, return Effect `Stream` values.
### Service ### Local service
`Service` discovers and manages the local OpenCode background service from a The Effect entrypoint exposes the same service lifecycle operations as Effect
Node application: values. Add `@effect/platform-node` and provide its filesystem layer when
running service operations.
- `Service.discover()` returns a healthy registered endpoint without starting
a process.
- `Service.start()` reuses a compatible service or starts one when needed.
- `Service.stop()` stops the registered service.
- `Service.headers(endpoint)` creates the authentication headers for a client.
```sh ```sh
bun add @effect/platform-node bun add @effect/platform-node
@@ -123,7 +145,8 @@ bun add @effect/platform-node
```ts ```ts
import { NodeFileSystem } from "@effect/platform-node" import { NodeFileSystem } from "@effect/platform-node"
import { OpenCode, Service } from "@opencode-ai/client/effect" import { OpenCode } from "@opencode-ai/client/effect"
import { Service } from "@opencode-ai/client/effect/service"
import { Effect } from "effect" import { Effect } from "effect"
import { FetchHttpClient } from "effect/unstable/http" import { FetchHttpClient } from "effect/unstable/http"
+1 -19
View File
@@ -1,34 +1,16 @@
import { Effect, Schema } from "effect" import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
export namespace ServiceStatus { export namespace ServiceStatus {
export const State = Schema.Union([
Schema.Struct({ type: Schema.Literal("starting") }),
Schema.Struct({ type: Schema.Literal("ready") }),
Schema.Struct({
type: Schema.Literal("stopping"),
targetVersion: Schema.String.pipe(Schema.optional),
}),
Schema.Struct({
type: Schema.Literal("failed"),
message: Schema.String,
action: Schema.String,
}),
]).annotate({ identifier: "ServiceStatus" })
export type State = typeof State.Type
export const Health = Schema.Struct({ export const Health = Schema.Struct({
healthy: Schema.Literal(true), healthy: Schema.Literal(true),
version: Schema.String, version: Schema.String,
pid: Schema.Int.check(Schema.isGreaterThan(0)), pid: Schema.Int.check(Schema.isGreaterThan(0)),
instanceID: Schema.String.pipe(Schema.optional),
status: State.pipe(Schema.withDecodingDefaultKey(Effect.succeed({ type: "ready" as const }))),
}).annotate({ identifier: "ServiceHealth" }) }).annotate({ identifier: "ServiceHealth" })
export type Health = typeof Health.Type export type Health = typeof Health.Type
export const StopRequest = Schema.Struct({ export const StopRequest = Schema.Struct({
instanceID: Schema.String, instanceID: Schema.String,
targetVersion: Schema.String.pipe(Schema.optional),
}).annotate({ identifier: "ServiceStopRequest" }) }).annotate({ identifier: "ServiceStopRequest" })
export type StopRequest = typeof StopRequest.Type export type StopRequest = typeof StopRequest.Type
+3 -3
View File
@@ -179,7 +179,7 @@ import type {
QuestionReplyErrors, QuestionReplyErrors,
QuestionReplyResponses, QuestionReplyResponses,
QuestionV2Reply, QuestionV2Reply,
ServiceStopRequestV2, ServiceStopRequest,
SessionAbortErrors, SessionAbortErrors,
SessionAbortResponses, SessionAbortResponses,
SessionChildrenErrors, SessionChildrenErrors,
@@ -5091,11 +5091,11 @@ export class Health extends HeyApiClient {
*/ */
public stop<ThrowOnError extends boolean = false>( public stop<ThrowOnError extends boolean = false>(
parameters: { parameters: {
serviceStopRequestV2: ServiceStopRequestV2 serviceStopRequest: ServiceStopRequest
}, },
options?: Options<never, ThrowOnError>, options?: Options<never, ThrowOnError>,
) { ) {
const params = buildClientParams([parameters], [{ args: [{ key: "serviceStopRequestV2", map: "body" }] }]) const params = buildClientParams([parameters], [{ args: [{ key: "serviceStopRequest", map: "body" }] }])
return (options?.client ?? this.client).post<V2HealthStopResponses, V2HealthStopErrors, ThrowOnError>({ return (options?.client ?? this.client).post<V2HealthStopResponses, V2HealthStopErrors, ThrowOnError>({
url: "/api/service/stop", url: "/api/service/stop",
...options, ...options,
+1 -45
View File
@@ -2795,29 +2795,10 @@ export type WorkspaceWarpError = {
} }
} }
export type ServiceStatus =
| {
type: "starting"
}
| {
type: "ready"
}
| {
type: "stopping"
targetVersion?: string
}
| {
type: "failed"
message: string
action: string
}
export type ServiceHealth = { export type ServiceHealth = {
healthy: true healthy: true
version: string version: string
pid: number pid: number
instanceID?: string
status?: ServiceStatus
} }
export type UnauthorizedError = { export type UnauthorizedError = {
@@ -2827,7 +2808,6 @@ export type UnauthorizedError = {
export type ServiceStopRequest = { export type ServiceStopRequest = {
instanceID: string instanceID: string
targetVersion?: string
} }
export type ServiceStopResponse = { export type ServiceStopResponse = {
@@ -8141,29 +8121,10 @@ export type BadRequestError = {
} }
} }
export type ServiceStatusV2 =
| {
type: "starting"
}
| {
type: "ready"
}
| {
type: "stopping"
targetVersion?: string | null
}
| {
type: "failed"
message: string
action: string
}
export type ServiceHealthV2 = { export type ServiceHealthV2 = {
healthy: true healthy: true
version: string version: string
pid: number pid: number
instanceID?: string | null
status?: ServiceStatusV2
} }
export type InvalidRequestErrorV2 = { export type InvalidRequestErrorV2 = {
@@ -8173,11 +8134,6 @@ export type InvalidRequestErrorV2 = {
field?: string | null field?: string | null
} }
export type ServiceStopRequestV2 = {
instanceID: string
targetVersion?: string | null
}
export type SessionsResponseV2 = { export type SessionsResponseV2 = {
data: Array<SessionInfoV2> data: Array<SessionInfoV2>
cursor: { cursor: {
@@ -15183,7 +15139,7 @@ export type V2HealthGetResponses = {
export type V2HealthGetResponse = V2HealthGetResponses[keyof V2HealthGetResponses] export type V2HealthGetResponse = V2HealthGetResponses[keyof V2HealthGetResponses]
export type V2HealthStopData = { export type V2HealthStopData = {
body: ServiceStopRequestV2 body: ServiceStopRequest
path?: never path?: never
query?: never query?: never
url: "/api/service/stop" url: "/api/service/stop"
-1
View File
@@ -10,7 +10,6 @@ export const HealthHandler = HttpApiBuilder.group(Api, "server.health", (handler
healthy: true as const, healthy: true as const,
version: InstallationVersion, version: InstallationVersion,
pid: process.pid, pid: process.pid,
status: { type: "ready" },
}), }),
) )
.handle("health.stop", () => Effect.succeed({ accepted: false })), .handle("health.stop", () => Effect.succeed({ accepted: false })),
+21 -22
View File
@@ -1,6 +1,7 @@
export * as ServerProcess from "./process" export * as ServerProcess from "./process"
import { NodeHttpServer, NodeHttpServerRequest } from "@effect/platform-node" import { NodeHttpServer, NodeHttpServerRequest } from "@effect/platform-node"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart" import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { ServiceStatus } from "@opencode-ai/protocol/groups/health" import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty" import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
@@ -45,7 +46,7 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(options:
const applicationScope = yield* Scope.fork(parentScope) const applicationScope = yield* Scope.fork(parentScope)
yield* Effect.addFinalizer(() => yield* Effect.addFinalizer(() =>
status status
.beginStopping() .beginStopping
.pipe( .pipe(
Effect.andThen(Ref.set(application, Option.none())), Effect.andThen(Ref.set(application, Option.none())),
Effect.andThen(Effect.sync(() => bound.server.closeAllConnections())), Effect.andThen(Effect.sync(() => bound.server.closeAllConnections())),
@@ -73,22 +74,17 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(options:
}).pipe( }).pipe(
Effect.catchCause((cause) => { Effect.catchCause((cause) => {
if (!options.service || Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause) if (!options.service || Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause)
return status return status.fail.pipe(
.fail({ Effect.andThen(
message: "The background service could not start.", Scope.close(applicationScope, Exit.failCause(cause)).pipe(
action: "Run `opencode service restart` after checking the service logs.", Effect.catchCause((cleanupCause) =>
}) Effect.logError("failed to clean up background service boot", { cause: cleanupCause }),
.pipe(
Effect.andThen(
Scope.close(applicationScope, Exit.failCause(cause)).pipe(
Effect.catchCause((cleanupCause) =>
Effect.logError("failed to clean up background service boot", { cause: cleanupCause }),
),
), ),
), ),
Effect.andThen(Effect.logError("background service boot failed", { cause })), ),
Effect.andThen(Effect.never), Effect.andThen(Effect.logError("background service boot failed", { cause })),
) Effect.andThen(Effect.never),
)
}), }),
) )
if (!options.service) return yield* boot if (!options.service) return yield* boot
@@ -189,18 +185,21 @@ const control = Effect.fnUntraced(function* (
}) })
const healthResponse = Effect.fnUntraced(function* (status: Status.Interface) { const healthResponse = Effect.fnUntraced(function* (status: Status.Interface) {
const health = yield* status.health const state = yield* status.current
return HttpServerResponse.jsonUnsafe(health, { return HttpServerResponse.jsonUnsafe({ healthy: true, version: InstallationVersion, pid: process.pid }, {
status: health.status.type === "ready" ? 200 : 503, status: state.type === "ready" ? 200 : state.type === "failed" ? 500 : 503,
headers: headers: state.type === "starting" || state.type === "stopping" ? { "retry-after": "1" } : undefined,
health.status.type === "starting" || health.status.type === "stopping" ? { "retry-after": "1" } : undefined,
}) })
}) })
function unavailable(status: ServiceStatus.State) { function unavailable(status: Status.State) {
if (status.type === "failed") if (status.type === "failed")
return HttpServerResponse.jsonUnsafe( return HttpServerResponse.jsonUnsafe(
{ code: "service_failed", message: status.message, action: status.action }, {
code: "service_failed",
message: "The background service could not start.",
action: "Run `opencode service restart` after checking the service logs.",
},
{ status: 503 }, { status: 503 },
) )
return HttpServerResponse.jsonUnsafe( return HttpServerResponse.jsonUnsafe(
+20 -32
View File
@@ -1,57 +1,45 @@
export * as Status from "./service-status" export * as Status from "./service-status"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { ServiceStatus } from "@opencode-ai/protocol/groups/health" import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
import { Effect, Ref } from "effect" import { Effect, Ref } from "effect"
export type State =
| { readonly type: "starting" }
| { readonly type: "ready" }
| { readonly type: "stopping" }
| { readonly type: "failed" }
export interface Interface { export interface Interface {
readonly health: Effect.Effect<ServiceStatus.Health> readonly current: Effect.Effect<State>
readonly current: Effect.Effect<ServiceStatus.State>
readonly ready: Effect.Effect<void> readonly ready: Effect.Effect<void>
readonly fail: (failure: { readonly message: string; readonly action: string }) => Effect.Effect<void> readonly fail: Effect.Effect<void>
readonly beginStopping: (targetVersion?: string) => Effect.Effect<void> readonly beginStopping: Effect.Effect<void>
readonly requestStop: (request: ServiceStatus.StopRequest) => Effect.Effect<boolean> readonly requestStop: (request: ServiceStatus.StopRequest) => Effect.Effect<boolean>
} }
export const make = Effect.fnUntraced(function* (options: { export const make = Effect.fnUntraced(function* (options: {
readonly instanceID: string readonly instanceID: string
readonly managed: boolean readonly managed: boolean
readonly initial?: ServiceStatus.State readonly initial?: State
}) { }) {
const current = yield* Ref.make(options.initial ?? ({ type: "starting" } satisfies ServiceStatus.State)) const current = yield* Ref.make(options.initial ?? ({ type: "starting" } satisfies State))
const transitionToStopping = (targetVersion?: string) => const beginStopping = Ref.update(current, (status) =>
Ref.update(current, (status) => { status.type === "stopping" ? status : ({ type: "stopping" } satisfies State),
if (status.type === "stopping") return status )
return (
targetVersion === undefined || targetVersion === InstallationVersion
? { type: "stopping" }
: { type: "stopping", targetVersion }
) satisfies ServiceStatus.State
})
return { return {
current: Ref.get(current), current: Ref.get(current),
health: Effect.gen(function* () {
return {
healthy: true as const,
version: InstallationVersion,
pid: process.pid,
instanceID: options.instanceID,
status: yield* Ref.get(current),
}
}),
ready: Ref.update(current, (status) => ready: Ref.update(current, (status) =>
status.type === "starting" ? ({ type: "ready" } satisfies ServiceStatus.State) : status, status.type === "starting" ? ({ type: "ready" } satisfies State) : status,
), ),
fail: (failure) => fail: Ref.update(current, (status) =>
Ref.update(current, (status) => status.type === "starting" ? ({ type: "failed" } satisfies State) : status,
status.type === "starting" ? ({ type: "failed", ...failure } satisfies ServiceStatus.State) : status, ),
), beginStopping,
beginStopping: transitionToStopping,
requestStop: (request) => { requestStop: (request) => {
if (!options.managed || request.instanceID !== options.instanceID) if (!options.managed || request.instanceID !== options.instanceID)
return Effect.succeed(false) return Effect.succeed(false)
return transitionToStopping(request.targetVersion).pipe(Effect.as(true)) return beginStopping.pipe(Effect.as(true))
}, },
} satisfies Interface } satisfies Interface
}) })
+10 -16
View File
@@ -1,4 +1,3 @@
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { expect } from "bun:test" import { expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { it } from "../../core/test/lib/effect" import { it } from "../../core/test/lib/effect"
@@ -16,14 +15,10 @@ it.effect("moves from starting to ready", () =>
it.effect("keeps a startup failure until shutdown", () => it.effect("keeps a startup failure until shutdown", () =>
Effect.gen(function* () { Effect.gen(function* () {
const status = yield* Status.make({ instanceID: "one", managed: true }) const status = yield* Status.make({ instanceID: "one", managed: true })
yield* status.fail({ message: "Could not open the database.", action: "Check the database path." }) yield* status.fail
yield* status.ready yield* status.ready
yield* status.fail({ message: "Different failure.", action: "Different action." }) yield* status.fail
expect(yield* status.current).toEqual({ expect(yield* status.current).toEqual({ type: "failed" })
type: "failed",
message: "Could not open the database.",
action: "Check the database path.",
})
}), }),
) )
@@ -31,21 +26,20 @@ it.effect("stops only the addressed managed instance", () =>
Effect.gen(function* () { Effect.gen(function* () {
const status = yield* Status.make({ instanceID: "one", managed: true }) const status = yield* Status.make({ instanceID: "one", managed: true })
expect(yield* status.requestStop({ instanceID: "other", targetVersion: "next" })).toBe(false) expect(yield* status.requestStop({ instanceID: "other" })).toBe(false)
expect(yield* status.current).toEqual({ type: "starting" }) expect(yield* status.current).toEqual({ type: "starting" })
expect(yield* status.requestStop({ instanceID: "one", targetVersion: "next" })).toBe(true) expect(yield* status.requestStop({ instanceID: "one" })).toBe(true)
expect(yield* status.requestStop({ instanceID: "one", targetVersion: InstallationVersion })).toBe(true) expect(yield* status.current).toEqual({ type: "stopping" })
expect(yield* status.current).toEqual({ type: "stopping", targetVersion: "next" })
}), }),
) )
it.effect("preserves the original stopping target after shutdown begins", () => it.effect("keeps stopping after shutdown begins", () =>
Effect.gen(function* () { Effect.gen(function* () {
const status = yield* Status.make({ instanceID: "one", managed: true }) const status = yield* Status.make({ instanceID: "one", managed: true })
yield* status.beginStopping("next") yield* status.beginStopping
expect(yield* status.current).toEqual({ type: "stopping", targetVersion: "next" }) expect(yield* status.current).toEqual({ type: "stopping" })
expect(yield* status.requestStop({ instanceID: "one" })).toBe(true) expect(yield* status.requestStop({ instanceID: "one" })).toBe(true)
expect(yield* status.current).toEqual({ type: "stopping", targetVersion: "next" }) expect(yield* status.current).toEqual({ type: "stopping" })
}), }),
) )
+6 -6
View File
@@ -1,7 +1,7 @@
import { render, TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@opentui/solid" import { render, TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { registerOpencodeSpinner } from "./component/register-spinner" import { registerOpencodeSpinner } from "./component/register-spinner"
import { Deferred, Effect } from "effect" import { Deferred, Effect } from "effect"
import { Service } from "@opencode-ai/client/effect" import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
import { OpenCode } from "@opencode-ai/client" import { OpenCode } from "@opencode-ai/client"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
@@ -138,9 +138,9 @@ const appBindingCommands = [
export type TuiInput = { export type TuiInput = {
server: { server: {
endpoint: Service.Endpoint endpoint: Endpoint
service?: { service?: {
reconnect: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise<Service.Endpoint> reconnect: (signal: AbortSignal) => Promise<Endpoint>
restart: () => Promise<void> restart: () => Promise<void>
} }
} }
@@ -189,8 +189,8 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
const managed = input.server.service const managed = input.server.service
const service = managed const service = managed
? { ? {
reconnect: async (onStatus: (status: Service.Status) => void, signal: AbortSignal) => { reconnect: async (signal: AbortSignal) => {
const endpoint = await managed.reconnect(onStatus, signal) const endpoint = await managed.reconnect(signal)
const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) } const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) }
return { api: OpenCode.make(next) } return { api: OpenCode.make(next) }
}, },
@@ -1114,7 +1114,7 @@ function App(props: { pair?: DialogPairCredentials }) {
<StartupLoading ready={plugins.ready} /> <StartupLoading ready={plugins.ready} />
</Show> </Show>
<Show when={showReconnecting()}> <Show when={showReconnecting()}>
<Reconnecting status={client.connection.service()} /> <Reconnecting />
</Show> </Show>
<Toast /> <Toast />
</box> </box>
+2 -43
View File
@@ -1,11 +1,8 @@
import type { Service } from "@opencode-ai/client/effect"
import { Show } from "solid-js"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { Spinner } from "./spinner" import { Spinner } from "./spinner"
export function Reconnecting(props: { status?: Service.Status }) { export function Reconnecting() {
const theme = useTheme().theme const theme = useTheme().theme
const copy = () => reconnectingCopy(props.status)
return ( return (
<box <box
@@ -20,46 +17,8 @@ export function Reconnecting(props: { status?: Service.Status }) {
justifyContent="center" justifyContent="center"
> >
<box width={62} maxWidth="90%" flexDirection="column" alignItems="center" gap={1}> <box width={62} maxWidth="90%" flexDirection="column" alignItems="center" gap={1}>
<Show when={!copy().loading} fallback={<Spinner color={theme.textMuted}>{copy().message}</Spinner>}> <Spinner color={theme.textMuted}>Waiting for background service...</Spinner>
<text fg={theme.error}>{copy().message}</text>
<Show when={copy().detail}>
{(detail) => (
<text fg={theme.textMuted} wrapMode="word">
{detail()}
</text>
)}
</Show>
<Show when={copy().action}>
{(action) => (
<text fg={theme.text} wrapMode="word">
{action()}
</text>
)}
</Show>
</Show>
</box> </box>
</box> </box>
) )
} }
export function reconnectingCopy(status?: Service.Status) {
if (status?.type === "starting")
return {
loading: true,
message: status.version ? `Starting OpenCode ${status.version}...` : "Starting background service...",
}
if (status?.type === "stopping")
return {
loading: true,
message: status.targetVersion ? `Updating to ${status.targetVersion}...` : "Restarting background service...",
}
if (status?.type === "failed")
return { loading: false, message: "Background service failed", detail: status.message, action: status.action }
if (status?.type === "unresponsive")
return {
loading: false,
message: "Background service is not responding",
action: "Run `opencode service restart` to recover it.",
}
return { loading: true, message: "Waiting for background service..." }
}
+3 -9
View File
@@ -1,7 +1,6 @@
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client" import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
import type { Service } from "@opencode-ai/client/effect"
import { createGlobalEmitter } from "@solid-primitives/event-bus" import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { createSignal, onCleanup, onMount } from "solid-js" import { onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { errorMessage } from "../util/error" import { errorMessage } from "../util/error"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
@@ -19,7 +18,7 @@ export type ClientConnectionEvent = {
} }
type ManagedService = { type ManagedService = {
reconnect: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise<{ api: OpenCodeClient }> reconnect: (signal: AbortSignal) => Promise<{ api: OpenCodeClient }>
restart: () => Promise<void> restart: () => Promise<void>
} }
@@ -43,7 +42,6 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
status: "connecting", status: "connecting",
attempt: 0, attempt: 0,
}) })
const [service, setService] = createSignal<Service.Status>()
let stream: AbortController | undefined let stream: AbortController | undefined
function record(status: ClientConnectionEvent["data"]["status"], attempt: number, error?: string) { function record(status: ClientConnectionEvent["data"]["status"], attempt: number, error?: string) {
@@ -81,7 +79,6 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
log.info("event stream connected") log.info("event stream connected")
events.emit(first.value.type, first.value) events.emit(first.value.type, first.value)
setConnection({ status: "connected", attempt: 0, error: undefined }) setConnection({ status: "connected", attempt: 0, error: undefined })
setService(undefined)
while (!abort.signal.aborted && !controller.signal.aborted) { while (!abort.signal.aborted && !controller.signal.aborted) {
const event = await iterator.next() const event = await iterator.next()
if (abort.signal.aborted || controller.signal.aborted) return undefined if (abort.signal.aborted || controller.signal.aborted) return undefined
@@ -116,7 +113,7 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
// moved (service restarted on a new port) or need starting. Static // moved (service restarted on a new port) or need starting. Static
// transports (--server, standalone) resolve to the same address. // transports (--server, standalone) resolve to the same address.
if (props.service) { if (props.service) {
const next = await props.service.reconnect(setService, controller.signal).catch((error) => { const next = await props.service.reconnect(controller.signal).catch((error) => {
if (!controller.signal.aborted) if (!controller.signal.aborted)
log.info("server resolution failed", { log.info("server resolution failed", {
attempt, attempt,
@@ -159,9 +156,6 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
error() { error() {
return connection.error return connection.error
}, },
service() {
return service()
},
internal: { internal: {
history() { history() {
return history.slice() return history.slice()
@@ -1,31 +0,0 @@
import { expect, test } from "bun:test"
import { reconnectingCopy } from "../../../src/component/reconnecting"
test("describes service status without transport diagnostics", () => {
expect(reconnectingCopy({ type: "starting", version: "2.0.0" })).toEqual({
loading: true,
message: "Starting OpenCode 2.0.0...",
})
expect(reconnectingCopy({ type: "stopping", targetVersion: "2.0.0" })).toEqual({
loading: true,
message: "Updating to 2.0.0...",
})
expect(
reconnectingCopy({
type: "failed",
message: "Could not open the database.",
action: "Check the service logs.",
}),
).toEqual({
loading: false,
message: "Background service failed",
detail: "Could not open the database.",
action: "Check the service logs.",
})
expect(reconnectingCopy({ type: "unresponsive" })).toEqual({
loading: false,
message: "Background service is not responding",
action: "Run `opencode service restart` to recover it.",
})
expect(JSON.stringify(reconnectingCopy())).not.toMatch(/Attempt|ECONNREFUSED|Event stream disconnected/)
})
+2 -41
View File
@@ -1,7 +1,6 @@
/** @jsxImportSource @opentui/solid */ /** @jsxImportSource @opentui/solid */
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client" import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
import type { Service } from "@opencode-ai/client/effect"
import { testRender } from "@opentui/solid" import { testRender } from "@opentui/solid"
import { onMount } from "solid-js" import { onMount } from "solid-js"
import { ClientProvider, useClient } from "../../../src/context/client" import { ClientProvider, useClient } from "../../../src/context/client"
@@ -53,7 +52,7 @@ function update(version: string): OpenCodeEvent {
} }
async function mount( async function mount(
reconnect?: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise<{ api: OpenCodeClient }>, reconnect?: (signal: AbortSignal) => Promise<{ api: OpenCodeClient }>,
log?: LogSink, log?: LogSink,
) { ) {
const events = createEventStream() const events = createEventStream()
@@ -279,48 +278,10 @@ describe("useEvent", () => {
} }
}) })
test("reports service status while endpoint resolution is pending", async () => {
const replacementEvents = createEventStream()
const replacement = { api: createApi(createFetch(undefined, replacementEvents).fetch) }
let report!: (status: Service.Status) => void
let resolve!: (value: typeof replacement) => void
const endpoint = new Promise<typeof replacement>((done) => {
resolve = done
})
const { app, events, client } = await mount(async (onStatus) => {
report = onStatus
onStatus({ type: "starting", version: "2.0.0" })
return endpoint
})
try {
await wait(() => client.connection.status() === "connected")
events.disconnect()
await wait(
() => client.connection.status() === "reconnecting" && client.connection.service()?.type === "starting",
)
expect(client.connection.service()).toEqual({ type: "starting", version: "2.0.0" })
report({ type: "failed", message: "Could not open the database.", action: "Check the service logs." })
await wait(() => client.connection.service()?.type === "failed")
expect(client.connection.service()).toEqual({
type: "failed",
message: "Could not open the database.",
action: "Check the service logs.",
})
resolve(replacement)
await wait(() => client.connection.status() === "connected")
expect(client.connection.service()).toBeUndefined()
} finally {
app.renderer.destroy()
}
})
test("cancels pending endpoint resolution on cleanup", async () => { test("cancels pending endpoint resolution on cleanup", async () => {
let aborted = false let aborted = false
const { app, events, client } = await mount( const { app, events, client } = await mount(
(_onStatus, signal) => (signal) =>
new Promise((_, reject) => { new Promise((_, reject) => {
signal.addEventListener( signal.addEventListener(
"abort", "abort",
+2 -1
View File
@@ -123,7 +123,8 @@ bun add @effect/platform-node
```ts ```ts
import { NodeFileSystem } from "@effect/platform-node" import { NodeFileSystem } from "@effect/platform-node"
import { OpenCode, Service } from "@opencode-ai/client/effect" import { OpenCode } from "@opencode-ai/client/effect"
import { Service } from "@opencode-ai/client/effect/service"
import { Effect } from "effect" import { Effect } from "effect"
import { FetchHttpClient } from "effect/unstable/http" import { FetchHttpClient } from "effect/unstable/http"