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

@ -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)),
)