diff --git a/packages/cli/script/service-smoke.ts b/packages/cli/script/service-smoke.ts index ff6b252708..c86e9f8280 100644 --- a/packages/cli/script/service-smoke.ts +++ b/packages/cli/script/service-smoke.ts @@ -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") diff --git a/packages/cli/src/commands/handlers/api.ts b/packages/cli/src/commands/handlers/api.ts index 781af277d7..aa612f67c1 100644 --- a/packages/cli/src/commands/handlers/api.ts +++ b/packages/cli/src/commands/handlers/api.ts @@ -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) { +function resolveRequest(endpoint: Endpoint, input: readonly string[], params: Record) { 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")) diff --git a/packages/cli/src/commands/handlers/console/login.ts b/packages/cli/src/commands/handlers/console/login.ts index 973fe1a326..9025c67c21 100644 --- a/packages/cli/src/commands/handlers/console/login.ts +++ b/packages/cli/src/commands/handlers/console/login.ts @@ -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" diff --git a/packages/cli/src/commands/handlers/debug/agents.ts b/packages/cli/src/commands/handlers/debug/agents.ts index 3126e8d277..f3cd7c1b12 100644 --- a/packages/cli/src/commands/handlers/debug/agents.ts +++ b/packages/cli/src/commands/handlers/debug/agents.ts @@ -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( diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index 3f187369b5..1f1a441571 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -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, diff --git a/packages/cli/src/commands/handlers/mcp/auth.ts b/packages/cli/src/commands/handlers/mcp/auth.ts index 6fc4200ff4..a6ebe09b47 100644 --- a/packages/cli/src/commands/handlers/mcp/auth.ts +++ b/packages/cli/src/commands/handlers/mcp/auth.ts @@ -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" diff --git a/packages/cli/src/commands/handlers/mcp/list.ts b/packages/cli/src/commands/handlers/mcp/list.ts index eb4412eedf..a44aa9af9c 100644 --- a/packages/cli/src/commands/handlers/mcp/list.ts +++ b/packages/cli/src/commands/handlers/mcp/list.ts @@ -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( diff --git a/packages/cli/src/commands/handlers/mcp/logout.ts b/packages/cli/src/commands/handlers/mcp/logout.ts index bcf06ea983..7e1aaac1d2 100644 --- a/packages/cli/src/commands/handlers/mcp/logout.ts +++ b/packages/cli/src/commands/handlers/mcp/logout.ts @@ -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" diff --git a/packages/cli/src/commands/handlers/pair.ts b/packages/cli/src/commands/handlers/pair.ts index 5c40a20444..be4c8d97fc 100644 --- a/packages/cli/src/commands/handlers/pair.ts +++ b/packages/cli/src/commands/handlers/pair.ts @@ -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" diff --git a/packages/cli/src/commands/handlers/service/restart.ts b/packages/cli/src/commands/handlers/service/restart.ts index 7dc3e02c8f..fbe1ecca63 100644 --- a/packages/cli/src/commands/handlers/service/restart.ts +++ b/packages/cli/src/commands/handlers/service/restart.ts @@ -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) }), diff --git a/packages/cli/src/commands/handlers/service/start.ts b/packages/cli/src/commands/handlers/service/start.ts index 602a26ecf1..8dff91376f 100644 --- a/packages/cli/src/commands/handlers/service/start.ts +++ b/packages/cli/src/commands/handlers/service/start.ts @@ -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" diff --git a/packages/cli/src/commands/handlers/service/status.ts b/packages/cli/src/commands/handlers/service/status.ts index 22a581f658..8f27ab2ec2 100644 --- a/packages/cli/src/commands/handlers/service/status.ts +++ b/packages/cli/src/commands/handlers/service/status.ts @@ -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) }), ) diff --git a/packages/cli/src/commands/handlers/service/stop.ts b/packages/cli/src/commands/handlers/service/stop.ts index 5bf45ccabc..ca2c164165 100644 --- a/packages/cli/src/commands/handlers/service/stop.ts +++ b/packages/cli/src/commands/handlers/service/stop.ts @@ -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" diff --git a/packages/cli/src/mini/mini.ts b/packages/cli/src/mini/mini.ts index ccbb900ba0..99ec8bcf3b 100644 --- a/packages/cli/src/mini/mini.ts +++ b/packages/cli/src/mini/mini.ts @@ -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" diff --git a/packages/cli/src/mini/run.ts b/packages/cli/src/mini/run.ts index ad95f52f84..cc4b7c91f8 100644 --- a/packages/cli/src/mini/run.ts +++ b/packages/cli/src/mini/run.ts @@ -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") diff --git a/packages/cli/src/server-process.ts b/packages/cli/src/server-process.ts index b22e39d73c..ae4e1a4b61 100644 --- a/packages/cli/src/server-process.ts +++ b/packages/cli/src/server-process.ts @@ -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" diff --git a/packages/cli/src/services/server-connection.ts b/packages/cli/src/services/server-connection.ts index 9a4bbcc39c..87b2c81e7c 100644 --- a/packages/cli/src/services/server-connection.ts +++ b/packages/cli/src/services/server-connection.ts @@ -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 } @@ -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, ) { 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 diff --git a/packages/cli/src/services/service-config.ts b/packages/cli/src/services/service-config.ts index 51df30d3b1..80e0fb42b0 100644 --- a/packages/cli/src/services/service-config.ts +++ b/packages/cli/src/services/service-config.ts @@ -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. diff --git a/packages/cli/src/services/standalone.ts b/packages/cli/src/services/standalone.ts index 099b1c9df1..a3098e0076 100644 --- a/packages/cli/src/services/standalone.ts +++ b/packages/cli/src/services/standalone.ts @@ -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)), ) diff --git a/packages/cli/test/fixture/standalone-owner.ts b/packages/cli/test/fixture/standalone-owner.ts index 7f92ac0636..62e1b74887 100644 --- a/packages/cli/test/fixture/standalone-owner.ts +++ b/packages/cli/test/fixture/standalone-owner.ts @@ -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" diff --git a/packages/cli/test/server-connection.test.ts b/packages/cli/test/server-connection.test.ts index 3dd68e2bc9..41a061f57c 100644 --- a/packages/cli/test/server-connection.test.ts +++ b/packages/cli/test/server-connection.test.ts @@ -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()) diff --git a/packages/cli/test/service.test.ts b/packages/cli/test/service.test.ts index b2bb525c76..d887e5b2b3 100644 --- a/packages/cli/test/service.test.ts +++ b/packages/cli/test/service.test.ts @@ -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" }) diff --git a/packages/client/package.json b/packages/client/package.json index 531e28caaa..d7b4c9b24e 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -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", diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index a6a3c7cee0..1da8209607 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -11,10 +11,7 @@ export type Endpoint0_0Output = EffectValue = () => Effect.Effect type Endpoint0_1Request = Parameters[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> export type HealthStopOperation = (input: Endpoint0_1Input) => Effect.Effect diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index 93720e998a..ed2918eb9d 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -17,14 +17,9 @@ const Endpoint0_0 = (raw: RawClient["server.health"]) => () => raw["health.get"]({}).pipe(Effect.mapError(mapClientError)) type Endpoint0_1Request = Parameters[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) }) diff --git a/packages/client/src/effect/index.ts b/packages/client/src/effect/index.ts index 67c2c42142..27424aad82 100644 --- a/packages/client/src/effect/index.ts +++ b/packages/client/src/effect/index.ts @@ -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" diff --git a/packages/client/src/effect/service.ts b/packages/client/src/effect/service.ts index 86cbae6403..aad67dba3c 100644 --- a/packages/client/src/effect/service.ts +++ b/packages/client/src/effect/service.ts @@ -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 -} - -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()("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() 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() - 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() + yield* announce("version-mismatch", service.version) + yield* kill(service, options).pipe(Effect.ignore) lastSpawn = 0 return Option.none() } 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 } diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 9935550259..b6f4d44b67 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -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, diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index a93dbb1e42..e2ed9a9f9c 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -1,10 +1,6 @@ export type JsonValue = null | boolean | number | string | Array | { [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 diff --git a/packages/client/src/promise/service.ts b/packages/client/src/promise/service.ts new file mode 100644 index 0000000000..e6037e387b --- /dev/null +++ b/packages/client/src/promise/service.ts @@ -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 { + const contenders = new Set() + 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 { + 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((resolve) => setTimeout(resolve, milliseconds)) +} + +/** Promise-based local service lifecycle operations. */ +export const Service = { discover, start, stop, headers } diff --git a/packages/client/src/service.ts b/packages/client/src/service.ts new file mode 100644 index 0000000000..11254fc843 --- /dev/null +++ b/packages/client/src/service.ts @@ -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 + /** 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 +} diff --git a/packages/client/test/effect.test.ts b/packages/client/test/effect.test.ts index 623907ea9e..b52d86d382 100644 --- a/packages/client/test/effect.test.ts +++ b/packages/client/test/effect.test.ts @@ -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 () => { diff --git a/packages/client/test/fixture/service.ts b/packages/client/test/fixture/service.ts index 7b78ac190f..40ef5fb5da 100644 --- a/packages/client/test/fixture/service.ts +++ b/packages/client/test/fixture/service.ts @@ -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 }) }, }) diff --git a/packages/client/test/import-boundaries.test.ts b/packages/client/test/import-boundaries.test.ts index 5b881edc32..6a979b00a7 100644 --- a/packages/client/test/import-boundaries.test.ts +++ b/packages/client/test/import-boundaries.test.ts @@ -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([]) }) }) diff --git a/packages/client/test/promise-service.test.ts b/packages/client/test/promise-service.test.ts new file mode 100644 index 0000000000..dc422f07bc --- /dev/null +++ b/packages/client/test/promise-service.test.ts @@ -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}`) +} diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index dea3225297..502bcae678 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -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 () => { diff --git a/packages/client/test/service.test.ts b/packages/client/test/service.test.ts index 484baf7922..c3ebcb1580 100644 --- a/packages/client/test/service.test.ts +++ b/packages/client/test/service.test.ts @@ -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") diff --git a/packages/docs/build/client.mdx b/packages/docs/build/client.mdx index 70eed5104f..f759e04d45 100644 --- a/packages/docs/build/client.mdx +++ b/packages/docs/build/client.mdx @@ -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" diff --git a/packages/protocol/src/groups/health.ts b/packages/protocol/src/groups/health.ts index 0bddc52c08..932f809e59 100644 --- a/packages/protocol/src/groups/health.ts +++ b/packages/protocol/src/groups/health.ts @@ -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 diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index cab630b456..32bc2d2074 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -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( parameters: { - serviceStopRequestV2: ServiceStopRequestV2 + serviceStopRequest: ServiceStopRequest }, options?: Options, ) { - const params = buildClientParams([parameters], [{ args: [{ key: "serviceStopRequestV2", map: "body" }] }]) + const params = buildClientParams([parameters], [{ args: [{ key: "serviceStopRequest", map: "body" }] }]) return (options?.client ?? this.client).post({ url: "/api/service/stop", ...options, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 3e6645e24c..94c6f4cde2 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -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 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" diff --git a/packages/server/src/handlers/health.ts b/packages/server/src/handlers/health.ts index f3e07688f1..111113efd4 100644 --- a/packages/server/src/handlers/health.ts +++ b/packages/server/src/handlers/health.ts @@ -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 })), diff --git a/packages/server/src/process.ts b/packages/server/src/process.ts index 6eb8937d00..800c100a7f 100644 --- a/packages/server/src/process.ts +++ b/packages/server/src/process.ts @@ -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* (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* (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( diff --git a/packages/server/src/service-status.ts b/packages/server/src/service-status.ts index 95eaf2f8c6..bf2853479c 100644 --- a/packages/server/src/service-status.ts +++ b/packages/server/src/service-status.ts @@ -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 - readonly current: Effect.Effect + readonly current: Effect.Effect readonly ready: Effect.Effect - readonly fail: (failure: { readonly message: string; readonly action: string }) => Effect.Effect - readonly beginStopping: (targetVersion?: string) => Effect.Effect + readonly fail: Effect.Effect + readonly beginStopping: Effect.Effect readonly requestStop: (request: ServiceStatus.StopRequest) => Effect.Effect } 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 }) diff --git a/packages/server/test/service-status.test.ts b/packages/server/test/service-status.test.ts index c8afc42c54..18f495e235 100644 --- a/packages/server/test/service-status.test.ts +++ b/packages/server/test/service-status.test.ts @@ -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" }) }), ) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 4e45e66312..b8c1f32063 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -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 + reconnect: (signal: AbortSignal) => Promise restart: () => Promise } } @@ -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 }) { - + diff --git a/packages/tui/src/component/reconnecting.tsx b/packages/tui/src/component/reconnecting.tsx index 101e5d1775..99fb3ecb79 100644 --- a/packages/tui/src/component/reconnecting.tsx +++ b/packages/tui/src/component/reconnecting.tsx @@ -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 ( - {copy().message}}> - {copy().message} - - {(detail) => ( - - {detail()} - - )} - - - {(action) => ( - - {action()} - - )} - - + Waiting for background service... ) } - -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..." } -} diff --git a/packages/tui/src/context/client.tsx b/packages/tui/src/context/client.tsx index 1c9d7e8d24..83182f241e 100644 --- a/packages/tui/src/context/client.tsx +++ b/packages/tui/src/context/client.tsx @@ -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 } @@ -43,7 +42,6 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext( status: "connecting", attempt: 0, }) - const [service, setService] = createSignal() 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() diff --git a/packages/tui/test/cli/tui/reconnecting.test.ts b/packages/tui/test/cli/tui/reconnecting.test.ts deleted file mode 100644 index b8cca1ccc9..0000000000 --- a/packages/tui/test/cli/tui/reconnecting.test.ts +++ /dev/null @@ -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/) -}) diff --git a/packages/tui/test/cli/tui/use-event.test.tsx b/packages/tui/test/cli/tui/use-event.test.tsx index c14f0e1e33..c26d2c876b 100644 --- a/packages/tui/test/cli/tui/use-event.test.tsx +++ b/packages/tui/test/cli/tui/use-event.test.tsx @@ -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((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", diff --git a/packages/www/content/docs/build/client.mdx b/packages/www/content/docs/build/client.mdx index 0c251ca386..a306c8e271 100644 --- a/packages/www/content/docs/build/client.mdx +++ b/packages/www/content/docs/build/client.mdx @@ -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"