Browse Source

fix(cli): report mismatched service status

Kit Langton 4 weeks ago
parent
commit
2ff3f43d59

+ 3 - 2
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)
   }),
 )

+ 55 - 0
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")

+ 22 - 0
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 = {}) {