From ab3566ab82f18ef192f942a07f46a3df53ddc30a Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 16 Sep 2026 21:43:58 -0400 Subject: [PATCH] feat(server): expose server info endpoint --- V2_HTTP_API_AUDIT.md | 4 +- .../regression/cross-server-tab-close.spec.ts | 3 +- .../regression/server-dialog-focus.spec.ts | 9 ++- .../regression/session-summary-mcp.spec.ts | 9 ++- .../session-timeline-transport.spec.ts | 4 +- packages/app/e2e/service-worker/cache.spec.ts | 13 ++-- packages/app/e2e/utils/mock-api.ts | 2 +- packages/app/e2e/utils/mock-server.ts | 8 ++- .../app/src/runtime/server/client.test.ts | 15 ++-- .../app/src/runtime/server/health.test.ts | 44 ++++++++---- packages/app/src/runtime/server/health.ts | 2 +- .../src/runtime/server/request-queue.test.ts | 4 +- packages/cli/script/service-smoke.ts | 18 ++--- packages/cli/src/commands/handlers/pair.ts | 2 +- .../cli/src/services/server-connection.ts | 2 +- packages/cli/test/auth.test.ts | 4 +- packages/cli/test/debug-config.test.ts | 9 ++- packages/cli/test/fixture/standalone-owner.ts | 2 +- packages/cli/test/import-export.test.ts | 13 ++-- packages/cli/test/mini.test.ts | 32 +++++++-- packages/cli/test/service.test.ts | 12 ++-- packages/cli/test/web-ui.test.ts | 18 +++-- packages/client/src/effect/api/api.ts | 7 +- .../client/src/effect/generated/client.ts | 8 +-- packages/client/src/effect/service.ts | 16 ++--- .../client/src/promise/generated/client.ts | 8 +-- .../client/src/promise/generated/types.ts | 4 +- packages/client/src/promise/service.ts | 17 ++--- packages/client/test/effect.test.ts | 18 +++-- packages/client/test/fixture/service.ts | 19 +++-- packages/client/test/promise.test.ts | 32 ++++++--- packages/client/test/service.test.ts | 2 +- packages/codemode/test/openapi.test.ts | 16 ++--- packages/core/src/plugin/skill/opencode.md | 4 +- packages/desktop/src/main/service/health.ts | 2 +- packages/desktop/src/main/ssh/controller.ts | 2 +- packages/protocol/openapi.json | 71 ++++++++++++++++--- packages/protocol/src/groups/server.ts | 19 ++--- packages/sdk/README.md | 2 +- packages/sdk/script/verify-package.ts | 2 +- packages/sdk/test/transport.test.ts | 4 +- packages/server/src/handlers/server.ts | 3 +- packages/server/src/process.ts | 17 +++-- packages/server/src/routes.ts | 2 +- packages/server/src/server-info.ts | 10 ++- packages/server/test/fetch.test.ts | 21 +++--- packages/server/test/process.test.ts | 10 +-- packages/server/test/workerd.test.ts | 4 +- packages/tui/src/component/devtools-bar.tsx | 6 +- packages/tui/src/component/dialog-pair.tsx | 2 +- services/www/openapi.json | 71 ++++++++++++++++--- services/www/public/openapi.json | 71 ++++++++++++++++--- .../src/docs/content/build/client/effect.mdx | 2 +- .../src/docs/content/build/client/index.mdx | 2 +- .../src/docs/content/build/sdk/cloudflare.mdx | 2 +- .../www/src/docs/content/build/sdk/effect.mdx | 4 +- services/www/src/docs/content/network.mdx | 2 +- .../www/src/docs/content/troubleshooting.mdx | 6 +- 58 files changed, 508 insertions(+), 209 deletions(-) diff --git a/V2_HTTP_API_AUDIT.md b/V2_HTTP_API_AUDIT.md index af80ad26953..ab850f81f39 100644 --- a/V2_HTTP_API_AUDIT.md +++ b/V2_HTTP_API_AUDIT.md @@ -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. | diff --git a/packages/app/e2e/regression/cross-server-tab-close.spec.ts b/packages/app/e2e/regression/cross-server-tab-close.spec.ts index cd23e9b18c7..ed6dc99b437 100644 --- a/packages/app/e2e/regression/cross-server-tab-close.spec.ts +++ b/packages/app/e2e/regression/cross-server-tab-close.spec.ts @@ -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) }) diff --git a/packages/app/e2e/regression/server-dialog-focus.spec.ts b/packages/app/e2e/regression/server-dialog-focus.spec.ts index cae31720785..4f469bca15d 100644 --- a/packages/app/e2e/regression/server-dialog-focus.spec.ts +++ b/packages/app/e2e/regression/server-dialog-focus.spec.ts @@ -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, {}) }) diff --git a/packages/app/e2e/regression/session-summary-mcp.spec.ts b/packages/app/e2e/regression/session-summary-mcp.spec.ts index 1470e1e7ea4..b474fcbe775 100644 --- a/packages/app/e2e/regression/session-summary-mcp.spec.ts +++ b/packages/app/e2e/regression/session-summary-mcp.spec.ts @@ -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 }) => { diff --git a/packages/app/e2e/regression/session-timeline-transport.spec.ts b/packages/app/e2e/regression/session-timeline-transport.spec.ts index 3cbd6fc210e..98906fa1e14 100644 --- a/packages/app/e2e/regression/session-timeline-transport.spec.ts +++ b/packages/app/e2e/regression/session-timeline-transport.spec.ts @@ -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) }) diff --git a/packages/app/e2e/service-worker/cache.spec.ts b/packages/app/e2e/service-worker/cache.spec.ts index 445981bf27b..17136a7ccce 100644 --- a/packages/app/e2e/service-worker/cache.spec.ts +++ b/packages/app/e2e/service-worker/cache.spec.ts @@ -81,10 +81,10 @@ const fixture = test.extend<{ site: Site }, { builds: RecordWorker observer") - 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) diff --git a/packages/app/e2e/utils/mock-api.ts b/packages/app/e2e/utils/mock-api.ts index f4b009dbbad..d51f96264ca 100644 --- a/packages/app/e2e/utils/mock-api.ts +++ b/packages/app/e2e/utils/mock-api.ts @@ -33,7 +33,7 @@ export class MockBadRequest extends Schema.TaggedError()("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" })), diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 6e5983f8bde..1612ca3583b 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -219,7 +219,13 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map 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({ diff --git a/packages/app/src/runtime/server/client.test.ts b/packages/app/src/runtime/server/client.test.ts index aecfeb49bf6..dac4167d830 100644 --- a/packages/app/src/runtime/server/client.test.ts +++ b/packages/app/src/runtime/server/client.test.ts @@ -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")}`, }, ]) diff --git a/packages/app/src/runtime/server/health.test.ts b/packages/app/src/runtime/server/health.test.ts index 9f145285ab5..cf81cbe9a43 100644 --- a/packages/app/src/runtime/server/health.test.ts +++ b/packages/app/src/runtime/server/health.test.ts @@ -17,7 +17,12 @@ describe("checkServerHealth", () => { const headers: Array = [] 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, { diff --git a/packages/app/src/runtime/server/health.ts b/packages/app/src/runtime/server/health.ts index df691acfd66..4a994fa785c 100644 --- a/packages/app/src/runtime/server/health.ts +++ b/packages/app/src/runtime/server/health.ts @@ -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 diff --git a/packages/app/src/runtime/server/request-queue.test.ts b/packages/app/src/runtime/server/request-queue.test.ts index 78f419d984b..291685dfaa0 100644 --- a/packages/app/src/runtime/server/request-queue.test.ts +++ b/packages/app/src/runtime/server/request-queue.test.ts @@ -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 }, ], }, }, diff --git a/packages/cli/script/service-smoke.ts b/packages/cli/script/service-smoke.ts index ec5e3a97612..faadf827768 100644 --- a/packages/cli/script/service-smoke.ts +++ b/packages/cli/script/service-smoke.ts @@ -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") diff --git a/packages/cli/src/commands/handlers/pair.ts b/packages/cli/src/commands/handlers/pair.ts index 524827c30f3..46b0dea87f9 100644 --- a/packages/cli/src/commands/handlers/pair.ts +++ b/packages/cli/src/commands/handlers/pair.ts @@ -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( diff --git a/packages/cli/src/services/server-connection.ts b/packages/cli/src/services/server-connection.ts index 8efc71e6890..5d2619ad424 100644 --- a/packages/cli/src/services/server-connection.ts +++ b/packages/cli/src/services/server-connection.ts @@ -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) diff --git a/packages/cli/test/auth.test.ts b/packages/cli/test/auth.test.ts index 450b4dedb0b..6cc6fea3892 100644 --- a/packages/cli/test/auth.test.ts +++ b/packages/cli/test/auth.test.ts @@ -272,7 +272,7 @@ function authServer(fetch: (request: Request, url: URL) => Response | Promise Response | Promise(data: T) { diff --git a/packages/cli/test/debug-config.test.ts b/packages/cli/test/debug-config.test.ts index 6a8d26d7715..f562836ab5d 100644 --- a/packages/cli/test/debug-config.test.ts +++ b/packages/cli/test/debug-config.test.ts @@ -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")) diff --git a/packages/cli/test/fixture/standalone-owner.ts b/packages/cli/test/fixture/standalone-owner.ts index 3053236932b..00aade1af94 100644 --- a/packages/cli/test/fixture/standalone-owner.ts +++ b/packages/cli/test/fixture/standalone-owner.ts @@ -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 diff --git a/packages/cli/test/import-export.test.ts b/packages/cli/test/import-export.test.ts index 2fc928e85c3..141c70ede0f 100644 --- a/packages/cli/test/import-export.test.ts +++ b/packages/cli/test/import-export.test.ts @@ -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, diff --git a/packages/cli/test/mini.test.ts b/packages/cli/test/mini.test.ts index 51f8d113601..af7aa8b6f08 100644 --- a/packages/cli/test/mini.test.ts +++ b/packages/cli/test/mini.test.ts @@ -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" }, + }) }, }) diff --git a/packages/cli/test/service.test.ts b/packages/cli/test/service.test.ts index ec0cf30170e..9a014136715 100644 --- a/packages/cli/test/service.test.ts +++ b/packages/cli/test/service.test.ts @@ -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) diff --git a/packages/cli/test/web-ui.test.ts b/packages/cli/test/web-ui.test.ts index 1d15d42b999..fa0e7270761 100644 --- a/packages/cli/test/web-ui.test.ts +++ b/packages/cli/test/web-ui.test.ts @@ -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) diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 8bf6d04e4b1..d407bb28093 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -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 + readonly paths: { readonly tmp: string } } -export type ServerStatusOperation = () => Effect.Effect +export type ServerInfoOperation = () => Effect.Effect export interface ServerApi { - readonly status: ServerStatusOperation + readonly info: ServerInfoOperation } export type LocationGetInput = { readonly location?: { readonly directory?: string | undefined } | undefined } diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index 816c6a48f4a..269d81c7ecb 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -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 = (stream: Stream.Stream) => stream -const EndpointServerStatus = (raw: RawClient["server.server"]) => () => - preserveEffect()(raw["server.status"]({}).pipe(Effect.mapError(mapClientError))) +const EndpointServerInfo = (raw: RawClient["server.server"]) => () => + preserveEffect()(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()( diff --git a/packages/client/src/effect/service.ts b/packages/client/src/effect/service.ts index ea0f5352f5e..d0514e3b75a 100644 --- a/packages/client/src/effect/service.ts +++ b/packages/client/src/effect/service.ts @@ -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, diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index a5c6da8922c..8c7eab44afb 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -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( - { method: "GET", path: `/api/status`, successStatus: 200, declaredStatuses: [400, 401], empty: false }, + info: (requestOptions?: RequestOptions) => + request( + { method: "GET", path: `/api/info`, successStatus: 200, declaredStatuses: [400, 401], empty: false }, requestOptions, ), }, diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 928c8ed7215..0cc2152133c 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -1,6 +1,6 @@ export type JsonValue = null | boolean | number | string | Array | { [key: string]: JsonValue } -export type ServerStatus = { version: string; pid: number; urls: Array } +export type ServerInfo = { version: string; pid: number; urls: Array; 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"] diff --git a/packages/client/src/promise/service.ts b/packages/client/src/promise/service.ts index 8d2f5872d2d..c7bbe36d15f 100644 --- a/packages/client/src/promise/service.ts +++ b/packages/client/src/promise/service.ts @@ -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 diff --git a/packages/client/test/effect.test.ts b/packages/client/test/effect.test.ts index ca7a278ce4e..1e5e5b326ae 100644 --- a/packages/client/test/effect.test.ts +++ b/packages/client/test/effect.test.ts @@ -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 () => { diff --git a/packages/client/test/fixture/service.ts b/packages/client/test/fixture/service.ts index 25ca34e1415..bd65e91741e 100644 --- a/packages/client/test/fixture/service.ts +++ b/packages/client/test/fixture/service.ts @@ -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" }, + }) }, }) diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index a2d417d2f69..185fd8a1581 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -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 () => { diff --git a/packages/client/test/service.test.ts b/packages/client/test/service.test.ts index 4c1c938ab87..83c754a50dd 100644 --- a/packages/client/test/service.test.ts +++ b/packages/client/test/service.test.ts @@ -300,5 +300,5 @@ function run(effect: Effect.Effect) { } 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()) } diff --git a/packages/codemode/test/openapi.test.ts b/packages/codemode/test/openapi.test.ts index 17a4236045f..43095eafd7d 100644 --- a/packages/codemode/test/openapi.test.ts +++ b/packages/codemode/test/openapi.test.ts @@ -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.", }, ], }) diff --git a/packages/core/src/plugin/skill/opencode.md b/packages/core/src/plugin/skill/opencode.md index 6ff909142b6..50bcd2c09d4 100644 --- a/packages/core/src/plugin/skill/opencode.md +++ b/packages/core/src/plugin/skill/opencode.md @@ -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 diff --git a/packages/desktop/src/main/service/health.ts b/packages/desktop/src/main/service/health.ts index 596b8a74054..73bdc9bfd2b 100644 --- a/packages/desktop/src/main/service/health.ts +++ b/packages/desktop/src/main/service/health.ts @@ -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), diff --git a/packages/desktop/src/main/ssh/controller.ts b/packages/desktop/src/main/ssh/controller.ts index bc1425d6025..8a15e8d7933 100644 --- a/packages/desktop/src/main/ssh/controller.ts +++ b/packages/desktop/src/main/ssh/controller.ts @@ -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( diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index 77a3c46e502..ecce0d54184 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -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": "" + }, + "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": { diff --git a/packages/protocol/src/groups/server.ts b/packages/protocol/src/groups/server.ts index 06f5d0ab90b..1ccee77b0ad 100644 --- a/packages/protocol/src/groups/server.ts +++ b/packages/protocol/src/groups/server.ts @@ -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.", }), ), ) diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 935c4f27c7f..5bc42ac0331 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -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()) } } ``` diff --git a/packages/sdk/script/verify-package.ts b/packages/sdk/script/verify-package.ts index b5757bfe1c1..021473f4315 100644 --- a/packages/sdk/script/verify-package.ts +++ b/packages/sdk/script/verify-package.ts @@ -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()) } } diff --git a/packages/sdk/test/transport.test.ts b/packages/sdk/test/transport.test.ts index 68dadb83e8a..2da7f7dac4a 100644 --- a/packages/sdk/test/transport.test.ts +++ b/packages/sdk/test/transport.test.ts @@ -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([]) diff --git a/packages/server/src/handlers/server.ts b/packages/server/src/handlers/server.ts index 2cffe46228d..15c6992d916 100644 --- a/packages/server/src/handlers/server.ts +++ b/packages/server/src/handlers/server.ts @@ -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, } }), ), diff --git a/packages/server/src/process.ts b/packages/server/src/process.ts index 814e658ebbc..3a70dbd39bf 100644 --- a/packages/server/src/process.ts +++ b/packages/server/src/process.ts @@ -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* ( // 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>, version: string, urls: () => ReadonlyArray, + 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, + 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, diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 2848b36fa7d..93ddd2e07cf 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -169,7 +169,7 @@ function makeRoutes( 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)))), diff --git a/packages/server/src/server-info.ts b/packages/server/src/server-info.ts index 278de0d8b38..6f47b0f0174 100644 --- a/packages/server/src/server-info.ts +++ b/packages/server/src/server-info.ts @@ -4,11 +4,15 @@ import type { ServerOptions } from "./options" export class Service extends Context.Service< Service, - { readonly urls: () => ReadonlyArray; readonly app: NonNullable } + { + readonly urls: () => ReadonlyArray + readonly app: NonNullable + readonly paths: { readonly tmp: string } + } >()("@opencode/server/ServerInfo") {} -export function layer(urls: () => ReadonlyArray, app: ServerOptions["app"] = {}) { - return Layer.succeed(Service, Service.of({ urls, app })) +export function layer(urls: () => ReadonlyArray, tmp: string, app: ServerOptions["app"] = {}) { + return Layer.succeed(Service, Service.of({ urls, app, paths: { tmp } })) } export function connectionURLs(value: string, requestedHostname?: string) { diff --git a/packages/server/test/fetch.test.ts b/packages/server/test/fetch.test.ts index 459aebe28ce..3bdfa1d1769 100644 --- a/packages/server/test/fetch.test.ts +++ b/packages/server/test/fetch.test.ts @@ -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) }), ) diff --git a/packages/server/test/process.test.ts b/packages/server/test/process.test.ts index d927ad5e699..82720818d3d 100644 --- a/packages/server/test/process.test.ts +++ b/packages/server/test/process.test.ts @@ -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) diff --git a/packages/server/test/workerd.test.ts b/packages/server/test/workerd.test.ts index 08a887869fe..ed2099473c7 100644 --- a/packages/server/test/workerd.test.ts +++ b/packages/server/test/workerd.test.ts @@ -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")}` }, }), ), diff --git a/packages/tui/src/component/devtools-bar.tsx b/packages/tui/src/component/devtools-bar.tsx index 538044397b8..8b6f3efbe90 100644 --- a/packages/tui/src/component/devtools-bar.tsx +++ b/packages/tui/src/component/devtools-bar.tsx @@ -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 = () => { diff --git a/packages/tui/src/component/dialog-pair.tsx b/packages/tui/src/component/dialog-pair.tsx index 508efb21514..5f0aa2569f3 100644 --- a/packages/tui/src/component/dialog-pair.tsx +++ b/packages/tui/src/component/dialog-pair.tsx @@ -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 }), diff --git a/services/www/openapi.json b/services/www/openapi.json index 77a3c46e502..ecce0d54184 100644 --- a/services/www/openapi.json +++ b/services/www/openapi.json @@ -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": "" + }, + "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": { diff --git a/services/www/public/openapi.json b/services/www/public/openapi.json index 77a3c46e502..ecce0d54184 100644 --- a/services/www/public/openapi.json +++ b/services/www/public/openapi.json @@ -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": "" + }, + "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": { diff --git a/services/www/src/docs/content/build/client/effect.mdx b/services/www/src/docs/content/build/client/effect.mdx index 2049d8edd3d..560030d7208 100644 --- a/services/www/src/docs/content/build/client/effect.mdx +++ b/services/www/src/docs/content/build/client/effect.mdx @@ -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( diff --git a/services/www/src/docs/content/build/client/index.mdx b/services/www/src/docs/content/build/client/index.mdx index 1437c82caf0..5634a90868e 100644 --- a/services/www/src/docs/content/build/client/index.mdx +++ b/services/www/src/docs/content/build/client/index.mdx @@ -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 diff --git a/services/www/src/docs/content/build/sdk/cloudflare.mdx b/services/www/src/docs/content/build/sdk/cloudflare.mdx index 095323bec84..94b203b9089 100644 --- a/services/www/src/docs/content/build/sdk/cloudflare.mdx +++ b/services/www/src/docs/content/build/sdk/cloudflare.mdx @@ -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()) } } ``` diff --git a/services/www/src/docs/content/build/sdk/effect.mdx b/services/www/src/docs/content/build/sdk/effect.mdx index 5dd007ce4ca..a91deec1214 100644 --- a/services/www/src/docs/content/build/sdk/effect.mdx +++ b/services/www/src/docs/content/build/sdk/effect.mdx @@ -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()))) diff --git a/services/www/src/docs/content/network.mdx b/services/www/src/docs/content/network.mdx index 21b5248300a..be70545b519 100644 --- a/services/www/src/docs/content/network.mdx +++ b/services/www/src/docs/content/network.mdx @@ -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 diff --git a/services/www/src/docs/content/troubleshooting.mdx b/services/www/src/docs/content/troubleshooting.mdx index 5affcd3c4a5..861582d6a4a 100644 --- a/services/www/src/docs/content/troubleshooting.mdx +++ b/services/www/src/docs/content/troubleshooting.mdx @@ -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: