diff --git a/packages/client/src/effect/service.ts b/packages/client/src/effect/service.ts index ff0b8a4778..01ebbb78c8 100644 --- a/packages/client/src/effect/service.ts +++ b/packages/client/src/effect/service.ts @@ -38,25 +38,85 @@ export type StartOptions = Options & { readonly onStart?: (reason: StartReason) => void } +type Registration = { + readonly url: string + readonly pid: number + readonly version?: string +} + export type Status = | { readonly status: "stopped" } + | { readonly status: "invalid"; readonly reason: "unreadable" | "malformed" } + | { + readonly status: "unhealthy" + readonly reason: "unreachable" | "invalid-response" + readonly registration: Registration + } + | { + readonly status: "unhealthy" + readonly reason: "http-error" + readonly registration: Registration + readonly statusCode: number + } + | { readonly status: "legacy"; readonly registration: Registration } + | { + readonly status: "inconsistent" + readonly fields: readonly ["pid" | "version", ...Array<"pid" | "version">] + readonly registration: Registration + readonly health: { readonly pid: number; readonly version: string } + } | { readonly status: "running" readonly url: string readonly pid: number - readonly version?: string - readonly compatible: boolean + readonly version: string + readonly compatible?: boolean } export const status = Effect.fn("service.status")(function* (options: Options = {}) { - const found = yield* discoverLocal({ ...options, version: undefined }) - if (found === undefined) return { status: "stopped" } as const + const registration = yield* inspectRegistration(options.file) + if (registration._tag === "Missing") return { status: "stopped" } as const + if (registration._tag === "Unreadable") return { status: "invalid", reason: "unreadable" } as const + if (registration._tag === "Malformed") return { status: "invalid", reason: "malformed" } as const + + const info = registration.info + const health = yield* inspectHealth(info) + if (health._tag !== "Healthy") { + if (health.reason === "legacy-response") { + return { status: "legacy", registration: publicRegistration(info) } as const + } + if (health.reason === "http-error") { + return { + status: "unhealthy", + reason: health.reason, + registration: publicRegistration(info), + statusCode: health.statusCode, + } as const + } + return { + status: "unhealthy", + reason: health.reason, + registration: publicRegistration(info), + } as const + } + const fields = [ + ...(health.pid === info.pid ? [] : (["pid"] as const)), + ...(info.version === undefined || health.version === info.version ? [] : (["version"] as const)), + ] + if (fields[0] !== undefined) { + return { + status: "inconsistent", + fields: [fields[0], ...fields.slice(1)], + registration: publicRegistration(info), + health: { pid: health.pid, version: health.version }, + } as const + } return { status: "running", - url: found.endpoint.url, - pid: found.info.pid, - version: found.info.version, - compatible: options.version === undefined || found.info.version === options.version, + url: info.url, + pid: health.pid, + version: health.version, + ...(options.version === undefined ? {} : { compatible: health.version === options.version }), } satisfies Status }) @@ -143,13 +203,27 @@ const decodeHealth = Schema.decodeUnknownOption( ) const decodeLegacyHealth = Schema.decodeUnknownOption(Schema.Struct({ healthy: Schema.Literal(true) })) -// 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) { +const inspectRegistration = Effect.fnUntraced(function* (file?: string) { const fs = yield* FileSystem.FileSystem - const text = yield* fs.readFileString(file ?? fallback()).pipe(Effect.option) - if (Option.isNone(text)) return undefined - return yield* decode(text.value).pipe(Effect.option, Effect.map(Option.getOrUndefined)) + const text = yield* fs.readFileString(file ?? fallback()).pipe( + Effect.map((value) => ({ _tag: "Found", value }) as const), + Effect.catch((error) => + Effect.succeed( + error.reason._tag === "NotFound" ? ({ _tag: "Missing" } as const) : ({ _tag: "Unreadable" } as const), + ), + ), + ) + if (text._tag !== "Found") return text + const info = yield* decode(text.value).pipe(Effect.option) + if (Option.isNone(info)) return { _tag: "Malformed" } as const + return { _tag: "Valid", info: info.value } as const +}) + +// Lifecycle operations intentionally treat missing and corrupt registrations +// alike; only status exposes that diagnostic distinction. +const read = Effect.fnUntraced(function* (file?: string) { + const registration = yield* inspectRegistration(file) + return registration._tag === "Valid" ? registration.info : undefined }) type LocalService = { @@ -157,37 +231,52 @@ type LocalService = { readonly endpoint: Endpoint } +const inspectHealth = Effect.fnUntraced(function* (info: Info) { + const response = yield* Effect.tryPromise(() => + fetch(new URL("/api/health", info.url), { + headers: headers(endpoint(info)), + signal: AbortSignal.timeout(2_000), + }), + ).pipe(Effect.option) + if (Option.isNone(response)) return { _tag: "Unhealthy", reason: "unreachable" } as const + if (!response.value.ok) return { _tag: "Unhealthy", reason: "http-error", statusCode: response.value.status } as const + const body = yield* Effect.tryPromise(() => response.value.json()).pipe(Effect.option) + if (Option.isNone(body)) return { _tag: "Unhealthy", reason: "invalid-response" } as const + const health = decodeHealth(body.value) + if (Option.isSome(health)) return { _tag: "Healthy", ...health.value } as const + if ( + Option.isSome(decodeLegacyHealth(body.value)) && + !(typeof body.value === "object" && body.value !== null && ("version" in body.value || "pid" in body.value)) + ) + return { _tag: "Unhealthy", reason: "legacy-response" } as const + return { _tag: "Unhealthy", reason: "invalid-response" } as const +}) + const probe = Effect.fnUntraced(function* (info: Info, version?: string, allowLegacy = false) { - const endpoint = { + const health = yield* inspectHealth(info) + if (health._tag === "Healthy") { + if (health.pid !== info.pid) return undefined + if (info.version !== undefined && health.version !== info.version) return undefined + if (version !== undefined && health.version !== version) return undefined + return { info, endpoint: endpoint(info) } satisfies LocalService + } + if (!allowLegacy || health.reason !== "legacy-response") return undefined + return { info, endpoint: endpoint(info) } satisfies LocalService +}) + +function endpoint(info: Info) { + return { url: info.url, auth: info.password === undefined ? undefined : { type: "basic" as const, username: "opencode", password: info.password }, } satisfies Endpoint - const response = yield* Effect.tryPromise(() => - fetch(new URL("/api/health", info.url), { - headers: headers(endpoint), - signal: AbortSignal.timeout(2_000), - }), - ).pipe(Effect.option, Effect.map(Option.getOrUndefined)) - if (response === undefined || !response.ok) return undefined - const body = yield* Effect.tryPromise(() => response.json()).pipe(Effect.option, Effect.map(Option.getOrUndefined)) - const health = decodeHealth(body) - if (Option.isSome(health)) { - if (health.value.pid !== info.pid) return undefined - if (info.version !== undefined && health.value.version !== info.version) return undefined - if (version !== undefined && health.value.version !== version) return undefined - return { info, endpoint } satisfies LocalService - } - if ( - !allowLegacy || - Option.isNone(decodeLegacyHealth(body)) || - (typeof body === "object" && body !== null && ("version" in body || "pid" in body)) - ) - return undefined - return { info, endpoint } satisfies LocalService -}) +} + +function publicRegistration(info: Info): Registration { + return { url: info.url, pid: info.pid, version: info.version } +} // Health-checked lookup without the version gate: lifecycle operations must be // able to see (and replace or stop) a server from a different version. diff --git a/packages/client/test/service.test.ts b/packages/client/test/service.test.ts new file mode 100644 index 0000000000..184f7516a7 --- /dev/null +++ b/packages/client/test/service.test.ts @@ -0,0 +1,104 @@ +import { NodeFileSystem } from "@effect/platform-node" +import { expect, test } from "bun:test" +import { Effect } from "effect" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { Service } from "../src/effect/index" + +test("service status distinguishes registration and health states", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-inspection-")) + const file = path.join(root, "service.json") + const expectedVersion = "0.0.0-client" + let response = Response.json({ healthy: true, version: expectedVersion, pid: process.pid }) + const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => response.clone() }) + const inspect = (version: string | null = expectedVersion) => + Effect.runPromise( + Service.status({ file, ...(version === null ? {} : { version }) }).pipe(Effect.provide(NodeFileSystem.layer)), + ) + const discover = (version: string | null) => + Effect.runPromise( + Service.discover({ file, ...(version === null ? {} : { version }) }).pipe(Effect.provide(NodeFileSystem.layer)), + ) + const registration = (input: { url?: string; pid?: number; version?: string } = {}) => ({ + url: input.url ?? server.url.toString(), + pid: input.pid ?? process.pid, + version: input.version ?? expectedVersion, + }) + const register = (input?: Parameters[0]) => Bun.write(file, JSON.stringify(registration(input))) + + try { + expect(await inspect()).toEqual({ status: "stopped" }) + + await Bun.write(file, "{") + expect(await inspect()).toEqual({ status: "invalid", reason: "malformed" }) + + await fs.rm(file) + await fs.mkdir(file) + expect(await inspect()).toEqual({ status: "invalid", reason: "unreadable" }) + await fs.rm(file, { recursive: true }) + + await register({ url: "http://127.0.0.1:1" }) + expect(await inspect()).toEqual({ + status: "unhealthy", + reason: "unreachable", + registration: registration({ url: "http://127.0.0.1:1" }), + }) + + await register() + response = new Response("Unavailable", { status: 503 }) + expect(await inspect()).toEqual({ + status: "unhealthy", + reason: "http-error", + registration: registration(), + statusCode: 503, + }) + + response = new Response("not json") + expect(await inspect()).toEqual({ + status: "unhealthy", + reason: "invalid-response", + registration: registration(), + }) + + response = Response.json({ healthy: true }) + expect(await inspect()).toEqual({ status: "legacy", registration: registration() }) + + response = Response.json({ healthy: true, version: "0.0.0-server", pid: process.pid + 1 }) + expect(await inspect()).toEqual({ + status: "inconsistent", + fields: ["pid", "version"], + registration: registration(), + health: { pid: process.pid + 1, version: "0.0.0-server" }, + }) + + response = Response.json({ healthy: true, version: "0.0.0-server", pid: process.pid }) + await register({ version: "0.0.0-server" }) + expect(await inspect()).toEqual({ + status: "running", + url: server.url.toString(), + pid: process.pid, + version: "0.0.0-server", + compatible: false, + }) + expect(await inspect("0.0.0-server")).toEqual({ + status: "running", + url: server.url.toString(), + pid: process.pid, + version: "0.0.0-server", + compatible: true, + }) + expect(await inspect(null)).toEqual({ + status: "running", + url: server.url.toString(), + pid: process.pid, + version: "0.0.0-server", + }) + expect(await discover(expectedVersion)).toBeUndefined() + expect((await discover("0.0.0-server"))?.url).toBe(server.url.toString()) + expect((await discover(null))?.url).toBe(server.url.toString()) + } finally { + server.stop(true) + await fs.rm(root, { recursive: true, force: true }) + } +})