refactor(app): route backend through adapters
This commit is contained in:
parent
4c0feeebb4
commit
08ea08e830
130 changed files with 5302 additions and 1953 deletions
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue