mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-29 06:31:55 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b9fc8b9183 | |||
| 2ff3f43d59 |
@@ -8,7 +8,8 @@ import { ServiceConfig } from "../../../services/service-config"
|
||||
export default Runtime.handler(
|
||||
Commands.commands.service.commands.status,
|
||||
Effect.fn("cli.service.status")(function* () {
|
||||
const found = yield* Service.discover(yield* ServiceConfig.options())
|
||||
process.stdout.write((found ? found.url : "stopped") + EOL)
|
||||
const options = yield* ServiceConfig.options()
|
||||
const found = yield* Service.status(options)
|
||||
process.stdout.write(JSON.stringify({ ...found, clientVersion: options.version }, null, 2) + EOL)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Database } from "@opencode-ai/core/database/database"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { EventTable } from "@opencode-ai/core/event/sql"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -35,6 +36,60 @@ test("local channel stores service config with the local service filename", asyn
|
||||
}
|
||||
})
|
||||
|
||||
test("service status reports a healthy version-mismatched server", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-status-"))
|
||||
const version = "0.0.0-older"
|
||||
const server = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
if (new URL(request.url).pathname !== "/api/health") return new Response("Not found", { status: 404 })
|
||||
return Response.json({ healthy: true, version, pid: process.pid })
|
||||
},
|
||||
})
|
||||
const env = {
|
||||
...process.env,
|
||||
HOME: root,
|
||||
OPENCODE_TEST_HOME: root,
|
||||
XDG_CACHE_HOME: path.join(root, "cache"),
|
||||
XDG_CONFIG_HOME: path.join(root, "config"),
|
||||
XDG_DATA_HOME: path.join(root, "data"),
|
||||
XDG_STATE_HOME: path.join(root, "state"),
|
||||
}
|
||||
|
||||
try {
|
||||
const registration = path.join(root, "state", "opencode", "service-local.json")
|
||||
await fs.mkdir(path.dirname(registration), { recursive: true })
|
||||
await Bun.write(
|
||||
registration,
|
||||
JSON.stringify({ id: "older-service", version, url: server.url.toString(), pid: process.pid }),
|
||||
)
|
||||
const child = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "service", "status"], {
|
||||
env,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
child.exited,
|
||||
])
|
||||
|
||||
expect(exitCode, stderr).toBe(0)
|
||||
expect(JSON.parse(stdout)).toEqual({
|
||||
status: "running",
|
||||
url: server.url.toString(),
|
||||
pid: process.pid,
|
||||
version,
|
||||
compatible: false,
|
||||
clientVersion: InstallationVersion,
|
||||
})
|
||||
} finally {
|
||||
server.stop(true)
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("concurrent service processes elect one server", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-election-"))
|
||||
const database = path.join(root, "opencode.db")
|
||||
|
||||
@@ -38,6 +38,88 @@ 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
|
||||
}
|
||||
|
||||
export const status = Effect.fn("service.status")(function* (options: Options = {}) {
|
||||
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: info.url,
|
||||
pid: health.pid,
|
||||
version: health.version,
|
||||
...(options.version === undefined ? {} : { compatible: health.version === options.version }),
|
||||
} satisfies Status
|
||||
})
|
||||
|
||||
// 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 = {}) {
|
||||
@@ -121,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 = {
|
||||
@@ -135,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.
|
||||
|
||||
@@ -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<typeof registration>[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 })
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user