mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-15 12:41:37 +03:00
refactor(client): share the service health probe between clients (#53237)
This commit is contained in:
committed by
GitHub
Unverified
parent
e1b94a138e
commit
fad352c5d0
@@ -1,7 +1,5 @@
|
||||
import { Effect, FileSystem, Option, Schedule, Schema } from "effect"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { DiscoverOptions, Endpoint, EnsureOptions, StopOptions } from "../service.js"
|
||||
import type { DiscoverOptions, EnsureOptions, StopOptions } from "../service.js"
|
||||
import {
|
||||
contenderFailure,
|
||||
contenderFinished,
|
||||
@@ -11,8 +9,10 @@ import {
|
||||
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
|
||||
import { matchesVersion } from "../service-version.js"
|
||||
import { PtyHandoff } from "../pty-handoff.js"
|
||||
import { fallback, headers, type LocalService, probeResult, same } from "../service-probe.js"
|
||||
|
||||
export * from "../service.js"
|
||||
export { headers }
|
||||
/** Contents of the local service registration file. */
|
||||
export type Info = import("../service.js").Info
|
||||
|
||||
@@ -40,9 +40,9 @@ export const incumbent = Effect.fn("service.incumbent")(function* (
|
||||
options: DiscoverOptions & { readonly url: string },
|
||||
) {
|
||||
const info = yield* read(options.file)
|
||||
const found = info === undefined ? undefined : yield* probe({ ...info, url: options.url })
|
||||
if (found === undefined) return undefined
|
||||
if (!found.compatible) return undefined
|
||||
if (info === undefined) return undefined
|
||||
const found = (yield* Effect.promise(() => probeResult({ ...info, url: options.url }))).service
|
||||
if (!found?.compatible) return undefined
|
||||
if (!matchesVersion(found.version, options)) return undefined
|
||||
return { endpoint: found.endpoint, state: found.state }
|
||||
})
|
||||
@@ -177,17 +177,6 @@ export const stop = Effect.fn("service.stop")(function* (options: StopOptions =
|
||||
if (info !== undefined) yield* terminate(info, options, ensureTiming(options))
|
||||
})
|
||||
|
||||
function fallback() {
|
||||
const state = process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state")
|
||||
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),
|
||||
@@ -198,13 +187,6 @@ export const Info = Schema.Struct({
|
||||
})
|
||||
|
||||
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
|
||||
const decodeInfo = Schema.decodeUnknownOption(
|
||||
Schema.Struct({
|
||||
version: Schema.String,
|
||||
pid: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
||||
}),
|
||||
)
|
||||
|
||||
// A missing or corrupt file means no valid info; callers treat both
|
||||
// the same (the registering server self-evicts, clients rediscover).
|
||||
const read = Effect.fnUntraced(function* (file?: string) {
|
||||
@@ -214,77 +196,10 @@ const read = Effect.fnUntraced(function* (file?: string) {
|
||||
return yield* decode(text.value).pipe(Effect.option, Effect.map(Option.getOrUndefined))
|
||||
})
|
||||
|
||||
type LocalService = {
|
||||
readonly info: Info
|
||||
readonly endpoint: Endpoint
|
||||
readonly version?: string
|
||||
readonly state: "ready" | "waiting" | "failed"
|
||||
readonly compatible: boolean
|
||||
}
|
||||
|
||||
const probe = Effect.fnUntraced(function* (info: Info) {
|
||||
return (yield* probeResult(info)).service
|
||||
})
|
||||
|
||||
const probeResult = Effect.fnUntraced(function* (info: Info, timeout = defaultEnsureTiming.requestTimeout) {
|
||||
const endpoint = {
|
||||
url: info.url,
|
||||
auth:
|
||||
info.password === undefined
|
||||
? undefined
|
||||
: { type: "basic" as const, username: "opencode", password: info.password },
|
||||
} satisfies Endpoint
|
||||
const signal = AbortSignal.timeout(timeout)
|
||||
const result = yield* Effect.promise(() =>
|
||||
fetch(new URL("/api/info", info.url), { headers: headers(endpoint), signal })
|
||||
.then(async (response) => ({
|
||||
response,
|
||||
body: response.status === 404 ? undefined : ((await response.json()) as unknown),
|
||||
}))
|
||||
.then(
|
||||
(value) => ({ value }),
|
||||
(cause: unknown) => ({ cause }),
|
||||
),
|
||||
)
|
||||
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
|
||||
const response = result.value.response
|
||||
// A missing health endpoint identifies protocol incompatibility, not an older
|
||||
// version. Only an unmet version requirement lets ensure replace this owner.
|
||||
if (response.status === 404)
|
||||
return {
|
||||
service: {
|
||||
info,
|
||||
endpoint,
|
||||
version: info.version,
|
||||
state: "ready" as const,
|
||||
compatible: false,
|
||||
} satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
const body = result.value.body
|
||||
const serverInfo = decodeInfo(body)
|
||||
if (Option.isSome(serverInfo)) {
|
||||
if (serverInfo.value.pid !== info.pid) return { service: undefined, timedOut: false }
|
||||
if (info.version !== undefined && serverInfo.value.version !== info.version)
|
||||
return { service: undefined, timedOut: false }
|
||||
return {
|
||||
service: {
|
||||
info,
|
||||
endpoint,
|
||||
version: serverInfo.value.version,
|
||||
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
|
||||
compatible: true,
|
||||
} satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
}
|
||||
return { service: undefined, timedOut: false }
|
||||
})
|
||||
|
||||
const registered = Effect.fnUntraced(function* (file?: string, timeout?: number) {
|
||||
const info = yield* read(file)
|
||||
if (info === undefined) return { info: undefined, service: undefined, timedOut: false }
|
||||
return { info, ...(yield* probeResult(info, timeout)) }
|
||||
return { info, ...(yield* Effect.promise(() => probeResult(info, timeout))) }
|
||||
})
|
||||
|
||||
// 50ms cadence bounded at ~5s, shared by stop escalation and each ensure
|
||||
@@ -303,10 +218,6 @@ const stopped = Effect.fnUntraced(function* (pid: number) {
|
||||
return yield* Effect.fail(new Error(`Server process ${pid} is still running`))
|
||||
})
|
||||
|
||||
function same(left: Info, right: Info) {
|
||||
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
||||
}
|
||||
|
||||
const terminate = Effect.fnUntraced(function* (info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
|
||||
const current = yield* read(options.file)
|
||||
if (current === undefined || !same(current, info)) return
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { readFile, rm } from "node:fs/promises"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { DiscoverOptions, Endpoint, Info, EnsureOptions, StopOptions } from "../service.js"
|
||||
import {
|
||||
contenderFailure,
|
||||
@@ -11,8 +9,10 @@ import {
|
||||
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
|
||||
import { matchesVersion } from "../service-version.js"
|
||||
import { PtyHandoff } from "../pty-handoff.js"
|
||||
import { fallback, headers, probeResult, same } from "../service-probe.js"
|
||||
|
||||
export * from "../service.js"
|
||||
export { headers }
|
||||
|
||||
// Find, start, and stop the local opencode background service.
|
||||
//
|
||||
@@ -148,18 +148,6 @@ export async function stop(options: StopOptions = {}) {
|
||||
if (info !== undefined) await terminate(info, options, ensureTiming(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
|
||||
@@ -170,73 +158,6 @@ async function read(file?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
type LocalService = {
|
||||
readonly info: Info
|
||||
readonly endpoint: Endpoint
|
||||
readonly version?: string
|
||||
readonly state: "ready" | "waiting" | "failed"
|
||||
readonly compatible: boolean
|
||||
}
|
||||
|
||||
async function probeResult(info: Info, timeout = defaultEnsureTiming.requestTimeout) {
|
||||
const endpoint = {
|
||||
url: info.url,
|
||||
auth:
|
||||
info.password === undefined
|
||||
? undefined
|
||||
: { type: "basic" as const, username: "opencode", password: info.password },
|
||||
} satisfies Endpoint
|
||||
const signal = AbortSignal.timeout(timeout)
|
||||
const result = await fetch(new URL("/api/info", info.url), { headers: headers(endpoint), signal })
|
||||
.then(async (response) => ({
|
||||
response,
|
||||
body: response.status === 404 ? undefined : ((await response.json()) as unknown),
|
||||
}))
|
||||
.then(
|
||||
(value) => ({ value }),
|
||||
(cause: unknown) => ({ cause }),
|
||||
)
|
||||
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
|
||||
const response = result.value.response
|
||||
// A missing health endpoint identifies protocol incompatibility, not an older
|
||||
// version. Only an unmet version requirement lets ensure replace this owner.
|
||||
if (response.status === 404)
|
||||
return {
|
||||
service: {
|
||||
info,
|
||||
endpoint,
|
||||
version: info.version,
|
||||
state: "ready" as const,
|
||||
compatible: false,
|
||||
} satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
const serverInfo = decodeInfo(result.value.body)
|
||||
if (serverInfo !== undefined) {
|
||||
if (serverInfo.pid !== info.pid) return { service: undefined, timedOut: false }
|
||||
if (info.version !== undefined && serverInfo.version !== info.version)
|
||||
return { service: undefined, timedOut: false }
|
||||
return {
|
||||
service: {
|
||||
info,
|
||||
endpoint,
|
||||
version: serverInfo.version,
|
||||
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
|
||||
compatible: true,
|
||||
} satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
}
|
||||
return { service: undefined, timedOut: false }
|
||||
}
|
||||
|
||||
function decodeInfo(input: unknown) {
|
||||
if (typeof input !== "object" || input === null) return
|
||||
if (!("version" in input) || typeof input.version !== "string") return
|
||||
if (!("pid" in input) || typeof input.pid !== "number" || !Number.isInteger(input.pid) || input.pid < 0) return
|
||||
return { version: input.version, pid: input.pid }
|
||||
}
|
||||
|
||||
async function registered(file?: string, timeout?: number) {
|
||||
const info = await read(file)
|
||||
if (info === undefined) return { info: undefined, service: undefined, timedOut: false }
|
||||
@@ -266,10 +187,6 @@ async function waitUntilStopped(pid: number, timing: EnsureTiming) {
|
||||
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 terminate(info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
|
||||
const current = await read(options.file)
|
||||
if (current === undefined || !same(current, info)) return
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { Endpoint, Info } from "./service.js"
|
||||
import { defaultEnsureTiming } from "./service-timing.js"
|
||||
|
||||
// Shared by the Effect and Promise clients. Keep this module free of Effect so the
|
||||
// Promise client never loads the Effect runtime.
|
||||
|
||||
export type LocalService = {
|
||||
readonly info: Info
|
||||
readonly endpoint: Endpoint
|
||||
readonly version?: string
|
||||
readonly state: "ready" | "waiting" | "failed"
|
||||
readonly compatible: boolean
|
||||
}
|
||||
|
||||
/** The default registration file. */
|
||||
export function fallback() {
|
||||
return join(process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state"), "opencode", "service.json")
|
||||
}
|
||||
|
||||
/** Whether two registrations describe the same service instance. */
|
||||
export function same(left: Info, right: Info) {
|
||||
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
||||
}
|
||||
|
||||
/** Ask a registered owner for its health and classify the answer. */
|
||||
export async function probeResult(info: Info, timeout = defaultEnsureTiming.requestTimeout) {
|
||||
const endpoint = {
|
||||
url: info.url,
|
||||
auth: info.password === undefined ? undefined : { type: "basic", username: "opencode", password: info.password },
|
||||
} satisfies Endpoint
|
||||
const signal = AbortSignal.timeout(timeout)
|
||||
const result = await fetch(new URL("/api/info", info.url), { headers: headers(endpoint), signal })
|
||||
.then(async (response) => ({
|
||||
response,
|
||||
body: response.status === 404 ? undefined : ((await response.json()) as unknown),
|
||||
}))
|
||||
.catch(() => undefined)
|
||||
if (result === undefined) return { service: undefined, timedOut: signal.aborted }
|
||||
const response = result.response
|
||||
// A missing health endpoint identifies protocol incompatibility, not an older
|
||||
// version. Only an unmet version requirement lets ensure replace this owner.
|
||||
if (response.status === 404)
|
||||
return {
|
||||
service: {
|
||||
info,
|
||||
endpoint,
|
||||
version: info.version,
|
||||
state: "ready",
|
||||
compatible: false,
|
||||
} satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
const serverInfo = decodeInfo(result.body)
|
||||
if (serverInfo === undefined) return { service: undefined, timedOut: false }
|
||||
if (serverInfo.pid !== info.pid) return { service: undefined, timedOut: false }
|
||||
if (info.version !== undefined && serverInfo.version !== info.version) return { service: undefined, timedOut: false }
|
||||
return {
|
||||
service: {
|
||||
info,
|
||||
endpoint,
|
||||
version: serverInfo.version,
|
||||
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
|
||||
compatible: true,
|
||||
} satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
}
|
||||
|
||||
/** 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"),
|
||||
}
|
||||
}
|
||||
|
||||
function decodeInfo(input: unknown) {
|
||||
if (typeof input !== "object" || input === null) return
|
||||
if (!("version" in input) || typeof input.version !== "string") return
|
||||
if (!("pid" in input) || typeof input.pid !== "number" || !Number.isInteger(input.pid) || input.pid < 0) return
|
||||
return { version: input.version, pid: input.pid }
|
||||
}
|
||||
Reference in New Issue
Block a user