feat(server): expose server info endpoint

This commit is contained in:
Dax Raad
2026-09-16 21:43:58 -04:00
parent b642c1d9ea
commit ab3566ab82
58 changed files with 508 additions and 209 deletions
+2 -2
View File
@@ -54,7 +54,7 @@ Review endpoints in document order. For each endpoint, select one disposition an
### [x] `GET /api/health` and `GET /api/server`
- **Decision:** Merge and rename
- **Replacement:** `GET /api/status` with operation ID `server.status`.
- **Replacement:** `GET /api/info` with operation ID `server.info`.
- **Notes:** Returns `version`, `pid`, and connection `urls`; readiness is conveyed by HTTP status.
### [x] `GET /api/project/current`
@@ -74,7 +74,7 @@ Review endpoints in document order. For each endpoint, select one disposition an
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [x] 001–002 | `GET` | `/api/status` | `server.status` | Keep | Replaces the former health and server endpoints. |
| [x] 001–002 | `GET` | `/api/info` | `server.info` | Keep | Replaces the former health and server endpoints. |
| [x] 003 | `GET` | `/api/location` | `location.get` | Keep | Workspace selectors and response fields removed until workspace support ships. |
| [x] 004 | `GET` | `/api/project` | `project.list` | Keep | Removed unused `time.initialized`; the database column remains for migration data. |
| [x] 005 | `PATCH` | `/api/project/{projectID}` | `project.update` | Keep | Request and response accepted as-is. |
@@ -62,7 +62,8 @@ async function mockServers(page: Page, requests: string[]) {
const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/api/status") return json(route, { version: "test", pid: 1, urls: [url.origin] })
if (url.pathname === "/api/info")
return json(route, { version: "test", pid: 1, urls: [url.origin], paths: { tmp: "/tmp/opencode" } })
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
@@ -16,8 +16,13 @@ test("server dialog keeps focus above fullscreen settings", async ({ page }) =>
body: 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n',
})
}
if (url.pathname === "/api/status") {
return json(route, { version: "2.0.0", pid: 1, urls: [url.origin] })
if (url.pathname === "/api/info") {
return json(route, {
version: "2.0.0",
pid: 1,
urls: [url.origin],
paths: { tmp: "/tmp/opencode" },
})
}
return json(route, {})
})
@@ -138,7 +138,14 @@ test("MCP authentication starts before a slow resource catalog finishes", async
test("multiple desktop connections show the session's server name", async ({ page }) => {
await mockStressTimeline(page)
await page.route("http://secondary.test/**", (route) =>
route.fulfill({ json: { version: "2.0.0", pid: 1, urls: ["http://secondary.test"] } }),
route.fulfill({
json: {
version: "2.0.0",
pid: 1,
urls: ["http://secondary.test"],
paths: { tmp: "/tmp/opencode" },
},
}),
)
await page.addInitScript(
({ directory, server }) => {
@@ -109,11 +109,11 @@ test("passes through non-event fetches", async ({ page }) => {
const timeline = await setupTimeline(page)
const health = await page.evaluate(async () => {
const response = await fetch("/api/status")
const response = await fetch("/api/info")
return response.json()
})
expect(health).toEqual({ version: "2.0.0", pid: 1, urls: [] })
expect(health).toEqual({ version: "2.0.0", pid: 1, urls: [], paths: { tmp: "/tmp/opencode" } })
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
})
@@ -81,10 +81,10 @@ const fixture = test.extend<{ site: Site }, { builds: Record<string, Record<stri
response.setHeader("cache-control", "no-store")
if (path === "/observer.html")
return void response.writeHead(200, { "content-type": "text/html" }).end("<title>Worker observer</title>")
if (path === "/api/status")
if (path === "/api/info")
return void response
.writeHead(200, { "content-type": "application/json" })
.end(`{"version":"test","pid":1,"urls":["${url.origin}"]}`)
.end(`{"version":"test","pid":1,"urls":["${url.origin}"],"paths":{"tmp":"/tmp/opencode"}}`)
if (path === "/sw.js" && state.legacy && state.version === "old") {
// Model the shipped worker's shared precache name and cache-first navigation behavior.
const urls = Object.keys(builds.old).filter(
@@ -334,8 +334,13 @@ fixture("upgrades the legacy shared precache only after old tabs close", async (
fixture("does not substitute cached HTML for API or missing asset navigations", async ({ page, site }) => {
await install(page, site.url)
const api = await page.goto(`${site.url}/api/status`)
expect(await api?.json()).toEqual({ version: "test", pid: 1, urls: ["http://localhost"] })
const api = await page.goto(`${site.url}/api/info`)
expect(await api?.json()).toEqual({
version: "test",
pid: 1,
urls: ["http://localhost"],
paths: { tmp: "/tmp/opencode" },
})
expect(api?.fromServiceWorker()).toBe(false)
const asset = await page.goto(`${site.url}/_assets/missing.js`)
expect(asset?.status()).toBe(404)
+1 -1
View File
@@ -33,7 +33,7 @@ export class MockBadRequest extends Schema.TaggedError<MockBadRequest>()("MockBa
}) {}
const Group = HttpApiGroup.make("mock")
.add(HttpApiEndpoint.get("status", "/api/status", { success: Json }))
.add(HttpApiEndpoint.get("info", "/api/info", { success: Json }))
.add(
HttpApiEndpoint.get("event", "/api/event", {
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })),
+7 -1
View File
@@ -219,7 +219,13 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
}),
)
.handleAll({
status: () => Effect.succeed({ version: "2.0.0", pid: 1, urls: config.server ? [config.server] : [] }),
info: () =>
Effect.succeed({
version: "2.0.0",
pid: 1,
urls: config.server ? [config.server] : [],
paths: { tmp: "/tmp/opencode" },
}),
config: () => Effect.succeed(configEntries),
reference: () =>
Effect.succeed({
+10 -5
View File
@@ -90,7 +90,12 @@ test("rotates HTTP and PTY clients together", async () => {
const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push({ url: request.url, authorization: request.headers.get("authorization") })
return Response.json({ version: "2.0.0-test", pid: 1, urls: [request.url] })
return Response.json({
version: "2.0.0-test",
pid: 1,
urls: [request.url],
paths: { tmp: "/tmp/opencode" },
})
}) as typeof globalThis.fetch
const transport = createServerTransport({
http: { url: "http://127.0.0.1:4100", password: "first" },
@@ -98,23 +103,23 @@ test("rotates HTTP and PTY clients together", async () => {
})
const initialPty = transport.pty
await transport.api.server.status()
await transport.api.server.info()
const replacement = transport.update({
url: "http://127.0.0.1:4200",
password: "second",
})
await transport.api.server.status()
await transport.api.server.info()
expect(replacement).toBe(transport.api)
expect(transport.pty).not.toBe(initialPty)
expect(transport.url).toBe("http://127.0.0.1:4200")
expect(requests).toEqual([
{
url: "http://127.0.0.1:4100/api/status",
url: "http://127.0.0.1:4100/api/info",
authorization: `Basic ${btoa("opencode:first")}`,
},
{
url: "http://127.0.0.1:4200/api/status",
url: "http://127.0.0.1:4200/api/info",
authorization: `Basic ${btoa("opencode:second")}`,
},
])
+29 -15
View File
@@ -17,7 +17,12 @@ describe("checkServerHealth", () => {
const headers: Array<string | null> = []
const fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
headers.push(new Headers(init?.headers).get("authorization"))
return Response.json({ version: "2.0.0", pid: 1, urls: [server.url] })
return Response.json({
version: "2.0.0",
pid: 1,
urls: [server.url],
paths: { tmp: "/tmp/opencode" },
})
}) as typeof globalThis.fetch
expect(await checkServerHealth({ ...server, password }, fetch)).toEqual({ healthy: true, version: "2.0.0" })
@@ -28,16 +33,19 @@ describe("checkServerHealth", () => {
let request: URL | undefined
const fetch = (async (input: RequestInfo | URL) => {
request = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input)
return new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url] }), {
status: 200,
headers: { "content-type": "application/json" },
})
return new Response(
JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url], paths: { tmp: "/tmp/opencode" } }),
{
status: 200,
headers: { "content-type": "application/json" },
},
)
}) as unknown as typeof globalThis.fetch
const result = await checkServerHealth(server, fetch)
expect(result).toEqual({ healthy: true, version: "1.2.3" })
expect(request?.pathname).toBe("/api/status")
expect(request?.pathname).toBe("/api/info")
})
test("allows slow servers thirty seconds by default", async () => {
@@ -52,7 +60,7 @@ describe("checkServerHealth", () => {
})
const fetch = (async () =>
new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url] }), {
new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url], paths: { tmp: "/tmp/opencode" } }), {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof globalThis.fetch
@@ -111,10 +119,13 @@ describe("checkServerHealth", () => {
let signal: AbortSignal | undefined
const fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
signal = abortFromInput(input, init)
return new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url] }), {
status: 200,
headers: { "content-type": "application/json" },
})
return new Response(
JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url], paths: { tmp: "/tmp/opencode" } }),
{
status: 200,
headers: { "content-type": "application/json" },
},
)
}) as unknown as typeof globalThis.fetch
const abort = new AbortController()
@@ -130,10 +141,13 @@ describe("checkServerHealth", () => {
const fetch = (async () => {
count += 1
if (count < 3) throw new TypeError("network")
return new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url] }), {
status: 200,
headers: { "content-type": "application/json" },
})
return new Response(
JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url], paths: { tmp: "/tmp/opencode" } }),
{
status: 200,
headers: { "content-type": "application/json" },
},
)
}) as unknown as typeof globalThis.fetch
const result = await checkServerHealth(server, fetch, {
+1 -1
View File
@@ -95,7 +95,7 @@ export async function checkServerHealth(
fetch,
headers,
})
.server.status({ signal })
.server.info({ signal })
.then((status) => ({ data: { healthy: true as const, version: status.version } }))
.catch((error) => ({ error }))
if ("data" in current) return current.data
@@ -195,7 +195,7 @@ describe("createRequestQueue", () => {
input.tick(50)
input.queue.fetch("http://server/api/worktree?location[directory]=%2Fc").catch(() => undefined)
input.tick(100)
input.queue.fetch("http://server/api/status").catch(() => undefined)
input.queue.fetch("http://server/api/info").catch(() => undefined)
expect(input.logs).toEqual([])
input.tick(2_000)
await new Promise((resolve) => setTimeout(resolve, 20))
@@ -210,7 +210,7 @@ describe("createRequestQueue", () => {
],
queued: [
{ method: "GET", url: "http://server/api/worktree?location[directory]=%2Fc", ms: 2_100 },
{ method: "GET", url: "http://server/api/status", ms: 2_000 },
{ method: "GET", url: "http://server/api/info", ms: 2_000 },
],
},
},
+9 -9
View File
@@ -2,7 +2,7 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Service } from "@opencode/client/effect/service"
import { ServerStatus } from "@opencode/protocol/groups/server"
import { ServerInfo } from "@opencode/protocol/groups/server"
import { Effect, Schema } from "effect"
import fs from "node:fs/promises"
import os from "node:os"
@@ -42,12 +42,12 @@ try {
const credential = btoa(`opencode:${info.password}`)
const headers = { authorization: "Basic " + credential }
const token = encodeURIComponent(credential)
const status = await waitForReady(info.url, headers)
if (status.pid !== info.pid) throw new Error("Status process does not match registration")
const tokenStatus = await fetch(new URL(`/api/status?auth_token=${token}`, info.url), {
const serverInfo = await waitForReady(info.url, headers)
if (serverInfo.pid !== info.pid) throw new Error("Server info does not match registration")
const tokenInfo = await fetch(new URL(`/api/info?auth_token=${token}`, info.url), {
signal: AbortSignal.timeout(5_000),
})
if (tokenStatus.status !== 200) throw new Error("Compiled service rejected query authentication")
if (tokenInfo.status !== 200) throw new Error("Compiled service rejected query authentication")
const tokenOpenApi = await fetch(new URL(`/openapi.json?auth_token=${token}`, info.url), {
signal: AbortSignal.timeout(5_000),
})
@@ -57,10 +57,10 @@ try {
await fs.writeFile(plugin, pluginSource())
await waitForPlugin(info.url, headers)
const unauthorizedStatus = await fetch(new URL("/api/status", info.url), {
const unauthorizedInfo = await fetch(new URL("/api/info", info.url), {
signal: AbortSignal.timeout(5_000),
})
if (unauthorizedStatus.status !== 401) throw new Error("Compiled service exposed status without authentication")
if (unauthorizedInfo.status !== 401) throw new Error("Compiled service exposed info without authentication")
const unauthorizedOpenApi = await fetch(new URL("/openapi.json", info.url), {
signal: AbortSignal.timeout(5_000),
})
@@ -128,11 +128,11 @@ async function waitForRegistration() {
async function waitForReady(url: string, headers: HeadersInit) {
const deadline = Date.now() + 20_000
while (Date.now() < deadline) {
const response = await fetch(new URL("/api/status", url), {
const response = await fetch(new URL("/api/info", url), {
headers,
signal: AbortSignal.timeout(1_000),
}).catch(() => undefined)
if (response?.ok) return Schema.decodeUnknownPromise(ServerStatus)(await response.json())
if (response?.ok) return Schema.decodeUnknownPromise(ServerInfo)(await response.json())
await Bun.sleep(25)
}
throw new Error("Compiled service did not become ready")
+1 -1
View File
@@ -15,7 +15,7 @@ export default Runtime.handler(
const urls = Option.isSome(input.url)
? [input.url.value]
: (yield* Effect.tryPromise(() =>
OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).server.status(),
OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).server.info(),
)).urls
const info = { urls, username: "opencode", password }
process.stdout.write(
@@ -29,7 +29,7 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg
} satisfies Endpoint
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const health = yield* Effect.tryPromise({
try: () => client.server.status({ signal: AbortSignal.timeout(5_000) }),
try: () => client.server.info({ signal: AbortSignal.timeout(5_000) }),
catch: (cause) => connectError(endpoint, cause),
})
if (health.version !== OPENCODE_VERSION)
+2 -2
View File
@@ -272,7 +272,7 @@ function authServer(fetch: (request: Request, url: URL) => Response | Promise<Re
fetch(request) {
const url = new URL(request.url)
requests?.push(url.pathname)
if (url.pathname === "/api/status") return status()
if (url.pathname === "/api/info") return status()
if (url.pathname === "/api/model/default") return Response.json(located(null))
return fetch(request, url)
},
@@ -280,7 +280,7 @@ function authServer(fetch: (request: Request, url: URL) => Response | Promise<Re
}
function status() {
return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] })
return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [], paths: { tmp: "/tmp/opencode" } })
}
function located<T>(data: T) {
+7 -2
View File
@@ -39,9 +39,14 @@ describe("debug config command", () => {
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/status") {
if (url.pathname === "/api/info") {
healthProbes += 1
return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] })
return Response.json({
version: OPENCODE_VERSION,
pid: process.pid,
urls: [],
paths: { tmp: "/tmp/opencode" },
})
}
requested = url
authorization.push(request.headers.get("authorization"))
@@ -12,7 +12,7 @@ await Effect.runPromise(
command: [process.execPath, path.join(import.meta.dir, "../../src/index.ts"), "serve"],
})
const response = yield* Effect.promise(() =>
fetch(new URL("/api/status", endpoint.url), { headers: Service.headers(endpoint) }),
fetch(new URL("/api/info", endpoint.url), { headers: Service.headers(endpoint) }),
)
console.log(`STANDALONE_READY ${endpoint.pid} ${endpoint.url} ${response.status}`)
return yield* Effect.never
+7 -6
View File
@@ -42,7 +42,8 @@ const sanitizedTransfer = {
],
}
const status = () => Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] })
const status = () =>
Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [], paths: { tmp: "/tmp/opencode" } })
function run(args: string[], stdin?: string) {
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
@@ -60,7 +61,7 @@ test("export is raw by default and supports explicit sanitization", async () =>
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/status") return status()
if (url.pathname === "/api/info") return status()
if (url.pathname === `/api/session/${info.id}`) return Response.json({ data: info })
if (url.pathname === `/api/experimental/session/${info.id}/export`) {
sanitization.push(url.searchParams.get("sanitize") ?? "")
@@ -98,7 +99,7 @@ test("export requires a session outside an interactive terminal", async () => {
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/status") return status()
if (url.pathname === "/api/info") return status()
if (url.pathname === "/api/location") {
return Response.json({
directory: "/project",
@@ -127,7 +128,7 @@ test("export reports a missing session without a stack trace", async () => {
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/status") return status()
if (url.pathname === "/api/info") return status()
if (url.pathname === `/api/experimental/session/${sessionID}/export`) {
return Response.json(
{ _tag: "SessionNotFoundError", sessionID, message: `Session not found: ${sessionID}` },
@@ -158,7 +159,7 @@ test("import validates a file and sends it to the resolved location", async () =
port: 0,
async fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/status") return status()
if (url.pathname === "/api/info") return status()
if (url.pathname === "/api/location") {
return Response.json({
directory: root,
@@ -201,7 +202,7 @@ test("import reports an existing session without a stack trace", async () => {
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/status") return status()
if (url.pathname === "/api/info") return status()
if (url.pathname === "/api/location") {
return Response.json({
directory: root,
+26 -6
View File
@@ -31,14 +31,24 @@ describe("mini command", () => {
const initial = Bun.serve({
port: 0,
fetch() {
return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] })
return Response.json({
version: OPENCODE_VERSION,
pid: process.pid,
urls: [],
paths: { tmp: "/tmp/opencode" },
})
},
})
const replacement = Bun.serve({
port: 0,
fetch(request) {
authorization.push(request.headers.get("authorization"))
return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] })
return Response.json({
version: OPENCODE_VERSION,
pid: process.pid,
urls: [],
paths: { tmp: "/tmp/opencode" },
})
},
})
const controller = new AbortController()
@@ -57,7 +67,7 @@ describe("mini command", () => {
})
const client = await connection.reconnect?.(controller.signal)
if (!client) throw new Error("Expected a replacement client")
await client.server.status()
await client.server.info()
expect(client).not.toBe(connection.sdk)
expect(signal).toBe(controller.signal)
@@ -147,8 +157,13 @@ describe("mini command", () => {
async fetch(request) {
const url = new URL(request.url)
requests.push(url.pathname)
if (url.pathname === "/api/status")
return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] })
if (url.pathname === "/api/info")
return Response.json({
version: OPENCODE_VERSION,
pid: process.pid,
urls: [],
paths: { tmp: "/tmp/opencode" },
})
if (url.pathname === "/api/location")
return Response.json({ directory: process.cwd(), project: { id: "global", directory: process.cwd() } })
if (url.pathname === "/api/session") {
@@ -190,7 +205,12 @@ describe("mini command", () => {
port: 0,
fetch(request) {
if (new URL(request.url).pathname === "/api/session") return new Response("boom", { status: 500 })
return Response.json({ version: "incompatible", pid: process.pid, urls: [] })
return Response.json({
version: "incompatible",
pid: process.pid,
urls: [],
paths: { tmp: "/tmp/opencode" },
})
},
})
+8 -4
View File
@@ -256,13 +256,14 @@ test("concurrent service processes elect one server", async () => {
expect((await Bun.file(config).json()).password).toBe(info.password)
expect(await Bun.file(registration + ".lock").exists()).toBe(false)
expect(
await fetch(new URL("/api/status", info.url), {
await fetch(new URL("/api/info", info.url), {
headers: { authorization: "Basic " + btoa(`opencode:${info.password}`) },
}).then((response) => response.json()),
).toEqual({
version: info.version,
pid: info.pid,
urls: [info.url],
paths: { tmp: path.join(os.tmpdir(), "opencode") },
})
const contender = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
try {
@@ -337,7 +338,7 @@ test.each([
const info = await waitForInfo(registration)
await Promise.all(
[...new Set([...cors, ...origins, "https://unlisted.example.com"])].map(async (origin) => {
const response = await fetch(new URL("/api/status", info.url), {
const response = await fetch(new URL("/api/info", info.url), {
method: "OPTIONS",
headers: { Origin: origin, "Access-Control-Request-Method": "GET" },
})
@@ -440,7 +441,10 @@ test("port contender recognizes an incumbent registered during the bind race", a
fetch() {
requests.count += 1
if (requests.count === 2) recognizing.resolve()
return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] }, { status: 503 })
return Response.json(
{ version: OPENCODE_VERSION, pid: process.pid, urls: [], paths: { tmp: "/tmp/opencode" } },
{ status: 503 },
)
},
})
const registration = path.join(root, "state", "opencode", "service-local.json")
@@ -575,7 +579,7 @@ async function waitForInfo(file: string, accept: (info: Info) => boolean = () =>
async function waitForFailed(info: Info) {
for (let attempt = 0; attempt < 400; attempt++) {
const status = await fetch(new URL("/api/status", info.url), {
const status = await fetch(new URL("/api/info", info.url), {
headers: { authorization: "Basic " + btoa(`opencode:${info.password}`) },
})
.then((response) => response.status)
+14 -4
View File
@@ -35,8 +35,13 @@ describe("web UI", () => {
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const pathname = new URL(request.url, "http://localhost").pathname
if (pathname === "/api/status")
return HttpServerResponse.jsonUnsafe({ version: "test", pid: 1, urls: [origin] })
if (pathname === "/api/info")
return HttpServerResponse.jsonUnsafe({
version: "test",
pid: 1,
urls: [origin],
paths: { tmp: "/tmp/opencode" },
})
return yield* Effect.fail(
new HttpServerError.HttpServerError({
reason: new HttpServerError.RouteNotFound({ request }),
@@ -47,8 +52,13 @@ describe("web UI", () => {
)
const origin = HttpServer.formatAddress(http.address)
const status = yield* Effect.promise(() => fetch(`${origin}/api/status`))
expect(yield* Effect.promise(() => status.json())).toEqual({ version: "test", pid: 1, urls: [origin] })
const status = yield* Effect.promise(() => fetch(`${origin}/api/info`))
expect(yield* Effect.promise(() => status.json())).toEqual({
version: "test",
pid: 1,
urls: [origin],
paths: { tmp: "/tmp/opencode" },
})
const missing = yield* Effect.promise(() => fetch(`${origin}/api/missing`))
expect(missing.status).toBe(404)
+4 -3
View File
@@ -39,15 +39,16 @@ import type { Vcs } from "@opencode/schema/vcs"
import type { WebSearch } from "@opencode/schema/websearch"
import type { Config } from "@opencode/schema/config"
export type ServerStatusOutput = {
export type ServerInfoOutput = {
readonly version: string
readonly pid: number
readonly urls: ReadonlyArray<string>
readonly paths: { readonly tmp: string }
}
export type ServerStatusOperation<E = never> = () => Effect.Effect<ServerStatusOutput, E>
export type ServerInfoOperation<E = never> = () => Effect.Effect<ServerInfoOutput, E>
export interface ServerApi<E = never> {
readonly status: ServerStatusOperation<E>
readonly info: ServerInfoOperation<E>
}
export type LocationGetInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
@@ -5,7 +5,7 @@ import { HttpClientError } from "effect/unstable/http"
import { HttpApiClient } from "effect/unstable/httpapi"
import { ClientApi } from "../../contract"
import type {
ServerStatusOutput,
ServerInfoOutput,
LocationGetInput,
LocationGetOutput,
LocationReloadOutput,
@@ -278,10 +278,10 @@ const preserveStream =
<E, R>(stream: Stream.Stream<A, E, R>) =>
stream
const EndpointServerStatus = (raw: RawClient["server.server"]) => () =>
preserveEffect<ServerStatusOutput>()(raw["server.status"]({}).pipe(Effect.mapError(mapClientError)))
const EndpointServerInfo = (raw: RawClient["server.server"]) => () =>
preserveEffect<ServerInfoOutput>()(raw["server.info"]({}).pipe(Effect.mapError(mapClientError)))
const adaptGroupServer = (raw: RawClient["server.server"]) => ({ status: EndpointServerStatus(raw) })
const adaptGroupServer = (raw: RawClient["server.server"]) => ({ info: EndpointServerInfo(raw) })
const EndpointLocationGet = (raw: RawClient["server.location"]) => (input?: LocationGetInput) =>
preserveEffect<LocationGetOutput>()(
+8 -8
View File
@@ -172,7 +172,7 @@ export const Info = Schema.Struct({
})
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
const decodeStatus = Schema.decodeUnknownOption(
const decodeInfo = Schema.decodeUnknownOption(
Schema.Struct({
version: Schema.String,
pid: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
@@ -213,7 +213,7 @@ const probeResult = Effect.fnUntraced(function* (
} satisfies Endpoint
const signal = AbortSignal.timeout(timeout)
const result = yield* Effect.promise(() =>
fetch(new URL("/api/status", info.url), { headers: headers(endpoint), signal })
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),
@@ -225,7 +225,7 @@ const probeResult = Effect.fnUntraced(function* (
)
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
const response = result.value.response
// The previous V2 service exposes /api/health instead. Its authenticated 404 is enough
// The previous V2 service exposes /api/status instead. Its authenticated 404 is enough
// to recognize the registered daemon as incompatible and route it through replacement.
if (response.status === 404)
return {
@@ -239,16 +239,16 @@ const probeResult = Effect.fnUntraced(function* (
timedOut: false,
}
const body = result.value.body
const status = decodeStatus(body)
if (Option.isSome(status)) {
if (status.value.pid !== info.pid) return { service: undefined, timedOut: false }
if (info.version !== undefined && status.value.version !== info.version)
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: status.value.version,
version: serverInfo.value.version,
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
compatible: true,
} satisfies LocalService,
@@ -1,5 +1,5 @@
import type {
ServerStatusOutput,
ServerInfoOutput,
LocationGetInput,
LocationGetOutput,
LocationReloadOutput,
@@ -398,9 +398,9 @@ export function make(options: ClientOptions) {
return {
server: {
status: (requestOptions?: RequestOptions) =>
request<ServerStatusOutput>(
{ method: "GET", path: `/api/status`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
info: (requestOptions?: RequestOptions) =>
request<ServerInfoOutput>(
{ method: "GET", path: `/api/info`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
requestOptions,
),
},
@@ -1,6 +1,6 @@
export type JsonValue = null | boolean | number | string | Array<JsonValue> | { [key: string]: JsonValue }
export type ServerStatus = { version: string; pid: number; urls: Array<string> }
export type ServerInfo = { version: string; pid: number; urls: Array<string>; paths: { tmp: string } }
export type LocationPublicInfo = { directory: string; project: { id: string; directory: string; canonical: string } }
@@ -2655,7 +2655,7 @@ export type WorktreeError = {
export const isWorktreeError = (value: unknown): value is WorktreeError =>
typeof value === "object" && value !== null && "name" in value && value["name"] === "WorktreeError"
export type ServerStatusOutput = ServerStatus
export type ServerInfoOutput = ServerInfo
export type LocationGetInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
+9 -8
View File
@@ -163,7 +163,7 @@ async function probeResult(info: Info, timeout = defaultEnsureTiming.requestTime
: { type: "basic" as const, username: "opencode", password: info.password },
} satisfies Endpoint
const signal = AbortSignal.timeout(timeout)
const result = await fetch(new URL("/api/status", info.url), { headers: headers(endpoint), signal })
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),
@@ -174,7 +174,7 @@ async function probeResult(info: Info, timeout = defaultEnsureTiming.requestTime
)
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
const response = result.value.response
// The previous V2 service exposes /api/health instead. Its authenticated 404 is enough
// The previous V2 service exposes /api/status instead. Its authenticated 404 is enough
// to recognize the registered daemon as incompatible and route it through replacement.
if (response.status === 404)
return {
@@ -187,15 +187,16 @@ async function probeResult(info: Info, timeout = defaultEnsureTiming.requestTime
} satisfies LocalService,
timedOut: false,
}
const status = decodeStatus(result.value.body)
if (status !== undefined) {
if (status.pid !== info.pid) return { service: undefined, timedOut: false }
if (info.version !== undefined && status.version !== info.version) return { service: undefined, 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: status.version,
version: serverInfo.version,
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
compatible: true,
} satisfies LocalService,
@@ -205,7 +206,7 @@ async function probeResult(info: Info, timeout = defaultEnsureTiming.requestTime
return { service: undefined, timedOut: false }
}
function decodeStatus(input: unknown) {
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
+14 -4
View File
@@ -15,21 +15,31 @@ import {
const synced = { type: "log.synced" as const, aggregateID: "ses_test", seq: Event.Seq.make(1) }
test("server.status decodes the readiness response", async () => {
test("server.info decodes the readiness response", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json({ version: "current", pid: 123, urls: ["http://localhost:3000"] }),
Response.json({
version: "current",
pid: 123,
urls: ["http://localhost:3000"],
paths: { tmp: "/tmp/opencode" },
}),
),
),
)
const result = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.server.status()
return yield* client.server.info()
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(result).toEqual({ version: "current", pid: 123, urls: ["http://localhost:3000"] })
expect(result).toEqual({
version: "current",
pid: 123,
urls: ["http://localhost:3000"],
paths: { tmp: "/tmp/opencode" },
})
})
test("vcs.base decodes nullable review-base metadata", async () => {
+15 -4
View File
@@ -52,7 +52,7 @@ const server = Bun.serve({
await writeFile(registration + ".prepared", JSON.stringify(handoff))
return Response.json({ handoff })
}
if (pathname !== "/api/status") return new Response(null, { status: 404 })
if (pathname !== "/api/info") return new Response(null, { status: 404 })
requests += 1
if (mode === "starting") await writeFile(registration + ".status-request", "")
if (mode === "hanging") {
@@ -65,10 +65,21 @@ const server = Bun.serve({
return new Response(null, { status: 503 })
}
if (mode === "starting" && !(await Bun.file(registration + ".release").exists()))
return Response.json({ version, pid: process.pid, urls: [server.url.toString()] }, { status: 503 })
return Response.json(
{ version, pid: process.pid, urls: [server.url.toString()], paths: { tmp: "/tmp/opencode" } },
{ status: 503 },
)
if (mode === "failed-owner")
return Response.json({ version, pid: process.pid, urls: [server.url.toString()] }, { status: 500 })
return Response.json({ version, pid: process.pid, urls: [server.url.toString()] })
return Response.json(
{ version, pid: process.pid, urls: [server.url.toString()], paths: { tmp: "/tmp/opencode" } },
{ status: 500 },
)
return Response.json({
version,
pid: process.pid,
urls: [server.url.toString()],
paths: { tmp: "/tmp/opencode" },
})
},
})
+24 -8
View File
@@ -192,19 +192,29 @@ test("websearch.query uses the public HTTP contract", async () => {
expect(await request?.json()).toEqual({ query: "opencode", providerID: "exa" })
})
test("server.status uses the public HTTP contract", async () => {
test("server.info uses the public HTTP contract", async () => {
let request: Request | undefined
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input) => {
request = input instanceof Request ? input : new Request(input)
return Response.json({ version: "2.0.0", pid: 1, urls: ["http://192.168.1.10:4096"] })
return Response.json({
version: "2.0.0",
pid: 1,
urls: ["http://192.168.1.10:4096"],
paths: { tmp: "/tmp/opencode" },
})
},
})
expect(await client.server.status()).toEqual({ version: "2.0.0", pid: 1, urls: ["http://192.168.1.10:4096"] })
expect(await client.server.info()).toEqual({
version: "2.0.0",
pid: 1,
urls: ["http://192.168.1.10:4096"],
paths: { tmp: "/tmp/opencode" },
})
expect(request?.method).toBe("GET")
expect(request?.url).toBe("http://localhost:3000/api/status")
expect(request?.url).toBe("http://localhost:3000/api/info")
})
test("experimental wellknown integration add uses the public HTTP contract", async () => {
@@ -686,7 +696,7 @@ test("event.subscribe reports heartbeat comments as stream activity", async () =
})
// Moved from packages/app/e2e/regression/session-timeline-transport.spec.ts
test("event transport passes through ordinary status requests", async () => {
test("event transport passes through ordinary info requests", async () => {
const requests: string[] = []
const event = { id: "evt_connected", created: 1, type: "server.connected", data: {} }
const client = OpenCode.make({
@@ -699,16 +709,22 @@ test("event transport passes through ordinary status requests", async () => {
headers: { "content-type": "text/event-stream" },
})
}
return Response.json({ version: "2.0.0", pid: 1, urls: ["http://localhost:3000"] })
return Response.json({
version: "2.0.0",
pid: 1,
urls: ["http://localhost:3000"],
paths: { tmp: "/tmp/opencode" },
})
},
})
await expect(client.event.subscribe()[Symbol.asyncIterator]().next()).resolves.toEqual({ done: false, value: event })
await expect(client.server.status()).resolves.toEqual({
await expect(client.server.info()).resolves.toEqual({
version: "2.0.0",
pid: 1,
urls: ["http://localhost:3000"],
paths: { tmp: "/tmp/opencode" },
})
expect(requests).toEqual(["/api/event", "/api/status"])
expect(requests).toEqual(["/api/event", "/api/info"])
})
test("event.subscribe terminates on malformed Promise SSE data", async () => {
+1 -1
View File
@@ -300,5 +300,5 @@ function run<A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem>) {
}
async function status(url: string) {
return fetch(new URL("/api/status", url), { signal: AbortSignal.timeout(1_000) }).then((response) => response.json())
return fetch(new URL("/api/info", url), { signal: AbortSignal.timeout(1_000) }).then((response) => response.json())
}
+8 -8
View File
@@ -235,7 +235,7 @@ describe("OpenAPI.fromSpec", () => {
path: "/api/fs/read/*",
reason: "binary responses are not supported",
})
expect(toolAt(result.tools, "server.status")).not.toBeUndefined()
expect(toolAt(result.tools, "server.info")).not.toBeUndefined()
expect(toolAt(result.tools, "session.get")).not.toBeUndefined()
expect(toolAt(result.tools, "session.create")).not.toBeUndefined()
@@ -978,10 +978,10 @@ describe("OpenAPI.fromSpec", () => {
expect(spec.security).toStrictEqual([])
expect(isRecord(components.securitySchemes) ? Object.keys(components.securitySchemes) : []).toStrictEqual([])
const status = toolAt(result.tools, "server.status")
const statusInput = Tool.isTool(status) && isRecord(status.input) ? status.input : undefined
expect(statusInput).toMatchObject({ type: "object", properties: {} })
const input = isRecord(statusInput) ? statusInput : {}
const info = toolAt(result.tools, "server.info")
const infoInput = Tool.isTool(info) && isRecord(info.input) ? info.input : undefined
expect(infoInput).toMatchObject({ type: "object", properties: {} })
const input = isRecord(infoInput) ? infoInput : {}
expect(Object.keys(isRecord(input.properties) ? input.properties : {})).toStrictEqual([])
})
@@ -994,7 +994,7 @@ describe("OpenAPI.fromSpec", () => {
runtime
.execute(
`
return search({ query: "server status", namespace: "opencode", limit: 1 })
return search({ query: "server info", namespace: "opencode", limit: 1 })
`,
)
.pipe(Effect.provide(layer)),
@@ -1005,8 +1005,8 @@ describe("OpenAPI.fromSpec", () => {
expect(result.value).toMatchObject({
items: [
{
path: "tools.opencode.server.status",
description: "Return the server identity, connection URLs, and readiness status.",
path: "tools.opencode.server.info",
description: "Return the server identity, connection URLs, paths, and readiness status.",
},
],
})
+2 -2
View File
@@ -194,7 +194,7 @@ HTTP method and path or an OpenAPI operation ID.
Call an endpoint with an HTTP method and path:
```sh
opencode api get /api/status
opencode api get /api/info
```
Pass a request body with `--data` or `-d`, and additional headers with
@@ -245,7 +245,7 @@ OpenCode runs a client and a background server. Start by determining whether a
problem belongs to the client, the shared server, or one project.
- Check the service with `opencode service status` and verify the API with
`opencode api get /api/status`.
`opencode api get /api/info`.
- Compare with `opencode --standalone`, which runs the TUI with a private
server, to isolate shared-service issues.
- Inspect `~/.local/share/opencode/log/opencode.log`. Filter `role=cli` for
+1 -1
View File
@@ -6,7 +6,7 @@ export async function checkHealth(url: string, password?: string | null): Promis
}
try {
const res = await fetch(new URL("/api/status", url), {
const res = await fetch(new URL("/api/info", url), {
method: "GET",
headers,
signal: AbortSignal.timeout(3000),
+1 -1
View File
@@ -354,7 +354,7 @@ const waitReady = Effect.fn("Ssh.waitReady")(function* (http: SshHttp, authentic
const checkHealth = Effect.fn("Ssh.checkHealth")(function* (http: SshHttp) {
const client = yield* HttpClient.HttpClient
return yield* client
.get(`${http.url}/api/status`, {
.get(`${http.url}/api/info`, {
headers: { authorization: `Basic ${Buffer.from(`opencode:${http.password}`).toString("base64")}` },
})
.pipe(
+63 -8
View File
@@ -6,19 +6,19 @@
"description": "Experimental HttpApi surface for selected instance routes."
},
"paths": {
"/api/status": {
"/api/info": {
"get": {
"tags": ["server"],
"operationId": "server.status",
"operationId": "server.info",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "ServerStatus",
"description": "ServerInfo",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServerStatus"
"$ref": "#/components/schemas/ServerInfo"
}
}
}
@@ -44,8 +44,8 @@
}
}
},
"description": "Return the server identity, connection URLs, and readiness status.",
"summary": "Get server status"
"description": "Return the server identity, connection URLs, paths, and readiness status.",
"summary": "Get server info"
}
},
"/api/location": {
@@ -121,6 +121,51 @@
"summary": "Get location"
}
},
"/api/location/reload": {
"post": {
"tags": ["location"],
"operationId": "location.reload",
"parameters": [],
"security": [],
"responses": {
"204": {
"description": "<No Content>"
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"503": {
"description": "ServiceUnavailableError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceUnavailableErrorEncoded"
}
}
}
}
},
"description": "Shut down and rebuild every loaded location. Pending permissions and forms are cancelled; running sessions continue with fresh services at the next step boundary. Emits location.shutdown for client recovery and responds once all replacement builds settle.",
"summary": "Reload locations"
}
},
"/api/agent": {
"get": {
"tags": ["agent"],
@@ -16554,7 +16599,7 @@
"required": ["_tag", "type", "message"],
"additionalProperties": false
},
"ServerStatus": {
"ServerInfo": {
"type": "object",
"properties": {
"version": {
@@ -16569,9 +16614,19 @@
"items": {
"type": "string"
}
},
"paths": {
"type": "object",
"properties": {
"tmp": {
"type": "string"
}
},
"required": ["tmp"],
"additionalProperties": false
}
},
"required": ["version", "pid", "urls"],
"required": ["version", "pid", "urls", "paths"],
"additionalProperties": false
},
"ServiceUnavailableErrorEncoded": {
+11 -8
View File
@@ -1,23 +1,26 @@
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
export const ServerStatus = Schema.Struct({
export const ServerInfo = Schema.Struct({
version: Schema.String,
// 0 means the runtime has no OS process identity (e.g. workerd).
pid: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
urls: Schema.Array(Schema.String),
}).annotate({ identifier: "ServerStatus" })
export type ServerStatus = typeof ServerStatus.Type
paths: Schema.Struct({
tmp: Schema.String,
}),
}).annotate({ identifier: "ServerInfo" })
export type ServerInfo = typeof ServerInfo.Type
export const ServerGroup = HttpApiGroup.make("server.server")
.add(
HttpApiEndpoint.get("server.status", "/api/status", {
success: ServerStatus,
HttpApiEndpoint.get("server.info", "/api/info", {
success: ServerInfo,
}).annotateMerge(
OpenApi.annotations({
identifier: "server.status",
summary: "Get server status",
description: "Return the server identity, connection URLs, and readiness status.",
identifier: "server.info",
summary: "Get server info",
description: "Return the server identity, connection URLs, paths, and readiness status.",
}),
),
)
+1 -1
View File
@@ -98,7 +98,7 @@ export class OpenCodeDO {
async fetch() {
const opencode = await this.opencode
return Response.json(await opencode.server.status())
return Response.json(await opencode.server.info())
}
}
```
+1 -1
View File
@@ -150,7 +150,7 @@ export class OpenCodeDO {
if (this.configurations !== 1 || admitted.some(item => item.payload.text !== "Packed prompt:packed-thread")) {
throw new Error("Packed instance configuration did not share or prepare prompts correctly")
}
return Response.json(await opencode.server.status())
return Response.json(await opencode.server.info())
}
}
+2 -2
View File
@@ -35,7 +35,7 @@ for (const entrypoint of ["create", "layer"] as const) {
).pipe(Effect.provideService(FetchHttpClient.Fetch, ambient))
yield* Effect.gen(function* () {
expect(yield* client.server.status()).toMatchObject({ version: "transport-test" })
expect(yield* client.server.info()).toMatchObject({ version: "transport-test" })
const session = yield* client.sessions.create({
location: Location.Ref.make({ directory: AbsolutePath.make(directory.path) }),
})
@@ -56,7 +56,7 @@ for (const entrypoint of ["create", "layer"] as const) {
yield* Scope.close(scope, Exit.void)
expect(
Exit.isFailure(
yield* client.server.status().pipe(Effect.provideService(FetchHttpClient.Fetch, ambient), Effect.exit),
yield* client.server.info().pipe(Effect.provideService(FetchHttpClient.Fetch, ambient), Effect.exit),
),
).toBe(true)
expect(calls).toEqual([])
+2 -1
View File
@@ -4,13 +4,14 @@ import { Api } from "../api"
import { ServerInfo } from "../server-info"
export const ServerHandler = HttpApiBuilder.group(Api, "server.server", (handlers) =>
handlers.handle("server.status", () =>
handlers.handle("server.info", () =>
Effect.gen(function* () {
const info = yield* ServerInfo.Service
return {
version: info.app.version ?? "unknown",
pid: process.pid ?? 0,
urls: info.urls(),
paths: info.paths,
}
}),
),
+10 -7
View File
@@ -6,6 +6,7 @@ import { SessionRestart } from "@opencode/core/session/execution/restart"
import { InstallationEvent } from "@opencode/schema/installation-event"
import { hasPtyConnectTicketURL } from "@opencode/protocol/groups/pty"
import { hasPersistentPtyConnectTicketURL } from "@opencode/protocol/groups/persistent-pty"
import { Global } from "@opencode/util/global"
import { Cause, Context, Effect, Exit, Latch, Layer, Option, Ref, Scope } from "effect"
import {
HttpMiddleware,
@@ -70,7 +71,7 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
// Request fibers may continue inbound trace context, but must not inherit the server startup parent.
yield* bound.http
.serve(
dispatch(password, status, application, options.app?.version ?? "unknown", urls).pipe(
dispatch(password, status, application, options.app?.version ?? "unknown", urls, Global.Path.tmp).pipe(
HttpMiddleware.cors({ allowedOrigins: (origin) => isAllowedCorsOrigin(origin, options), maxAge: 86_400 }),
),
errorResponseLogger,
@@ -182,18 +183,19 @@ function dispatch(
application: Ref.Ref<Option.Option<App>>,
version: string,
urls: () => ReadonlyArray<string>,
tmp: string,
): App {
const auth = ServerAuth.Config.of({ password: Option.some(password), username: "opencode" })
return Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const url = new URL(request.url, "http://localhost")
if (request.method === "GET" && url.pathname === "/api/status") {
if (!(yield* authorizedRequest(request, auth))) return unauthorized()
return yield* statusResponse(status, version, urls)
}
const state = yield* status.current
const app = yield* Ref.get(application)
const ready = state.type === "ready" && Option.isSome(app)
if (request.method === "GET" && url.pathname === "/api/info" && !ready) {
if (!(yield* authorizedRequest(request, auth))) return unauthorized()
return yield* infoResponse(status, version, urls, tmp)
}
if (
(!ready || (!hasPtyConnectTicketURL(url) && !hasPersistentPtyConnectTicketURL(url))) &&
!(yield* authorizedRequest(request, auth))
@@ -211,14 +213,15 @@ function unauthorized() {
})
}
const statusResponse = Effect.fnUntraced(function* (
const infoResponse = Effect.fnUntraced(function* (
status: Status.Interface,
version: string,
urls: () => ReadonlyArray<string>,
tmp: string,
) {
const state = yield* status.current
return HttpServerResponse.jsonUnsafe(
{ version, pid: process.pid, urls: urls() },
{ version, pid: process.pid, urls: urls(), paths: { tmp } },
{
status: state.type === "ready" ? 200 : state.type === "failed" ? 500 : 503,
headers: state.type === "starting" || state.type === "stopping" ? { "retry-after": "1" } : undefined,
+1 -1
View File
@@ -169,7 +169,7 @@ function makeRoutes<AuthError, AuthServices>(
WellKnown.Service,
)(context),
),
ServerInfo.layer(serviceURLs, options.app),
ServerInfo.layer(serviceURLs, Context.get(context, Global.Service).tmp, options.app),
)
const api = HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(
Layer.provide(handlers.pipe(Layer.provide(services), Layer.provide(Layer.succeed(CorsConfig, options)))),
+7 -3
View File
@@ -4,11 +4,15 @@ import type { ServerOptions } from "./options"
export class Service extends Context.Service<
Service,
{ readonly urls: () => ReadonlyArray<string>; readonly app: NonNullable<ServerOptions["app"]> }
{
readonly urls: () => ReadonlyArray<string>
readonly app: NonNullable<ServerOptions["app"]>
readonly paths: { readonly tmp: string }
}
>()("@opencode/server/ServerInfo") {}
export function layer(urls: () => ReadonlyArray<string>, app: ServerOptions["app"] = {}) {
return Layer.succeed(Service, Service.of({ urls, app }))
export function layer(urls: () => ReadonlyArray<string>, tmp: string, app: ServerOptions["app"] = {}) {
return Layer.succeed(Service, Service.of({ urls, app, paths: { tmp } }))
}
export function connectionURLs(value: string, requestedHostname?: string) {
+11 -10
View File
@@ -4,7 +4,7 @@ import { createServer } from "node:http"
import path from "node:path"
import { Agent } from "@opencode/schema/agent"
import { Integration } from "@opencode/schema/integration"
import { ServerStatus } from "@opencode/protocol/groups/server"
import { ServerInfo } from "@opencode/protocol/groups/server"
import { Effect, Schedule, Schema } from "effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
@@ -94,19 +94,20 @@ it.live("serves the HttpApi and enforces Basic auth like the Node server", () =>
Effect.gen(function* () {
const handler = yield* ServerFetch.make({ ...options, password: "secret" })
const denied = yield* Effect.promise(() => handler(new Request("http://opencode.local/api/status")))
const denied = yield* Effect.promise(() => handler(new Request("http://opencode.local/api/info")))
expect(denied.status).toBe(401)
const response = yield* Effect.promise(() =>
handler(
new Request("http://opencode.local/api/status", {
new Request("http://opencode.local/api/info", {
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
}),
),
)
expect(response.status).toBe(200)
const body = yield* Effect.promise(() => response.json()).pipe(Effect.flatMap(Schema.decodeUnknownEffect(ServerStatus)))
const body = yield* Effect.promise(() => response.json()).pipe(Effect.flatMap(Schema.decodeUnknownEffect(ServerInfo)))
expect(body.version).toBe("test-version")
expect(body.paths.tmp).toEndWith("opencode")
}),
)
@@ -124,12 +125,12 @@ it.live("serves unauthenticated and answers CORS preflight when no password is c
Effect.gen(function* () {
const handler = yield* ServerFetch.make(options)
const response = yield* Effect.promise(() => handler(new Request("http://opencode.local/api/status")))
const response = yield* Effect.promise(() => handler(new Request("http://opencode.local/api/info")))
expect(response.status).toBe(200)
const preflight = yield* Effect.promise(() =>
handler(
new Request("http://opencode.local/api/status", {
new Request("http://opencode.local/api/info", {
method: "OPTIONS",
headers: {
origin: "http://localhost:3000",
@@ -156,7 +157,7 @@ it.live("applies custom CORS origins to HTTP responses and PTY ticket checks", (
const allowed = origin !== "https://untrusted.example.com"
const preflight = yield* Effect.promise(() =>
handler(
new Request("http://opencode.local/api/status", {
new Request("http://opencode.local/api/info", {
method: "OPTIONS",
headers: {
origin,
@@ -172,7 +173,7 @@ it.live("applies custom CORS origins to HTTP responses and PTY ticket checks", (
const response = yield* Effect.promise(() =>
handler(
new Request("http://opencode.local/api/status", {
new Request("http://opencode.local/api/info", {
headers: { origin, authorization: `Basic ${btoa("opencode:secret")}` },
}),
),
@@ -465,7 +466,7 @@ it.live("stays serviceable when the first request aborts", () =>
const aborted = yield* Effect.promise(() => {
const controller = new AbortController()
const first = handler(new Request("http://opencode.local/api/status", { signal: controller.signal }))
const first = handler(new Request("http://opencode.local/api/info", { signal: controller.signal }))
controller.abort()
return first.then(
() => "resolved" as const,
@@ -474,7 +475,7 @@ it.live("stays serviceable when the first request aborts", () =>
})
expect(["resolved", "rejected"]).toContain(aborted)
const second = yield* Effect.promise(() => handler(new Request("http://opencode.local/api/status")))
const second = yield* Effect.promise(() => handler(new Request("http://opencode.local/api/info")))
expect(second.status).toBe(200)
}),
)
+5 -5
View File
@@ -26,7 +26,7 @@ it.live("authenticates API and frontend requests while allowing browser prefligh
),
)
const response = yield* Effect.promise(() =>
fetch(new URL("/api/status", HttpServer.formatAddress(server.address)), {
fetch(new URL("/api/info", HttpServer.formatAddress(server.address)), {
method: "OPTIONS",
headers: {
origin: "http://localhost:3000",
@@ -41,7 +41,7 @@ it.live("authenticates API and frontend requests while allowing browser prefligh
expect(response.headers.get("access-control-allow-headers")).toBe("authorization")
const status = yield* Effect.promise(() =>
fetch(new URL("/api/status", HttpServer.formatAddress(server.address)), {
fetch(new URL("/api/info", HttpServer.formatAddress(server.address)), {
headers: {
authorization: `Basic ${btoa("opencode:secret")}`,
origin: "http://localhost:3000",
@@ -59,7 +59,7 @@ it.live("authenticates API and frontend requests while allowing browser prefligh
Effect.gen(function* () {
const allowed = origin === "https://untrusted.example.com" ? null : origin
const preflight = yield* Effect.promise(() =>
fetch(new URL("/api/status", HttpServer.formatAddress(server.address)), {
fetch(new URL("/api/info", HttpServer.formatAddress(server.address)), {
method: "OPTIONS",
headers: {
origin,
@@ -72,7 +72,7 @@ it.live("authenticates API and frontend requests while allowing browser prefligh
expect(preflight.headers.get("access-control-allow-origin")).toBe(allowed)
const status = yield* Effect.promise(() =>
fetch(new URL("/api/status", HttpServer.formatAddress(server.address)), {
fetch(new URL("/api/info", HttpServer.formatAddress(server.address)), {
headers: { origin, authorization: `Basic ${btoa("opencode:secret")}` },
}),
)
@@ -81,7 +81,7 @@ it.live("authenticates API and frontend requests while allowing browser prefligh
yield* Effect.promise(() => status.arrayBuffer())
const denied = yield* Effect.promise(() =>
fetch(new URL("/api/status", HttpServer.formatAddress(server.address)), { headers: { origin } }),
fetch(new URL("/api/info", HttpServer.formatAddress(server.address)), { headers: { origin } }),
)
expect(denied.status).toBe(401)
expect(denied.headers.get("access-control-allow-origin")).toBe(allowed)
+2 -2
View File
@@ -15,12 +15,12 @@ it.live("boots the workerd profile over durable object storage", () =>
config: { content: "{}" },
})
const unauthorized = yield* Effect.promise(() => handler(new Request("http://opencode.local/api/status")))
const unauthorized = yield* Effect.promise(() => handler(new Request("http://opencode.local/api/info")))
expect(unauthorized.status).toBe(401)
const status = yield* Effect.promise(() =>
handler(
new Request("http://opencode.local/api/status", {
new Request("http://opencode.local/api/info", {
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
}),
),
+3 -3
View File
@@ -54,10 +54,10 @@ export function DevToolsBar() {
)
const groups = createMemo(() => DevTools.data().filter((group) => group.id !== "theme-performance"))
const [server] = createResource(connected, async () => {
const status = await client.api.server.status()
const info = await client.api.server.info()
return {
health: status,
address: status.urls[0] ? new URL(status.urls[0]).host : "Unknown",
health: info,
address: info.urls[0] ? new URL(info.urls[0]).host : "Unknown",
}
})
const close = () => {
+1 -1
View File
@@ -26,7 +26,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
dialog.setCentered(true)
const [server] = createResource(() =>
client.api.server.status().catch((error) => {
client.api.server.info().catch((error) => {
setLoadError(error)
return undefined
}),
+63 -8
View File
@@ -6,19 +6,19 @@
"description": "Experimental HttpApi surface for selected instance routes."
},
"paths": {
"/api/status": {
"/api/info": {
"get": {
"tags": ["server"],
"operationId": "server.status",
"operationId": "server.info",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "ServerStatus",
"description": "ServerInfo",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServerStatus"
"$ref": "#/components/schemas/ServerInfo"
}
}
}
@@ -44,8 +44,8 @@
}
}
},
"description": "Return the server identity, connection URLs, and readiness status.",
"summary": "Get server status"
"description": "Return the server identity, connection URLs, paths, and readiness status.",
"summary": "Get server info"
}
},
"/api/location": {
@@ -121,6 +121,51 @@
"summary": "Get location"
}
},
"/api/location/reload": {
"post": {
"tags": ["location"],
"operationId": "location.reload",
"parameters": [],
"security": [],
"responses": {
"204": {
"description": "<No Content>"
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"503": {
"description": "ServiceUnavailableError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceUnavailableErrorEncoded"
}
}
}
}
},
"description": "Shut down and rebuild every loaded location. Pending permissions and forms are cancelled; running sessions continue with fresh services at the next step boundary. Emits location.shutdown for client recovery and responds once all replacement builds settle.",
"summary": "Reload locations"
}
},
"/api/agent": {
"get": {
"tags": ["agent"],
@@ -16554,7 +16599,7 @@
"required": ["_tag", "type", "message"],
"additionalProperties": false
},
"ServerStatus": {
"ServerInfo": {
"type": "object",
"properties": {
"version": {
@@ -16569,9 +16614,19 @@
"items": {
"type": "string"
}
},
"paths": {
"type": "object",
"properties": {
"tmp": {
"type": "string"
}
},
"required": ["tmp"],
"additionalProperties": false
}
},
"required": ["version", "pid", "urls"],
"required": ["version", "pid", "urls", "paths"],
"additionalProperties": false
},
"ServiceUnavailableErrorEncoded": {
+63 -8
View File
@@ -6,19 +6,19 @@
"description": "Experimental HttpApi surface for selected instance routes."
},
"paths": {
"/api/status": {
"/api/info": {
"get": {
"tags": ["server"],
"operationId": "server.status",
"operationId": "server.info",
"parameters": [],
"security": [],
"responses": {
"200": {
"description": "ServerStatus",
"description": "ServerInfo",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServerStatus"
"$ref": "#/components/schemas/ServerInfo"
}
}
}
@@ -44,8 +44,8 @@
}
}
},
"description": "Return the server identity, connection URLs, and readiness status.",
"summary": "Get server status"
"description": "Return the server identity, connection URLs, paths, and readiness status.",
"summary": "Get server info"
}
},
"/api/location": {
@@ -121,6 +121,51 @@
"summary": "Get location"
}
},
"/api/location/reload": {
"post": {
"tags": ["location"],
"operationId": "location.reload",
"parameters": [],
"security": [],
"responses": {
"204": {
"description": "<No Content>"
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
},
"503": {
"description": "ServiceUnavailableError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServiceUnavailableErrorEncoded"
}
}
}
}
},
"description": "Shut down and rebuild every loaded location. Pending permissions and forms are cancelled; running sessions continue with fresh services at the next step boundary. Emits location.shutdown for client recovery and responds once all replacement builds settle.",
"summary": "Reload locations"
}
},
"/api/agent": {
"get": {
"tags": ["agent"],
@@ -16554,7 +16599,7 @@
"required": ["_tag", "type", "message"],
"additionalProperties": false
},
"ServerStatus": {
"ServerInfo": {
"type": "object",
"properties": {
"version": {
@@ -16569,9 +16614,19 @@
"items": {
"type": "string"
}
},
"paths": {
"type": "object",
"properties": {
"tmp": {
"type": "string"
}
},
"required": ["tmp"],
"additionalProperties": false
}
},
"required": ["version", "pid", "urls"],
"required": ["version", "pid", "urls", "paths"],
"additionalProperties": false
},
"ServiceUnavailableErrorEncoded": {
@@ -134,7 +134,7 @@ const program = Effect.gen(function* () {
HttpClient.mapRequest(httpClient, (request) => HttpClientRequest.setHeaders(request, Service.headers(endpoint))),
),
)
return yield* client.server.status()
return yield* client.server.info()
})
const health = await Effect.runPromise(
@@ -156,7 +156,7 @@ const client = OpenCode.make({
headers: Service.headers(endpoint),
})
const status = await client.server.status()
const info = await client.server.info()
```
`Service.ensure()` accepts an optional registration file, version, service
@@ -30,7 +30,7 @@ export class OpenCodeDO {
async fetch() {
const opencode = await this.opencode
return Response.json(await opencode.server.status())
return Response.json(await opencode.server.info())
}
}
```
@@ -38,7 +38,7 @@ The embedded host uses the same schema values, declared errors, request options,
`@opencode/client/effect`.
```ts
const status = yield* opencode.server.status()
const info = yield* opencode.server.info()
const sessions = yield* opencode.sessions.list()
```
@@ -94,7 +94,7 @@ import { Effect } from "effect"
const program = Effect.gen(function* () {
const opencode = yield* OpenCode.Service
return yield* opencode.server.status()
return yield* opencode.server.info()
})
const health = await Effect.runPromise(program.pipe(Effect.provide(OpenCode.layer())))
+1 -1
View File
@@ -97,7 +97,7 @@ opencode serve
Check that the CLI can reach the background service after applying the settings:
```bash
opencode api get /api/status
opencode api get /api/info
```
A successful health response verifies the local client-to-service connection. Run a normal provider request to verify the
@@ -12,7 +12,7 @@ state. Start by determining whether an issue is in a client, the shared server,
## Check the background service
Show the current server status:
Show the current server information:
```bash
opencode service status
@@ -21,7 +21,7 @@ opencode service status
Verify that its API is healthy:
```bash
opencode api get /api/status
opencode api get /api/info
```
If the service is stuck or unhealthy, restart it:
@@ -117,7 +117,7 @@ On macOS and Linux, you can signal a running OpenCode process to capture diagnos
from the health endpoint:
```bash
opencode api get /api/status
opencode api get /api/info
```
Use the `pid` from the response with one of these signals: