mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
refactor(protocol): consolidate server status
This commit is contained in:
+10
-5
@@ -1,7 +1,7 @@
|
||||
# V2 HTTP API audit checklist
|
||||
|
||||
**Source:** `packages/protocol/openapi.json`
|
||||
**Current endpoint count:** 143
|
||||
**Current endpoint count:** 142
|
||||
**Last regenerated:** 2026-09-13
|
||||
|
||||
## How to use this checklist
|
||||
@@ -28,7 +28,7 @@ Review endpoints in document order. For each endpoint, select one disposition an
|
||||
|
||||
## Progress
|
||||
|
||||
- [ ] Group 1: Foundation and placement (7)
|
||||
- [ ] Group 1: Foundation and placement (6)
|
||||
- [ ] Group 2: Configuration and capability catalogs (17)
|
||||
- [ ] Group 3: Credentials, integrations, MCP, and web search (22)
|
||||
- [ ] Group 4: Session lifecycle (12)
|
||||
@@ -41,6 +41,12 @@ Review endpoints in document order. For each endpoint, select one disposition an
|
||||
|
||||
## Resolved during audit
|
||||
|
||||
### [x] `GET /api/health` and `GET /api/server`
|
||||
|
||||
- **Decision:** Merge and rename
|
||||
- **Replacement:** `GET /api/status` with operation ID `server.status`.
|
||||
- **Notes:** Returns `version`, `pid`, and connection `urls`; readiness is conveyed by HTTP status.
|
||||
|
||||
### [x] `GET /api/project/current`
|
||||
|
||||
- **Decision:** Remove
|
||||
@@ -49,12 +55,11 @@ Review endpoints in document order. For each endpoint, select one disposition an
|
||||
|
||||
## Group 1: Foundation and placement
|
||||
|
||||
**Endpoints:** 7
|
||||
**Endpoints:** 6
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [x] 001 | `GET` | `/api/health` | `health.get` | Change | Keep path; rename operation to `health.get`; remove redundant `healthy` response field. |
|
||||
| [ ] 002 | `GET` | `/api/server` | `server.get` | | |
|
||||
| [x] 001–002 | `GET` | `/api/status` | `server.status` | Keep | Replaces the former health and server endpoints. |
|
||||
| [ ] 003 | `GET` | `/api/location` | `location.get` | | |
|
||||
| [ ] 004 | `GET` | `/api/project` | `project.list` | | |
|
||||
| [ ] 005 | `PATCH` | `/api/project/{projectID}` | `project.update` | | |
|
||||
|
||||
@@ -62,7 +62,7 @@ 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/health") return json(route, { pid: 1 })
|
||||
if (url.pathname === "/api/status") return json(route, { version: "test", pid: 1, urls: [url.origin] })
|
||||
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,8 @@ 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/global/health" || url.pathname === "/api/health") {
|
||||
return json(route, { healthy: true, version: "2.0.0" })
|
||||
if (url.pathname === "/api/status") {
|
||||
return json(route, { version: "2.0.0", pid: 1, urls: [url.origin] })
|
||||
}
|
||||
return json(route, {})
|
||||
})
|
||||
|
||||
@@ -137,7 +137,9 @@ 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: { healthy: true, version: "2.0.0" } }))
|
||||
await page.route("http://secondary.test/**", (route) =>
|
||||
route.fulfill({ json: { version: "2.0.0", pid: 1, urls: ["http://secondary.test"] } }),
|
||||
)
|
||||
await page.addInitScript(
|
||||
({ directory, server }) => {
|
||||
const current = { type: "http", http: { url: server }, displayName: "Design 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/health")
|
||||
const response = await fetch("/api/status")
|
||||
return response.json()
|
||||
})
|
||||
|
||||
expect(health).toEqual({ healthy: true, version: "2.0.0", pid: 1 })
|
||||
expect(health).toEqual({ version: "2.0.0", pid: 1, urls: ["http://localhost"] })
|
||||
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
|
||||
})
|
||||
|
||||
|
||||
@@ -75,13 +75,16 @@ const fixture = test.extend<{ site: Site }, { builds: Record<string, Record<stri
|
||||
const blocked: ServerResponse[] = []
|
||||
const release = () => blocked.splice(0).forEach((response) => response.end(builds.new["/large.bin"]))
|
||||
const server = createServer((request, response) => {
|
||||
const path = new URL(request.url ?? "/", "http://localhost").pathname
|
||||
const url = new URL(request.url ?? "/", "http://localhost")
|
||||
const path = url.pathname
|
||||
requests.push(path)
|
||||
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/health")
|
||||
return void response.writeHead(200, { "content-type": "application/json" }).end('{"healthy":true}')
|
||||
if (path === "/api/status")
|
||||
return void response
|
||||
.writeHead(200, { "content-type": "application/json" })
|
||||
.end(`{"version":"test","pid":1,"urls":["${url.origin}"]}`)
|
||||
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(
|
||||
@@ -331,8 +334,8 @@ 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/health`)
|
||||
expect(await api?.json()).toEqual({ healthy: true })
|
||||
const api = await page.goto(`${site.url}/api/status`)
|
||||
expect(await api?.json()).toEqual({ version: "test", pid: 1, urls: [site.url] })
|
||||
expect(api?.fromServiceWorker()).toBe(false)
|
||||
const asset = await page.goto(`${site.url}/_assets/missing.js`)
|
||||
expect(asset?.status()).toBe(404)
|
||||
|
||||
@@ -32,7 +32,7 @@ export class MockBadRequest extends Schema.TaggedError<MockBadRequest>()("MockBa
|
||||
}) {}
|
||||
|
||||
const Group = HttpApiGroup.make("mock")
|
||||
.add(HttpApiEndpoint.get("health", "/api/health", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("status", "/api/status", { success: Json }))
|
||||
.add(
|
||||
HttpApiEndpoint.get("event", "/api/event", {
|
||||
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })),
|
||||
|
||||
@@ -219,7 +219,7 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
|
||||
}),
|
||||
)
|
||||
.handleAll({
|
||||
health: () => Effect.succeed({ healthy: true, version: "2.0.0", pid: 1 }),
|
||||
status: () => Effect.succeed({ version: "2.0.0", pid: 1, urls: config.server ? [config.server] : [] }),
|
||||
config: () => Effect.succeed([]),
|
||||
reference: () =>
|
||||
Effect.succeed({
|
||||
@@ -285,12 +285,6 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
|
||||
canonical: project.canonical ?? config.directory,
|
||||
})
|
||||
},
|
||||
projectCurrent: () =>
|
||||
Effect.succeed({
|
||||
id: (config.project as { id?: string }).id,
|
||||
directory: config.directory,
|
||||
canonical: config.directory,
|
||||
}),
|
||||
configPreferences: () => Effect.succeed(preferences.current),
|
||||
configUpdatePreferences: (ctx) =>
|
||||
Effect.sync(() => {
|
||||
|
||||
@@ -90,7 +90,7 @@ 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({ healthy: true, version: "2.0.0-test", pid: 1 })
|
||||
return Response.json({ version: "2.0.0-test", pid: 1, urls: [request.url] })
|
||||
}) as typeof globalThis.fetch
|
||||
const transport = createServerTransport({
|
||||
http: { url: "http://127.0.0.1:4100", password: "first" },
|
||||
@@ -98,23 +98,23 @@ test("rotates HTTP and PTY clients together", async () => {
|
||||
})
|
||||
const initialPty = transport.pty
|
||||
|
||||
await transport.api.health.get()
|
||||
await transport.api.server.status()
|
||||
const replacement = transport.update({
|
||||
url: "http://127.0.0.1:4200",
|
||||
password: "second",
|
||||
})
|
||||
await transport.api.health.get()
|
||||
await transport.api.server.status()
|
||||
|
||||
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/health",
|
||||
url: "http://127.0.0.1:4100/api/status",
|
||||
authorization: `Basic ${btoa("opencode:first")}`,
|
||||
},
|
||||
{
|
||||
url: "http://127.0.0.1:4200/api/health",
|
||||
url: "http://127.0.0.1:4200/api/status",
|
||||
authorization: `Basic ${btoa("opencode:second")}`,
|
||||
},
|
||||
])
|
||||
|
||||
@@ -17,11 +17,10 @@ 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({ healthy: true, version: "2.0.0" })
|
||||
return Response.json({ version: "2.0.0", pid: 1, urls: [server.url] })
|
||||
}) as typeof globalThis.fetch
|
||||
|
||||
const legacy = { ...server, username: "legacy", password }
|
||||
expect(await checkServerHealth(legacy, fetch)).toEqual({ healthy: true, version: "2.0.0" })
|
||||
expect(await checkServerHealth({ ...server, password }, fetch)).toEqual({ healthy: true, version: "2.0.0" })
|
||||
expect(headers).toEqual([password ? `Basic ${btoa(`opencode:${password}`)}` : null])
|
||||
})
|
||||
|
||||
@@ -29,7 +28,7 @@ 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({ healthy: true, version: "1.2.3" }), {
|
||||
return new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url] }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
@@ -38,27 +37,7 @@ describe("checkServerHealth", () => {
|
||||
const result = await checkServerHealth(server, fetch)
|
||||
|
||||
expect(result).toEqual({ healthy: true, version: "1.2.3" })
|
||||
expect(request?.pathname).toBe("/api/health")
|
||||
})
|
||||
|
||||
test("identifies a V1 server without a version as incompatible", async () => {
|
||||
const requests: string[] = []
|
||||
const fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input)
|
||||
requests.push(url.pathname)
|
||||
return new Response(
|
||||
JSON.stringify(url.pathname === "/global/health" ? { version: "1.18.15" } : { healthy: true }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
)
|
||||
}) as unknown as typeof globalThis.fetch
|
||||
|
||||
const result = await checkServerHealth(server, fetch)
|
||||
|
||||
expect(result).toEqual({ healthy: false, version: "1.18.15", incompatible: true })
|
||||
expect(requests).toEqual(["/api/health", "/global/health"])
|
||||
expect(request?.pathname).toBe("/api/status")
|
||||
})
|
||||
|
||||
test("allows slow servers thirty seconds by default", async () => {
|
||||
@@ -73,7 +52,7 @@ describe("checkServerHealth", () => {
|
||||
})
|
||||
|
||||
const fetch = (async () =>
|
||||
new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
|
||||
new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url] }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})) as unknown as typeof globalThis.fetch
|
||||
@@ -132,7 +111,7 @@ describe("checkServerHealth", () => {
|
||||
let signal: AbortSignal | undefined
|
||||
const fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
signal = abortFromInput(input, init)
|
||||
return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
|
||||
return new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url] }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
@@ -151,7 +130,7 @@ describe("checkServerHealth", () => {
|
||||
const fetch = (async () => {
|
||||
count += 1
|
||||
if (count < 3) throw new TypeError("network")
|
||||
return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
|
||||
return new Response(JSON.stringify({ version: "1.2.3", pid: 1, urls: [server.url] }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
|
||||
@@ -95,23 +95,10 @@ export async function checkServerHealth(
|
||||
fetch,
|
||||
headers,
|
||||
})
|
||||
.health.get({ signal })
|
||||
.then(async (x) => {
|
||||
if (typeof x.healthy !== "boolean") return { error: new Error("Invalid health response") }
|
||||
if (x.healthy && typeof x.version !== "string") {
|
||||
const legacy = await fetch(new URL("/global/health", server.url), { headers, signal })
|
||||
.then((response) => response.json())
|
||||
.catch(() => undefined)
|
||||
const version =
|
||||
typeof legacy === "object" && legacy !== null && "version" in legacy && typeof legacy.version === "string"
|
||||
? legacy.version
|
||||
: "1"
|
||||
return { data: { healthy: false, version, incompatible: true } }
|
||||
}
|
||||
return { data: { healthy: x.healthy, version: x.version } }
|
||||
})
|
||||
.server.status({ signal })
|
||||
.then((status) => ({ data: { healthy: true as const, version: status.version } }))
|
||||
.catch((error) => ({ error }))
|
||||
if ("data" in current && current.data) return current.data
|
||||
if ("data" in current) return current.data
|
||||
if (signal?.aborted) return { healthy: false }
|
||||
|
||||
return next(count, current.error)
|
||||
|
||||
@@ -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/health").catch(() => undefined)
|
||||
input.queue.fetch("http://server/api/status").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/health", ms: 2_000 },
|
||||
{ method: "GET", url: "http://server/api/status", ms: 2_000 },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { ServiceStatus } from "@opencode/protocol/groups/health"
|
||||
import { ServerStatus } 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 health = await waitForReady(info.url, headers)
|
||||
if (health.pid !== info.pid) throw new Error("Health process does not match registration")
|
||||
const tokenHealth = await fetch(new URL(`/api/health?auth_token=${token}`, info.url), {
|
||||
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), {
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
})
|
||||
if (tokenHealth.status !== 200) throw new Error("Compiled service rejected query authentication")
|
||||
if (tokenStatus.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),
|
||||
})
|
||||
@@ -58,10 +58,10 @@ try {
|
||||
await fs.writeFile(plugin, pluginSource())
|
||||
await waitForPlugin(info.url, headers)
|
||||
|
||||
const unauthorizedHealth = await fetch(new URL("/api/health", info.url), {
|
||||
const unauthorizedStatus = await fetch(new URL("/api/status", info.url), {
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
})
|
||||
if (unauthorizedHealth.status !== 401) throw new Error("Compiled service exposed health without authentication")
|
||||
if (unauthorizedStatus.status !== 401) throw new Error("Compiled service exposed status without authentication")
|
||||
const unauthorizedOpenApi = await fetch(new URL("/openapi.json", info.url), {
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
})
|
||||
@@ -129,11 +129,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/health", url), {
|
||||
const response = await fetch(new URL("/api/status", url), {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(1_000),
|
||||
}).catch(() => undefined)
|
||||
if (response?.ok) return Schema.decodeUnknownPromise(ServiceStatus.Health)(await response.json())
|
||||
if (response?.ok) return Schema.decodeUnknownPromise(ServerStatus)(await response.json())
|
||||
await Bun.sleep(25)
|
||||
}
|
||||
throw new Error("Compiled service did not become ready")
|
||||
|
||||
@@ -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.get(),
|
||||
OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).server.status(),
|
||||
)).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.health.get({ signal: AbortSignal.timeout(5_000) }),
|
||||
try: () => client.server.status({ signal: AbortSignal.timeout(5_000) }),
|
||||
catch: (cause) => connectError(endpoint, cause),
|
||||
})
|
||||
if (health.version !== OPENCODE_VERSION)
|
||||
|
||||
@@ -272,15 +272,15 @@ 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/health") return health()
|
||||
if (url.pathname === "/api/status") return status()
|
||||
if (url.pathname === "/api/model/default") return Response.json(located(null))
|
||||
return fetch(request, url)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function health() {
|
||||
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
|
||||
function status() {
|
||||
return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] })
|
||||
}
|
||||
|
||||
function located<T>(data: T) {
|
||||
|
||||
@@ -39,9 +39,9 @@ describe("debug config command", () => {
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/api/health") {
|
||||
if (url.pathname === "/api/status") {
|
||||
healthProbes += 1
|
||||
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
|
||||
return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] })
|
||||
}
|
||||
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/health", endpoint.url), { headers: Service.headers(endpoint) }),
|
||||
fetch(new URL("/api/status", endpoint.url), { headers: Service.headers(endpoint) }),
|
||||
)
|
||||
console.log(`STANDALONE_READY ${endpoint.pid} ${endpoint.url} ${response.status}`)
|
||||
return yield* Effect.never
|
||||
|
||||
@@ -42,7 +42,7 @@ const sanitizedTransfer = {
|
||||
],
|
||||
}
|
||||
|
||||
const health = () => Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
|
||||
const status = () => Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] })
|
||||
|
||||
function run(args: string[], stdin?: string) {
|
||||
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
|
||||
@@ -60,7 +60,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/health") return health()
|
||||
if (url.pathname === "/api/status") return status()
|
||||
if (url.pathname === `/api/session/${info.id}`) return Response.json({ data: info })
|
||||
if (url.pathname === `/api/session/${info.id}/export`) {
|
||||
sanitization.push(url.searchParams.get("sanitize") ?? "")
|
||||
@@ -98,7 +98,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/health") return health()
|
||||
if (url.pathname === "/api/status") return status()
|
||||
if (url.pathname === "/api/location") {
|
||||
return Response.json({
|
||||
directory: "/project",
|
||||
@@ -127,7 +127,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/health") return health()
|
||||
if (url.pathname === "/api/status") return status()
|
||||
if (url.pathname === `/api/session/${sessionID}/export`) {
|
||||
return Response.json(
|
||||
{ _tag: "SessionNotFoundError", sessionID, message: `Session not found: ${sessionID}` },
|
||||
@@ -158,7 +158,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/health") return health()
|
||||
if (url.pathname === "/api/status") return status()
|
||||
if (url.pathname === "/api/location") {
|
||||
return Response.json({
|
||||
directory: root,
|
||||
@@ -201,7 +201,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/health") return health()
|
||||
if (url.pathname === "/api/status") return status()
|
||||
if (url.pathname === "/api/location") {
|
||||
return Response.json({
|
||||
directory: root,
|
||||
|
||||
@@ -33,7 +33,7 @@ test("mini handler passes resolved CLI keybinds to the runtime", async () => {
|
||||
const handler = (await import("../src/commands/handlers/mini")).default
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: () => Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid }),
|
||||
fetch: () => Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] }),
|
||||
})
|
||||
|
||||
try {
|
||||
|
||||
@@ -31,14 +31,14 @@ describe("mini command", () => {
|
||||
const initial = Bun.serve({
|
||||
port: 0,
|
||||
fetch() {
|
||||
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
|
||||
return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] })
|
||||
},
|
||||
})
|
||||
const replacement = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
authorization.push(request.headers.get("authorization"))
|
||||
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
|
||||
return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] })
|
||||
},
|
||||
})
|
||||
const controller = new AbortController()
|
||||
@@ -57,7 +57,7 @@ describe("mini command", () => {
|
||||
})
|
||||
const client = await connection.reconnect?.(controller.signal)
|
||||
if (!client) throw new Error("Expected a replacement client")
|
||||
await client.health.get()
|
||||
await client.server.status()
|
||||
|
||||
expect(client).not.toBe(connection.sdk)
|
||||
expect(signal).toBe(controller.signal)
|
||||
@@ -147,8 +147,8 @@ describe("mini command", () => {
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
requests.push(url.pathname)
|
||||
if (url.pathname === "/api/health")
|
||||
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
|
||||
if (url.pathname === "/api/status")
|
||||
return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] })
|
||||
if (url.pathname === "/api/location")
|
||||
return Response.json({ directory: process.cwd(), project: { id: "global", directory: process.cwd() } })
|
||||
if (url.pathname === "/api/session") {
|
||||
@@ -190,7 +190,7 @@ describe("mini command", () => {
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
if (new URL(request.url).pathname === "/api/session") return new Response("boom", { status: 500 })
|
||||
return Response.json({ healthy: true, version: "incompatible", pid: process.pid })
|
||||
return Response.json({ version: "incompatible", pid: process.pid, urls: [] })
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -16,9 +16,9 @@ test("resolution groups Effect-native lifecycle operations only for the managed
|
||||
port: 0,
|
||||
fetch() {
|
||||
return Response.json({
|
||||
healthy: true,
|
||||
version: OPENCODE_VERSION,
|
||||
pid: process.pid,
|
||||
urls: [],
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -256,13 +256,13 @@ 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/health", info.url), {
|
||||
await fetch(new URL("/api/status", info.url), {
|
||||
headers: { authorization: "Basic " + btoa(`opencode:${info.password}`) },
|
||||
}).then((response) => response.json()),
|
||||
).toEqual({
|
||||
healthy: true,
|
||||
version: info.version,
|
||||
pid: info.pid,
|
||||
urls: [info.url],
|
||||
})
|
||||
const contender = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
|
||||
try {
|
||||
@@ -337,7 +337,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/health", info.url), {
|
||||
const response = await fetch(new URL("/api/status", info.url), {
|
||||
method: "OPTIONS",
|
||||
headers: { Origin: origin, "Access-Control-Request-Method": "GET" },
|
||||
})
|
||||
@@ -440,7 +440,7 @@ 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({ healthy: true, version: OPENCODE_VERSION, pid: process.pid }, { status: 503 })
|
||||
return Response.json({ version: OPENCODE_VERSION, pid: process.pid, urls: [] }, { status: 503 })
|
||||
},
|
||||
})
|
||||
const registration = path.join(root, "state", "opencode", "service-local.json")
|
||||
@@ -575,7 +575,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/health", info.url), {
|
||||
const status = await fetch(new URL("/api/status", info.url), {
|
||||
headers: { authorization: "Basic " + btoa(`opencode:${info.password}`) },
|
||||
})
|
||||
.then((response) => response.status)
|
||||
|
||||
@@ -35,7 +35,8 @@ describe("web UI", () => {
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const pathname = new URL(request.url, "http://localhost").pathname
|
||||
if (pathname === "/api/health") return HttpServerResponse.jsonUnsafe({ healthy: true })
|
||||
if (pathname === "/api/status")
|
||||
return HttpServerResponse.jsonUnsafe({ version: "test", pid: 1, urls: [origin] })
|
||||
return yield* Effect.fail(
|
||||
new HttpServerError.HttpServerError({
|
||||
reason: new HttpServerError.RouteNotFound({ request }),
|
||||
@@ -46,8 +47,8 @@ describe("web UI", () => {
|
||||
)
|
||||
const origin = HttpServer.formatAddress(http.address)
|
||||
|
||||
const health = yield* Effect.promise(() => fetch(`${origin}/api/health`))
|
||||
expect(yield* Effect.promise(() => health.json())).toEqual({ healthy: true })
|
||||
const status = yield* Effect.promise(() => fetch(`${origin}/api/status`))
|
||||
expect(yield* Effect.promise(() => status.json())).toEqual({ version: "test", pid: 1, urls: [origin] })
|
||||
|
||||
const missing = yield* Effect.promise(() => fetch(`${origin}/api/missing`))
|
||||
expect(missing.status).toBe(404)
|
||||
|
||||
@@ -40,18 +40,15 @@ import type { Vcs } from "@opencode/schema/vcs"
|
||||
import type { WebSearch } from "@opencode/schema/websearch"
|
||||
import type { Config } from "@opencode/schema/config"
|
||||
|
||||
export type HealthGetOutput = { readonly healthy: true; readonly version: string; readonly pid: number }
|
||||
export type HealthGetOperation<E = never> = () => Effect.Effect<HealthGetOutput, E>
|
||||
|
||||
export interface HealthApi<E = never> {
|
||||
readonly get: HealthGetOperation<E>
|
||||
export type ServerStatusOutput = {
|
||||
readonly version: string
|
||||
readonly pid: number
|
||||
readonly urls: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
export type ServerGetOutput = { readonly urls: ReadonlyArray<string> }
|
||||
export type ServerGetOperation<E = never> = () => Effect.Effect<ServerGetOutput, E>
|
||||
export type ServerStatusOperation<E = never> = () => Effect.Effect<ServerStatusOutput, E>
|
||||
|
||||
export interface ServerApi<E = never> {
|
||||
readonly get: ServerGetOperation<E>
|
||||
readonly status: ServerStatusOperation<E>
|
||||
}
|
||||
|
||||
export type LocationGetInput = {
|
||||
@@ -2176,7 +2173,6 @@ export interface ConfigApi<E = never> {
|
||||
}
|
||||
|
||||
export interface AppApi<E = never> {
|
||||
readonly health: HealthApi<E>
|
||||
readonly server: ServerApi<E>
|
||||
readonly location: LocationApi<E>
|
||||
readonly agent: AgentApi<E>
|
||||
|
||||
@@ -5,8 +5,7 @@ import { HttpClientError } from "effect/unstable/http"
|
||||
import { HttpApiClient } from "effect/unstable/httpapi"
|
||||
import { ClientApi } from "../../contract"
|
||||
import type {
|
||||
HealthGetOutput,
|
||||
ServerGetOutput,
|
||||
ServerStatusOutput,
|
||||
LocationGetInput,
|
||||
LocationGetOutput,
|
||||
AgentListInput,
|
||||
@@ -293,15 +292,10 @@ const preserveStream =
|
||||
<E, R>(stream: Stream.Stream<A, E, R>) =>
|
||||
stream
|
||||
|
||||
const EndpointHealthGet = (raw: RawClient["server.health"]) => () =>
|
||||
preserveEffect<HealthGetOutput>()(raw["health.get"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
const EndpointServerStatus = (raw: RawClient["server.server"]) => () =>
|
||||
preserveEffect<ServerStatusOutput>()(raw["server.status"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
|
||||
const adaptGroupHealth = (raw: RawClient["server.health"]) => ({ get: EndpointHealthGet(raw) })
|
||||
|
||||
const EndpointServerGet = (raw: RawClient["server.server"]) => () =>
|
||||
preserveEffect<ServerGetOutput>()(raw["server.get"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
|
||||
const adaptGroupServer = (raw: RawClient["server.server"]) => ({ get: EndpointServerGet(raw) })
|
||||
const adaptGroupServer = (raw: RawClient["server.server"]) => ({ status: EndpointServerStatus(raw) })
|
||||
|
||||
const EndpointLocationGet = (raw: RawClient["server.location"]) => (input?: LocationGetInput) =>
|
||||
preserveEffect<LocationGetOutput>()(
|
||||
@@ -1614,7 +1608,6 @@ const adaptGroupConfig = (raw: RawClient["server.config"]) => ({
|
||||
})
|
||||
|
||||
const adaptClient = (raw: RawClient) => ({
|
||||
health: adaptGroupHealth(raw["server.health"]),
|
||||
server: adaptGroupServer(raw["server.server"]),
|
||||
location: adaptGroupLocation(raw["server.location"]),
|
||||
agent: adaptGroupAgent(raw["server.agent"]),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ServiceStatus } from "@opencode/protocol/groups/health"
|
||||
import { ServerStatus } from "@opencode/protocol/groups/server"
|
||||
import { Effect, FileSystem, Option, Schedule, Schema } from "effect"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
@@ -41,7 +41,7 @@ export const incumbent = Effect.fn("service.incumbent")(function* (
|
||||
) {
|
||||
const info = yield* read(options.file)
|
||||
const found = info === undefined ? undefined : yield* probe({ ...info, url: options.url })
|
||||
if (found === undefined || found.legacy) return undefined
|
||||
if (found === undefined) return undefined
|
||||
if (!matchesVersion(found.version, options)) return undefined
|
||||
return { endpoint: found.endpoint, state: found.state }
|
||||
})
|
||||
@@ -75,7 +75,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
})
|
||||
})
|
||||
const found = yield* Effect.gen(function* () {
|
||||
const registration = yield* registered(options.file, true, timing.requestTimeout)
|
||||
const registration = yield* registered(options.file, timing.requestTimeout)
|
||||
const info = registration.info
|
||||
const service = registration.service
|
||||
if (registration.timedOut && info !== undefined) {
|
||||
@@ -94,7 +94,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
} else timeouts = undefined
|
||||
if (service !== undefined) {
|
||||
spawnDelay = timing.spawnDelay
|
||||
const compatible = !service.legacy && matchesVersion(service.version, options)
|
||||
const compatible = matchesVersion(service.version, options)
|
||||
if (compatible && service.state === "ready") {
|
||||
yield* Effect.tryPromise(() => PtyHandoff.complete(options.file ?? fallback(), service.info))
|
||||
return Option.some(service)
|
||||
@@ -103,11 +103,11 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
return yield* Effect.fail(new Error("Background service failed to start"))
|
||||
if (compatible) return Option.none<LocalService>()
|
||||
yield* announce("version-mismatch", service.version)
|
||||
if (!service.legacy && service.state !== "ready")
|
||||
if (service.state !== "ready")
|
||||
yield* Effect.logWarning("Background service is not ready; replacement cannot preserve persistent terminals")
|
||||
yield* stop({
|
||||
file: options.file,
|
||||
pty: !service.legacy && service.state === "ready" ? "handoff" : "clear",
|
||||
pty: service.state === "ready" ? "handoff" : "clear",
|
||||
}).pipe(Effect.ignore)
|
||||
lastSpawn = 0
|
||||
return Option.none<LocalService>()
|
||||
@@ -171,8 +171,7 @@ export const Info = Schema.Struct({
|
||||
})
|
||||
|
||||
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
|
||||
const decodeHealth = Schema.decodeUnknownOption(ServiceStatus.Health)
|
||||
const decodeLegacyHealth = Schema.decodeUnknownOption(Schema.Struct({ healthy: Schema.Literal(true) }))
|
||||
const decodeStatus = Schema.decodeUnknownOption(ServerStatus)
|
||||
|
||||
// A missing or corrupt file means no valid info; callers treat both
|
||||
// the same (the registering server self-evicts, clients rediscover).
|
||||
@@ -188,16 +187,14 @@ type LocalService = {
|
||||
readonly endpoint: Endpoint
|
||||
readonly version?: string
|
||||
readonly state: "ready" | "waiting" | "failed"
|
||||
readonly legacy: boolean
|
||||
}
|
||||
|
||||
const probe = Effect.fnUntraced(function* (info: Info, allowLegacy = false) {
|
||||
return (yield* probeResult(info, allowLegacy)).service
|
||||
const probe = Effect.fnUntraced(function* (info: Info) {
|
||||
return (yield* probeResult(info)).service
|
||||
})
|
||||
|
||||
const probeResult = Effect.fnUntraced(function* (
|
||||
info: Info,
|
||||
allowLegacy = false,
|
||||
timeout = defaultEnsureTiming.requestTimeout,
|
||||
) {
|
||||
const endpoint = {
|
||||
@@ -209,10 +206,7 @@ const probeResult = Effect.fnUntraced(function* (
|
||||
} satisfies Endpoint
|
||||
const signal = AbortSignal.timeout(timeout)
|
||||
const result = yield* Effect.promise(() =>
|
||||
fetch(new URL("/api/health", info.url), {
|
||||
headers: headers(endpoint),
|
||||
signal,
|
||||
})
|
||||
fetch(new URL("/api/status", info.url), { headers: headers(endpoint), signal })
|
||||
.then(async (response) => ({ response, body: (await response.json()) as unknown }))
|
||||
.then(
|
||||
(value) => ({ value }),
|
||||
@@ -222,38 +216,28 @@ const probeResult = Effect.fnUntraced(function* (
|
||||
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
|
||||
const response = result.value.response
|
||||
const body = result.value.body
|
||||
const health = decodeHealth(body)
|
||||
if (Option.isSome(health)) {
|
||||
if (health.value.pid !== info.pid) return { service: undefined, timedOut: false }
|
||||
if (info.version !== undefined && health.value.version !== info.version)
|
||||
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)
|
||||
return { service: undefined, timedOut: false }
|
||||
return {
|
||||
service: {
|
||||
info,
|
||||
endpoint,
|
||||
version: health.value.version,
|
||||
version: status.value.version,
|
||||
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
|
||||
legacy: false,
|
||||
} satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
}
|
||||
if (
|
||||
!allowLegacy ||
|
||||
Option.isNone(decodeLegacyHealth(body)) ||
|
||||
(typeof body === "object" && body !== null && ("version" in body || "pid" in body))
|
||||
)
|
||||
return { service: undefined, timedOut: false }
|
||||
return {
|
||||
service: { info, endpoint, state: "ready", legacy: true } satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
return { service: undefined, timedOut: false }
|
||||
})
|
||||
|
||||
const registered = Effect.fnUntraced(function* (file?: string, allowLegacy = false, timeout?: number) {
|
||||
const registered = Effect.fnUntraced(function* (file?: string, timeout?: number) {
|
||||
const info = yield* read(file)
|
||||
if (info === undefined) return { info: undefined, service: undefined, timedOut: false }
|
||||
return { info, ...(yield* probeResult(info, allowLegacy, timeout)) }
|
||||
return { info, ...(yield* probeResult(info, timeout)) }
|
||||
})
|
||||
|
||||
// 50ms cadence bounded at ~5s, shared by stop escalation and each ensure
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type {
|
||||
HealthGetOutput,
|
||||
ServerGetOutput,
|
||||
ServerStatusOutput,
|
||||
LocationGetInput,
|
||||
LocationGetOutput,
|
||||
AgentListInput,
|
||||
@@ -412,17 +411,10 @@ export function make(options: ClientOptions) {
|
||||
})
|
||||
|
||||
return {
|
||||
health: {
|
||||
get: (requestOptions?: RequestOptions) =>
|
||||
request<HealthGetOutput>(
|
||||
{ method: "GET", path: `/api/health`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
server: {
|
||||
get: (requestOptions?: RequestOptions) =>
|
||||
request<ServerGetOutput>(
|
||||
{ method: "GET", path: `/api/server`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
|
||||
status: (requestOptions?: RequestOptions) =>
|
||||
request<ServerStatusOutput>(
|
||||
{ method: "GET", path: `/api/status`, 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 ServiceHealth = { healthy: true; version: string; pid: number }
|
||||
export type ServerStatus = { version: string; pid: number; urls: Array<string> }
|
||||
|
||||
export type ModelRef = { id: string; providerID: string; variant?: string }
|
||||
|
||||
@@ -2602,9 +2602,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 HealthGetOutput = ServiceHealth
|
||||
|
||||
export type ServerGetOutput = { urls: Array<string> }
|
||||
export type ServerStatusOutput = ServerStatus
|
||||
|
||||
export type LocationGetInput = {
|
||||
readonly location?: {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
|
||||
import { matchesVersion } from "../service-version.js"
|
||||
import { PtyHandoff } from "../pty-handoff.js"
|
||||
import type { ServiceHealth } from "./generated/types.js"
|
||||
import type { ServerStatus } from "./generated/types.js"
|
||||
|
||||
export * from "../service.js"
|
||||
|
||||
@@ -57,7 +57,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
try {
|
||||
while (true) {
|
||||
if (Date.now() >= deadline) throw new Error("Timed out waiting for the background service to start")
|
||||
const registration = await registered(options.file, true, timing.requestTimeout)
|
||||
const registration = await registered(options.file, timing.requestTimeout)
|
||||
if (registration.timedOut && registration.info !== undefined) {
|
||||
timeouts = {
|
||||
info: registration.info,
|
||||
@@ -76,7 +76,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
if (registration.service !== undefined) {
|
||||
spawnDelay = timing.spawnDelay
|
||||
const service = registration.service
|
||||
const compatible = !service.legacy && matchesVersion(service.version, options)
|
||||
const compatible = matchesVersion(service.version, options)
|
||||
if (compatible && service.state === "ready") {
|
||||
await PtyHandoff.complete(options.file ?? fallback(), service.info)
|
||||
return service.endpoint
|
||||
@@ -84,11 +84,11 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
|
||||
if (!compatible) {
|
||||
announce("version-mismatch", service.version)
|
||||
if (!service.legacy && service.state !== "ready")
|
||||
if (service.state !== "ready")
|
||||
console.warn("Background service is not ready; replacement cannot preserve persistent terminals")
|
||||
await stop({
|
||||
file: options.file,
|
||||
pty: !service.legacy && service.state === "ready" ? "handoff" : "clear",
|
||||
pty: service.state === "ready" ? "handoff" : "clear",
|
||||
}).catch(() => undefined)
|
||||
lastSpawn = 0
|
||||
}
|
||||
@@ -151,10 +151,9 @@ type LocalService = {
|
||||
readonly endpoint: Endpoint
|
||||
readonly version?: string
|
||||
readonly state: "ready" | "waiting" | "failed"
|
||||
readonly legacy: boolean
|
||||
}
|
||||
|
||||
async function probeResult(info: Info, allowLegacy = false, timeout = defaultEnsureTiming.requestTimeout) {
|
||||
async function probeResult(info: Info, timeout = defaultEnsureTiming.requestTimeout) {
|
||||
const endpoint = {
|
||||
url: info.url,
|
||||
auth:
|
||||
@@ -163,13 +162,10 @@ async function probeResult(info: Info, allowLegacy = false, timeout = defaultEns
|
||||
: { type: "basic" as const, username: "opencode", password: info.password },
|
||||
} satisfies Endpoint
|
||||
const signal = AbortSignal.timeout(timeout)
|
||||
const result = await fetch(new URL("/api/health", info.url), {
|
||||
headers: headers(endpoint),
|
||||
signal,
|
||||
})
|
||||
const result = await fetch(new URL("/api/status", info.url), { headers: headers(endpoint), signal })
|
||||
.then(async (response) => ({
|
||||
response,
|
||||
body: (await response.json()) as ServiceHealth | { readonly healthy: true },
|
||||
body: (await response.json()) as ServerStatus,
|
||||
}))
|
||||
.then(
|
||||
(value) => ({ value }),
|
||||
@@ -187,22 +183,17 @@ async function probeResult(info: Info, allowLegacy = false, timeout = defaultEns
|
||||
endpoint,
|
||||
version: body.version,
|
||||
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
|
||||
legacy: false,
|
||||
} satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
}
|
||||
if (!allowLegacy || body?.healthy !== true) return { service: undefined, timedOut: false }
|
||||
return {
|
||||
service: { info, endpoint, state: "ready", legacy: true } satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
return { service: undefined, timedOut: false }
|
||||
}
|
||||
|
||||
async function registered(file?: string, allowLegacy = false, timeout?: number) {
|
||||
async function registered(file?: string, timeout?: number) {
|
||||
const info = await read(file)
|
||||
if (info === undefined) return { info: undefined, service: undefined, timedOut: false }
|
||||
return { info, ...(await probeResult(info, allowLegacy, timeout)) }
|
||||
return { info, ...(await probeResult(info, timeout)) }
|
||||
}
|
||||
|
||||
function signal(pid: number, name: NodeJS.Signals) {
|
||||
|
||||
@@ -15,16 +15,21 @@ import {
|
||||
|
||||
const synced = { type: "log.synced" as const, aggregateID: "ses_test", seq: Event.Seq.make(1) }
|
||||
|
||||
test("health.get decodes the readiness response", async () => {
|
||||
test("server.status decodes the readiness response", async () => {
|
||||
const httpClient = HttpClient.make((request) =>
|
||||
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ healthy: true, version: "old", pid: 123 }))),
|
||||
Effect.succeed(
|
||||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
Response.json({ version: "current", pid: 123, urls: ["http://localhost:3000"] }),
|
||||
),
|
||||
),
|
||||
)
|
||||
const result = await Effect.gen(function* () {
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||
return yield* client.health.get()
|
||||
return yield* client.server.status()
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
|
||||
expect(result).toEqual({ healthy: true, version: "old", pid: 123 })
|
||||
expect(result).toEqual({ version: "current", pid: 123, urls: ["http://localhost:3000"] })
|
||||
})
|
||||
|
||||
test("vcs.base decodes nullable review-base metadata", async () => {
|
||||
|
||||
@@ -52,9 +52,9 @@ const server = Bun.serve({
|
||||
await writeFile(registration + ".prepared", JSON.stringify(handoff))
|
||||
return Response.json({ handoff })
|
||||
}
|
||||
if (pathname !== "/api/health") return new Response(null, { status: 404 })
|
||||
if (pathname !== "/api/status") return new Response(null, { status: 404 })
|
||||
requests += 1
|
||||
if (mode === "starting") await writeFile(registration + ".health-request", "")
|
||||
if (mode === "starting") await writeFile(registration + ".status-request", "")
|
||||
if (mode === "hanging") {
|
||||
await appendFile(registration + ".requests", process.pid + "\n")
|
||||
return new Promise<Response>(() => {})
|
||||
@@ -64,12 +64,11 @@ const server = Bun.serve({
|
||||
while (!(await Bun.file(registration + ".release").exists())) await Bun.sleep(5)
|
||||
return new Response(null, { status: 503 })
|
||||
}
|
||||
if (mode === "legacy") return Response.json({ healthy: true })
|
||||
if (mode === "starting" && !(await Bun.file(registration + ".release").exists()))
|
||||
return Response.json({ healthy: true, version, pid: process.pid }, { status: 503 })
|
||||
if (mode === "failed-owner") return Response.json({ healthy: true, version, pid: process.pid }, { status: 500 })
|
||||
if (mode === "starting" || mode === "graceful") return Response.json({ healthy: true, version, pid: process.pid })
|
||||
return Response.json({ healthy: true, version, pid: process.pid })
|
||||
return Response.json({ version, pid: process.pid, urls: [server.url.toString()] }, { 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()] })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -77,7 +76,7 @@ await writeFile(
|
||||
registration + ".tmp",
|
||||
JSON.stringify({
|
||||
id,
|
||||
version: mode === "legacy" ? undefined : version,
|
||||
version,
|
||||
url: server.url.toString(),
|
||||
pid: process.pid,
|
||||
password: "private",
|
||||
|
||||
@@ -5,7 +5,6 @@ test("exposes every standard HTTP API group", () => {
|
||||
const client = OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||
|
||||
expect(Object.keys(client)).toEqual([
|
||||
"health",
|
||||
"server",
|
||||
"location",
|
||||
"agent",
|
||||
@@ -54,7 +53,7 @@ test("exposes every standard HTTP API group", () => {
|
||||
expect(Object.keys(client.experimental)).toEqual(["persistentPty"])
|
||||
expect(client.experimental.persistentPty.read).toBeFunction()
|
||||
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
|
||||
expect(Object.keys(client.project)).toEqual(["list", "update", "current"])
|
||||
expect(Object.keys(client.project)).toEqual(["list", "update"])
|
||||
expect(Object.keys(client.worktree)).toEqual(["list", "create", "remove", "refresh"])
|
||||
})
|
||||
|
||||
@@ -194,19 +193,19 @@ test("websearch.query uses the public HTTP contract", async () => {
|
||||
expect(await request?.json()).toEqual({ query: "opencode", providerID: "exa" })
|
||||
})
|
||||
|
||||
test("server.get uses the public HTTP contract", async () => {
|
||||
test("server.status 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({ urls: ["http://192.168.1.10:4096"] })
|
||||
return Response.json({ version: "2.0.0", pid: 1, urls: ["http://192.168.1.10:4096"] })
|
||||
},
|
||||
})
|
||||
|
||||
expect(await client.server.get()).toEqual({ urls: ["http://192.168.1.10:4096"] })
|
||||
expect(await client.server.status()).toEqual({ version: "2.0.0", pid: 1, urls: ["http://192.168.1.10:4096"] })
|
||||
expect(request?.method).toBe("GET")
|
||||
expect(request?.url).toBe("http://localhost:3000/api/server")
|
||||
expect(request?.url).toBe("http://localhost:3000/api/status")
|
||||
})
|
||||
|
||||
test("experimental wellknown integration add uses the public HTTP contract", async () => {
|
||||
@@ -701,7 +700,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 health requests", async () => {
|
||||
test("event transport passes through ordinary status requests", async () => {
|
||||
const requests: string[] = []
|
||||
const event = { id: "evt_connected", created: 1, type: "server.connected", data: {} }
|
||||
const client = OpenCode.make({
|
||||
@@ -714,12 +713,16 @@ test("event transport passes through ordinary health requests", async () => {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}
|
||||
return Response.json({ healthy: true, version: "2.0.0", pid: 1 })
|
||||
return Response.json({ version: "2.0.0", pid: 1, urls: ["http://localhost:3000"] })
|
||||
},
|
||||
})
|
||||
await expect(client.event.subscribe()[Symbol.asyncIterator]().next()).resolves.toEqual({ done: false, value: event })
|
||||
await expect(client.health.get()).resolves.toEqual({ healthy: true, version: "2.0.0", pid: 1 })
|
||||
expect(requests).toEqual(["/api/event", "/api/health"])
|
||||
await expect(client.server.status()).resolves.toEqual({
|
||||
version: "2.0.0",
|
||||
pid: 1,
|
||||
urls: ["http://localhost:3000"],
|
||||
})
|
||||
expect(requests).toEqual(["/api/event", "/api/status"])
|
||||
})
|
||||
|
||||
test("event.subscribe terminates on malformed Promise SSE data", async () => {
|
||||
|
||||
@@ -34,7 +34,7 @@ test("a concurrent same-version start cannot invalidate a resolved endpoint", as
|
||||
|
||||
expect(starts).toEqual([])
|
||||
expect(await Bun.file(registration).json()).toEqual(original)
|
||||
expect(await health(resolved.url)).toEqual({ healthy: true, version: "test", pid: original.pid })
|
||||
expect(await status(resolved.url)).toMatchObject({ version: "test", pid: original.pid })
|
||||
})
|
||||
|
||||
test("reuses a compatible registered service", async () => {
|
||||
@@ -107,7 +107,7 @@ test("waits for a registered service to finish starting", async () => {
|
||||
await fixture.waitForFile()
|
||||
const result = run(ensure({ file: registration, version: "test", command: [] }))
|
||||
|
||||
await fixture.waitForFile(registration + ".health-request")
|
||||
await fixture.waitForFile(registration + ".status-request")
|
||||
expect(process.exitCode).toBe(null)
|
||||
await writeFile(registration + ".release", "")
|
||||
expect((await result).url).toBe((await Bun.file(registration).json()).url)
|
||||
@@ -146,7 +146,7 @@ test("evicts an unresponsive registered service before starting its replacement"
|
||||
expect(await existing.exited).toBe(0)
|
||||
expect(replacement.pid).not.toBe(original.pid)
|
||||
expect(endpoint.url).toBe(replacement.url)
|
||||
expect(await health(endpoint.url)).toEqual({ healthy: true, version: "test", pid: replacement.pid })
|
||||
expect(await status(endpoint.url)).toMatchObject({ version: "test", pid: replacement.pid })
|
||||
})
|
||||
|
||||
test("signals an unresponsive registered service process", async () => {
|
||||
@@ -180,20 +180,6 @@ test("signals an incompatible service before starting its replacement", async ()
|
||||
expect(endpoint.url).toBe(replacement.url)
|
||||
})
|
||||
|
||||
test("a legacy health response is still replaced", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
const existing = fixture.spawn("legacy")
|
||||
await fixture.waitForFile()
|
||||
|
||||
const starts: EnsureReason[] = []
|
||||
const result = run(ensure({ file: registration, command: [], onStart: (reason) => starts.push(reason) }))
|
||||
|
||||
await expect(result).rejects.toThrow("Missing service command")
|
||||
expect(starts).toEqual(["version-mismatch"])
|
||||
await existing.exited
|
||||
})
|
||||
|
||||
test("waits for a slow winner while bounding lock probes", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
@@ -208,7 +194,7 @@ test("waits for a slow winner while bounding lock probes", async () => {
|
||||
fixture.track(info.pid)
|
||||
|
||||
expect(endpoint.url).toBe(info.url)
|
||||
expect(await health(endpoint.url)).toEqual({ healthy: true, version: "test", pid: info.pid })
|
||||
expect(await status(endpoint.url)).toMatchObject({ version: "test", pid: info.pid })
|
||||
expect((await Bun.file(registration + ".starts").text()).trim().split("\n")).toHaveLength(2)
|
||||
})
|
||||
|
||||
@@ -313,6 +299,6 @@ function run<A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem>) {
|
||||
return Effect.runPromise(effect.pipe(Effect.provide(NodeFileSystem.layer)))
|
||||
}
|
||||
|
||||
async function health(url: string) {
|
||||
return fetch(new URL("/api/health", url), { signal: AbortSignal.timeout(1_000) }).then((response) => response.json())
|
||||
async function status(url: string) {
|
||||
return fetch(new URL("/api/status", url), { signal: AbortSignal.timeout(1_000) }).then((response) => response.json())
|
||||
}
|
||||
|
||||
+8
-58
@@ -6,10 +6,10 @@
|
||||
"description": "Experimental HttpApi surface for selected instance routes."
|
||||
},
|
||||
"paths": {
|
||||
"/api/health": {
|
||||
"/api/status": {
|
||||
"get": {
|
||||
"tags": ["health"],
|
||||
"operationId": "health.get",
|
||||
"tags": ["server"],
|
||||
"operationId": "server.status",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
@@ -20,10 +20,6 @@
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"healthy": {
|
||||
"type": "boolean",
|
||||
"enum": [true]
|
||||
},
|
||||
"version": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -31,56 +27,10 @@
|
||||
"type": "integer",
|
||||
"allOf": [
|
||||
{
|
||||
"exclusiveMinimum": 0
|
||||
"minimum": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["healthy", "version", "pid"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Check whether the API server is ready to accept requests.",
|
||||
"summary": "Check server health"
|
||||
}
|
||||
},
|
||||
"/api/server": {
|
||||
"get": {
|
||||
"tags": ["server"],
|
||||
"operationId": "server.get",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
},
|
||||
"urls": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -88,7 +38,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["urls"],
|
||||
"required": ["version", "pid", "urls"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
@@ -115,8 +65,8 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Return the URLs that can be used to connect to this server.",
|
||||
"summary": "Get server information"
|
||||
"description": "Return the server identity, connection URLs, and readiness status.",
|
||||
"summary": "Get server status"
|
||||
}
|
||||
},
|
||||
"/api/location": {
|
||||
|
||||
@@ -235,7 +235,7 @@ describe("OpenAPI.fromSpec", () => {
|
||||
path: "/api/fs/read/*",
|
||||
reason: "binary responses are not supported",
|
||||
})
|
||||
expect(toolAt(result.tools, "health.get")).not.toBeUndefined()
|
||||
expect(toolAt(result.tools, "server.status")).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 health = toolAt(result.tools, "health.get")
|
||||
const healthInput = Tool.isTool(health) && isRecord(health.input) ? health.input : undefined
|
||||
expect(healthInput).toMatchObject({ type: "object", properties: {} })
|
||||
const input = isRecord(healthInput) ? healthInput : {}
|
||||
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 : {}
|
||||
expect(Object.keys(isRecord(input.properties) ? input.properties : {})).toStrictEqual([])
|
||||
})
|
||||
|
||||
@@ -994,7 +994,7 @@ describe("OpenAPI.fromSpec", () => {
|
||||
runtime
|
||||
.execute(
|
||||
`
|
||||
return search({ query: "global health", namespace: "opencode", limit: 1 })
|
||||
return search({ query: "server status", namespace: "opencode", limit: 1 })
|
||||
`,
|
||||
)
|
||||
.pipe(Effect.provide(layer)),
|
||||
@@ -1005,12 +1005,12 @@ describe("OpenAPI.fromSpec", () => {
|
||||
expect(result.value).toMatchObject({
|
||||
items: [
|
||||
{
|
||||
path: "tools.opencode.health.get",
|
||||
description: "Check whether the API server is ready to accept requests.",
|
||||
path: "tools.opencode.server.status",
|
||||
description: "Return the server identity, connection URLs, and readiness status.",
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(JSON.stringify(result.value)).toContain("healthy: true")
|
||||
expect(JSON.stringify(result.value)).toContain("version: string")
|
||||
})
|
||||
|
||||
test("invokes real opencode path parameters and JSON request bodies", async () => {
|
||||
|
||||
@@ -190,7 +190,7 @@ HTTP method and path or an OpenAPI operation ID.
|
||||
Call an endpoint with an HTTP method and path:
|
||||
|
||||
```sh
|
||||
opencode api get /api/health
|
||||
opencode api get /api/status
|
||||
```
|
||||
|
||||
Pass a request body with `--data` or `-d`, and additional headers with
|
||||
@@ -241,7 +241,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/health`.
|
||||
`opencode api get /api/status`.
|
||||
- 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
|
||||
|
||||
@@ -6,7 +6,7 @@ export async function checkHealth(url: string, password?: string | null): Promis
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(new URL("/api/health", url), {
|
||||
const res = await fetch(new URL("/api/status", url), {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: AbortSignal.timeout(3000),
|
||||
|
||||
@@ -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/health`, {
|
||||
.get(`${http.url}/api/status`, {
|
||||
headers: { authorization: `Basic ${Buffer.from(`opencode:${http.password}`).toString("base64")}` },
|
||||
})
|
||||
.pipe(
|
||||
|
||||
@@ -6,71 +6,19 @@
|
||||
"description": "Experimental HttpApi surface for selected instance routes."
|
||||
},
|
||||
"paths": {
|
||||
"/api/health": {
|
||||
"get": {
|
||||
"tags": ["health"],
|
||||
"operationId": "health.get",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "ServiceHealth",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ServiceHealth"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Report the owning server process and its application status.",
|
||||
"summary": "Check server health"
|
||||
}
|
||||
},
|
||||
"/api/server": {
|
||||
"/api/status": {
|
||||
"get": {
|
||||
"tags": ["server"],
|
||||
"operationId": "server.get",
|
||||
"operationId": "server.status",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"description": "ServerStatus",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"urls": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["urls"],
|
||||
"additionalProperties": false
|
||||
"$ref": "#/components/schemas/ServerStatus"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,8 +44,8 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Return the URLs that can be used to connect to this server.",
|
||||
"summary": "Get server information"
|
||||
"description": "Return the server identity, connection URLs, and readiness status.",
|
||||
"summary": "Get server status"
|
||||
}
|
||||
},
|
||||
"/api/location": {
|
||||
@@ -17959,22 +17907,24 @@
|
||||
"required": ["_tag", "type", "message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ServiceHealth": {
|
||||
"ServerStatus": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"healthy": {
|
||||
"type": "boolean",
|
||||
"enum": [true]
|
||||
},
|
||||
"version": {
|
||||
"type": "string"
|
||||
},
|
||||
"pid": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"urls": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["healthy", "version", "pid"],
|
||||
"required": ["version", "pid", "urls"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ServiceUnavailableErrorEncoded": {
|
||||
@@ -20199,9 +20149,6 @@
|
||||
},
|
||||
"security": [],
|
||||
"tags": [
|
||||
{
|
||||
"name": "health"
|
||||
},
|
||||
{
|
||||
"name": "server"
|
||||
},
|
||||
|
||||
@@ -16,7 +16,6 @@ import { EventGroup, makeEventGroup } from "./groups/event.js"
|
||||
import type { Definition } from "@opencode/schema/event"
|
||||
import { AgentGroup } from "./groups/agent.js"
|
||||
import { PluginGroup } from "./groups/plugin.js"
|
||||
import { HealthGroup } from "./groups/health.js"
|
||||
import { ServerGroup } from "./groups/server.js"
|
||||
import { DebugGroup } from "./groups/debug.js"
|
||||
import { PtyGroup } from "./groups/pty.js"
|
||||
@@ -85,7 +84,6 @@ type ApiGroups<
|
||||
SessionLocationService,
|
||||
Event extends HttpApiGroup.Constraint,
|
||||
> =
|
||||
| typeof HealthGroup
|
||||
| typeof ServerGroup
|
||||
| typeof DebugGroup
|
||||
| typeof MigrationGroup
|
||||
@@ -151,7 +149,6 @@ const makeApiFromGroup = <
|
||||
Group
|
||||
> =>
|
||||
HttpApi.make("server")
|
||||
.add(HealthGroup)
|
||||
.add(ServerGroup)
|
||||
.add(LocationGroup.middleware(locationMiddleware))
|
||||
.add(AgentGroup.middleware(locationMiddleware))
|
||||
|
||||
@@ -33,7 +33,6 @@ export const ClientApi: ClientApiShape = makeDefaultApi({
|
||||
})
|
||||
|
||||
export const groupNames = {
|
||||
"server.health": "health",
|
||||
"server.server": "server",
|
||||
"server.debug": "debug",
|
||||
"server.migration": "migration",
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
|
||||
export namespace ServiceStatus {
|
||||
export const Health = Schema.Struct({
|
||||
healthy: Schema.Literal(true),
|
||||
version: Schema.String,
|
||||
// 0 means the runtime has no OS process identity (e.g. workerd).
|
||||
pid: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
||||
}).annotate({ identifier: "ServiceHealth" })
|
||||
export type Health = typeof Health.Type
|
||||
}
|
||||
|
||||
export const HealthGroup = HttpApiGroup.make("server.health")
|
||||
.add(
|
||||
HttpApiEndpoint.get("health.get", "/api/health", {
|
||||
success: ServiceStatus.Health,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "health.get",
|
||||
summary: "Check server health",
|
||||
description: "Report the owning server process and its application status.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "health" }))
|
||||
@@ -1,15 +1,23 @@
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
|
||||
export const ServerStatus = 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
|
||||
|
||||
export const ServerGroup = HttpApiGroup.make("server.server")
|
||||
.add(
|
||||
HttpApiEndpoint.get("server.get", "/api/server", {
|
||||
success: Schema.Struct({ urls: Schema.Array(Schema.String) }),
|
||||
HttpApiEndpoint.get("server.status", "/api/status", {
|
||||
success: ServerStatus,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "server.get",
|
||||
summary: "Get server information",
|
||||
description: "Return the URLs that can be used to connect to this server.",
|
||||
identifier: "server.status",
|
||||
summary: "Get server status",
|
||||
description: "Return the server identity, connection URLs, and readiness status.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -98,7 +98,7 @@ export class OpenCodeDO {
|
||||
|
||||
async fetch() {
|
||||
const opencode = await this.opencode
|
||||
return Response.json(await opencode.health.get())
|
||||
return Response.json(await opencode.server.status())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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.health.get())
|
||||
return Response.json(await opencode.server.status())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.health.get()).toMatchObject({ healthy: true, version: "transport-test" })
|
||||
expect(yield* client.server.status()).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.health.get().pipe(Effect.provideService(FetchHttpClient.Fetch, ambient), Effect.exit),
|
||||
yield* client.server.status().pipe(Effect.provideService(FetchHttpClient.Fetch, ambient), Effect.exit),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(calls).toEqual([])
|
||||
|
||||
@@ -13,7 +13,6 @@ import { RpcHandler } from "./handlers/rpc"
|
||||
import { EventHandler } from "./handlers/event"
|
||||
import { AgentHandler } from "./handlers/agent"
|
||||
import { PluginHandler } from "./handlers/plugin"
|
||||
import { HealthHandler } from "./handlers/health"
|
||||
import { ServerHandler } from "./handlers/server"
|
||||
import { DebugHandler } from "./handlers/debug"
|
||||
import { PtyHandler } from "./handlers/pty"
|
||||
@@ -34,7 +33,6 @@ import { ConfigHandler } from "./handlers/config"
|
||||
import { WorkspaceHandler } from "./handlers/workspace"
|
||||
|
||||
export const handlers = Layer.mergeAll(
|
||||
HealthHandler,
|
||||
ServerHandler,
|
||||
DebugHandler,
|
||||
MigrationHandler,
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
import { ServerInfo } from "../server-info"
|
||||
|
||||
export const HealthHandler = HttpApiBuilder.group(Api, "server.health", (handlers) =>
|
||||
handlers.handle("health.get", () =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* ServerInfo.Service
|
||||
return {
|
||||
healthy: true as const,
|
||||
version: info.app.version ?? "unknown",
|
||||
// Runtimes without OS process identity (workerd) report 0.
|
||||
pid: process.pid ?? 0,
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -4,10 +4,14 @@ import { Api } from "../api"
|
||||
import { ServerInfo } from "../server-info"
|
||||
|
||||
export const ServerHandler = HttpApiBuilder.group(Api, "server.server", (handlers) =>
|
||||
handlers.handle("server.get", () =>
|
||||
handlers.handle("server.status", () =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* ServerInfo.Service
|
||||
return { urls: info.urls() }
|
||||
return {
|
||||
version: info.app.version ?? "unknown",
|
||||
pid: process.pid ?? 0,
|
||||
urls: info.urls(),
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -60,11 +60,17 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
|
||||
const shutdown = yield* Latch.make()
|
||||
const status = yield* Status.make()
|
||||
const bound = yield* listen({ hostname, port })
|
||||
const urls = () => {
|
||||
const address = bound.server.address()
|
||||
if (address === null || typeof address === "string") return []
|
||||
const host = address.family === "IPv6" ? `[${address.address}]` : address.address
|
||||
return ServerInfo.connectionURLs(`http://${host}:${address.port}`, hostname)
|
||||
}
|
||||
const application = yield* Ref.make(Option.none<App>())
|
||||
// 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").pipe(
|
||||
dispatch(password, status, application, options.app?.version ?? "unknown", urls).pipe(
|
||||
HttpMiddleware.cors({ allowedOrigins: (origin) => isAllowedCorsOrigin(origin, options), maxAge: 86_400 }),
|
||||
),
|
||||
errorResponseLogger,
|
||||
@@ -94,12 +100,7 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
|
||||
...options,
|
||||
password,
|
||||
},
|
||||
() => {
|
||||
const address = bound.server.address()
|
||||
if (address === null || typeof address === "string") return []
|
||||
const host = address.family === "IPv6" ? `[${address.address}]` : address.address
|
||||
return ServerInfo.connectionURLs(`http://${host}:${address.port}`, hostname)
|
||||
},
|
||||
urls,
|
||||
).pipe(Layer.provideMerge(NodeHttpServer.layerHttpServices)),
|
||||
applicationScope,
|
||||
)
|
||||
@@ -180,14 +181,15 @@ function dispatch(
|
||||
status: Status.Interface,
|
||||
application: Ref.Ref<Option.Option<App>>,
|
||||
version: string,
|
||||
urls: () => ReadonlyArray<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/health") {
|
||||
if (request.method === "GET" && url.pathname === "/api/status") {
|
||||
if (!(yield* authorizedRequest(request, auth))) return unauthorized()
|
||||
return yield* healthResponse(status, version)
|
||||
return yield* statusResponse(status, version, urls)
|
||||
}
|
||||
const state = yield* status.current
|
||||
const app = yield* Ref.get(application)
|
||||
@@ -209,10 +211,14 @@ function unauthorized() {
|
||||
})
|
||||
}
|
||||
|
||||
const healthResponse = Effect.fnUntraced(function* (status: Status.Interface, version: string) {
|
||||
const statusResponse = Effect.fnUntraced(function* (
|
||||
status: Status.Interface,
|
||||
version: string,
|
||||
urls: () => ReadonlyArray<string>,
|
||||
) {
|
||||
const state = yield* status.current
|
||||
return HttpServerResponse.jsonUnsafe(
|
||||
{ healthy: true, version, pid: process.pid },
|
||||
{ version, pid: process.pid, urls: urls() },
|
||||
{
|
||||
status: state.type === "ready" ? 200 : state.type === "failed" ? 500 : 503,
|
||||
headers: state.type === "starting" || state.type === "stopping" ? { "retry-after": "1" } : undefined,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Workspace } from "@opencode/core/workspace"
|
||||
import { WorkspaceDriver } from "@opencode/core/workspace/driver"
|
||||
import { Agent } from "@opencode/schema/agent"
|
||||
import { Integration } from "@opencode/schema/integration"
|
||||
import { ServerStatus } 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"
|
||||
@@ -103,20 +104,19 @@ 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/health")))
|
||||
const denied = yield* Effect.promise(() => handler(new Request("http://opencode.local/api/status")))
|
||||
expect(denied.status).toBe(401)
|
||||
|
||||
const response = yield* Effect.promise(() =>
|
||||
handler(
|
||||
new Request("http://opencode.local/api/health", {
|
||||
new Request("http://opencode.local/api/status", {
|
||||
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
const body: unknown = yield* Effect.promise(() => response.json())
|
||||
if (typeof body !== "object" || body === null) throw new Error("Expected a health response object")
|
||||
expect((body as Record<string, unknown>)["healthy"]).toBe(true)
|
||||
const body = yield* Effect.promise(() => response.json()).pipe(Effect.flatMap(Schema.decodeUnknownEffect(ServerStatus)))
|
||||
expect(body.version).toBe("test-version")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -134,12 +134,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/health")))
|
||||
const response = yield* Effect.promise(() => handler(new Request("http://opencode.local/api/status")))
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
const preflight = yield* Effect.promise(() =>
|
||||
handler(
|
||||
new Request("http://opencode.local/api/health", {
|
||||
new Request("http://opencode.local/api/status", {
|
||||
method: "OPTIONS",
|
||||
headers: {
|
||||
origin: "http://localhost:3000",
|
||||
@@ -166,7 +166,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/health", {
|
||||
new Request("http://opencode.local/api/status", {
|
||||
method: "OPTIONS",
|
||||
headers: {
|
||||
origin,
|
||||
@@ -182,7 +182,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/health", {
|
||||
new Request("http://opencode.local/api/status", {
|
||||
headers: { origin, authorization: `Basic ${btoa("opencode:secret")}` },
|
||||
}),
|
||||
),
|
||||
@@ -533,7 +533,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/health", { signal: controller.signal }))
|
||||
const first = handler(new Request("http://opencode.local/api/status", { signal: controller.signal }))
|
||||
controller.abort()
|
||||
return first.then(
|
||||
() => "resolved" as const,
|
||||
@@ -542,7 +542,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/health")))
|
||||
const second = yield* Effect.promise(() => handler(new Request("http://opencode.local/api/status")))
|
||||
expect(second.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -26,7 +26,7 @@ it.live("authenticates API and frontend requests while allowing browser prefligh
|
||||
),
|
||||
)
|
||||
const response = yield* Effect.promise(() =>
|
||||
fetch(new URL("/api/health", HttpServer.formatAddress(server.address)), {
|
||||
fetch(new URL("/api/status", HttpServer.formatAddress(server.address)), {
|
||||
method: "OPTIONS",
|
||||
headers: {
|
||||
origin: "http://localhost:3000",
|
||||
@@ -40,8 +40,8 @@ it.live("authenticates API and frontend requests while allowing browser prefligh
|
||||
expect(response.headers.get("access-control-allow-origin")).toBe("http://localhost:3000")
|
||||
expect(response.headers.get("access-control-allow-headers")).toBe("authorization")
|
||||
|
||||
const health = yield* Effect.promise(() =>
|
||||
fetch(new URL("/api/health", HttpServer.formatAddress(server.address)), {
|
||||
const status = yield* Effect.promise(() =>
|
||||
fetch(new URL("/api/status", HttpServer.formatAddress(server.address)), {
|
||||
headers: {
|
||||
authorization: `Basic ${btoa("opencode:secret")}`,
|
||||
origin: "http://localhost:3000",
|
||||
@@ -49,9 +49,9 @@ it.live("authenticates API and frontend requests while allowing browser prefligh
|
||||
}),
|
||||
)
|
||||
|
||||
expect(health.status).toBe(200)
|
||||
expect(health.headers.get("access-control-allow-origin")).toBe("http://localhost:3000")
|
||||
expect(yield* Effect.promise(() => health.json())).toMatchObject({ version: "test-version" })
|
||||
expect(status.status).toBe(200)
|
||||
expect(status.headers.get("access-control-allow-origin")).toBe("http://localhost:3000")
|
||||
expect(yield* Effect.promise(() => status.json())).toMatchObject({ version: "test-version" })
|
||||
|
||||
yield* Effect.forEach(
|
||||
["http://192.168.1.10:3001", "https://example.com", "https://untrusted.example.com"],
|
||||
@@ -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/health", HttpServer.formatAddress(server.address)), {
|
||||
fetch(new URL("/api/status", HttpServer.formatAddress(server.address)), {
|
||||
method: "OPTIONS",
|
||||
headers: {
|
||||
origin,
|
||||
@@ -71,17 +71,17 @@ it.live("authenticates API and frontend requests while allowing browser prefligh
|
||||
expect(preflight.status).toBe(204)
|
||||
expect(preflight.headers.get("access-control-allow-origin")).toBe(allowed)
|
||||
|
||||
const health = yield* Effect.promise(() =>
|
||||
fetch(new URL("/api/health", HttpServer.formatAddress(server.address)), {
|
||||
const status = yield* Effect.promise(() =>
|
||||
fetch(new URL("/api/status", HttpServer.formatAddress(server.address)), {
|
||||
headers: { origin, authorization: `Basic ${btoa("opencode:secret")}` },
|
||||
}),
|
||||
)
|
||||
expect(health.status).toBe(200)
|
||||
expect(health.headers.get("access-control-allow-origin")).toBe(allowed)
|
||||
yield* Effect.promise(() => health.arrayBuffer())
|
||||
expect(status.status).toBe(200)
|
||||
expect(status.headers.get("access-control-allow-origin")).toBe(allowed)
|
||||
yield* Effect.promise(() => status.arrayBuffer())
|
||||
|
||||
const denied = yield* Effect.promise(() =>
|
||||
fetch(new URL("/api/health", HttpServer.formatAddress(server.address)), { headers: { origin } }),
|
||||
fetch(new URL("/api/status", HttpServer.formatAddress(server.address)), { headers: { origin } }),
|
||||
)
|
||||
expect(denied.status).toBe(401)
|
||||
expect(denied.headers.get("access-control-allow-origin")).toBe(allowed)
|
||||
|
||||
@@ -15,19 +15,19 @@ it.live("boots the workerd profile over durable object storage", () =>
|
||||
config: { content: "{}" },
|
||||
})
|
||||
|
||||
const unauthorized = yield* Effect.promise(() => handler(new Request("http://opencode.local/api/health")))
|
||||
const unauthorized = yield* Effect.promise(() => handler(new Request("http://opencode.local/api/status")))
|
||||
expect(unauthorized.status).toBe(401)
|
||||
|
||||
const health = yield* Effect.promise(() =>
|
||||
const status = yield* Effect.promise(() =>
|
||||
handler(
|
||||
new Request("http://opencode.local/api/health", {
|
||||
new Request("http://opencode.local/api/status", {
|
||||
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(health.status).toBe(200)
|
||||
expect(status.status).toBe(200)
|
||||
|
||||
const body: unknown = yield* Effect.promise(() => health.json())
|
||||
expect(body).toMatchObject({ healthy: true, version: "workerd-test" })
|
||||
const body: unknown = yield* Effect.promise(() => status.json())
|
||||
expect(body).toMatchObject({ version: "workerd-test" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -54,10 +54,10 @@ export function DevToolsBar() {
|
||||
)
|
||||
const groups = createMemo(() => DevTools.data().filter((group) => group.id !== "theme-performance"))
|
||||
const [server] = createResource(connected, async () => {
|
||||
const [health, info] = await Promise.all([client.api.health.get(), client.api.server.get()])
|
||||
const status = await client.api.server.status()
|
||||
return {
|
||||
health,
|
||||
address: info.urls[0] ? new URL(info.urls[0]).host : "Unknown",
|
||||
health: status,
|
||||
address: status.urls[0] ? new URL(status.urls[0]).host : "Unknown",
|
||||
}
|
||||
})
|
||||
const close = () => {
|
||||
|
||||
@@ -26,7 +26,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
||||
dialog.setCentered(true)
|
||||
|
||||
const [server] = createResource(() =>
|
||||
client.api.server.get().catch((error) => {
|
||||
client.api.server.status().catch((error) => {
|
||||
setLoadError(error)
|
||||
return undefined
|
||||
}),
|
||||
|
||||
+14
-67
@@ -6,71 +6,19 @@
|
||||
"description": "Experimental HttpApi surface for selected instance routes."
|
||||
},
|
||||
"paths": {
|
||||
"/api/health": {
|
||||
"get": {
|
||||
"tags": ["health"],
|
||||
"operationId": "health.get",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "ServiceHealth",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ServiceHealth"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Report the owning server process and its application status.",
|
||||
"summary": "Check server health"
|
||||
}
|
||||
},
|
||||
"/api/server": {
|
||||
"/api/status": {
|
||||
"get": {
|
||||
"tags": ["server"],
|
||||
"operationId": "server.get",
|
||||
"operationId": "server.status",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"description": "ServerStatus",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"urls": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["urls"],
|
||||
"additionalProperties": false
|
||||
"$ref": "#/components/schemas/ServerStatus"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,8 +44,8 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Return the URLs that can be used to connect to this server.",
|
||||
"summary": "Get server information"
|
||||
"description": "Return the server identity, connection URLs, and readiness status.",
|
||||
"summary": "Get server status"
|
||||
}
|
||||
},
|
||||
"/api/location": {
|
||||
@@ -17959,22 +17907,24 @@
|
||||
"required": ["_tag", "type", "message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ServiceHealth": {
|
||||
"ServerStatus": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"healthy": {
|
||||
"type": "boolean",
|
||||
"enum": [true]
|
||||
},
|
||||
"version": {
|
||||
"type": "string"
|
||||
},
|
||||
"pid": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"urls": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["healthy", "version", "pid"],
|
||||
"required": ["version", "pid", "urls"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ServiceUnavailableErrorEncoded": {
|
||||
@@ -20199,9 +20149,6 @@
|
||||
},
|
||||
"security": [],
|
||||
"tags": [
|
||||
{
|
||||
"name": "health"
|
||||
},
|
||||
{
|
||||
"name": "server"
|
||||
},
|
||||
|
||||
@@ -6,71 +6,19 @@
|
||||
"description": "Experimental HttpApi surface for selected instance routes."
|
||||
},
|
||||
"paths": {
|
||||
"/api/health": {
|
||||
"get": {
|
||||
"tags": ["health"],
|
||||
"operationId": "health.get",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "ServiceHealth",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ServiceHealth"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Report the owning server process and its application status.",
|
||||
"summary": "Check server health"
|
||||
}
|
||||
},
|
||||
"/api/server": {
|
||||
"/api/status": {
|
||||
"get": {
|
||||
"tags": ["server"],
|
||||
"operationId": "server.get",
|
||||
"operationId": "server.status",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"description": "ServerStatus",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"urls": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["urls"],
|
||||
"additionalProperties": false
|
||||
"$ref": "#/components/schemas/ServerStatus"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,8 +44,8 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Return the URLs that can be used to connect to this server.",
|
||||
"summary": "Get server information"
|
||||
"description": "Return the server identity, connection URLs, and readiness status.",
|
||||
"summary": "Get server status"
|
||||
}
|
||||
},
|
||||
"/api/location": {
|
||||
@@ -17959,22 +17907,24 @@
|
||||
"required": ["_tag", "type", "message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ServiceHealth": {
|
||||
"ServerStatus": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"healthy": {
|
||||
"type": "boolean",
|
||||
"enum": [true]
|
||||
},
|
||||
"version": {
|
||||
"type": "string"
|
||||
},
|
||||
"pid": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"urls": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["healthy", "version", "pid"],
|
||||
"required": ["version", "pid", "urls"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ServiceUnavailableErrorEncoded": {
|
||||
@@ -20199,9 +20149,6 @@
|
||||
},
|
||||
"security": [],
|
||||
"tags": [
|
||||
{
|
||||
"name": "health"
|
||||
},
|
||||
{
|
||||
"name": "server"
|
||||
},
|
||||
|
||||
@@ -134,7 +134,7 @@ const program = Effect.gen(function* () {
|
||||
HttpClient.mapRequest(httpClient, (request) => HttpClientRequest.setHeaders(request, Service.headers(endpoint))),
|
||||
),
|
||||
)
|
||||
return yield* client.health.get()
|
||||
return yield* client.server.status()
|
||||
})
|
||||
|
||||
const health = await Effect.runPromise(
|
||||
|
||||
@@ -156,7 +156,7 @@ const client = OpenCode.make({
|
||||
headers: Service.headers(endpoint),
|
||||
})
|
||||
|
||||
const health = await client.health.get()
|
||||
const status = await client.server.status()
|
||||
```
|
||||
|
||||
`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.health.get())
|
||||
return Response.json(await opencode.server.status())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -38,7 +38,7 @@ The embedded host uses the same schema values, declared errors, request options,
|
||||
`@opencode/client/effect`.
|
||||
|
||||
```ts
|
||||
const health = yield* opencode.health.get()
|
||||
const status = yield* opencode.server.status()
|
||||
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.health.get()
|
||||
return yield* opencode.server.status()
|
||||
})
|
||||
|
||||
const health = await Effect.runPromise(program.pipe(Effect.provide(OpenCode.layer())))
|
||||
|
||||
@@ -97,7 +97,7 @@ opencode serve
|
||||
Check that the CLI can reach the background service after applying the settings:
|
||||
|
||||
```bash
|
||||
opencode api get /api/health
|
||||
opencode api get /api/status
|
||||
```
|
||||
|
||||
A successful health response verifies the local client-to-service connection. Run a normal provider request to verify the
|
||||
|
||||
@@ -21,7 +21,7 @@ opencode service status
|
||||
Verify that its API is healthy:
|
||||
|
||||
```bash
|
||||
opencode api get /api/health
|
||||
opencode api get /api/status
|
||||
```
|
||||
|
||||
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/health
|
||||
opencode api get /api/status
|
||||
```
|
||||
|
||||
Use the `pid` from the response with one of these signals:
|
||||
|
||||
Reference in New Issue
Block a user