refactor(client): simplify local service lifecycle
This commit is contained in:
parent
4f1298063b
commit
75bc611ef1
51 changed files with 635 additions and 534 deletions
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue