refactor(client): simplify local service lifecycle

This commit is contained in:
Dax Raad 2026-07-15 13:16:49 -04:00
commit 75bc611ef1
51 changed files with 635 additions and 534 deletions

View file

@ -1,6 +1,6 @@
#!/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 { Schema } from "effect"
import fs from "node:fs/promises"
@ -37,8 +37,7 @@ try {
const headers = { authorization: "Basic " + credential }
const token = encodeURIComponent(credential)
const health = await waitForReady(info.url, headers)
if (health.pid !== info.pid || health.instanceID !== info.id)
throw new Error("Health identity does not match registration")
if (health.pid !== info.pid) throw new Error("Health process does not match registration")
const tokenHealth = await fetch(
new URL(`/api/health?auth_token=${token}`, info.url),
{ signal: AbortSignal.timeout(5_000) },
@ -75,7 +74,7 @@ try {
await fetch(new URL("/api/service/stop", info.url), {
method: "POST",
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),
}).then((response) => response.json()),
)
@ -120,19 +119,11 @@ async function waitForRegistration() {
async function waitForReady(url: string, headers: HeadersInit) {
const deadline = Date.now() + 20_000
while (Date.now() < deadline) {
const health = await fetch(new URL("/api/health", url), {
const response = await fetch(new URL("/api/health", url), {
headers,
signal: AbortSignal.timeout(1_000),
})
.then((response) => 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)
}).catch(() => undefined)
if (response?.ok) return Schema.decodeUnknownPromise(ServiceStatus.Health)(await response.json())
await Bun.sleep(25)
}
throw new Error("Compiled service did not become ready")

View file

@ -2,7 +2,7 @@ import { EOL } from "node:os"
import { Effect, Option } from "effect"
import { Commands } from "../commands"
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"
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] }
}
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)
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"))

View file

@ -1,5 +1,5 @@
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 { AppProcess } from "@opencode-ai/core/process"
import { Commands } from "../../commands"

View file

@ -3,7 +3,7 @@ import { Effect } from "effect"
import { OpenCode } from "@opencode-ai/client"
import { Commands } from "../../commands"
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"
export default Runtime.handler(

View file

@ -21,8 +21,8 @@ export default Runtime.handler(Commands, (input) =>
const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
onStart: (reason, existing) => {
if (reason === "version-mismatch" && preflight.begin(existing?.version)) return
onStart: (reason, previousVersion) => {
if (reason === "version-mismatch" && preflight.begin(previousVersion)) return
process.stderr.write(
reason === "version-mismatch"
? "Restarting background server (version mismatch)...\n"
@ -48,7 +48,7 @@ export default Runtime.handler(Commands, (input) =>
endpoint: server.endpoint,
service: service
? {
reconnect: (onStatus, signal) => runServicePromise(service.reconnect(onStatus), { signal }),
reconnect: (signal) => runServicePromise(service.reconnect(), { signal }),
restart: () => runServicePromise(service.restart()),
}
: undefined,

View file

@ -8,7 +8,7 @@ import {
} from "@opencode-ai/client"
import { Commands } from "../../commands"
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 { resolveIntegration } from "./resolve"

View file

@ -3,7 +3,7 @@ import { Effect } from "effect"
import { OpenCode, type McpServer } from "@opencode-ai/client"
import { Commands } from "../../commands"
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"
export default Runtime.handler(

View file

@ -3,7 +3,7 @@ import { Effect } from "effect"
import { OpenCode } from "@opencode-ai/client"
import { Commands } from "../../commands"
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 { resolveIntegration } from "./resolve"

View file

@ -1,6 +1,6 @@
import { EOL } from "os"
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 { renderUnicodeCompact } from "uqr"
import { Commands } from "../commands"

View file

@ -1,6 +1,6 @@
import { EOL } from "os"
import { Effect } from "effect"
import { Service } from "@opencode-ai/client/effect"
import { Service } from "@opencode-ai/client/effect/service"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
@ -9,7 +9,7 @@ export default Runtime.handler(
Commands.commands.service.commands.restart,
Effect.fn("cli.service.restart")(function* () {
const options = yield* ServiceConfig.options()
yield* Service.stop(options, { targetVersion: options.version })
yield* Service.stop(options)
const transport = yield* Service.start(options)
process.stdout.write(transport.url + EOL)
}),

View file

@ -1,6 +1,6 @@
import { EOL } from "os"
import { Effect } from "effect"
import { Service } from "@opencode-ai/client/effect"
import { Service } from "@opencode-ai/client/effect/service"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"

View file

@ -1,6 +1,6 @@
import { EOL } from "os"
import { Effect } from "effect"
import { Service } from "@opencode-ai/client/effect"
import { Service } from "@opencode-ai/client/effect/service"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
@ -9,12 +9,7 @@ export default Runtime.handler(
Commands.commands.service.commands.status,
Effect.fn("cli.service.status")(function* () {
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 })
process.stdout.write((found?.url ?? status.type) + EOL)
process.stdout.write((found?.url ?? "stopped") + EOL)
}),
)

View file

@ -1,5 +1,5 @@
import { Effect } from "effect"
import { Service } from "@opencode-ai/client/effect"
import { Service } from "@opencode-ai/client/effect/service"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"

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 { ServerConnection } from "../services/server-connection"
import { waitForCatalogReady } from "./catalog.shared"

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 { FSUtil } from "@opencode-ai/core/fs-util"
import { Model } from "@opencode-ai/schema/model"
@ -55,7 +55,7 @@ async function run(input: RunCommandInput) {
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 requestedDirectory = prepared.directory ?? (await client.location.get()).directory
if (!requestedDirectory) fail("Failed to resolve server directory")

View file

@ -1,7 +1,7 @@
export * as ServerProcess from "./server-process"
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 { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Global } from "@opencode-ai/core/global"

View file

@ -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 { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Effect, Redacted } from "effect"
@ -10,11 +10,11 @@ export type Args = {
readonly server?: string
readonly standalone?: boolean
readonly mismatch?: "replace" | "ignore" | "error"
readonly onStart?: Service.StartOptions["onStart"]
readonly onStart?: StartOptions["onStart"]
}
export type Resolved = {
readonly endpoint: Service.Endpoint
readonly endpoint: Endpoint
readonly service?: ReturnType<typeof managedService>
}
@ -26,7 +26,7 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg
const endpoint = {
url: args.server,
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 health = yield* Effect.tryPromise({
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
})
function managedService(options: Service.StartOptions) {
function managedService(options: StartOptions) {
const reconnectOptions = { ...options, version: undefined }
return {
reconnect: (onStatus: (status: Service.Status) => void) => Service.start({ ...reconnectOptions, onStatus }),
reconnect: () => Service.start(reconnectOptions),
restart: () =>
Effect.gen(function* () {
yield* Service.stop(options, { targetVersion: options.version })
yield* Service.stop(options)
yield* Service.start(options)
}),
}
}
const resolveManaged = Effect.fnUntraced(function* (
options: Service.StartOptions,
options: StartOptions,
mismatch: NonNullable<Args["mismatch"]>,
) {
if (mismatch === "replace") return yield* Service.start(options)
@ -76,7 +76,7 @@ const resolveManaged = Effect.fnUntraced(function* (
return yield* Service.start(options)
})
function connectError(endpoint: Service.Endpoint, cause: unknown) {
function connectError(endpoint: Endpoint, cause: unknown) {
if (isUnauthorizedError(cause)) {
return new Error(
endpoint.auth === undefined

View file

@ -1,12 +1,12 @@
import { Global } from "@opencode-ai/core/global"
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
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 { randomBytes } from "crypto"
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
// registration file (by channel), which version, and how to spawn opencode.

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 { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Effect, Schema, Stream } from "effect"
@ -46,7 +46,7 @@ const makeEndpoint = Effect.fn("cli.standalone.endpoint")(
url: ready.url,
auth: { type: "basic" as const, username: "opencode", password },
pid: proc.pid,
} satisfies Service.Endpoint & { readonly pid: number }
} satisfies Endpoint & { readonly pid: number }
},
Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node)),
)

View file

@ -1,5 +1,5 @@
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 { Standalone } from "../../src/services/standalone"

View file

@ -19,8 +19,6 @@ test("resolution groups Effect-native lifecycle operations only for the managed
healthy: true,
version: InstallationVersion,
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.service).toBeDefined()
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(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() }))
expect(explicit.endpoint.url).toBe(server.url.toString())

View file

@ -1,5 +1,5 @@
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 { EventV2 } from "@opencode-ai/core/event"
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), {
headers: { authorization: "Basic " + btoa(`opencode:${info.password}`) },
}).then((response) => response.json()),
).toMatchObject({
).toEqual({
healthy: true,
version: info.version,
pid: info.pid,
instanceID: info.id,
status: { type: "ready" },
})
const blockedTemp = registration + "." + info.id + ".tmp"
await fs.mkdir(blockedTemp)
@ -193,7 +192,7 @@ test("concurrent service processes elect one server", async () => {
).toEqual({ timeSuspended: null })
expect(await waitForExecutionStart(database, sessionID)).toBe(1)
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
} finally {
@ -227,19 +226,6 @@ test("a failed service stays registered and owns the lock until stopped", async
try {
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)
const contender = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })

View file

@ -19,8 +19,10 @@
".": "./src/promise/index.ts",
"./promise": "./src/promise/index.ts",
"./promise/api": "./src/promise/api.ts",
"./service": "./src/promise/service.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": {
"build": "bun run script/build-package.ts",

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>
type Endpoint0_1Request = Parameters<RawClient["server.health"]["health.stop"]>[0]
export type Endpoint0_1Input = {
readonly instanceID: Endpoint0_1Request["payload"]["instanceID"]
readonly targetVersion?: Endpoint0_1Request["payload"]["targetVersion"]
}
export type Endpoint0_1Input = { readonly instanceID: Endpoint0_1Request["payload"]["instanceID"] }
export type Endpoint0_1Output = EffectValue<ReturnType<RawClient["server.health"]["health.stop"]>>
export type HealthStopOperation<E = never> = (input: Endpoint0_1Input) => Effect.Effect<Endpoint0_1Output, E>

View file

@ -17,14 +17,9 @@ const Endpoint0_0 = (raw: RawClient["server.health"]) => () =>
raw["health.get"]({}).pipe(Effect.mapError(mapClientError))
type Endpoint0_1Request = Parameters<RawClient["server.health"]["health.stop"]>[0]
type Endpoint0_1Input = {
readonly instanceID: Endpoint0_1Request["payload"]["instanceID"]
readonly targetVersion?: Endpoint0_1Request["payload"]["targetVersion"]
}
type Endpoint0_1Input = { readonly instanceID: Endpoint0_1Request["payload"]["instanceID"] }
const Endpoint0_1 = (raw: RawClient["server.health"]) => (input: Endpoint0_1Input) =>
raw["health.stop"]({ payload: { instanceID: input["instanceID"], targetVersion: input["targetVersion"] } }).pipe(
Effect.mapError(mapClientError),
)
raw["health.stop"]({ payload: { instanceID: input["instanceID"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup0 = (raw: RawClient["server.health"]) => ({ get: Endpoint0_0(raw), stop: Endpoint0_1(raw) })

View file

@ -17,7 +17,6 @@ export type {
SessionApi,
SkillApi,
} from "./api.js"
export { Service } from "./service.js"
export { Agent } from "@opencode-ai/schema/agent"
export { Command } from "@opencode-ai/schema/command"
export { Credential } from "@opencode-ai/schema/credential"

View file

@ -3,6 +3,16 @@ import { Effect, FileSystem, Option, Schedule, Schema } from "effect"
import { spawn, type ChildProcess } from "node:child_process"
import { homedir } from "node:os"
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.
//
@ -12,48 +22,6 @@ import { join } from "node:path"
// is all a client needs to connect. The daemon's own configuration (port,
// 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 = {
readonly child: ChildProcess
readonly error: () => Error | undefined
@ -61,24 +29,14 @@ type Contender = {
// Read-only lookup: registration file plus health check and version gate.
// 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
})
export const status = Effect.fn("service.status")(function* (options: Options = {}) {
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 discoverLocal = Effect.fnUntraced(function* (options: DiscoverOptions) {
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
return found
})
@ -86,18 +44,18 @@ const discoverLocal = Effect.fnUntraced(function* (options: Options) {
// Idempotent ensure-running: reuses a healthy compatible server, replaces a
// version-mismatched one, and otherwise spawns small contenders until a server
// 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 = {}) {
const contenders = new Set<Contender>()
let announced = false
let reported: Status | undefined
let lastSpawn = 0
let spawnDelay = 5_000
let ownerHeld = false
const announce = (reason: StartReason, existing?: Info) =>
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) =>
Effect.sync(() => {
if (announced) return
announced = true
options.onStart?.(reason, existing)
options.onStart?.(reason, previousVersion)
})
const spawnContender = Effect.gen(function* () {
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 info = registration.info
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) {
ownerHeld = false
spawnDelay = 5_000
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
if (compatible && service.status.type === "ready") return Option.some(service)
if (compatible && service.status.type === "failed")
return yield* new FailedError({ message: service.status.message, action: service.status.action })
if (compatible || service.status.type === "stopping") return Option.none<LocalService>()
yield* announce("version-mismatch", service.info)
yield* kill(service, options, options.version).pipe(Effect.ignore)
if (compatible && service.state === "ready") return Option.some(service)
if (compatible && service.state === "failed") return yield* Effect.fail(new Error("Background service failed to start"))
if (compatible) return Option.none<LocalService>()
yield* announce("version-mismatch", service.version)
yield* kill(service, options).pipe(Effect.ignore)
lastSpawn = 0
return Option.none<LocalService>()
} 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
})
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) {
const error = contender.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
}
export type StopMetadata = {
readonly targetVersion?: string
}
export const stop = Effect.fn("service.stop")(function* (options: Options = {}, metadata: StopMetadata = {}) {
/** Stop the registered local service. */
export const stop = Effect.fn("service.stop")(function* (options: StopOptions = {}) {
const existing = yield* find(options)
if (existing !== undefined) yield* kill(existing, options, metadata.targetVersion)
if (existing !== undefined) yield* kill(existing, options)
})
function fallback() {
@ -204,11 +134,13 @@ function fallback() {
return join(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 " + btoa(endpoint.auth.username + ":" + endpoint.auth.password) }
}
/** Schema for the local service registration file. */
export const Info = Schema.Struct({
id: 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)),
password: Schema.optional(Schema.String),
})
export type Info = typeof Info.Type
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
const decodeHealth = Schema.decodeUnknownOption(ServiceStatus.Health)
@ -235,7 +166,7 @@ type LocalService = {
readonly info: Info
readonly endpoint: Endpoint
readonly version?: string
readonly status: ServiceStatus.State
readonly state: "ready" | "waiting" | "failed"
readonly legacy: boolean
}
@ -259,13 +190,11 @@ const probe = Effect.fnUntraced(function* (info: Info, allowLegacy = false) {
if (Option.isSome(health)) {
if (health.value.pid !== info.pid) 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 {
info,
endpoint,
version: health.value.version,
status: health.value.status,
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
legacy: false,
} 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))
)
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) {
@ -284,9 +213,9 @@ const registered = Effect.fnUntraced(function* (file?: string, allowLegacy = fal
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.
const find = Effect.fnUntraced(function* (options: Options) {
const find = Effect.fnUntraced(function* (options: { readonly file?: string }) {
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
}
const kill = Effect.fnUntraced(function* (service: LocalService, options: Options, targetVersion?: string) {
const requested = yield* requestStop(service, targetVersion)
const kill = Effect.fnUntraced(function* (
service: LocalService,
options: { readonly file?: string },
) {
const requested = yield* requestStop(service)
if (requested === "rejected") return
if (requested === "unsupported") {
// 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 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
const response = yield* Effect.tryPromise(() =>
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, targetVersion }),
body: JSON.stringify({ instanceID: service.info.id }),
signal: AbortSignal.timeout(2_000),
}),
).pipe(Effect.option, Effect.map(Option.getOrUndefined))
@ -347,4 +279,5 @@ const requestStop = Effect.fnUntraced(function* (service: LocalService, targetVe
return "accepted" as const
})
export * as Service from "./service.js"
/** Effect-based local service lifecycle operations. */
export const Service = { discover, start, stop, headers, Info }

View file

@ -339,7 +339,7 @@ export function make(options: ClientOptions) {
{
method: "POST",
path: `/api/service/stop`,
body: { instanceID: input["instanceID"], targetVersion: input["targetVersion"] },
body: { instanceID: input["instanceID"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,

View file

@ -1,10 +1,6 @@
export type JsonValue = null | boolean | number | string | Array<JsonValue> | { [key: string]: JsonValue }
export type ServiceStatus =
| { type: "starting" }
| { type: "ready" }
| { type: "stopping"; targetVersion?: string | null }
| { type: "failed"; message: string; action: string }
export type ServiceHealth = { healthy: true; version: string; pid: number }
export type ServiceStopResponse = { accepted: boolean }
@ -506,14 +502,6 @@ export type VcsFileStatus = {
status: "added" | "deleted" | "modified"
}
export type ServiceHealth = {
healthy: true
version: string
pid: number
instanceID?: string | null
status?: ServiceStatus
}
export type SessionMessageModelSelected = {
id: string
metadata?: { [x: string]: JsonValue }
@ -2501,10 +2489,7 @@ export const isProjectCopyError = (value: unknown): value is ProjectCopyError =>
export type HealthGetOutput = ServiceHealth
export type HealthStopInput = {
readonly instanceID: { readonly instanceID: string; readonly targetVersion?: string | undefined }["instanceID"]
readonly targetVersion?: { readonly instanceID: string; readonly targetVersion?: string | undefined }["targetVersion"]
}
export type HealthStopInput = { readonly instanceID: { readonly instanceID: string }["instanceID"] }
export type HealthStopOutput = ServiceStopResponse

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 }

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
}

View file

@ -15,7 +15,7 @@ import {
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) =>
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()
}).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 () => {

View file

@ -48,23 +48,11 @@ const server = Bun.serve({
}
if (mode === "legacy") return Response.json({ healthy: true })
if (mode === "starting" && !(await Bun.file(registration + ".release").exists()))
return Response.json(
{ healthy: true, version, pid: process.pid, instanceID: id, status: { type: "starting" } },
{ status: 503 },
)
return Response.json({ healthy: true, version, pid: process.pid }, { status: 503 })
if (mode === "failed-owner")
return Response.json(
{
healthy: true,
version,
pid: process.pid,
instanceID: id,
status: { type: "failed", message: "Could not open the database.", action: "Check the service logs." },
},
{ status: 503 },
)
return Response.json({ healthy: true, version, pid: process.pid }, { status: 500 })
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 })
},
})

View file

@ -20,15 +20,28 @@ describe("public import boundaries", () => {
expect(within(root, core)).toEqual([])
expect(within(root, server)).toEqual([])
// The effect entry includes local service lifecycle (node spawn/fs), so it
// bundles for bun; the boundary assertions below are what matter.
const network = await bundleInputs("@opencode-ai/client/effect", "bun")
const network = await bundleInputs("@opencode-ai/client/effect", "browser")
expect(within(network, effect).length).toBeGreaterThan(0)
expect(within(network, schema).length).toBeGreaterThan(0)
expect(within(network, protocol).length).toBeGreaterThan(0)
expect(within(network, core)).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([])
})
})

View file

@ -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}`)
}

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?.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 () => {

View file

@ -4,7 +4,7 @@ import { Effect } from "effect"
import { mkdtemp, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
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 processes: Bun.Subprocess[] = []
@ -23,7 +23,7 @@ test("a concurrent same-version start cannot invalidate a resolved endpoint", as
await waitForFile(registration)
const original = await Bun.file(registration).json()
const starts: Service.StartReason[] = []
const starts: StartReason[] = []
const first = run(
Service.start({
file: registration,
@ -43,7 +43,6 @@ test("a concurrent same-version start cannot invalidate a resolved endpoint", as
expect(starts).toEqual([])
expect(await Bun.file(registration).json()).toEqual(original)
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 () => {
@ -51,15 +50,10 @@ test("waits for a registered service to finish starting", async () => {
const registration = join(directory, "service.json")
const process = spawn(registration, "starting")
await waitForFile(registration)
const statuses: Service.Status[] = []
const result = run(
Service.start({ file: registration, version: "test", command: [], onStatus: (status) => statuses.push(status) }),
)
const result = run(Service.start({ file: registration, version: "test", command: [] }))
await Bun.sleep(500)
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", "")
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")
await waitForFile(registration)
await expect(run(Service.start({ file: registration, version: "test", command: [] }))).rejects.toMatchObject({
message: "Could not open the database.",
action: "Check the service logs.",
})
await expect(run(Service.start({ file: registration, version: "test", command: [] }))).rejects.toThrow(
"Background service failed to start",
)
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 registration = join(directory, "service.json")
const process = spawn(registration, "graceful")
await waitForFile(registration)
const info = await Bun.file(registration).json()
await run(Service.stop({ file: registration }, { targetVersion: "next" }))
await run(Service.stop({ file: registration }))
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 () => {
@ -120,7 +113,7 @@ test("a legacy health response is still replaced", async () => {
const existing = spawn(registration, "legacy")
await waitForFile(registration)
const starts: Service.StartReason[] = []
const starts: StartReason[] = []
const result = run(Service.start({ file: registration, command: [], onStart: (reason) => starts.push(reason) }))
await expect(result).rejects.toThrow("Missing service command")

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
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
`client.session.log(...)`, return Effect `Stream` values.
### Service
### Local service
`Service` discovers and manages the local OpenCode background service from a
Node application:
- `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.
The Effect entrypoint exposes the same service lifecycle operations as Effect
values. Add `@effect/platform-node` and provide its filesystem layer when
running service operations.
```sh
bun add @effect/platform-node
@ -123,7 +145,8 @@ bun add @effect/platform-node
```ts
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 { FetchHttpClient } from "effect/unstable/http"

View file

@ -1,34 +1,16 @@
import { Effect, Schema } from "effect"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
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({
healthy: Schema.Literal(true),
version: Schema.String,
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" })
export type Health = typeof Health.Type
export const StopRequest = Schema.Struct({
instanceID: Schema.String,
targetVersion: Schema.String.pipe(Schema.optional),
}).annotate({ identifier: "ServiceStopRequest" })
export type StopRequest = typeof StopRequest.Type

View file

@ -179,7 +179,7 @@ import type {
QuestionReplyErrors,
QuestionReplyResponses,
QuestionV2Reply,
ServiceStopRequestV2,
ServiceStopRequest,
SessionAbortErrors,
SessionAbortResponses,
SessionChildrenErrors,
@ -5091,11 +5091,11 @@ export class Health extends HeyApiClient {
*/
public stop<ThrowOnError extends boolean = false>(
parameters: {
serviceStopRequestV2: ServiceStopRequestV2
serviceStopRequest: ServiceStopRequest
},
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>({
url: "/api/service/stop",
...options,

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 = {
healthy: true
version: string
pid: number
instanceID?: string
status?: ServiceStatus
}
export type UnauthorizedError = {
@ -2827,7 +2808,6 @@ export type UnauthorizedError = {
export type ServiceStopRequest = {
instanceID: string
targetVersion?: string
}
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 = {
healthy: true
version: string
pid: number
instanceID?: string | null
status?: ServiceStatusV2
}
export type InvalidRequestErrorV2 = {
@ -8173,11 +8134,6 @@ export type InvalidRequestErrorV2 = {
field?: string | null
}
export type ServiceStopRequestV2 = {
instanceID: string
targetVersion?: string | null
}
export type SessionsResponseV2 = {
data: Array<SessionInfoV2>
cursor: {
@ -15183,7 +15139,7 @@ export type V2HealthGetResponses = {
export type V2HealthGetResponse = V2HealthGetResponses[keyof V2HealthGetResponses]
export type V2HealthStopData = {
body: ServiceStopRequestV2
body: ServiceStopRequest
path?: never
query?: never
url: "/api/service/stop"

View file

@ -10,7 +10,6 @@ export const HealthHandler = HttpApiBuilder.group(Api, "server.health", (handler
healthy: true as const,
version: InstallationVersion,
pid: process.pid,
status: { type: "ready" },
}),
)
.handle("health.stop", () => Effect.succeed({ accepted: false })),

View file

@ -1,6 +1,7 @@
export * as ServerProcess from "./process"
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 { ServiceStatus } from "@opencode-ai/protocol/groups/health"
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)
yield* Effect.addFinalizer(() =>
status
.beginStopping()
.beginStopping
.pipe(
Effect.andThen(Ref.set(application, Option.none())),
Effect.andThen(Effect.sync(() => bound.server.closeAllConnections())),
@ -73,22 +74,17 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(options:
}).pipe(
Effect.catchCause((cause) => {
if (!options.service || Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause)
return status
.fail({
message: "The background service could not start.",
action: "Run `opencode service restart` after checking the service logs.",
})
.pipe(
Effect.andThen(
Scope.close(applicationScope, Exit.failCause(cause)).pipe(
Effect.catchCause((cleanupCause) =>
Effect.logError("failed to clean up background service boot", { cause: cleanupCause }),
),
return status.fail.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
@ -189,18 +185,21 @@ const control = Effect.fnUntraced(function* (
})
const healthResponse = Effect.fnUntraced(function* (status: Status.Interface) {
const health = yield* status.health
return HttpServerResponse.jsonUnsafe(health, {
status: health.status.type === "ready" ? 200 : 503,
headers:
health.status.type === "starting" || health.status.type === "stopping" ? { "retry-after": "1" } : undefined,
const state = yield* status.current
return HttpServerResponse.jsonUnsafe({ healthy: true, version: InstallationVersion, pid: process.pid }, {
status: state.type === "ready" ? 200 : state.type === "failed" ? 500 : 503,
headers: state.type === "starting" || state.type === "stopping" ? { "retry-after": "1" } : undefined,
})
})
function unavailable(status: ServiceStatus.State) {
function unavailable(status: Status.State) {
if (status.type === "failed")
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 },
)
return HttpServerResponse.jsonUnsafe(

View file

@ -1,57 +1,45 @@
export * as Status from "./service-status"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
import { Effect, Ref } from "effect"
export type State =
| { readonly type: "starting" }
| { readonly type: "ready" }
| { readonly type: "stopping" }
| { readonly type: "failed" }
export interface Interface {
readonly health: Effect.Effect<ServiceStatus.Health>
readonly current: Effect.Effect<ServiceStatus.State>
readonly current: Effect.Effect<State>
readonly ready: Effect.Effect<void>
readonly fail: (failure: { readonly message: string; readonly action: string }) => Effect.Effect<void>
readonly beginStopping: (targetVersion?: string) => Effect.Effect<void>
readonly fail: Effect.Effect<void>
readonly beginStopping: Effect.Effect<void>
readonly requestStop: (request: ServiceStatus.StopRequest) => Effect.Effect<boolean>
}
export const make = Effect.fnUntraced(function* (options: {
readonly instanceID: string
readonly managed: boolean
readonly initial?: ServiceStatus.State
readonly initial?: State
}) {
const current = yield* Ref.make(options.initial ?? ({ type: "starting" } satisfies ServiceStatus.State))
const transitionToStopping = (targetVersion?: string) =>
Ref.update(current, (status) => {
if (status.type === "stopping") return status
return (
targetVersion === undefined || targetVersion === InstallationVersion
? { type: "stopping" }
: { type: "stopping", targetVersion }
) satisfies ServiceStatus.State
})
const current = yield* Ref.make(options.initial ?? ({ type: "starting" } satisfies State))
const beginStopping = Ref.update(current, (status) =>
status.type === "stopping" ? status : ({ type: "stopping" } satisfies State),
)
return {
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) =>
status.type === "starting" ? ({ type: "ready" } satisfies ServiceStatus.State) : status,
status.type === "starting" ? ({ type: "ready" } satisfies State) : status,
),
fail: (failure) =>
Ref.update(current, (status) =>
status.type === "starting" ? ({ type: "failed", ...failure } satisfies ServiceStatus.State) : status,
),
beginStopping: transitionToStopping,
fail: Ref.update(current, (status) =>
status.type === "starting" ? ({ type: "failed" } satisfies State) : status,
),
beginStopping,
requestStop: (request) => {
if (!options.managed || request.instanceID !== options.instanceID)
return Effect.succeed(false)
return transitionToStopping(request.targetVersion).pipe(Effect.as(true))
return beginStopping.pipe(Effect.as(true))
},
} satisfies Interface
})

View file

@ -1,4 +1,3 @@
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { expect } from "bun:test"
import { Effect } from "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", () =>
Effect.gen(function* () {
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.fail({ message: "Different failure.", action: "Different action." })
expect(yield* status.current).toEqual({
type: "failed",
message: "Could not open the database.",
action: "Check the database path.",
})
yield* status.fail
expect(yield* status.current).toEqual({ type: "failed" })
}),
)
@ -31,21 +26,20 @@ it.effect("stops only the addressed managed instance", () =>
Effect.gen(function* () {
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.requestStop({ instanceID: "one", targetVersion: "next" })).toBe(true)
expect(yield* status.requestStop({ instanceID: "one", targetVersion: InstallationVersion })).toBe(true)
expect(yield* status.current).toEqual({ type: "stopping", targetVersion: "next" })
expect(yield* status.requestStop({ instanceID: "one" })).toBe(true)
expect(yield* status.current).toEqual({ type: "stopping" })
}),
)
it.effect("preserves the original stopping target after shutdown begins", () =>
it.effect("keeps stopping after shutdown begins", () =>
Effect.gen(function* () {
const status = yield* Status.make({ instanceID: "one", managed: true })
yield* status.beginStopping("next")
expect(yield* status.current).toEqual({ type: "stopping", targetVersion: "next" })
yield* status.beginStopping
expect(yield* status.current).toEqual({ type: "stopping" })
expect(yield* status.requestStop({ instanceID: "one" })).toBe(true)
expect(yield* status.current).toEqual({ type: "stopping", targetVersion: "next" })
expect(yield* status.current).toEqual({ type: "stopping" })
}),
)

View file

@ -1,7 +1,7 @@
import { render, TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { registerOpencodeSpinner } from "./component/register-spinner"
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 { Global } from "@opencode-ai/core/global"
import { Flag } from "@opencode-ai/core/flag/flag"
@ -138,9 +138,9 @@ const appBindingCommands = [
export type TuiInput = {
server: {
endpoint: Service.Endpoint
endpoint: Endpoint
service?: {
reconnect: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise<Service.Endpoint>
reconnect: (signal: AbortSignal) => Promise<Endpoint>
restart: () => Promise<void>
}
}
@ -189,8 +189,8 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
const managed = input.server.service
const service = managed
? {
reconnect: async (onStatus: (status: Service.Status) => void, signal: AbortSignal) => {
const endpoint = await managed.reconnect(onStatus, signal)
reconnect: async (signal: AbortSignal) => {
const endpoint = await managed.reconnect(signal)
const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) }
return { api: OpenCode.make(next) }
},
@ -1114,7 +1114,7 @@ function App(props: { pair?: DialogPairCredentials }) {
<StartupLoading ready={plugins.ready} />
</Show>
<Show when={showReconnecting()}>
<Reconnecting status={client.connection.service()} />
<Reconnecting />
</Show>
<Toast />
</box>

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 { Spinner } from "./spinner"
export function Reconnecting(props: { status?: Service.Status }) {
export function Reconnecting() {
const theme = useTheme().theme
const copy = () => reconnectingCopy(props.status)
return (
<box
@ -20,46 +17,8 @@ export function Reconnecting(props: { status?: Service.Status }) {
justifyContent="center"
>
<box width={62} maxWidth="90%" flexDirection="column" alignItems="center" gap={1}>
<Show when={!copy().loading} fallback={<Spinner color={theme.textMuted}>{copy().message}</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>
<Spinner color={theme.textMuted}>Waiting for background service...</Spinner>
</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..." }
}

View file

@ -1,7 +1,6 @@
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
import type { Service } from "@opencode-ai/client/effect"
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 { errorMessage } from "../util/error"
import { createSimpleContext } from "./helper"
@ -19,7 +18,7 @@ export type ClientConnectionEvent = {
}
type ManagedService = {
reconnect: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise<{ api: OpenCodeClient }>
reconnect: (signal: AbortSignal) => Promise<{ api: OpenCodeClient }>
restart: () => Promise<void>
}
@ -43,7 +42,6 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
status: "connecting",
attempt: 0,
})
const [service, setService] = createSignal<Service.Status>()
let stream: AbortController | undefined
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")
events.emit(first.value.type, first.value)
setConnection({ status: "connected", attempt: 0, error: undefined })
setService(undefined)
while (!abort.signal.aborted && !controller.signal.aborted) {
const event = await iterator.next()
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
// transports (--server, standalone) resolve to the same address.
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)
log.info("server resolution failed", {
attempt,
@ -159,9 +156,6 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
error() {
return connection.error
},
service() {
return service()
},
internal: {
history() {
return history.slice()

View file

@ -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/)
})

View file

@ -1,7 +1,6 @@
/** @jsxImportSource @opentui/solid */
import { describe, expect, test } from "bun:test"
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
import type { Service } from "@opencode-ai/client/effect"
import { testRender } from "@opentui/solid"
import { onMount } from "solid-js"
import { ClientProvider, useClient } from "../../../src/context/client"
@ -53,7 +52,7 @@ function update(version: string): OpenCodeEvent {
}
async function mount(
reconnect?: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise<{ api: OpenCodeClient }>,
reconnect?: (signal: AbortSignal) => Promise<{ api: OpenCodeClient }>,
log?: LogSink,
) {
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 () => {
let aborted = false
const { app, events, client } = await mount(
(_onStatus, signal) =>
(signal) =>
new Promise((_, reject) => {
signal.addEventListener(
"abort",

View file

@ -123,7 +123,8 @@ bun add @effect/platform-node
```ts
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 { FetchHttpClient } from "effect/unstable/http"