refactor(client): simplify local service lifecycle
This commit is contained in:
parent
4f1298063b
commit
75bc611ef1
51 changed files with 635 additions and 534 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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" })
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -11,10 +11,7 @@ export type Endpoint0_0Output = EffectValue<ReturnType<RawClient["server.health"
|
|||
export type HealthGetOperation<E = never> = () => Effect.Effect<Endpoint0_0Output, E>
|
||||
|
||||
type Endpoint0_1Request = Parameters<RawClient["server.health"]["health.stop"]>[0]
|
||||
export type Endpoint0_1Input = {
|
||||
readonly instanceID: Endpoint0_1Request["payload"]["instanceID"]
|
||||
readonly targetVersion?: Endpoint0_1Request["payload"]["targetVersion"]
|
||||
}
|
||||
export type Endpoint0_1Input = { readonly instanceID: Endpoint0_1Request["payload"]["instanceID"] }
|
||||
export type Endpoint0_1Output = EffectValue<ReturnType<RawClient["server.health"]["health.stop"]>>
|
||||
export type HealthStopOperation<E = never> = (input: Endpoint0_1Input) => Effect.Effect<Endpoint0_1Output, E>
|
||||
|
||||
|
|
|
|||
|
|
@ -17,14 +17,9 @@ const Endpoint0_0 = (raw: RawClient["server.health"]) => () =>
|
|||
raw["health.get"]({}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint0_1Request = Parameters<RawClient["server.health"]["health.stop"]>[0]
|
||||
type Endpoint0_1Input = {
|
||||
readonly instanceID: Endpoint0_1Request["payload"]["instanceID"]
|
||||
readonly targetVersion?: Endpoint0_1Request["payload"]["targetVersion"]
|
||||
}
|
||||
type Endpoint0_1Input = { readonly instanceID: Endpoint0_1Request["payload"]["instanceID"] }
|
||||
const Endpoint0_1 = (raw: RawClient["server.health"]) => (input: Endpoint0_1Input) =>
|
||||
raw["health.stop"]({ payload: { instanceID: input["instanceID"], targetVersion: input["targetVersion"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
raw["health.stop"]({ payload: { instanceID: input["instanceID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup0 = (raw: RawClient["server.health"]) => ({ get: Endpoint0_0(raw), stop: Endpoint0_1(raw) })
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -3,6 +3,16 @@ import { Effect, FileSystem, Option, Schedule, Schema } from "effect"
|
|||
import { spawn, type ChildProcess } from "node:child_process"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type {
|
||||
DiscoverOptions,
|
||||
Endpoint,
|
||||
StartOptions,
|
||||
StopOptions,
|
||||
} from "../service.js"
|
||||
|
||||
export * from "../service.js"
|
||||
/** Contents of the local service registration file. */
|
||||
export type Info = import("../service.js").Info
|
||||
|
||||
// Find, start, and stop the local opencode background service.
|
||||
//
|
||||
|
|
@ -12,48 +22,6 @@ import { join } from "node:path"
|
|||
// is all a client needs to connect. The daemon's own configuration (port,
|
||||
// persisted password) is CLI-owned and never read here.
|
||||
|
||||
export type Endpoint = {
|
||||
readonly url: string
|
||||
readonly auth?: {
|
||||
readonly type: "basic"
|
||||
readonly username: string
|
||||
readonly password: string
|
||||
}
|
||||
}
|
||||
|
||||
export type Options = {
|
||||
// Absolute path to the service registration file. Defaults to
|
||||
// opencode/service.json in the XDG state directory.
|
||||
readonly file?: string
|
||||
// When set, discovery only returns a server reporting this exact version,
|
||||
// and start() replaces a healthy server whose version differs.
|
||||
readonly version?: string
|
||||
// Argv used to spawn the service. Defaults to ["opencode", "serve",
|
||||
// "--service"] resolved from PATH.
|
||||
readonly command?: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
export type StartReason = "missing" | "version-mismatch"
|
||||
|
||||
export type StartOptions = Options & {
|
||||
// Called once when start() decides it must spawn: either no service was
|
||||
// found, or a healthy service with a different version is being replaced.
|
||||
// `existing` carries the registration of the service being replaced.
|
||||
readonly onStart?: (reason: StartReason, existing?: Info) => void
|
||||
readonly onStatus?: (status: Status) => void
|
||||
}
|
||||
|
||||
export type Status =
|
||||
| { readonly type: "missing" }
|
||||
| { readonly type: "unreachable" }
|
||||
| { readonly type: "unresponsive" }
|
||||
| (ServiceStatus.State & { readonly version?: string })
|
||||
|
||||
export class FailedError extends Schema.TaggedErrorClass<FailedError>()("ServiceFailedError", {
|
||||
message: Schema.String,
|
||||
action: Schema.String,
|
||||
}) {}
|
||||
|
||||
type Contender = {
|
||||
readonly child: ChildProcess
|
||||
readonly error: () => Error | undefined
|
||||
|
|
@ -61,24 +29,14 @@ type Contender = {
|
|||
|
||||
// Read-only lookup: registration file plus health check and version gate.
|
||||
// Never spawns; escalation to start() is the caller's policy.
|
||||
export const discover = Effect.fn("service.discover")(function* (options: Options = {}) {
|
||||
/** Discover a healthy, compatible local service without starting one. */
|
||||
export const discover = Effect.fn("service.discover")(function* (options: DiscoverOptions = {}) {
|
||||
return (yield* discoverLocal(options))?.endpoint
|
||||
})
|
||||
|
||||
export const status = Effect.fn("service.status")(function* (options: Options = {}) {
|
||||
const result = yield* registered(options.file, true)
|
||||
if (result.info === undefined) return { type: "missing" } satisfies Status
|
||||
if (result.service === undefined) return { type: "unreachable" } satisfies Status
|
||||
return publicStatus(result.service)
|
||||
})
|
||||
|
||||
function publicStatus(service: LocalService): Status {
|
||||
return { ...service.status, version: service.version }
|
||||
}
|
||||
|
||||
const discoverLocal = Effect.fnUntraced(function* (options: Options) {
|
||||
const discoverLocal = Effect.fnUntraced(function* (options: DiscoverOptions) {
|
||||
const found = (yield* registered(options.file)).service
|
||||
if (found?.status.type !== "ready") return undefined
|
||||
if (found?.state !== "ready") return undefined
|
||||
if (options.version !== undefined && found.version !== options.version) return undefined
|
||||
return found
|
||||
})
|
||||
|
|
@ -86,18 +44,18 @@ const discoverLocal = Effect.fnUntraced(function* (options: Options) {
|
|||
// Idempotent ensure-running: reuses a healthy compatible server, replaces a
|
||||
// version-mismatched one, and otherwise spawns small contenders until a server
|
||||
// becomes discoverable. A contender is never killed merely for slow startup.
|
||||
/** Ensure a healthy, compatible local service is running. */
|
||||
export const start = Effect.fn("service.start")(function* (options: StartOptions = {}) {
|
||||
const contenders = new Set<Contender>()
|
||||
let announced = false
|
||||
let reported: Status | undefined
|
||||
let lastSpawn = 0
|
||||
let spawnDelay = 5_000
|
||||
let ownerHeld = false
|
||||
const announce = (reason: StartReason, existing?: Info) =>
|
||||
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) =>
|
||||
Effect.sync(() => {
|
||||
if (announced) return
|
||||
announced = true
|
||||
options.onStart?.(reason, existing)
|
||||
options.onStart?.(reason, previousVersion)
|
||||
})
|
||||
const spawnContender = Effect.gen(function* () {
|
||||
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
|
||||
|
|
@ -119,24 +77,15 @@ export const start = Effect.fn("service.start")(function* (options: StartOptions
|
|||
const registration = yield* registered(options.file, true)
|
||||
const info = registration.info
|
||||
const service = registration.service
|
||||
const current: Status =
|
||||
service === undefined ? { type: info === undefined ? "missing" : "unreachable" } : publicStatus(service)
|
||||
const next = ownerHeld && service === undefined ? ({ type: "unresponsive" } satisfies Status) : current
|
||||
yield* Effect.sync(() => {
|
||||
if (sameStatus(reported, next)) return
|
||||
reported = next
|
||||
options.onStatus?.(next)
|
||||
})
|
||||
if (service !== undefined) {
|
||||
ownerHeld = false
|
||||
spawnDelay = 5_000
|
||||
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
|
||||
if (compatible && service.status.type === "ready") return Option.some(service)
|
||||
if (compatible && service.status.type === "failed")
|
||||
return yield* new FailedError({ message: service.status.message, action: service.status.action })
|
||||
if (compatible || service.status.type === "stopping") return Option.none<LocalService>()
|
||||
yield* announce("version-mismatch", service.info)
|
||||
yield* kill(service, options, options.version).pipe(Effect.ignore)
|
||||
if (compatible && service.state === "ready") return Option.some(service)
|
||||
if (compatible && service.state === "failed") return yield* Effect.fail(new Error("Background service failed to start"))
|
||||
if (compatible) return Option.none<LocalService>()
|
||||
yield* announce("version-mismatch", service.version)
|
||||
yield* kill(service, options).pipe(Effect.ignore)
|
||||
lastSpawn = 0
|
||||
return Option.none<LocalService>()
|
||||
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
|
||||
|
|
@ -160,22 +109,6 @@ export const start = Effect.fn("service.start")(function* (options: StartOptions
|
|||
return Option.getOrThrow(found).endpoint
|
||||
})
|
||||
|
||||
function sameStatus(left: Status | undefined, right: Status) {
|
||||
if (left?.type !== right.type) return false
|
||||
if (right.type === "failed")
|
||||
return (
|
||||
left.type === "failed" &&
|
||||
left.version === right.version &&
|
||||
left.message === right.message &&
|
||||
left.action === right.action
|
||||
)
|
||||
if (right.type === "stopping")
|
||||
return left.type === "stopping" && left.version === right.version && left.targetVersion === right.targetVersion
|
||||
if (right.type === "starting" || right.type === "ready")
|
||||
return left.type === right.type && left.version === right.version
|
||||
return true
|
||||
}
|
||||
|
||||
function contenderFailure(contender: Contender) {
|
||||
const error = contender.error()
|
||||
if (error !== undefined) return error
|
||||
|
|
@ -190,13 +123,10 @@ function contenderFinished(contender: Contender) {
|
|||
return contender.error() !== undefined || contender.child.exitCode !== null || contender.child.signalCode !== null
|
||||
}
|
||||
|
||||
export type StopMetadata = {
|
||||
readonly targetVersion?: string
|
||||
}
|
||||
|
||||
export const stop = Effect.fn("service.stop")(function* (options: Options = {}, metadata: StopMetadata = {}) {
|
||||
/** Stop the registered local service. */
|
||||
export const stop = Effect.fn("service.stop")(function* (options: StopOptions = {}) {
|
||||
const existing = yield* find(options)
|
||||
if (existing !== undefined) yield* kill(existing, options, metadata.targetVersion)
|
||||
if (existing !== undefined) yield* kill(existing, options)
|
||||
})
|
||||
|
||||
function fallback() {
|
||||
|
|
@ -204,11 +134,13 @@ function fallback() {
|
|||
return join(state, "opencode", "service.json")
|
||||
}
|
||||
|
||||
/** Create HTTP authentication headers for a service endpoint. */
|
||||
export function headers(endpoint: Endpoint) {
|
||||
if (endpoint.auth === undefined) return undefined
|
||||
return { authorization: "Basic " + btoa(endpoint.auth.username + ":" + endpoint.auth.password) }
|
||||
}
|
||||
|
||||
/** Schema for the local service registration file. */
|
||||
export const Info = Schema.Struct({
|
||||
id: Schema.optional(Schema.String),
|
||||
version: Schema.optional(Schema.String),
|
||||
|
|
@ -216,7 +148,6 @@ export const Info = Schema.Struct({
|
|||
pid: Schema.Int.check(Schema.isGreaterThan(0)),
|
||||
password: Schema.optional(Schema.String),
|
||||
})
|
||||
export type Info = typeof Info.Type
|
||||
|
||||
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
|
||||
const decodeHealth = Schema.decodeUnknownOption(ServiceStatus.Health)
|
||||
|
|
@ -235,7 +166,7 @@ type LocalService = {
|
|||
readonly info: Info
|
||||
readonly endpoint: Endpoint
|
||||
readonly version?: string
|
||||
readonly status: ServiceStatus.State
|
||||
readonly state: "ready" | "waiting" | "failed"
|
||||
readonly legacy: boolean
|
||||
}
|
||||
|
||||
|
|
@ -259,13 +190,11 @@ const probe = Effect.fnUntraced(function* (info: Info, allowLegacy = false) {
|
|||
if (Option.isSome(health)) {
|
||||
if (health.value.pid !== info.pid) return undefined
|
||||
if (info.version !== undefined && health.value.version !== info.version) return undefined
|
||||
if (info.id !== undefined && health.value.instanceID !== undefined && health.value.instanceID !== info.id)
|
||||
return undefined
|
||||
return {
|
||||
info,
|
||||
endpoint,
|
||||
version: health.value.version,
|
||||
status: health.value.status,
|
||||
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
|
||||
legacy: false,
|
||||
} satisfies LocalService
|
||||
}
|
||||
|
|
@ -275,7 +204,7 @@ const probe = Effect.fnUntraced(function* (info: Info, allowLegacy = false) {
|
|||
(typeof body === "object" && body !== null && ("version" in body || "pid" in body))
|
||||
)
|
||||
return undefined
|
||||
return { info, endpoint, status: { type: "ready" }, legacy: true } satisfies LocalService
|
||||
return { info, endpoint, state: "ready", legacy: true } satisfies LocalService
|
||||
})
|
||||
|
||||
const registered = Effect.fnUntraced(function* (file?: string, allowLegacy = false) {
|
||||
|
|
@ -284,9 +213,9 @@ const registered = Effect.fnUntraced(function* (file?: string, allowLegacy = fal
|
|||
return { info, service: yield* probe(info, allowLegacy) }
|
||||
})
|
||||
|
||||
// Health-checked lookup without the version gate: status operations must be
|
||||
// Health-checked lookup without the version gate: lifecycle operations must be
|
||||
// able to see (and replace or stop) a server from a different version.
|
||||
const find = Effect.fnUntraced(function* (options: Options) {
|
||||
const find = Effect.fnUntraced(function* (options: { readonly file?: string }) {
|
||||
return (yield* registered(options.file, true)).service
|
||||
})
|
||||
|
||||
|
|
@ -309,8 +238,11 @@ function same(left: Info, right: Info) {
|
|||
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
||||
}
|
||||
|
||||
const kill = Effect.fnUntraced(function* (service: LocalService, options: Options, targetVersion?: string) {
|
||||
const requested = yield* requestStop(service, targetVersion)
|
||||
const kill = Effect.fnUntraced(function* (
|
||||
service: LocalService,
|
||||
options: { readonly file?: string },
|
||||
) {
|
||||
const requested = yield* requestStop(service)
|
||||
if (requested === "rejected") return
|
||||
if (requested === "unsupported") {
|
||||
// A stale registration may point at a reused PID. Authenticate again
|
||||
|
|
@ -330,13 +262,13 @@ const kill = Effect.fnUntraced(function* (service: LocalService, options: Option
|
|||
|
||||
const decodeStopResponse = Schema.decodeUnknownOption(ServiceStatus.StopResponse)
|
||||
|
||||
const requestStop = Effect.fnUntraced(function* (service: LocalService, targetVersion?: string) {
|
||||
const requestStop = Effect.fnUntraced(function* (service: LocalService) {
|
||||
if (service.info.id === undefined || service.legacy) return "unsupported" as const
|
||||
const response = yield* Effect.tryPromise(() =>
|
||||
fetch(new URL("/api/service/stop", service.info.url), {
|
||||
method: "POST",
|
||||
headers: { ...headers(service.endpoint), "content-type": "application/json" },
|
||||
body: JSON.stringify({ instanceID: service.info.id, targetVersion }),
|
||||
body: JSON.stringify({ instanceID: service.info.id }),
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
}),
|
||||
).pipe(Effect.option, Effect.map(Option.getOrUndefined))
|
||||
|
|
@ -347,4 +279,5 @@ const requestStop = Effect.fnUntraced(function* (service: LocalService, targetVe
|
|||
return "accepted" as const
|
||||
})
|
||||
|
||||
export * as Service from "./service.js"
|
||||
/** Effect-based local service lifecycle operations. */
|
||||
export const Service = { discover, start, stop, headers, Info }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,6 @@
|
|||
export type JsonValue = null | boolean | number | string | Array<JsonValue> | { [key: string]: JsonValue }
|
||||
|
||||
export type ServiceStatus =
|
||||
| { type: "starting" }
|
||||
| { type: "ready" }
|
||||
| { type: "stopping"; targetVersion?: string | null }
|
||||
| { type: "failed"; message: string; action: string }
|
||||
export type ServiceHealth = { healthy: true; version: string; pid: number }
|
||||
|
||||
export type ServiceStopResponse = { accepted: boolean }
|
||||
|
||||
|
|
@ -506,14 +502,6 @@ export type VcsFileStatus = {
|
|||
status: "added" | "deleted" | "modified"
|
||||
}
|
||||
|
||||
export type ServiceHealth = {
|
||||
healthy: true
|
||||
version: string
|
||||
pid: number
|
||||
instanceID?: string | null
|
||||
status?: ServiceStatus
|
||||
}
|
||||
|
||||
export type SessionMessageModelSelected = {
|
||||
id: string
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
|
|
@ -2501,10 +2489,7 @@ export const isProjectCopyError = (value: unknown): value is ProjectCopyError =>
|
|||
|
||||
export type HealthGetOutput = ServiceHealth
|
||||
|
||||
export type HealthStopInput = {
|
||||
readonly instanceID: { readonly instanceID: string; readonly targetVersion?: string | undefined }["instanceID"]
|
||||
readonly targetVersion?: { readonly instanceID: string; readonly targetVersion?: string | undefined }["targetVersion"]
|
||||
}
|
||||
export type HealthStopInput = { readonly instanceID: { readonly instanceID: string }["instanceID"] }
|
||||
|
||||
export type HealthStopOutput = ServiceStopResponse
|
||||
|
||||
|
|
|
|||
252
packages/client/src/promise/service.ts
Normal file
252
packages/client/src/promise/service.ts
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
import { readFile } from "node:fs/promises"
|
||||
import { spawn, type ChildProcess } from "node:child_process"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type {
|
||||
DiscoverOptions,
|
||||
Endpoint,
|
||||
Info,
|
||||
StartOptions,
|
||||
StopOptions,
|
||||
} from "../service.js"
|
||||
import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js"
|
||||
|
||||
export * from "../service.js"
|
||||
|
||||
// Find, start, and stop the local opencode background service.
|
||||
//
|
||||
// The registration file is the complete discovery contract. This module is
|
||||
// intentionally implemented with Node APIs so Promise clients do not need
|
||||
// Effect or @effect/platform-node at runtime.
|
||||
|
||||
type Contender = {
|
||||
readonly child: ChildProcess
|
||||
readonly error: () => Error | undefined
|
||||
}
|
||||
|
||||
/** Discover a healthy, compatible local service without starting one. */
|
||||
export async function discover(options: DiscoverOptions = {}) {
|
||||
return (await discoverLocal(options))?.endpoint
|
||||
}
|
||||
|
||||
async function discoverLocal(options: DiscoverOptions) {
|
||||
const found = (await registered(options.file)).service
|
||||
if (found?.state !== "ready") return undefined
|
||||
if (options.version !== undefined && found.version !== options.version) return undefined
|
||||
return found
|
||||
}
|
||||
|
||||
/** Ensure a healthy, compatible local service is running. */
|
||||
export async function start(options: StartOptions = {}): Promise<Endpoint> {
|
||||
const contenders = new Set<Contender>()
|
||||
let announced = false
|
||||
let lastSpawn = 0
|
||||
let spawnDelay = 5_000
|
||||
let ownerHeld = false
|
||||
|
||||
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) => {
|
||||
if (announced) return
|
||||
announced = true
|
||||
options.onStart?.(reason, previousVersion)
|
||||
}
|
||||
const spawnContender = () => {
|
||||
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
|
||||
if (command === undefined) throw new Error("Missing service command")
|
||||
try {
|
||||
const child = spawn(command, args, { detached: true, stdio: "ignore" })
|
||||
let error: Error | undefined
|
||||
child.once("error", (cause) => {
|
||||
error = new Error("Failed to start server", { cause })
|
||||
})
|
||||
child.unref()
|
||||
return { child, error: () => error }
|
||||
} catch (cause) {
|
||||
throw new Error("Failed to start server", { cause })
|
||||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const registration = await registered(options.file, true)
|
||||
|
||||
if (registration.service !== undefined) {
|
||||
ownerHeld = false
|
||||
spawnDelay = 5_000
|
||||
const service = registration.service
|
||||
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
|
||||
if (compatible && service.state === "ready") return service.endpoint
|
||||
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
|
||||
if (!compatible) {
|
||||
announce("version-mismatch", service.version)
|
||||
await kill(service, options).catch(() => undefined)
|
||||
lastSpawn = 0
|
||||
}
|
||||
} else {
|
||||
if (lastSpawn === 0 && registration.info !== undefined) lastSpawn = Date.now()
|
||||
const failure = [...contenders].map(contenderFailure).find((error) => error !== undefined)
|
||||
if (failure !== undefined) throw failure
|
||||
const finished = [...contenders].filter(contenderFinished)
|
||||
if (finished.some((item) => item.child.exitCode === 0)) {
|
||||
ownerHeld = true
|
||||
spawnDelay = Math.min(spawnDelay * 2, 30_000)
|
||||
}
|
||||
finished.forEach((item) => contenders.delete(item))
|
||||
// Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
|
||||
if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
|
||||
announce("missing")
|
||||
contenders.add(spawnContender())
|
||||
lastSpawn = Date.now()
|
||||
}
|
||||
}
|
||||
await delay(1_000)
|
||||
}
|
||||
}
|
||||
|
||||
function contenderFailure(contender: Contender) {
|
||||
const error = contender.error()
|
||||
if (error !== undefined) return error
|
||||
if (contender.child.exitCode !== null && contender.child.exitCode !== 0)
|
||||
return new Error(`Server process exited with code ${contender.child.exitCode}`)
|
||||
if (contender.child.signalCode !== null)
|
||||
return new Error(`Server process terminated by ${contender.child.signalCode}`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
function contenderFinished(contender: Contender) {
|
||||
return contender.error() !== undefined || contender.child.exitCode !== null || contender.child.signalCode !== null
|
||||
}
|
||||
|
||||
/** Stop the registered local service. */
|
||||
export async function stop(options: StopOptions = {}) {
|
||||
const existing = await find(options)
|
||||
if (existing !== undefined) await kill(existing, options)
|
||||
}
|
||||
|
||||
function fallback() {
|
||||
return join(process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state"), "opencode", "service.json")
|
||||
}
|
||||
|
||||
/** Create HTTP authentication headers for a service endpoint. */
|
||||
export function headers(endpoint: Endpoint) {
|
||||
if (endpoint.auth === undefined) return undefined
|
||||
return { authorization: "Basic " + Buffer.from(endpoint.auth.username + ":" + endpoint.auth.password).toString("base64") }
|
||||
}
|
||||
|
||||
async function read(file?: string) {
|
||||
const text = await readFile(file ?? fallback(), "utf8").catch(() => undefined)
|
||||
if (text === undefined) return undefined
|
||||
try {
|
||||
return JSON.parse(text) as Info
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
type LocalService = {
|
||||
readonly info: Info
|
||||
readonly endpoint: Endpoint
|
||||
readonly version?: string
|
||||
readonly state: "ready" | "waiting" | "failed"
|
||||
readonly legacy: boolean
|
||||
}
|
||||
|
||||
async function probe(info: Info, allowLegacy = false): Promise<LocalService | undefined> {
|
||||
const endpoint = {
|
||||
url: info.url,
|
||||
auth:
|
||||
info.password === undefined
|
||||
? undefined
|
||||
: { type: "basic" as const, username: "opencode", password: info.password },
|
||||
} satisfies Endpoint
|
||||
const response = await fetch(new URL("/api/health", info.url), {
|
||||
headers: headers(endpoint),
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
}).catch(() => undefined)
|
||||
const body = (await response?.json().catch(() => undefined)) as ServiceHealth | { readonly healthy: true } | undefined
|
||||
if (body !== undefined && "version" in body && "pid" in body) {
|
||||
if (body.pid !== info.pid) return undefined
|
||||
if (info.version !== undefined && body.version !== info.version) return undefined
|
||||
return {
|
||||
info,
|
||||
endpoint,
|
||||
version: body.version,
|
||||
state: response?.ok ? "ready" : response?.status === 500 ? "failed" : "waiting",
|
||||
legacy: false,
|
||||
}
|
||||
}
|
||||
if (!allowLegacy || body?.healthy !== true) return undefined
|
||||
return { info, endpoint, state: "ready", legacy: true }
|
||||
}
|
||||
|
||||
async function registered(file?: string, allowLegacy = false) {
|
||||
const info = await read(file)
|
||||
if (info === undefined) return { info: undefined, service: undefined }
|
||||
return { info, service: await probe(info, allowLegacy) }
|
||||
}
|
||||
|
||||
async function find(options: { readonly file?: string }) {
|
||||
return (await registered(options.file, true)).service
|
||||
}
|
||||
|
||||
function signal(pid: number, name: NodeJS.Signals) {
|
||||
try {
|
||||
process.kill(pid, name)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function stopped(pid: number) {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return false
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
async function waitUntilStopped(pid: number) {
|
||||
for (let attempt = 0; attempt <= 100; attempt++) {
|
||||
if (stopped(pid)) return true
|
||||
if (attempt < 100) await delay(50)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function same(left: Info, right: Info) {
|
||||
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
||||
}
|
||||
|
||||
async function kill(service: LocalService, options: { readonly file?: string }) {
|
||||
const requested = await requestStop(service)
|
||||
if (requested === "rejected") return
|
||||
if (requested === "unsupported") {
|
||||
const current = await find(options)
|
||||
if (current === undefined || !same(current.info, service.info)) return
|
||||
signal(service.info.pid, "SIGTERM")
|
||||
}
|
||||
if (await waitUntilStopped(service.info.pid)) return
|
||||
|
||||
const latest = await find(options)
|
||||
if (latest === undefined || !same(latest.info, service.info)) return
|
||||
signal(service.info.pid, "SIGKILL")
|
||||
if (!(await waitUntilStopped(service.info.pid))) throw new Error(`Server process ${service.info.pid} is still running`)
|
||||
}
|
||||
|
||||
async function requestStop(service: LocalService) {
|
||||
if (service.info.id === undefined || service.legacy) return "unsupported" as const
|
||||
const response = await fetch(new URL("/api/service/stop", service.info.url), {
|
||||
method: "POST",
|
||||
headers: { ...headers(service.endpoint), "content-type": "application/json" },
|
||||
body: JSON.stringify({ instanceID: service.info.id }),
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
}).catch(() => undefined)
|
||||
if (response === undefined || response.status === 404 || response.status === 405) return "unsupported" as const
|
||||
const body = (await response.json().catch(() => undefined)) as ServiceStopResponse | undefined
|
||||
if (!response.ok || body?.accepted !== true) return "rejected" as const
|
||||
return "accepted" as const
|
||||
}
|
||||
|
||||
function delay(milliseconds: number) {
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, milliseconds))
|
||||
}
|
||||
|
||||
/** Promise-based local service lifecycle operations. */
|
||||
export const Service = { discover, start, stop, headers }
|
||||
53
packages/client/src/service.ts
Normal file
53
packages/client/src/service.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/** Connection details for a local OpenCode service. */
|
||||
export type Endpoint = {
|
||||
/** Base URL of the service. */
|
||||
readonly url: string
|
||||
/** Authentication required by the service, when configured. */
|
||||
readonly auth?: {
|
||||
/** HTTP authentication scheme. */
|
||||
readonly type: "basic"
|
||||
/** Basic authentication username. */
|
||||
readonly username: string
|
||||
/** Basic authentication password. */
|
||||
readonly password: string
|
||||
}
|
||||
}
|
||||
|
||||
/** Options used to discover the local OpenCode service. */
|
||||
export type DiscoverOptions = {
|
||||
/** Absolute registration file path. Defaults to the XDG state directory. */
|
||||
readonly file?: string
|
||||
/** Required service version. */
|
||||
readonly version?: string
|
||||
}
|
||||
|
||||
/** Reason a new service process must be started. */
|
||||
export type StartReason = "missing" | "version-mismatch"
|
||||
|
||||
/** Options used to ensure the local OpenCode service is running. */
|
||||
export type StartOptions = DiscoverOptions & {
|
||||
/** Service command and arguments. Defaults to `opencode serve --service`. */
|
||||
readonly command?: ReadonlyArray<string>
|
||||
/** Called once before spawning a new service process. */
|
||||
readonly onStart?: (reason: StartReason, previousVersion?: string) => void
|
||||
}
|
||||
|
||||
/** Options used to stop the local OpenCode service. */
|
||||
export type StopOptions = {
|
||||
/** Absolute registration file path. Defaults to the XDG state directory. */
|
||||
readonly file?: string
|
||||
}
|
||||
|
||||
/** Contents of the local service registration file. */
|
||||
export type Info = {
|
||||
/** Unique service instance identifier. */
|
||||
readonly id?: string
|
||||
/** OpenCode version served by the process. */
|
||||
readonly version?: string
|
||||
/** Base URL advertised by the service. */
|
||||
readonly url: string
|
||||
/** Operating system process identifier. */
|
||||
readonly pid: number
|
||||
/** Private service password, when authentication is enabled. */
|
||||
readonly password?: string
|
||||
}
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
96
packages/client/test/promise-service.test.ts
Normal file
96
packages/client/test/promise-service.test.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { afterEach, expect, test } from "bun:test"
|
||||
import { mkdtemp, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { Service, type StartReason } from "../src/promise/service"
|
||||
|
||||
const fixture = join(import.meta.dir, "fixture/service.ts")
|
||||
const processes: Bun.Subprocess[] = []
|
||||
const directories: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
processes.forEach((process) => process.kill("SIGTERM"))
|
||||
await Promise.all(processes.splice(0).map((process) => process.exited))
|
||||
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
test("discovers a registered service", async () => {
|
||||
const registration = await setup("graceful")
|
||||
|
||||
expect(await Service.discover({ file: registration, version: "test" })).toEqual(
|
||||
expect.objectContaining({ url: expect.stringMatching(/^http:\/\//) }),
|
||||
)
|
||||
expect(await Service.discover({ file: registration, version: "other" })).toBeUndefined()
|
||||
})
|
||||
|
||||
test("starts a missing service with native promises", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const starts: StartReason[] = []
|
||||
|
||||
const endpoint = await Service.start({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, registration, "coordinated"],
|
||||
onStart: (reason) => starts.push(reason),
|
||||
})
|
||||
const info = await Bun.file(registration).json()
|
||||
try {
|
||||
expect(endpoint.url).toBe(info.url)
|
||||
expect(starts).toEqual(["missing"])
|
||||
} finally {
|
||||
process.kill(info.pid, "SIGTERM")
|
||||
await waitForExit(info.pid)
|
||||
}
|
||||
}, 15_000)
|
||||
|
||||
test("reports a failed registered service", async () => {
|
||||
const registration = await setup("failed-owner")
|
||||
|
||||
await expect(Service.start({ file: registration, version: "test", command: [] })).rejects.toThrow(
|
||||
"Background service failed to start",
|
||||
)
|
||||
})
|
||||
|
||||
test("requests graceful stop of the exact service instance", async () => {
|
||||
const registration = await setup("graceful")
|
||||
const info = await Bun.file(registration).json()
|
||||
|
||||
await Service.stop({ file: registration })
|
||||
|
||||
expect(await Bun.file(registration + ".stop").json()).toEqual({ instanceID: info.id })
|
||||
})
|
||||
|
||||
async function setup(mode: string) {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
processes.push(Bun.spawn([process.execPath, fixture, registration, mode], { stdout: "ignore", stderr: "inherit" }))
|
||||
await waitForFile(registration)
|
||||
return registration
|
||||
}
|
||||
|
||||
async function temp() {
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-promise-service-"))
|
||||
directories.push(directory)
|
||||
return directory
|
||||
}
|
||||
|
||||
async function waitForFile(file: string) {
|
||||
for (let attempt = 0; attempt < 600; attempt++) {
|
||||
if (await Bun.file(file).exists()) return
|
||||
await Bun.sleep(5)
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${file}`)
|
||||
}
|
||||
|
||||
async function waitForExit(pid: number) {
|
||||
for (let attempt = 0; attempt < 600; attempt++) {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await Bun.sleep(5)
|
||||
}
|
||||
throw new Error(`Timed out waiting for process ${pid}`)
|
||||
}
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
43
packages/docs/build/client.mdx
vendored
43
packages/docs/build/client.mdx
vendored
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -179,7 +179,7 @@ import type {
|
|||
QuestionReplyErrors,
|
||||
QuestionReplyResponses,
|
||||
QuestionV2Reply,
|
||||
ServiceStopRequestV2,
|
||||
ServiceStopRequest,
|
||||
SessionAbortErrors,
|
||||
SessionAbortResponses,
|
||||
SessionChildrenErrors,
|
||||
|
|
@ -5091,11 +5091,11 @@ export class Health extends HeyApiClient {
|
|||
*/
|
||||
public stop<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
serviceStopRequestV2: ServiceStopRequestV2
|
||||
serviceStopRequest: ServiceStopRequest
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams([parameters], [{ args: [{ key: "serviceStopRequestV2", map: "body" }] }])
|
||||
const params = buildClientParams([parameters], [{ args: [{ key: "serviceStopRequest", map: "body" }] }])
|
||||
return (options?.client ?? this.client).post<V2HealthStopResponses, V2HealthStopErrors, ThrowOnError>({
|
||||
url: "/api/service/stop",
|
||||
...options,
|
||||
|
|
|
|||
|
|
@ -2795,29 +2795,10 @@ export type WorkspaceWarpError = {
|
|||
}
|
||||
}
|
||||
|
||||
export type ServiceStatus =
|
||||
| {
|
||||
type: "starting"
|
||||
}
|
||||
| {
|
||||
type: "ready"
|
||||
}
|
||||
| {
|
||||
type: "stopping"
|
||||
targetVersion?: string
|
||||
}
|
||||
| {
|
||||
type: "failed"
|
||||
message: string
|
||||
action: string
|
||||
}
|
||||
|
||||
export type ServiceHealth = {
|
||||
healthy: true
|
||||
version: string
|
||||
pid: number
|
||||
instanceID?: string
|
||||
status?: ServiceStatus
|
||||
}
|
||||
|
||||
export type UnauthorizedError = {
|
||||
|
|
@ -2827,7 +2808,6 @@ export type UnauthorizedError = {
|
|||
|
||||
export type ServiceStopRequest = {
|
||||
instanceID: string
|
||||
targetVersion?: string
|
||||
}
|
||||
|
||||
export type ServiceStopResponse = {
|
||||
|
|
@ -8141,29 +8121,10 @@ export type BadRequestError = {
|
|||
}
|
||||
}
|
||||
|
||||
export type ServiceStatusV2 =
|
||||
| {
|
||||
type: "starting"
|
||||
}
|
||||
| {
|
||||
type: "ready"
|
||||
}
|
||||
| {
|
||||
type: "stopping"
|
||||
targetVersion?: string | null
|
||||
}
|
||||
| {
|
||||
type: "failed"
|
||||
message: string
|
||||
action: string
|
||||
}
|
||||
|
||||
export type ServiceHealthV2 = {
|
||||
healthy: true
|
||||
version: string
|
||||
pid: number
|
||||
instanceID?: string | null
|
||||
status?: ServiceStatusV2
|
||||
}
|
||||
|
||||
export type InvalidRequestErrorV2 = {
|
||||
|
|
@ -8173,11 +8134,6 @@ export type InvalidRequestErrorV2 = {
|
|||
field?: string | null
|
||||
}
|
||||
|
||||
export type ServiceStopRequestV2 = {
|
||||
instanceID: string
|
||||
targetVersion?: string | null
|
||||
}
|
||||
|
||||
export type SessionsResponseV2 = {
|
||||
data: Array<SessionInfoV2>
|
||||
cursor: {
|
||||
|
|
@ -15183,7 +15139,7 @@ export type V2HealthGetResponses = {
|
|||
export type V2HealthGetResponse = V2HealthGetResponses[keyof V2HealthGetResponses]
|
||||
|
||||
export type V2HealthStopData = {
|
||||
body: ServiceStopRequestV2
|
||||
body: ServiceStopRequest
|
||||
path?: never
|
||||
query?: never
|
||||
url: "/api/service/stop"
|
||||
|
|
|
|||
|
|
@ -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 })),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export * as ServerProcess from "./process"
|
||||
|
||||
import { NodeHttpServer, NodeHttpServerRequest } from "@effect/platform-node"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
|
||||
import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
|
||||
import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
|
||||
|
|
@ -45,7 +46,7 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(options:
|
|||
const applicationScope = yield* Scope.fork(parentScope)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
status
|
||||
.beginStopping()
|
||||
.beginStopping
|
||||
.pipe(
|
||||
Effect.andThen(Ref.set(application, Option.none())),
|
||||
Effect.andThen(Effect.sync(() => bound.server.closeAllConnections())),
|
||||
|
|
@ -73,22 +74,17 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(options:
|
|||
}).pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
if (!options.service || Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause)
|
||||
return status
|
||||
.fail({
|
||||
message: "The background service could not start.",
|
||||
action: "Run `opencode service restart` after checking the service logs.",
|
||||
})
|
||||
.pipe(
|
||||
Effect.andThen(
|
||||
Scope.close(applicationScope, Exit.failCause(cause)).pipe(
|
||||
Effect.catchCause((cleanupCause) =>
|
||||
Effect.logError("failed to clean up background service boot", { cause: cleanupCause }),
|
||||
),
|
||||
return status.fail.pipe(
|
||||
Effect.andThen(
|
||||
Scope.close(applicationScope, Exit.failCause(cause)).pipe(
|
||||
Effect.catchCause((cleanupCause) =>
|
||||
Effect.logError("failed to clean up background service boot", { cause: cleanupCause }),
|
||||
),
|
||||
),
|
||||
Effect.andThen(Effect.logError("background service boot failed", { cause })),
|
||||
Effect.andThen(Effect.never),
|
||||
)
|
||||
),
|
||||
Effect.andThen(Effect.logError("background service boot failed", { cause })),
|
||||
Effect.andThen(Effect.never),
|
||||
)
|
||||
}),
|
||||
)
|
||||
if (!options.service) return yield* boot
|
||||
|
|
@ -189,18 +185,21 @@ const control = Effect.fnUntraced(function* (
|
|||
})
|
||||
|
||||
const healthResponse = Effect.fnUntraced(function* (status: Status.Interface) {
|
||||
const health = yield* status.health
|
||||
return HttpServerResponse.jsonUnsafe(health, {
|
||||
status: health.status.type === "ready" ? 200 : 503,
|
||||
headers:
|
||||
health.status.type === "starting" || health.status.type === "stopping" ? { "retry-after": "1" } : undefined,
|
||||
const state = yield* status.current
|
||||
return HttpServerResponse.jsonUnsafe({ healthy: true, version: InstallationVersion, pid: process.pid }, {
|
||||
status: state.type === "ready" ? 200 : state.type === "failed" ? 500 : 503,
|
||||
headers: state.type === "starting" || state.type === "stopping" ? { "retry-after": "1" } : undefined,
|
||||
})
|
||||
})
|
||||
|
||||
function unavailable(status: ServiceStatus.State) {
|
||||
function unavailable(status: Status.State) {
|
||||
if (status.type === "failed")
|
||||
return HttpServerResponse.jsonUnsafe(
|
||||
{ code: "service_failed", message: status.message, action: status.action },
|
||||
{
|
||||
code: "service_failed",
|
||||
message: "The background service could not start.",
|
||||
action: "Run `opencode service restart` after checking the service logs.",
|
||||
},
|
||||
{ status: 503 },
|
||||
)
|
||||
return HttpServerResponse.jsonUnsafe(
|
||||
|
|
|
|||
|
|
@ -1,57 +1,45 @@
|
|||
export * as Status from "./service-status"
|
||||
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
|
||||
import { Effect, Ref } from "effect"
|
||||
|
||||
export type State =
|
||||
| { readonly type: "starting" }
|
||||
| { readonly type: "ready" }
|
||||
| { readonly type: "stopping" }
|
||||
| { readonly type: "failed" }
|
||||
|
||||
export interface Interface {
|
||||
readonly health: Effect.Effect<ServiceStatus.Health>
|
||||
readonly current: Effect.Effect<ServiceStatus.State>
|
||||
readonly current: Effect.Effect<State>
|
||||
readonly ready: Effect.Effect<void>
|
||||
readonly fail: (failure: { readonly message: string; readonly action: string }) => Effect.Effect<void>
|
||||
readonly beginStopping: (targetVersion?: string) => Effect.Effect<void>
|
||||
readonly fail: Effect.Effect<void>
|
||||
readonly beginStopping: Effect.Effect<void>
|
||||
readonly requestStop: (request: ServiceStatus.StopRequest) => Effect.Effect<boolean>
|
||||
}
|
||||
|
||||
export const make = Effect.fnUntraced(function* (options: {
|
||||
readonly instanceID: string
|
||||
readonly managed: boolean
|
||||
readonly initial?: ServiceStatus.State
|
||||
readonly initial?: State
|
||||
}) {
|
||||
const current = yield* Ref.make(options.initial ?? ({ type: "starting" } satisfies ServiceStatus.State))
|
||||
const transitionToStopping = (targetVersion?: string) =>
|
||||
Ref.update(current, (status) => {
|
||||
if (status.type === "stopping") return status
|
||||
return (
|
||||
targetVersion === undefined || targetVersion === InstallationVersion
|
||||
? { type: "stopping" }
|
||||
: { type: "stopping", targetVersion }
|
||||
) satisfies ServiceStatus.State
|
||||
})
|
||||
const current = yield* Ref.make(options.initial ?? ({ type: "starting" } satisfies State))
|
||||
const beginStopping = Ref.update(current, (status) =>
|
||||
status.type === "stopping" ? status : ({ type: "stopping" } satisfies State),
|
||||
)
|
||||
|
||||
return {
|
||||
current: Ref.get(current),
|
||||
health: Effect.gen(function* () {
|
||||
return {
|
||||
healthy: true as const,
|
||||
version: InstallationVersion,
|
||||
pid: process.pid,
|
||||
instanceID: options.instanceID,
|
||||
status: yield* Ref.get(current),
|
||||
}
|
||||
}),
|
||||
ready: Ref.update(current, (status) =>
|
||||
status.type === "starting" ? ({ type: "ready" } satisfies ServiceStatus.State) : status,
|
||||
status.type === "starting" ? ({ type: "ready" } satisfies State) : status,
|
||||
),
|
||||
fail: (failure) =>
|
||||
Ref.update(current, (status) =>
|
||||
status.type === "starting" ? ({ type: "failed", ...failure } satisfies ServiceStatus.State) : status,
|
||||
),
|
||||
beginStopping: transitionToStopping,
|
||||
fail: Ref.update(current, (status) =>
|
||||
status.type === "starting" ? ({ type: "failed" } satisfies State) : status,
|
||||
),
|
||||
beginStopping,
|
||||
requestStop: (request) => {
|
||||
if (!options.managed || request.instanceID !== options.instanceID)
|
||||
return Effect.succeed(false)
|
||||
return transitionToStopping(request.targetVersion).pipe(Effect.as(true))
|
||||
return beginStopping.pipe(Effect.as(true))
|
||||
},
|
||||
} satisfies Interface
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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" })
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { render, TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { registerOpencodeSpinner } from "./component/register-spinner"
|
||||
import { Deferred, Effect } from "effect"
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
|
||||
import { OpenCode } from "@opencode-ai/client"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
|
|
@ -138,9 +138,9 @@ const appBindingCommands = [
|
|||
|
||||
export type TuiInput = {
|
||||
server: {
|
||||
endpoint: Service.Endpoint
|
||||
endpoint: Endpoint
|
||||
service?: {
|
||||
reconnect: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise<Service.Endpoint>
|
||||
reconnect: (signal: AbortSignal) => Promise<Endpoint>
|
||||
restart: () => Promise<void>
|
||||
}
|
||||
}
|
||||
|
|
@ -189,8 +189,8 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||
const managed = input.server.service
|
||||
const service = managed
|
||||
? {
|
||||
reconnect: async (onStatus: (status: Service.Status) => void, signal: AbortSignal) => {
|
||||
const endpoint = await managed.reconnect(onStatus, signal)
|
||||
reconnect: async (signal: AbortSignal) => {
|
||||
const endpoint = await managed.reconnect(signal)
|
||||
const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) }
|
||||
return { api: OpenCode.make(next) }
|
||||
},
|
||||
|
|
@ -1114,7 +1114,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
|||
<StartupLoading ready={plugins.ready} />
|
||||
</Show>
|
||||
<Show when={showReconnecting()}>
|
||||
<Reconnecting status={client.connection.service()} />
|
||||
<Reconnecting />
|
||||
</Show>
|
||||
<Toast />
|
||||
</box>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
import type { Service } from "@opencode-ai/client/effect"
|
||||
import { Show } from "solid-js"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { Spinner } from "./spinner"
|
||||
|
||||
export function Reconnecting(props: { status?: Service.Status }) {
|
||||
export function Reconnecting() {
|
||||
const theme = useTheme().theme
|
||||
const copy = () => reconnectingCopy(props.status)
|
||||
|
||||
return (
|
||||
<box
|
||||
|
|
@ -20,46 +17,8 @@ export function Reconnecting(props: { status?: Service.Status }) {
|
|||
justifyContent="center"
|
||||
>
|
||||
<box width={62} maxWidth="90%" flexDirection="column" alignItems="center" gap={1}>
|
||||
<Show when={!copy().loading} fallback={<Spinner color={theme.textMuted}>{copy().message}</Spinner>}>
|
||||
<text fg={theme.error}>{copy().message}</text>
|
||||
<Show when={copy().detail}>
|
||||
{(detail) => (
|
||||
<text fg={theme.textMuted} wrapMode="word">
|
||||
{detail()}
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={copy().action}>
|
||||
{(action) => (
|
||||
<text fg={theme.text} wrapMode="word">
|
||||
{action()}
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
<Spinner color={theme.textMuted}>Waiting for background service...</Spinner>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
export function reconnectingCopy(status?: Service.Status) {
|
||||
if (status?.type === "starting")
|
||||
return {
|
||||
loading: true,
|
||||
message: status.version ? `Starting OpenCode ${status.version}...` : "Starting background service...",
|
||||
}
|
||||
if (status?.type === "stopping")
|
||||
return {
|
||||
loading: true,
|
||||
message: status.targetVersion ? `Updating to ${status.targetVersion}...` : "Restarting background service...",
|
||||
}
|
||||
if (status?.type === "failed")
|
||||
return { loading: false, message: "Background service failed", detail: status.message, action: status.action }
|
||||
if (status?.type === "unresponsive")
|
||||
return {
|
||||
loading: false,
|
||||
message: "Background service is not responding",
|
||||
action: "Run `opencode service restart` to recover it.",
|
||||
}
|
||||
return { loading: true, message: "Waiting for background service..." }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
|
||||
import type { Service } from "@opencode-ai/client/effect"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { createSimpleContext } from "./helper"
|
||||
|
|
@ -19,7 +18,7 @@ export type ClientConnectionEvent = {
|
|||
}
|
||||
|
||||
type ManagedService = {
|
||||
reconnect: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise<{ api: OpenCodeClient }>
|
||||
reconnect: (signal: AbortSignal) => Promise<{ api: OpenCodeClient }>
|
||||
restart: () => Promise<void>
|
||||
}
|
||||
|
||||
|
|
@ -43,7 +42,6 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
|
|||
status: "connecting",
|
||||
attempt: 0,
|
||||
})
|
||||
const [service, setService] = createSignal<Service.Status>()
|
||||
let stream: AbortController | undefined
|
||||
|
||||
function record(status: ClientConnectionEvent["data"]["status"], attempt: number, error?: string) {
|
||||
|
|
@ -81,7 +79,6 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
|
|||
log.info("event stream connected")
|
||||
events.emit(first.value.type, first.value)
|
||||
setConnection({ status: "connected", attempt: 0, error: undefined })
|
||||
setService(undefined)
|
||||
while (!abort.signal.aborted && !controller.signal.aborted) {
|
||||
const event = await iterator.next()
|
||||
if (abort.signal.aborted || controller.signal.aborted) return undefined
|
||||
|
|
@ -116,7 +113,7 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
|
|||
// moved (service restarted on a new port) or need starting. Static
|
||||
// transports (--server, standalone) resolve to the same address.
|
||||
if (props.service) {
|
||||
const next = await props.service.reconnect(setService, controller.signal).catch((error) => {
|
||||
const next = await props.service.reconnect(controller.signal).catch((error) => {
|
||||
if (!controller.signal.aborted)
|
||||
log.info("server resolution failed", {
|
||||
attempt,
|
||||
|
|
@ -159,9 +156,6 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
|
|||
error() {
|
||||
return connection.error
|
||||
},
|
||||
service() {
|
||||
return service()
|
||||
},
|
||||
internal: {
|
||||
history() {
|
||||
return history.slice()
|
||||
|
|
|
|||
|
|
@ -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/)
|
||||
})
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
/** @jsxImportSource @opentui/solid */
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
|
||||
import type { Service } from "@opencode-ai/client/effect"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { onMount } from "solid-js"
|
||||
import { ClientProvider, useClient } from "../../../src/context/client"
|
||||
|
|
@ -53,7 +52,7 @@ function update(version: string): OpenCodeEvent {
|
|||
}
|
||||
|
||||
async function mount(
|
||||
reconnect?: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise<{ api: OpenCodeClient }>,
|
||||
reconnect?: (signal: AbortSignal) => Promise<{ api: OpenCodeClient }>,
|
||||
log?: LogSink,
|
||||
) {
|
||||
const events = createEventStream()
|
||||
|
|
@ -279,48 +278,10 @@ describe("useEvent", () => {
|
|||
}
|
||||
})
|
||||
|
||||
test("reports service status while endpoint resolution is pending", async () => {
|
||||
const replacementEvents = createEventStream()
|
||||
const replacement = { api: createApi(createFetch(undefined, replacementEvents).fetch) }
|
||||
let report!: (status: Service.Status) => void
|
||||
let resolve!: (value: typeof replacement) => void
|
||||
const endpoint = new Promise<typeof replacement>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
const { app, events, client } = await mount(async (onStatus) => {
|
||||
report = onStatus
|
||||
onStatus({ type: "starting", version: "2.0.0" })
|
||||
return endpoint
|
||||
})
|
||||
|
||||
try {
|
||||
await wait(() => client.connection.status() === "connected")
|
||||
events.disconnect()
|
||||
await wait(
|
||||
() => client.connection.status() === "reconnecting" && client.connection.service()?.type === "starting",
|
||||
)
|
||||
expect(client.connection.service()).toEqual({ type: "starting", version: "2.0.0" })
|
||||
|
||||
report({ type: "failed", message: "Could not open the database.", action: "Check the service logs." })
|
||||
await wait(() => client.connection.service()?.type === "failed")
|
||||
expect(client.connection.service()).toEqual({
|
||||
type: "failed",
|
||||
message: "Could not open the database.",
|
||||
action: "Check the service logs.",
|
||||
})
|
||||
|
||||
resolve(replacement)
|
||||
await wait(() => client.connection.status() === "connected")
|
||||
expect(client.connection.service()).toBeUndefined()
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("cancels pending endpoint resolution on cleanup", async () => {
|
||||
let aborted = false
|
||||
const { app, events, client } = await mount(
|
||||
(_onStatus, signal) =>
|
||||
(signal) =>
|
||||
new Promise((_, reject) => {
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
|
|
|
|||
3
packages/www/content/docs/build/client.mdx
vendored
3
packages/www/content/docs/build/client.mdx
vendored
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue