diff --git a/packages/cli/src/commands/handlers/service/status.ts b/packages/cli/src/commands/handlers/service/status.ts index bf58968eef..6f6495026c 100644 --- a/packages/cli/src/commands/handlers/service/status.ts +++ b/packages/cli/src/commands/handlers/service/status.ts @@ -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) }), ) diff --git a/packages/cli/test/service.test.ts b/packages/cli/test/service.test.ts index a793ce0257..75299cc5dd 100644 --- a/packages/cli/test/service.test.ts +++ b/packages/cli/test/service.test.ts @@ -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") diff --git a/packages/client/src/effect/service.ts b/packages/client/src/effect/service.ts index de98066a09..ff0b8a4778 100644 --- a/packages/client/src/effect/service.ts +++ b/packages/client/src/effect/service.ts @@ -38,6 +38,28 @@ export type StartOptions = Options & { readonly onStart?: (reason: StartReason) => void } +export type Status = + | { readonly status: "stopped" } + | { + 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 found = yield* discoverLocal({ ...options, version: undefined }) + if (found === undefined) return { status: "stopped" } 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, + } 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 = {}) {