feat(app): add dual-server compatibility (#38462)
This commit is contained in:
parent
84c79c1399
commit
d03e0c5e54
8 changed files with 756 additions and 20 deletions
95
packages/app/src/utils/server-compat.test.ts
Normal file
95
packages/app/src/utils/server-compat.test.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { createApiForServer, createSdkForServer } from "./server"
|
||||||
|
import { createCompatibleApi } from "./server-compat"
|
||||||
|
|
||||||
|
function setup(protocol: "v1" | "v2") {
|
||||||
|
const requests: Request[] = []
|
||||||
|
const fetcher = Object.assign(
|
||||||
|
async (input: string | URL | Request, init?: RequestInit) => {
|
||||||
|
const request = new Request(input, init)
|
||||||
|
requests.push(request)
|
||||||
|
if (request.method === "PATCH") {
|
||||||
|
return Response.json({
|
||||||
|
id: "ses_1",
|
||||||
|
slug: "ses_1",
|
||||||
|
projectID: "project",
|
||||||
|
directory: "/repo",
|
||||||
|
title: "Session",
|
||||||
|
version: "1",
|
||||||
|
time: { created: 1, updated: 1 },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (request.method === "POST" && request.url.endsWith("/prompt_async"))
|
||||||
|
return new Response(undefined, { status: 204 })
|
||||||
|
if (request.method === "POST" && request.url.endsWith("/prompt")) {
|
||||||
|
return Response.json({
|
||||||
|
admittedSeq: 1,
|
||||||
|
id: "msg_1",
|
||||||
|
sessionID: "ses_1",
|
||||||
|
timeCreated: 1,
|
||||||
|
type: "user",
|
||||||
|
data: { text: "hello" },
|
||||||
|
delivery: "steer",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (request.method === "GET") return Response.json([])
|
||||||
|
return new Response(undefined, { status: 204 })
|
||||||
|
},
|
||||||
|
{ preconnect: globalThis.fetch.preconnect },
|
||||||
|
)
|
||||||
|
const server = { url: "http://localhost:4096" }
|
||||||
|
const api = createCompatibleApi({
|
||||||
|
protocol: Promise.resolve(protocol),
|
||||||
|
current: createApiForServer({ server, fetch: fetcher }),
|
||||||
|
legacy: (directory) => createSdkForServer({ server, fetch: fetcher, directory, throwOnError: true }),
|
||||||
|
directory: "/repo",
|
||||||
|
})
|
||||||
|
return { api, requests }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createCompatibleApi", () => {
|
||||||
|
test("routes V1 archive through the legacy session update", async () => {
|
||||||
|
const { api, requests } = setup("v1")
|
||||||
|
await api.session.archive({ sessionID: "ses_1", directory: "/repo" })
|
||||||
|
|
||||||
|
const url = new URL(requests[0]!.url)
|
||||||
|
expect(url.pathname).toBe("/session/ses_1")
|
||||||
|
expect(requests[0]!.headers.get("x-opencode-directory")).toBe("%2Frepo")
|
||||||
|
expect(requests[0]!.method).toBe("PATCH")
|
||||||
|
expect(await requests[0]!.json()).toMatchObject({ time: { archived: expect.any(Number) } })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("converts current prompts to the V1 prompt contract", async () => {
|
||||||
|
const { api, requests } = setup("v1")
|
||||||
|
await api.session.prompt({
|
||||||
|
sessionID: "ses_1",
|
||||||
|
id: "msg_1",
|
||||||
|
text: "hello",
|
||||||
|
agent: "build",
|
||||||
|
model: { providerID: "provider", modelID: "model" },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(new URL(requests[0]!.url).pathname).toBe("/session/ses_1/prompt_async")
|
||||||
|
expect(await requests[0]!.json()).toMatchObject({
|
||||||
|
messageID: "msg_1",
|
||||||
|
agent: "build",
|
||||||
|
model: { providerID: "provider", modelID: "model" },
|
||||||
|
parts: [{ type: "text", text: "hello" }],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps V2 session actions on the current API", async () => {
|
||||||
|
const { api, requests } = setup("v2")
|
||||||
|
await api.session.archive({ sessionID: "ses_1" })
|
||||||
|
|
||||||
|
expect(new URL(requests[0]!.url).pathname).toBe("/api/session/ses_1/archive")
|
||||||
|
expect(requests[0]!.method).toBe("POST")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses the global V1 session search endpoint", async () => {
|
||||||
|
const { api, requests } = setup("v1")
|
||||||
|
await api.session.list({ parentID: null, search: "session", limit: 50 })
|
||||||
|
|
||||||
|
expect(new URL(requests[0]!.url).pathname).toBe("/experimental/session")
|
||||||
|
})
|
||||||
|
})
|
||||||
496
packages/app/src/utils/server-compat.ts
Normal file
496
packages/app/src/utils/server-compat.ts
Normal file
|
|
@ -0,0 +1,496 @@
|
||||||
|
import type { ServerApi } from "./server"
|
||||||
|
import type { ServerProtocol } from "./server-protocol"
|
||||||
|
import type { OpencodeClient, Session } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import type {
|
||||||
|
Project,
|
||||||
|
ProjectCurrent,
|
||||||
|
SessionApi,
|
||||||
|
SessionCommandInput,
|
||||||
|
SessionCommandOutput,
|
||||||
|
SessionCompactInput,
|
||||||
|
SessionCompactOutput,
|
||||||
|
SessionInfo,
|
||||||
|
SessionPromptInput,
|
||||||
|
SessionPromptOutput,
|
||||||
|
SessionShellInput,
|
||||||
|
SessionShellOutput,
|
||||||
|
} from "@opencode-ai/client/promise"
|
||||||
|
|
||||||
|
type LegacyClient = OpencodeClient
|
||||||
|
type LegacyFor = (directory?: string) => LegacyClient
|
||||||
|
type CompatibleSessionApi = Omit<
|
||||||
|
SessionApi,
|
||||||
|
"prompt" | "command" | "shell" | "compact" | "rename" | "archive" | "remove"
|
||||||
|
> & {
|
||||||
|
prompt: (input: SessionPromptInput & LegacyPrompt) => Promise<SessionPromptOutput>
|
||||||
|
command: (input: SessionCommandInput) => Promise<SessionCommandOutput>
|
||||||
|
shell: (input: SessionShellInput & LegacyPrompt) => Promise<SessionShellOutput>
|
||||||
|
compact: (input: SessionCompactInput & { model?: LegacyPrompt["model"] }) => Promise<SessionCompactOutput>
|
||||||
|
rename: (input: Parameters<SessionApi["rename"]>[0] & LegacyLocation) => ReturnType<SessionApi["rename"]>
|
||||||
|
archive: (input: Parameters<SessionApi["archive"]>[0] & LegacyLocation) => ReturnType<SessionApi["archive"]>
|
||||||
|
remove: (input: Parameters<SessionApi["remove"]>[0] & LegacyLocation) => ReturnType<SessionApi["remove"]>
|
||||||
|
}
|
||||||
|
export type CompatibleApi = Omit<ServerApi, "session"> & { readonly session: CompatibleSessionApi }
|
||||||
|
type LegacyPrompt = {
|
||||||
|
agent?: string
|
||||||
|
model?: { providerID: string; modelID: string }
|
||||||
|
variant?: string
|
||||||
|
}
|
||||||
|
type LegacyLocation = { directory?: string }
|
||||||
|
|
||||||
|
function mime(uri: string) {
|
||||||
|
const match = /^data:([^;,]+)/.exec(uri)
|
||||||
|
return match?.[1] ?? "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionInfo(session: Session): SessionInfo {
|
||||||
|
return {
|
||||||
|
id: session.id,
|
||||||
|
parentID: session.parentID,
|
||||||
|
projectID: session.projectID,
|
||||||
|
agent: session.agent,
|
||||||
|
model: session.model && {
|
||||||
|
id: session.model.id,
|
||||||
|
providerID: session.model.providerID,
|
||||||
|
variant: session.model.variant,
|
||||||
|
},
|
||||||
|
cost: session.cost ?? 0,
|
||||||
|
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
time: session.time,
|
||||||
|
title: session.title,
|
||||||
|
location: { directory: session.directory, workspaceID: session.workspaceID },
|
||||||
|
subpath: session.path,
|
||||||
|
revert: session.revert && {
|
||||||
|
messageID: session.revert.messageID,
|
||||||
|
partID: session.revert.partID,
|
||||||
|
snapshot: session.revert.snapshot,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCompatibleApi(input: {
|
||||||
|
protocol: Promise<ServerProtocol>
|
||||||
|
current: ServerApi
|
||||||
|
legacy: LegacyFor
|
||||||
|
directory?: string
|
||||||
|
}): CompatibleApi {
|
||||||
|
const directory = (location?: { directory?: string }) => location?.directory ?? input.directory
|
||||||
|
const legacy = (location?: { directory?: string }) => input.legacy(directory(location))
|
||||||
|
const isV1 = () => input.protocol.then((protocol) => protocol === "v1")
|
||||||
|
const located = <T>(data: T, value?: { directory?: string }) => ({
|
||||||
|
location: {
|
||||||
|
directory: directory(value) ?? "",
|
||||||
|
project: { id: "", directory: directory(value) ?? "" },
|
||||||
|
},
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
...input.current,
|
||||||
|
session: {
|
||||||
|
...input.current.session,
|
||||||
|
async list(
|
||||||
|
value?: Parameters<ServerApi["session"]["list"]>[0],
|
||||||
|
options?: Parameters<ServerApi["session"]["list"]>[1],
|
||||||
|
) {
|
||||||
|
if (!(await isV1())) return input.current.session.list(value, options)
|
||||||
|
if (!value?.directory && value?.search !== undefined) {
|
||||||
|
const result = await legacy().experimental.session.list(
|
||||||
|
{
|
||||||
|
roots: value.parentID === null ? true : undefined,
|
||||||
|
search: value.search,
|
||||||
|
limit: value.limit,
|
||||||
|
},
|
||||||
|
options,
|
||||||
|
)
|
||||||
|
return { data: (result.data ?? []).map(sessionInfo), cursor: {} }
|
||||||
|
}
|
||||||
|
const result = await legacy({ directory: value?.directory }).session.list({
|
||||||
|
directory: value?.directory,
|
||||||
|
roots: value?.parentID === null ? true : undefined,
|
||||||
|
search: value?.search,
|
||||||
|
limit: value?.limit,
|
||||||
|
})
|
||||||
|
return { data: (result.data ?? []).map(sessionInfo), cursor: {} }
|
||||||
|
},
|
||||||
|
async create(value?: Parameters<ServerApi["session"]["create"]>[0]) {
|
||||||
|
if (!(await isV1())) return input.current.session.create(value)
|
||||||
|
const result = await legacy(value?.location ?? undefined).session.create({
|
||||||
|
directory: directory(value?.location ?? undefined),
|
||||||
|
})
|
||||||
|
if (!result.data) throw new Error("Failed to create session")
|
||||||
|
return sessionInfo(result.data)
|
||||||
|
},
|
||||||
|
async get(value: Parameters<ServerApi["session"]["get"]>[0]) {
|
||||||
|
if (!(await isV1())) return input.current.session.get(value)
|
||||||
|
const result = await legacy().session.get(value)
|
||||||
|
if (!result.data) throw new Error(`Session not found: ${value.sessionID}`)
|
||||||
|
return sessionInfo(result.data)
|
||||||
|
},
|
||||||
|
async active() {
|
||||||
|
if (!(await isV1())) return input.current.session.active()
|
||||||
|
const result = await legacy().session.status()
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(result.data ?? {}).flatMap(([sessionID, status]) =>
|
||||||
|
status.type === "idle" ? [] : [[sessionID, { type: "running" as const }]],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
async rename(value: Parameters<ServerApi["session"]["rename"]>[0] & LegacyLocation) {
|
||||||
|
if (!(await isV1())) return input.current.session.rename(value)
|
||||||
|
await legacy(value).session.update({ sessionID: value.sessionID, title: value.title })
|
||||||
|
},
|
||||||
|
async archive(value: Parameters<ServerApi["session"]["archive"]>[0] & LegacyLocation) {
|
||||||
|
if (!(await isV1())) return input.current.session.archive(value)
|
||||||
|
await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } })
|
||||||
|
},
|
||||||
|
async remove(value: Parameters<ServerApi["session"]["remove"]>[0] & LegacyLocation) {
|
||||||
|
if (!(await isV1())) return input.current.session.remove(value)
|
||||||
|
await legacy(value).session.delete(value)
|
||||||
|
},
|
||||||
|
async fork(value: Parameters<ServerApi["session"]["fork"]>[0]) {
|
||||||
|
if (!(await isV1())) return input.current.session.fork(value)
|
||||||
|
const result = await legacy().session.fork(value)
|
||||||
|
if (!result.data) throw new Error("Failed to fork session")
|
||||||
|
return sessionInfo(result.data)
|
||||||
|
},
|
||||||
|
async interrupt(value: Parameters<ServerApi["session"]["interrupt"]>[0]) {
|
||||||
|
if (!(await isV1())) return input.current.session.interrupt(value)
|
||||||
|
await legacy().session.abort(value)
|
||||||
|
},
|
||||||
|
async prompt(value: SessionPromptInput & LegacyPrompt) {
|
||||||
|
if (!(await isV1())) return input.current.session.prompt(value)
|
||||||
|
await legacy().session.promptAsync({
|
||||||
|
sessionID: value.sessionID,
|
||||||
|
messageID: value.id ?? undefined,
|
||||||
|
agent: value.agent,
|
||||||
|
model: value.model,
|
||||||
|
variant: value.variant,
|
||||||
|
parts: [
|
||||||
|
{ type: "text", text: value.text },
|
||||||
|
...(value.files ?? []).map((file) => ({
|
||||||
|
type: "file" as const,
|
||||||
|
mime: mime(file.uri),
|
||||||
|
url: file.uri,
|
||||||
|
filename: file.name,
|
||||||
|
})),
|
||||||
|
...(value.agents ?? []).map((agent) => ({
|
||||||
|
type: "agent" as const,
|
||||||
|
name: agent.name,
|
||||||
|
source: agent.mention
|
||||||
|
? { value: agent.mention.text, start: agent.mention.start, end: agent.mention.end }
|
||||||
|
: undefined,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
admittedSeq: 0,
|
||||||
|
id: value.id ?? "",
|
||||||
|
sessionID: value.sessionID,
|
||||||
|
timeCreated: Date.now(),
|
||||||
|
type: "user",
|
||||||
|
data: { text: value.text },
|
||||||
|
delivery: value.delivery ?? "steer",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async command(value: SessionCommandInput) {
|
||||||
|
if (!(await isV1())) return input.current.session.command(value)
|
||||||
|
await legacy().session.command({
|
||||||
|
sessionID: value.sessionID,
|
||||||
|
messageID: value.id ?? undefined,
|
||||||
|
command: value.command,
|
||||||
|
arguments: value.arguments ?? "",
|
||||||
|
agent: value.agent ?? undefined,
|
||||||
|
model: value.model ? `${value.model.providerID}/${value.model.id}` : undefined,
|
||||||
|
variant: value.model?.variant,
|
||||||
|
parts: value.files?.map((file) => ({
|
||||||
|
type: "file" as const,
|
||||||
|
mime: mime(file.uri),
|
||||||
|
url: file.uri,
|
||||||
|
filename: file.name,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
admittedSeq: 0,
|
||||||
|
id: value.id ?? "",
|
||||||
|
sessionID: value.sessionID,
|
||||||
|
timeCreated: Date.now(),
|
||||||
|
type: "user",
|
||||||
|
data: { text: `/${value.command} ${value.arguments ?? ""}`.trim() },
|
||||||
|
delivery: value.delivery ?? "steer",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async shell(value: SessionShellInput & LegacyPrompt) {
|
||||||
|
if (!(await isV1())) return input.current.session.shell(value)
|
||||||
|
await legacy().session.shell({
|
||||||
|
sessionID: value.sessionID,
|
||||||
|
command: value.command,
|
||||||
|
agent: value.agent,
|
||||||
|
model: value.model,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
compact: async (value: SessionCompactInput & { model?: LegacyPrompt["model"] }) => {
|
||||||
|
if (!(await isV1())) return input.current.session.compact(value)
|
||||||
|
if (!value.model) throw new Error("A model is required to compact a V1 session")
|
||||||
|
await legacy().session.summarize({
|
||||||
|
sessionID: value.sessionID,
|
||||||
|
providerID: value.model.providerID,
|
||||||
|
modelID: value.model.modelID,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
admittedSeq: 0,
|
||||||
|
id: value.id ?? "",
|
||||||
|
sessionID: value.sessionID,
|
||||||
|
timeCreated: Date.now(),
|
||||||
|
type: "compaction",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
revert: {
|
||||||
|
stage: async (value: Parameters<ServerApi["session"]["revert"]["stage"]>[0]) => {
|
||||||
|
if (!(await isV1())) return input.current.session.revert.stage(value)
|
||||||
|
await legacy().session.revert(value)
|
||||||
|
return { messageID: value.messageID }
|
||||||
|
},
|
||||||
|
clear: async (value: Parameters<ServerApi["session"]["revert"]["clear"]>[0]) => {
|
||||||
|
if (!(await isV1())) return input.current.session.revert.clear(value)
|
||||||
|
await legacy().session.unrevert(value)
|
||||||
|
},
|
||||||
|
commit: input.current.session.revert.commit,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
...input.current.project,
|
||||||
|
async list() {
|
||||||
|
if (!(await isV1())) return input.current.project.list()
|
||||||
|
return ((await legacy().project.list()).data ?? []) as Project[]
|
||||||
|
},
|
||||||
|
async current(value?: Parameters<ServerApi["project"]["current"]>[0]) {
|
||||||
|
if (!(await isV1())) return input.current.project.current(value)
|
||||||
|
const result = await legacy(value?.location).project.current()
|
||||||
|
if (!result.data) throw new Error("Project not found")
|
||||||
|
return { id: result.data.id, directory: result.data.worktree } satisfies ProjectCurrent
|
||||||
|
},
|
||||||
|
async update(value: Parameters<ServerApi["project"]["update"]>[0]) {
|
||||||
|
if (!(await isV1())) return input.current.project.update(value)
|
||||||
|
const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID)
|
||||||
|
const result = await legacy({ directory: project?.worktree }).project.update({
|
||||||
|
...value,
|
||||||
|
directory: project?.worktree,
|
||||||
|
})
|
||||||
|
if (!result.data) throw new Error(`Project not found: ${value.projectID}`)
|
||||||
|
return result.data as Project
|
||||||
|
},
|
||||||
|
async directories(value: Parameters<ServerApi["project"]["directories"]>[0]) {
|
||||||
|
if (!(await isV1())) return input.current.project.directories(value)
|
||||||
|
const result = await legacy(value.location).worktree.list()
|
||||||
|
return (result.data ?? []).map((item) => ({ directory: item }))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
path: {
|
||||||
|
...input.current.path,
|
||||||
|
async get(value?: Parameters<ServerApi["path"]["get"]>[0]) {
|
||||||
|
if (!(await isV1())) return input.current.path.get(value)
|
||||||
|
const result = await legacy(value?.location).path.get()
|
||||||
|
if (!result.data) throw new Error("Path unavailable")
|
||||||
|
return result.data
|
||||||
|
},
|
||||||
|
},
|
||||||
|
vcs: {
|
||||||
|
...input.current.vcs,
|
||||||
|
async get(value?: Parameters<ServerApi["vcs"]["get"]>[0]) {
|
||||||
|
if (!(await isV1())) return input.current.vcs.get(value)
|
||||||
|
const result = await legacy(value?.location).vcs.get()
|
||||||
|
return located({ branch: result.data?.branch, defaultBranch: undefined }, value?.location)
|
||||||
|
},
|
||||||
|
async status(value?: Parameters<ServerApi["vcs"]["status"]>[0]) {
|
||||||
|
if (!(await isV1())) return input.current.vcs.status(value)
|
||||||
|
const result = await legacy(value?.location).vcs.status()
|
||||||
|
return located(result.data ?? [], value?.location)
|
||||||
|
},
|
||||||
|
async diff(value: Parameters<ServerApi["vcs"]["diff"]>[0]) {
|
||||||
|
if (!(await isV1())) return input.current.vcs.diff(value)
|
||||||
|
const result = await legacy(value.location).vcs.diff({
|
||||||
|
mode: value.mode === "working" ? "git" : value.mode,
|
||||||
|
context: value.context,
|
||||||
|
})
|
||||||
|
return located(
|
||||||
|
(result.data ?? []).map((file) => ({
|
||||||
|
file: file.file,
|
||||||
|
patch: file.patch ?? "",
|
||||||
|
additions: file.additions,
|
||||||
|
deletions: file.deletions,
|
||||||
|
status: file.status ?? "modified",
|
||||||
|
})),
|
||||||
|
value.location,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
file: {
|
||||||
|
...input.current.file,
|
||||||
|
async list(value?: Parameters<ServerApi["file"]["list"]>[0]) {
|
||||||
|
if (!(await isV1())) return input.current.file.list(value)
|
||||||
|
const result = await legacy(value?.location).file.list({ path: value?.path ?? "" })
|
||||||
|
return located(result.data ?? [], value?.location)
|
||||||
|
},
|
||||||
|
async find(value: Parameters<ServerApi["file"]["find"]>[0]) {
|
||||||
|
if (!(await isV1())) return input.current.file.find(value)
|
||||||
|
const result = await legacy(value.location).find.files({
|
||||||
|
query: value.query,
|
||||||
|
type: value.type,
|
||||||
|
limit: value.limit,
|
||||||
|
})
|
||||||
|
return located(
|
||||||
|
(result.data ?? []).map((path) => ({ path, type: value.type ?? "file" })),
|
||||||
|
value.location,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
integration: {
|
||||||
|
...input.current.integration,
|
||||||
|
async get(value: Parameters<ServerApi["integration"]["get"]>[0]) {
|
||||||
|
if (!(await isV1())) return input.current.integration.get(value)
|
||||||
|
const methods = ((await legacy(value.location).provider.auth()).data?.[value.integrationID] ?? []).map(
|
||||||
|
(method, index) =>
|
||||||
|
method.type === "api"
|
||||||
|
? { type: "key" as const, label: method.label }
|
||||||
|
: { type: "oauth" as const, id: String(index), label: method.label, prompts: method.prompts },
|
||||||
|
)
|
||||||
|
return located(
|
||||||
|
{
|
||||||
|
id: value.integrationID,
|
||||||
|
name: value.integrationID,
|
||||||
|
methods,
|
||||||
|
connections: [],
|
||||||
|
},
|
||||||
|
value.location,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
connect: {
|
||||||
|
...input.current.integration.connect,
|
||||||
|
key: async (value: Parameters<ServerApi["integration"]["connect"]["key"]>[0]) => {
|
||||||
|
if (!(await isV1())) return input.current.integration.connect.key(value)
|
||||||
|
await legacy(value.location).auth.set({
|
||||||
|
providerID: value.integrationID,
|
||||||
|
auth: { type: "api", key: value.key },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
oauth: {
|
||||||
|
...input.current.integration.oauth,
|
||||||
|
connect: async (value: Parameters<ServerApi["integration"]["oauth"]["connect"]>[0]) => {
|
||||||
|
if (!(await isV1())) return input.current.integration.oauth.connect(value)
|
||||||
|
const method = Number(value.methodID)
|
||||||
|
const result = await legacy(value.location).provider.oauth.authorize(
|
||||||
|
{ providerID: value.integrationID, method, inputs: value.inputs },
|
||||||
|
{ throwOnError: true },
|
||||||
|
)
|
||||||
|
if (!result.data) throw new Error("Failed to start OAuth authorization")
|
||||||
|
return located(
|
||||||
|
{
|
||||||
|
attemptID: `${value.integrationID}:${method}`,
|
||||||
|
url: result.data.url,
|
||||||
|
instructions: result.data.instructions,
|
||||||
|
mode: result.data.method,
|
||||||
|
time: { created: Date.now(), expires: Date.now() + 10 * 60 * 1000 },
|
||||||
|
},
|
||||||
|
value.location,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
complete: async (value: Parameters<ServerApi["integration"]["oauth"]["complete"]>[0]) => {
|
||||||
|
if (!(await isV1())) return input.current.integration.oauth.complete(value)
|
||||||
|
const method = Number(value.attemptID.split(":").at(-1))
|
||||||
|
await legacy(value.location).provider.oauth.callback(
|
||||||
|
{ providerID: value.integrationID, method, code: value.code },
|
||||||
|
{ throwOnError: true },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
status: async (value: Parameters<ServerApi["integration"]["oauth"]["status"]>[0]) => {
|
||||||
|
if (!(await isV1())) return input.current.integration.oauth.status(value)
|
||||||
|
const method = Number(value.attemptID.split(":").at(-1))
|
||||||
|
await legacy(value.location).provider.oauth.callback(
|
||||||
|
{ providerID: value.integrationID, method },
|
||||||
|
{ throwOnError: true },
|
||||||
|
)
|
||||||
|
return located(
|
||||||
|
{ status: "complete" as const, time: { created: Date.now(), expires: Date.now() } },
|
||||||
|
value.location,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pty: {
|
||||||
|
...input.current.pty,
|
||||||
|
async shells(value?: Parameters<ServerApi["pty"]["shells"]>[0]) {
|
||||||
|
if (!(await isV1())) return input.current.pty.shells(value)
|
||||||
|
return located((await legacy(value?.location).pty.shells()).data ?? [], value?.location)
|
||||||
|
},
|
||||||
|
async list(value?: Parameters<ServerApi["pty"]["list"]>[0]) {
|
||||||
|
if (!(await isV1())) return input.current.pty.list(value)
|
||||||
|
return located((await legacy(value?.location).pty.list()).data ?? [], value?.location)
|
||||||
|
},
|
||||||
|
async create(value?: Parameters<ServerApi["pty"]["create"]>[0]) {
|
||||||
|
if (!(await isV1())) return input.current.pty.create(value)
|
||||||
|
const result = await legacy(value?.location).pty.create({
|
||||||
|
command: value?.command,
|
||||||
|
args: value?.args ? [...value.args] : undefined,
|
||||||
|
cwd: value?.cwd,
|
||||||
|
title: value?.title,
|
||||||
|
env: value?.env,
|
||||||
|
})
|
||||||
|
if (!result.data) throw new Error("Failed to create terminal")
|
||||||
|
return located(result.data, value?.location)
|
||||||
|
},
|
||||||
|
async get(value: Parameters<ServerApi["pty"]["get"]>[0]) {
|
||||||
|
if (!(await isV1())) return input.current.pty.get(value)
|
||||||
|
const result = await legacy(value.location).pty.get({ ptyID: value.ptyID })
|
||||||
|
if (!result.data) throw new Error(`Terminal not found: ${value.ptyID}`)
|
||||||
|
return located(result.data, value.location)
|
||||||
|
},
|
||||||
|
async update(value: Parameters<ServerApi["pty"]["update"]>[0]) {
|
||||||
|
if (!(await isV1())) return input.current.pty.update(value)
|
||||||
|
const result = await legacy(value.location).pty.update({
|
||||||
|
ptyID: value.ptyID,
|
||||||
|
title: value.title,
|
||||||
|
size: value.size,
|
||||||
|
})
|
||||||
|
if (!result.data) throw new Error(`Terminal not found: ${value.ptyID}`)
|
||||||
|
return located(result.data, value.location)
|
||||||
|
},
|
||||||
|
async remove(value: Parameters<ServerApi["pty"]["remove"]>[0]) {
|
||||||
|
if (!(await isV1())) return input.current.pty.remove(value)
|
||||||
|
await legacy(value.location).pty.remove({ ptyID: value.ptyID })
|
||||||
|
},
|
||||||
|
async connectToken(value: Parameters<ServerApi["pty"]["connectToken"]>[0]) {
|
||||||
|
if (!(await isV1())) return input.current.pty.connectToken(value)
|
||||||
|
const result = await legacy(value.location).pty.connectToken({ ptyID: value.ptyID })
|
||||||
|
if (!result.data) throw new Error(`Failed to connect terminal: ${value.ptyID}`)
|
||||||
|
return located(result.data, value.location)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
permission: {
|
||||||
|
...input.current.permission,
|
||||||
|
async reply(value: Parameters<ServerApi["permission"]["reply"]>[0]) {
|
||||||
|
if (!(await isV1())) return input.current.permission.reply(value)
|
||||||
|
await legacy().permission.respond({
|
||||||
|
sessionID: value.sessionID,
|
||||||
|
permissionID: value.requestID,
|
||||||
|
response: value.reply,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
question: {
|
||||||
|
...input.current.question,
|
||||||
|
async reply(value: Parameters<ServerApi["question"]["reply"]>[0]) {
|
||||||
|
if (!(await isV1())) return input.current.question.reply(value)
|
||||||
|
await legacy().question.reply({
|
||||||
|
requestID: value.requestID,
|
||||||
|
answers: value.answers.map((answer) => [...answer]),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async reject(value: Parameters<ServerApi["question"]["reject"]>[0]) {
|
||||||
|
if (!(await isV1())) return input.current.question.reject(value)
|
||||||
|
await legacy().question.reject({ requestID: value.requestID })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,15 +14,45 @@ function abortFromInput(input: RequestInfo | URL, init?: RequestInit) {
|
||||||
|
|
||||||
describe("checkServerHealth", () => {
|
describe("checkServerHealth", () => {
|
||||||
test("returns healthy response with version", async () => {
|
test("returns healthy response with version", async () => {
|
||||||
const fetch = (async () =>
|
let request: URL | undefined
|
||||||
new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), {
|
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" }), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
})) as unknown as typeof globalThis.fetch
|
})
|
||||||
|
}) as unknown as typeof globalThis.fetch
|
||||||
|
|
||||||
const result = await checkServerHealth(server, fetch)
|
const result = await checkServerHealth(server, fetch)
|
||||||
|
|
||||||
expect(result).toEqual({ healthy: true, version: "1.2.3" })
|
expect(result).toEqual({ healthy: true, version: "1.2.3" })
|
||||||
|
expect(request?.pathname).toBe("/api/health")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("falls back to the V1 health endpoint", async () => {
|
||||||
|
const paths: string[] = []
|
||||||
|
const fetch = (async (input: RequestInfo | URL) => {
|
||||||
|
const url = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input)
|
||||||
|
paths.push(url.pathname)
|
||||||
|
if (url.pathname === "/api/health") return new Response(undefined, { status: 404 })
|
||||||
|
return Response.json({ healthy: true, version: "1.18.4" })
|
||||||
|
}) as unknown as typeof globalThis.fetch
|
||||||
|
|
||||||
|
expect(await checkServerHealth(server, fetch)).toEqual({ healthy: true, version: "1.18.4" })
|
||||||
|
expect(paths).toEqual(["/api/health", "/global/health"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("falls back when the current health response is malformed", async () => {
|
||||||
|
const paths: string[] = []
|
||||||
|
const fetch = (async (input: RequestInfo | URL) => {
|
||||||
|
const url = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input)
|
||||||
|
paths.push(url.pathname)
|
||||||
|
if (url.pathname === "/api/health") return Response.json({})
|
||||||
|
return Response.json({ healthy: true, version: "1.18.4" })
|
||||||
|
}) as unknown as typeof globalThis.fetch
|
||||||
|
|
||||||
|
expect(await checkServerHealth(server, fetch)).toEqual({ healthy: true, version: "1.18.4" })
|
||||||
|
expect(paths).toEqual(["/api/health", "/global/health"])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("allows slow servers thirty seconds by default", async () => {
|
test("allows slow servers thirty seconds by default", async () => {
|
||||||
|
|
@ -142,7 +172,7 @@ describe("checkServerHealth", () => {
|
||||||
retryDelayMs: 1,
|
retryDelayMs: 1,
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(count).toBe(3)
|
expect(count).toBe(6)
|
||||||
expect(result).toEqual({ healthy: false })
|
expect(result).toEqual({ healthy: false })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/server"
|
||||||
import { createSdkForServer } from "./server"
|
import { authTokenFromCredentials, createSdkForServer } from "./server"
|
||||||
|
import { ClientError, OpenCode } from "@opencode-ai/client"
|
||||||
import { Accessor, createEffect, onCleanup } from "solid-js"
|
import { Accessor, createEffect, onCleanup } from "solid-js"
|
||||||
import { createStore, reconcile } from "solid-js/store"
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
|
|
||||||
|
|
@ -61,6 +62,7 @@ function wait(ms: number, signal?: AbortSignal) {
|
||||||
|
|
||||||
function retryable(error: unknown, signal?: AbortSignal) {
|
function retryable(error: unknown, signal?: AbortSignal) {
|
||||||
if (signal?.aborted) return false
|
if (signal?.aborted) return false
|
||||||
|
if (error instanceof ClientError) return error.reason === "Transport"
|
||||||
if (!(error instanceof Error)) return false
|
if (!(error instanceof Error)) return false
|
||||||
if (error.name === "AbortError" || error.name === "TimeoutError") return false
|
if (error.name === "AbortError" || error.name === "TimeoutError") return false
|
||||||
if (error instanceof TypeError) return true
|
if (error instanceof TypeError) return true
|
||||||
|
|
@ -82,15 +84,31 @@ export async function checkServerHealth(
|
||||||
.then(() => attempt(count + 1))
|
.then(() => attempt(count + 1))
|
||||||
.catch(() => ({ healthy: false }))
|
.catch(() => ({ healthy: false }))
|
||||||
}
|
}
|
||||||
const attempt = (count: number): Promise<ServerHealth> =>
|
const attempt = async (count: number): Promise<ServerHealth> => {
|
||||||
createSdkForServer({
|
const current = await OpenCode.make({
|
||||||
server,
|
baseUrl: server.url,
|
||||||
fetch,
|
fetch,
|
||||||
signal,
|
headers: server.password
|
||||||
|
? {
|
||||||
|
Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
})
|
})
|
||||||
|
.health.get({ signal })
|
||||||
|
.then((x) =>
|
||||||
|
typeof x.healthy === "boolean"
|
||||||
|
? { data: { healthy: x.healthy, version: x.version } }
|
||||||
|
: { error: new Error("Invalid health response") },
|
||||||
|
)
|
||||||
|
.catch((error) => ({ error }))
|
||||||
|
if ("data" in current && current.data) return current.data
|
||||||
|
if (signal?.aborted) return { healthy: false }
|
||||||
|
|
||||||
|
return createSdkForServer({ server, fetch, signal })
|
||||||
.global.health()
|
.global.health()
|
||||||
.then((x) => (x.error ? next(count, x.error) : { healthy: x.data?.healthy === true, version: x.data?.version }))
|
.then((x) => (x.error ? next(count, x.error) : { healthy: x.data?.healthy === true, version: x.data?.version }))
|
||||||
.catch((error) => next(count, error))
|
.catch((error) => next(count, error))
|
||||||
|
}
|
||||||
return attempt(0).finally(() => timeout?.clear?.())
|
return attempt(0).finally(() => timeout?.clear?.())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
40
packages/app/src/utils/server-protocol.test.ts
Normal file
40
packages/app/src/utils/server-protocol.test.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { detectServerProtocol } from "./server-protocol"
|
||||||
|
|
||||||
|
const server = { url: "http://localhost:4096" }
|
||||||
|
const json = (value: unknown, status = 200) =>
|
||||||
|
new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } })
|
||||||
|
const mockFetch = (run: (input: string | URL | Request) => Promise<Response>) =>
|
||||||
|
Object.assign(run, { preconnect: globalThis.fetch.preconnect })
|
||||||
|
|
||||||
|
describe("detectServerProtocol", () => {
|
||||||
|
test("prefers the legacy health endpoint when both API generations exist", async () => {
|
||||||
|
const fetcher = mockFetch((input) => {
|
||||||
|
const path = new URL(input instanceof Request ? input.url : input).pathname
|
||||||
|
if (path === "/global/health") return Promise.resolve(json({ healthy: true, version: "1.18.4" }))
|
||||||
|
return Promise.resolve(json({ healthy: true, version: "2.0.0", pid: 123 }))
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await detectServerProtocol(server, fetcher)).toBe("v1")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("recognizes V2 health by its process identifier", async () => {
|
||||||
|
const fetcher = mockFetch((input) => {
|
||||||
|
const path = new URL(input instanceof Request ? input.url : input).pathname
|
||||||
|
if (path === "/global/health") return Promise.resolve(json({}, 404))
|
||||||
|
return Promise.resolve(json({ healthy: true, version: "2.0.0", pid: 123 }))
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await detectServerProtocol(server, fetcher)).toBe("v2")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("recognizes the transitional V1 API health response", async () => {
|
||||||
|
const fetcher = mockFetch((input) => {
|
||||||
|
const path = new URL(input instanceof Request ? input.url : input).pathname
|
||||||
|
if (path === "/global/health") return Promise.resolve(json({}, 404))
|
||||||
|
return Promise.resolve(json({ healthy: true }))
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await detectServerProtocol(server, fetcher)).toBe("v1")
|
||||||
|
})
|
||||||
|
})
|
||||||
35
packages/app/src/utils/server-protocol.ts
Normal file
35
packages/app/src/utils/server-protocol.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import type { ServerConnection } from "@/context/server"
|
||||||
|
import { authTokenFromCredentials } from "./server"
|
||||||
|
|
||||||
|
export type ServerProtocol = "v1" | "v2"
|
||||||
|
|
||||||
|
function headers(server: ServerConnection.HttpBase) {
|
||||||
|
if (!server.password) return
|
||||||
|
return {
|
||||||
|
Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probe(server: ServerConnection.HttpBase, fetch: typeof globalThis.fetch, path: string) {
|
||||||
|
const response = await fetch(new URL(path, server.url), {
|
||||||
|
headers: headers(server),
|
||||||
|
signal: AbortSignal.timeout(5_000),
|
||||||
|
})
|
||||||
|
if (!response.ok || !response.headers.get("content-type")?.includes("application/json")) return
|
||||||
|
const value: unknown = await response.json()
|
||||||
|
if (!value || typeof value !== "object") return
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function detectServerProtocol(
|
||||||
|
server: ServerConnection.HttpBase,
|
||||||
|
fetch: typeof globalThis.fetch,
|
||||||
|
): Promise<ServerProtocol> {
|
||||||
|
const legacy = await probe(server, fetch, "/global/health").catch(() => undefined)
|
||||||
|
if (legacy && "healthy" in legacy && legacy.healthy === true) return "v1"
|
||||||
|
|
||||||
|
const current = await probe(server, fetch, "/api/health").catch(() => undefined)
|
||||||
|
if (current && "pid" in current && typeof current.pid === "number") return "v2"
|
||||||
|
if (current && "healthy" in current && current.healthy === true) return "v1"
|
||||||
|
return "v2"
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
||||||
import type { ServerConnection } from "@/context/server"
|
import type { ServerConnection } from "@/context/server"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
|
|
||||||
|
|
@ -39,3 +40,23 @@ export function createSdkForServer({
|
||||||
baseUrl: server.url,
|
baseUrl: server.url,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createApiForServer(input: {
|
||||||
|
server: ServerConnection.HttpBase
|
||||||
|
fetch?: typeof globalThis.fetch
|
||||||
|
}): OpenCodeClient {
|
||||||
|
return OpenCode.make({
|
||||||
|
baseUrl: input.server.url,
|
||||||
|
fetch: input.fetch,
|
||||||
|
headers: input.server.password
|
||||||
|
? {
|
||||||
|
Authorization: `Basic ${authTokenFromCredentials({
|
||||||
|
username: input.server.username,
|
||||||
|
password: input.server.password,
|
||||||
|
})}`,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerApi = OpenCodeClient
|
||||||
|
|
|
||||||
|
|
@ -182,9 +182,9 @@ export async function spawnLocalServer(
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
|
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
|
||||||
let healthUrl: URL
|
let healthUrls: URL[]
|
||||||
try {
|
try {
|
||||||
healthUrl = new URL("/global/health", url)
|
healthUrls = [new URL("/api/health", url), new URL("/global/health", url)]
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -195,16 +195,17 @@ export async function checkHealth(url: string, password?: string | null): Promis
|
||||||
headers.set("authorization", `Basic ${auth}`)
|
headers.set("authorization", `Basic ${auth}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
for (const healthUrl of healthUrls) {
|
||||||
const res = await fetch(healthUrl, {
|
try {
|
||||||
method: "GET",
|
const res = await fetch(healthUrl, {
|
||||||
headers,
|
method: "GET",
|
||||||
signal: AbortSignal.timeout(3000),
|
headers,
|
||||||
})
|
signal: AbortSignal.timeout(3000),
|
||||||
return res.ok
|
})
|
||||||
} catch {
|
if (res.ok) return true
|
||||||
return false
|
} catch {}
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSidecarEnv(): Record<string, string> {
|
function createSidecarEnv(): Record<string, string> {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue