refactor(app): route backend through adapters

This commit is contained in:
Brendan Allan 2026-07-16 06:24:08 +00:00
commit 08ea08e830
130 changed files with 5302 additions and 1953 deletions

View file

@ -1,6 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { FileDiffInfo } from "@opencode-ai/sdk/v2"
import type { Message } from "@opencode-ai/sdk/v2/client"
import type { AppFileDiff as FileDiffInfo, AppMessage as Message } from "@/context/backend"
import { diffs, message } from "./diffs"
const item = {

View file

@ -1,7 +1,6 @@
import type { FileDiffInfo } from "@opencode-ai/sdk/v2"
import type { Message } from "@opencode-ai/sdk/v2/client"
import type { AppFileDiff, AppMessage } from "@/context/backend"
type Diff = FileDiffInfo
type Diff = AppFileDiff
function diff(value: unknown): value is Diff {
if (!value || typeof value !== "object" || Array.isArray(value)) return false
@ -25,7 +24,7 @@ export function diffs(value: unknown): Diff[] {
return Object.values(value).filter(diff)
}
export function message(value: Message): Message {
export function message(value: AppMessage): AppMessage {
if (value.role !== "user") return value
const raw = value.summary as unknown

View file

@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { Part } from "@opencode-ai/sdk/v2"
import type { AppPart as Part } from "@/context/backend"
import { extractPromptFromParts } from "./prompt"
describe("extractPromptFromParts", () => {

View file

@ -1,4 +1,7 @@
import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@opencode-ai/sdk/v2"
import type { AppFilePart as FilePart, AppPart } from "@/context/backend"
type Part = AppPart
type TextPart = Extract<AppPart, { type: "text" }>
type MessageAgentPart = Extract<AppPart, { type: "agent" }>
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
type Inline =

View file

@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { SessionNotFoundError } from "@opencode-ai/sdk/v2/client"
import type { AppSessionNotFoundError as SessionNotFoundError } from "@/context/backend"
import type { ConfigInvalidError, ProviderModelNotFoundError } from "./server-errors"
import { formatServerError, isSessionNotFoundError, parseReadableConfigInvalidError } from "./server-errors"

View file

@ -13,16 +13,40 @@ function abortFromInput(input: RequestInfo | URL, init?: RequestInit) {
}
describe("checkServerHealth", () => {
test("returns healthy response with version", async () => {
test("prefers native v2 health", async () => {
const paths: string[] = []
const fetch = (async () =>
new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
new Response(JSON.stringify({ healthy: true }), {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof globalThis.fetch
const trackingFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
paths.push(new URL(input instanceof Request ? input.url : input).pathname)
return fetch(input, init)
}) as typeof globalThis.fetch
const result = await checkServerHealth(server, trackingFetch)
expect(result).toEqual({ healthy: true, version: "v2" })
expect(paths).toEqual(["/api/health"])
})
test("falls back to v1 and preserves installation version", async () => {
const paths: string[] = []
const fetch = (async (input: RequestInfo | URL) => {
const path = new URL(input instanceof Request ? input.url : input).pathname
paths.push(path)
if (path === "/api/health") return new Response(null, { status: 404 })
return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
headers: { "content-type": "application/json" },
})
}) as typeof globalThis.fetch
const result = await checkServerHealth(server, fetch)
expect(result).toEqual({ healthy: true, version: "1.2.3" })
expect(result).toEqual({ healthy: true, version: "v1", installationVersion: "1.2.3" })
expect(paths).toEqual(["/api/health", "/global/health"])
})
test("allows slow servers thirty seconds by default", async () => {
@ -57,7 +81,7 @@ describe("checkServerHealth", () => {
const result = await checkServerHealth(server, fetch)
expect(result).toEqual({ healthy: false })
expect(result).toEqual({ healthy: false, version: "v2" })
})
test("uses timeout fallback when AbortSignal.timeout is unavailable", async () => {
@ -89,7 +113,7 @@ describe("checkServerHealth", () => {
})
expect(aborted).toBe(true)
expect(result).toEqual({ healthy: false })
expect(result).toEqual({ healthy: false, version: "v2" })
})
test("uses provided abort signal", async () => {
@ -127,7 +151,7 @@ describe("checkServerHealth", () => {
})
expect(count).toBe(3)
expect(result).toEqual({ healthy: true, version: "1.2.3" })
expect(result).toEqual({ healthy: true, version: "v2" })
})
test("returns unhealthy when retries are exhausted", async () => {
@ -143,6 +167,56 @@ describe("checkServerHealth", () => {
})
expect(count).toBe(3)
expect(result).toEqual({ healthy: false })
expect(result).toEqual({ healthy: false, version: "v2" })
})
test("does not fall back to v1 for a transient v2 server response", async () => {
const paths: string[] = []
const fetch = (async (input: RequestInfo | URL) => {
paths.push(new URL(input instanceof Request ? input.url : input).pathname)
return new Response(null, { status: 503 })
}) as typeof globalThis.fetch
const result = await checkServerHealth(server, fetch, { retryCount: 0 })
expect(result).toEqual({ healthy: false, version: "v2" })
expect(paths).toEqual(["/api/health"])
})
test("retries transient v2 responses before succeeding", async () => {
const paths: string[] = []
const fetch = (async (input: RequestInfo | URL) => {
const path = new URL(input instanceof Request ? input.url : input).pathname
paths.push(path)
if (paths.length === 1) return new Response(null, { status: 503 })
return new Response(JSON.stringify({ healthy: true }), {
headers: { "content-type": "application/json" },
})
}) as typeof globalThis.fetch
expect(await checkServerHealth(server, fetch, { retryCount: 1, retryDelayMs: 1 })).toEqual({
healthy: true,
version: "v2",
})
expect(paths).toEqual(["/api/health", "/api/health"])
})
test("falls back to v1 for method-not-allowed v2 health", async () => {
const paths: string[] = []
const fetch = (async (input: RequestInfo | URL) => {
const path = new URL(input instanceof Request ? input.url : input).pathname
paths.push(path)
if (path === "/api/health") return new Response(null, { status: 405 })
return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
headers: { "content-type": "application/json" },
})
}) as typeof globalThis.fetch
expect(await checkServerHealth(server, fetch)).toEqual({
healthy: true,
version: "v1",
installationVersion: "1.2.3",
})
expect(paths).toEqual(["/api/health", "/global/health"])
})
})

View file

@ -1,10 +1,14 @@
import { usePlatform } from "@/context/platform"
import { ServerConnection } from "@/context/server"
import { createSdkForServer } from "./server"
import { createV1RawClient, createV2RawClient } from "@/context/backend-client"
import { Accessor, createEffect, onCleanup } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
export type ServerHealth = { healthy: boolean; version?: string }
export type ServerHealth = {
healthy: boolean
version: "v1" | "v2"
installationVersion?: string
}
interface CheckServerHealthOptions {
timeoutMs?: number
@ -64,9 +68,40 @@ function retryable(error: unknown, signal?: AbortSignal) {
if (!(error instanceof Error)) return false
if (error.name === "AbortError" || error.name === "TimeoutError") return false
if (error instanceof TypeError) return true
if (error !== null && typeof error === "object" && "reason" in error && error.reason === "Transport") return true
if (error.cause !== null && typeof error.cause === "object" && "status" in error.cause) {
const status = error.cause.status
if (status === 408 || status === 429 || (typeof status === "number" && status >= 500)) return true
}
return /network|fetch|econnreset|econnrefused|enotfound|timedout/i.test(error.message)
}
function unsupported(error: unknown) {
if (error === null || typeof error !== "object" || !("cause" in error)) return false
const cause = error.cause
if (cause === null || typeof cause !== "object" || !("status" in cause)) return false
return cause.status === 404 || cause.status === 405
}
function abortable<T>(promise: Promise<T>, signal?: AbortSignal) {
if (!signal) return promise
if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"))
return new Promise<T>((resolve, reject) => {
const abort = () => reject(new DOMException("Aborted", "AbortError"))
signal.addEventListener("abort", abort, { once: true })
promise.then(
(value) => {
signal.removeEventListener("abort", abort)
resolve(value)
},
(error) => {
signal.removeEventListener("abort", abort)
reject(error)
},
)
})
}
export async function checkServerHealth(
server: ServerConnection.HttpBase,
fetch: typeof globalThis.fetch,
@ -77,20 +112,33 @@ export async function checkServerHealth(
const retryCount = opts?.retryCount ?? defaultRetryCount
const retryDelayMs = opts?.retryDelayMs ?? defaultRetryDelayMs
const next = (count: number, error: unknown) => {
if (count >= retryCount || !retryable(error, signal)) return Promise.resolve({ healthy: false } as const)
if (count >= retryCount || !retryable(error, signal))
return Promise.resolve({ healthy: false, version: "v2" } as const)
return wait(retryDelayMs * (count + 1), signal)
.then(() => attempt(count + 1))
.catch(() => ({ healthy: false }))
.catch(() => ({ healthy: false, version: "v2" as const }))
}
const attempt = async (count: number): Promise<ServerHealth> => {
try {
const result = await abortable(createV2RawClient(server, fetch).health.get({ signal }), signal)
return { healthy: result.healthy === true, version: "v2" }
} catch (error) {
if (!unsupported(error)) return next(count, error)
}
try {
const result = await abortable(createV1RawClient(server, fetch).global.health({ signal }), signal)
if (result.error) return { healthy: false, version: "v1" }
return {
healthy: result.data?.healthy === true,
version: "v1",
installationVersion: result.data?.version,
}
} catch (error) {
if (signal?.aborted) return { healthy: false, version: "v1" }
return { healthy: false, version: "v1" }
}
}
const attempt = (count: number): Promise<ServerHealth> =>
createSdkForServer({
server,
fetch,
signal,
})
.global.health()
.then((x) => (x.error ? next(count, x.error) : { healthy: x.data?.healthy === true, version: x.data?.version }))
.catch((error) => next(count, error))
return attempt(0).finally(() => timeout?.clear?.())
}
@ -116,8 +164,11 @@ export function useCheckServerHealth() {
}
}
export const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Accessor<boolean>) => {
const checkServerHealth = useCheckServerHealth()
export const useServerHealth = (
servers: Accessor<ServerConnection.Any[]>,
enabled: Accessor<boolean>,
checkServerHealth = useCheckServerHealth(),
) => {
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
createEffect(() => {

View file

@ -1,5 +1,3 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import type { ServerConnection } from "@/context/server"
import { decode64 } from "@/utils/base64"
export function authTokenFromCredentials(input: { username?: string; password: string }) {
@ -16,26 +14,3 @@ export function authFromToken(token: string | null) {
password: decoded.slice(separator + 1),
}
}
export function createSdkForServer({
server,
...config
}: Omit<NonNullable<Parameters<typeof createOpencodeClient>[0]>, "baseUrl"> & {
server: ServerConnection.HttpBase
}) {
const auth = (() => {
if (!server.password) return
return {
Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`,
}
})()
return createOpencodeClient({
...config,
headers: {
...(config.headers instanceof Headers ? Object.fromEntries(config.headers.entries()) : config.headers),
...auth,
},
baseUrl: server.url,
})
}

View file

@ -1,52 +0,0 @@
import { describe, expect, test } from "bun:test"
import { terminalWebSocketURL } from "./terminal-websocket-url"
describe("terminalWebSocketURL", () => {
test("uses query auth without embedding credentials in websocket URL", () => {
const url = terminalWebSocketURL({
url: "http://127.0.0.1:49365",
id: "pty_test",
directory: "/tmp/project",
cursor: 0,
sameOrigin: false,
username: "opencode",
password: "secret",
})
expect(url.protocol).toBe("ws:")
expect(url.username).toBe("")
expect(url.password).toBe("")
expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret"))
})
test("omits query auth for same-origin saved credentials", () => {
const url = terminalWebSocketURL({
url: "https://app.example.test",
id: "pty_test",
directory: "/tmp/project",
cursor: 10,
sameOrigin: true,
username: "opencode",
password: "secret",
})
expect(url.protocol).toBe("wss:")
expect(url.searchParams.has("auth_token")).toBe(false)
})
test("uses query auth for same-origin credentials from auth_token", () => {
const url = terminalWebSocketURL({
url: "https://app.example.test",
id: "pty_test",
directory: "/tmp/project",
cursor: 10,
sameOrigin: true,
username: "opencode",
password: "secret",
authToken: true,
})
expect(url.protocol).toBe("wss:")
expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret"))
})
})

View file

@ -1,28 +0,0 @@
import { authTokenFromCredentials } from "@/utils/server"
export function terminalWebSocketURL(input: {
url: string
id: string
directory: string
cursor: number
ticket?: string
sameOrigin?: boolean
username?: string
password?: string
authToken?: boolean
}) {
const next = new URL(`${input.url}/pty/${input.id}/connect`)
next.searchParams.set("directory", input.directory)
next.searchParams.set("cursor", String(input.cursor))
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
if (input.ticket) {
next.searchParams.set("ticket", input.ticket)
return next
}
if (input.password && (!input.sameOrigin || input.authToken))
next.searchParams.set(
"auth_token",
authTokenFromCredentials({ username: input.username, password: input.password }),
)
return next
}