fix(cli): unify server resolution
This commit is contained in:
@@ -37,6 +37,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
|||||||
Spec.make("api", {
|
Spec.make("api", {
|
||||||
description: "Make a request to the running server",
|
description: "Make a request to the running server",
|
||||||
params: {
|
params: {
|
||||||
|
...ServerParams,
|
||||||
request: Argument.string("operation | method path").pipe(
|
request: Argument.string("operation | method path").pipe(
|
||||||
Argument.withDescription("OpenAPI operation ID, or an HTTP method followed by a path"),
|
Argument.withDescription("OpenAPI operation ID, or an HTTP method followed by a path"),
|
||||||
Argument.variadic({ min: 1, max: 2 }),
|
Argument.variadic({ min: 1, max: 2 }),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Effect, Option } from "effect"
|
|||||||
import { Commands } from "../commands"
|
import { Commands } from "../commands"
|
||||||
import { Runtime } from "../../framework/runtime"
|
import { Runtime } from "../../framework/runtime"
|
||||||
import { Service } from "@opencode-ai/client/effect"
|
import { Service } from "@opencode-ai/client/effect"
|
||||||
import { ServiceConfig } from "../../services/service-config"
|
import { Server } from "../../services/server"
|
||||||
|
|
||||||
const methods = new Set(["delete", "get", "head", "options", "patch", "post", "put"])
|
const methods = new Set(["delete", "get", "head", "options", "patch", "post", "put"])
|
||||||
|
|
||||||
@@ -18,9 +18,12 @@ type OpenApi = {
|
|||||||
export default Runtime.handler(
|
export default Runtime.handler(
|
||||||
Commands.commands.api,
|
Commands.commands.api,
|
||||||
Effect.fn("cli.api")(function* (input) {
|
Effect.fn("cli.api")(function* (input) {
|
||||||
const options = yield* ServiceConfig.options()
|
const server = yield* Server.resolve({
|
||||||
const found = yield* Service.discover(options)
|
server: Option.getOrUndefined(input.server),
|
||||||
const endpoint = found ?? (yield* Service.start(options))
|
standalone: input.standalone,
|
||||||
|
mismatch: "ignore",
|
||||||
|
})
|
||||||
|
const endpoint = server.endpoint
|
||||||
const params = Option.getOrElse(input.param, () => ({}))
|
const params = Option.getOrElse(input.param, () => ({}))
|
||||||
const request = yield* resolveRequest(endpoint, input.request, params)
|
const request = yield* resolveRequest(endpoint, input.request, params)
|
||||||
const headers = new Headers(Service.headers(endpoint))
|
const headers = new Headers(Service.headers(endpoint))
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ import { Standalone } from "./standalone"
|
|||||||
export type Args = {
|
export type Args = {
|
||||||
readonly server?: string
|
readonly server?: string
|
||||||
readonly standalone?: boolean
|
readonly standalone?: boolean
|
||||||
|
readonly mismatch?: "replace" | "ignore" | "error"
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Resolved = {
|
export type Resolved = {
|
||||||
readonly endpoint: Service.Endpoint
|
readonly endpoint: Service.Endpoint
|
||||||
readonly discover?: () => Promise<Service.Endpoint>
|
readonly reconnect?: (attempt: number) => Promise<Service.Endpoint>
|
||||||
readonly reload?: () => Promise<void>
|
readonly reload?: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,11 +46,19 @@ export const resolve = Effect.fn("cli.server.resolve")(function* (args: Args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const options = yield* ServiceConfig.options()
|
const options = yield* ServiceConfig.options()
|
||||||
const endpoint = yield* Service.start(options)
|
const endpoint = yield* resolveManaged(options, args.mismatch ?? "replace")
|
||||||
const reconnectOptions = { ...options, version: undefined }
|
const reconnectOptions = { ...options, version: undefined }
|
||||||
return {
|
return {
|
||||||
endpoint,
|
endpoint,
|
||||||
discover: () => Effect.runPromise(Service.start(reconnectOptions).pipe(Effect.provide(NodeFileSystem.layer))),
|
reconnect: (attempt) =>
|
||||||
|
Effect.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if (attempt > 3) return yield* Service.start(reconnectOptions)
|
||||||
|
const endpoint = yield* Service.discover(reconnectOptions)
|
||||||
|
if (endpoint !== undefined) return endpoint
|
||||||
|
return yield* Effect.fail(new Error("Background server is unavailable"))
|
||||||
|
}).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||||
|
),
|
||||||
reload: () =>
|
reload: () =>
|
||||||
Effect.runPromise(
|
Effect.runPromise(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -60,6 +69,20 @@ export const resolve = Effect.fn("cli.server.resolve")(function* (args: Args) {
|
|||||||
} satisfies Resolved
|
} satisfies Resolved
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const resolveManaged = Effect.fnUntraced(function* (
|
||||||
|
options: Service.Options,
|
||||||
|
mismatch: NonNullable<Args["mismatch"]>,
|
||||||
|
) {
|
||||||
|
if (mismatch === "replace") return yield* Service.start(options)
|
||||||
|
if (mismatch === "ignore") return yield* Service.start({ ...options, version: undefined })
|
||||||
|
|
||||||
|
const compatible = yield* Service.discover(options)
|
||||||
|
if (compatible !== undefined) return compatible
|
||||||
|
const existing = yield* Service.discover({ ...options, version: undefined })
|
||||||
|
if (existing !== undefined) return yield* Effect.fail(new Error("Background server version does not match this client"))
|
||||||
|
return yield* Service.start(options)
|
||||||
|
})
|
||||||
|
|
||||||
function connectError(endpoint: Service.Endpoint, cause: unknown) {
|
function connectError(endpoint: Service.Endpoint, cause: unknown) {
|
||||||
if (isUnauthorizedError(cause)) {
|
if (isUnauthorizedError(cause)) {
|
||||||
return new Error(
|
return new Error(
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ const appBindingCommands = [
|
|||||||
export type TuiInput = {
|
export type TuiInput = {
|
||||||
server: {
|
server: {
|
||||||
endpoint: Service.Endpoint
|
endpoint: Service.Endpoint
|
||||||
discover?: () => Promise<Service.Endpoint>
|
reconnect?: (attempt: number) => Promise<Service.Endpoint>
|
||||||
reload?: () => Promise<void>
|
reload?: () => Promise<void>
|
||||||
}
|
}
|
||||||
args: Args
|
args: Args
|
||||||
@@ -200,10 +200,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||||||
Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory)),
|
Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const discover = input.server.discover
|
const reconnectEndpoint = input.server.reconnect
|
||||||
const reconnect = discover
|
const reconnect = reconnectEndpoint
|
||||||
? async () => {
|
? async (attempt: number) => {
|
||||||
const endpoint = await discover()
|
const endpoint = await reconnectEndpoint(attempt)
|
||||||
const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) }
|
const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) }
|
||||||
return {
|
return {
|
||||||
client: createOpencodeClient({ ...next, directory }),
|
client: createOpencodeClient({ ...next, directory }),
|
||||||
@@ -339,7 +339,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||||||
<SDKProvider
|
<SDKProvider
|
||||||
client={createOpencodeClient({ ...options, directory })}
|
client={createOpencodeClient({ ...options, directory })}
|
||||||
api={api}
|
api={api}
|
||||||
discover={reconnect}
|
reconnect={reconnect}
|
||||||
reload={input.server.reload}
|
reload={input.server.reload}
|
||||||
>
|
>
|
||||||
<PermissionProvider>
|
<PermissionProvider>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||||||
init: (props: {
|
init: (props: {
|
||||||
client: OpencodeClient
|
client: OpencodeClient
|
||||||
api: OpenCodeClient
|
api: OpenCodeClient
|
||||||
discover?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }>
|
reconnect?: (attempt: number) => Promise<{ client: OpencodeClient; api: OpenCodeClient }>
|
||||||
// Stops and starts the managed service; present only in service mode.
|
// Stops and starts the managed service; present only in service mode.
|
||||||
reload?: () => Promise<void>
|
reload?: () => Promise<void>
|
||||||
}) => {
|
}) => {
|
||||||
@@ -122,8 +122,8 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||||||
// Re-resolve the transport before retrying: the server may have
|
// Re-resolve the transport before retrying: the server may have
|
||||||
// moved (service restarted on a new port) or need starting. Static
|
// moved (service restarted on a new port) or need starting. Static
|
||||||
// transports (--server, standalone) resolve to the same address.
|
// transports (--server, standalone) resolve to the same address.
|
||||||
if (props.discover) {
|
if (props.reconnect) {
|
||||||
const next = await props.discover().catch(() => undefined)
|
const next = await props.reconnect(attempt).catch(() => undefined)
|
||||||
if (abort.signal.aborted || controller.signal.aborted) return
|
if (abort.signal.aborted || controller.signal.aborted) return
|
||||||
if (next) {
|
if (next) {
|
||||||
client = next.client
|
client = next.client
|
||||||
@@ -135,7 +135,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
|||||||
attempt,
|
attempt,
|
||||||
error: message,
|
error: message,
|
||||||
})
|
})
|
||||||
await wait(250, controller.signal)
|
await wait(1_000, controller.signal)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
return ready
|
return ready
|
||||||
|
|||||||
@@ -50,7 +50,10 @@ function update(version: string): V2Event {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mount(discover?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }>, log?: LogSink) {
|
async function mount(
|
||||||
|
reconnect?: (attempt: number) => Promise<{ client: OpencodeClient; api: OpenCodeClient }>,
|
||||||
|
log?: LogSink,
|
||||||
|
) {
|
||||||
const events = createEventStream()
|
const events = createEventStream()
|
||||||
const calls = createFetch(undefined, events)
|
const calls = createFetch(undefined, events)
|
||||||
const seen: V2Event[] = []
|
const seen: V2Event[] = []
|
||||||
@@ -64,7 +67,7 @@ async function mount(discover?: () => Promise<{ client: OpencodeClient; api: Ope
|
|||||||
|
|
||||||
const app = await testRender(() => (
|
const app = await testRender(() => (
|
||||||
<TestTuiContexts log={log}>
|
<TestTuiContexts log={log}>
|
||||||
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)} discover={discover}>
|
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)} reconnect={reconnect}>
|
||||||
<ProjectProvider>
|
<ProjectProvider>
|
||||||
<Probe
|
<Probe
|
||||||
onReady={async (ctx) => {
|
onReady={async (ctx) => {
|
||||||
@@ -183,27 +186,28 @@ describe("useEvent", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("rediscovers the server after the event stream drops", async () => {
|
test("reconnects to the server after the event stream drops", async () => {
|
||||||
let calls = 0
|
const attempts: number[] = []
|
||||||
const replacementEvents = createEventStream()
|
const replacementEvents = createEventStream()
|
||||||
const replacementCalls = createFetch(undefined, replacementEvents)
|
const replacementCalls = createFetch(undefined, replacementEvents)
|
||||||
const replacement = { client: createClient(replacementCalls.fetch), api: createApi(replacementCalls.fetch) }
|
const replacement = { client: createClient(replacementCalls.fetch), api: createApi(replacementCalls.fetch) }
|
||||||
const { app, events, sdk, seen } = await mount(async () => {
|
const { app, events, sdk, seen } = await mount(async (attempt) => {
|
||||||
calls += 1
|
attempts.push(attempt)
|
||||||
return replacement
|
return replacement
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await wait(() => sdk.connection.status() === "connected")
|
await wait(() => sdk.connection.status() === "connected")
|
||||||
// Discovery only runs when the stream is down, never while connected.
|
// Reconnection only runs when the stream is down, never while connected.
|
||||||
expect(calls).toBe(0)
|
expect(attempts).toEqual([])
|
||||||
events.disconnect()
|
events.disconnect()
|
||||||
await wait(() => sdk.connection.status() === "connected" && calls > 0)
|
await wait(() => sdk.connection.status() === "connected" && attempts.length > 0)
|
||||||
replacementEvents.emit(event(vcs("rediscovered"), { directory: "/tmp/rediscovered" }))
|
replacementEvents.emit(event(vcs("rediscovered"), { directory: "/tmp/rediscovered" }))
|
||||||
await wait(() => seen.some((item) => item.type === "vcs.branch.updated" && item.data.branch === "rediscovered"))
|
await wait(() => seen.some((item) => item.type === "vcs.branch.updated" && item.data.branch === "rediscovered"))
|
||||||
|
|
||||||
expect(sdk.client).toBe(replacement.client)
|
expect(sdk.client).toBe(replacement.client)
|
||||||
expect(sdk.api).toBe(replacement.api)
|
expect(sdk.api).toBe(replacement.api)
|
||||||
|
expect(attempts).toEqual([1])
|
||||||
const history = sdk.connection.internal.history()
|
const history = sdk.connection.internal.history()
|
||||||
expect(history.map((event) => [event.data.status, event.data.attempt])).toEqual([
|
expect(history.map((event) => [event.data.status, event.data.attempt])).toEqual([
|
||||||
["connecting", 0],
|
["connecting", 0],
|
||||||
@@ -218,7 +222,7 @@ describe("useEvent", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("keeps the current client when discovery fails", async () => {
|
test("keeps the current client when reconnection fails", async () => {
|
||||||
let calls = 0
|
let calls = 0
|
||||||
const { app, events, sdk, seen } = await mount(async () => {
|
const { app, events, sdk, seen } = await mount(async () => {
|
||||||
calls += 1
|
calls += 1
|
||||||
@@ -229,7 +233,7 @@ describe("useEvent", () => {
|
|||||||
await wait(() => sdk.connection.status() === "connected")
|
await wait(() => sdk.connection.status() === "connected")
|
||||||
const original = sdk.client
|
const original = sdk.client
|
||||||
events.disconnect()
|
events.disconnect()
|
||||||
// Discovery rejects; the loop retries against the last known transport,
|
// Reconnection rejects; the loop retries against the last known transport,
|
||||||
// which succeeds once the fixture accepts the reconnect.
|
// which succeeds once the fixture accepts the reconnect.
|
||||||
await wait(() => calls > 0 && sdk.connection.status() === "connected")
|
await wait(() => calls > 0 && sdk.connection.status() === "connected")
|
||||||
events.emit(event(vcs("recovered"), { directory: "/tmp/recovered" }))
|
events.emit(event(vcs("recovered"), { directory: "/tmp/recovered" }))
|
||||||
|
|||||||
Reference in New Issue
Block a user