refactor(cli): define server connection boundary (#37133)
This commit is contained in:
parent
e8bd386973
commit
f5dd181443
14 changed files with 250 additions and 121 deletions
|
|
@ -30,6 +30,50 @@ This proposal does not introduce a supervisor process, warm candidate server,
|
|||
protocol negotiation, idle background restart, or general execution-recovery
|
||||
framework.
|
||||
|
||||
## Architecture at a Glance
|
||||
|
||||
```text
|
||||
╭───────────────────╮
|
||||
│ CLI ServiceConfig │
|
||||
╰─────────┬─────────╯
|
||||
│
|
||||
▼
|
||||
╭──────────────────────╮
|
||||
│ CLI ServerConnection │
|
||||
╰───────────┬──────────╯
|
||||
╭──────────────────╰───────────────────╮
|
||||
▼ ▼
|
||||
╭──────────────────────────╮ ╭─────────────────────────╮
|
||||
│ Client Service lifecycle │ │ CLI runPromiseWith seam │
|
||||
╰─────────────┬────────────╯ ╰─────────────┬───────────╯
|
||||
╰─────╮ │
|
||||
▼ ▼
|
||||
╭────────────────────────────╮ ╭─────────────╮
|
||||
│ Background service process │ │ TUI / Solid │
|
||||
╰──────────────┬─────────────╯ ╰──────┬──────╯
|
||||
│ │
|
||||
╰────────────◀────────────────────╯
|
||||
╭───────────────────────╮
|
||||
│ Server HTTP transport │
|
||||
╰───────────┬───────────╯
|
||||
│
|
||||
▼
|
||||
╭──────────────────╮
|
||||
│ Core application │
|
||||
╰──────────────────╯
|
||||
```
|
||||
|
||||
| Owner | Responsibility |
|
||||
| ------------------------------------------------ | --------------------------------------------------------------------------------------------------- |
|
||||
| `packages/client/src/effect/service.ts` | Effect-native discovery, start, and stop lifecycle operations |
|
||||
| `packages/cli/src/services/service-config.ts` | CLI registration path, installed version, and daemon command |
|
||||
| `packages/cli/src/services/server-connection.ts` | Resolve an endpoint and, only for the shared service, grouped reconnect and restart Effects |
|
||||
| `packages/cli/src/server-process.ts` | Daemon election, registration, and server process boot |
|
||||
| `packages/server/src/process.ts` | HTTP lifecycle shell and application transport |
|
||||
| `packages/core` | Application behavior behind the transport |
|
||||
| CLI default handler | Convert lifecycle Effects with the outer `FileSystem` context and pass grouped Promise capabilities |
|
||||
| `packages/tui` Solid client context | Own event-stream reconnect, endpoint replacement, status, and user-triggered restart UI |
|
||||
|
||||
## Implementation Status
|
||||
|
||||
| Area | State |
|
||||
|
|
@ -164,19 +208,22 @@ This design gives each concept one authority.
|
|||
|
||||
## System Model
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
TUI[Fresh or existing TUI]
|
||||
REG[Registration file]
|
||||
LOCK[Process-held OS service lock]
|
||||
SHELL[Lifecycle shell]
|
||||
APP[OpenCode application]
|
||||
|
||||
TUI -->|discover| REG
|
||||
TUI -->|observe| SHELL
|
||||
TUI -->|normal requests| APP
|
||||
SHELL --> APP
|
||||
LOCK -->|authorizes one owner| SHELL
|
||||
```text
|
||||
╭───────────────────────╮ ╭──────────────────────────────╮
|
||||
│ Fresh or existing TUI │ │ Process-held OS service lock │
|
||||
╰───────────┬───────────╯ ╰───────────────┬──────────────╯
|
||||
╰─────┬ normal requests observe ───────────────────────╮ │
|
||||
│ discover │ ├──╯ authorizes one owner
|
||||
▼ │ ▼
|
||||
╭───────────────────╮ │ ╭─────────────────╮
|
||||
│ Registration file │ │ │ Lifecycle shell │
|
||||
╰───────────────────╯ │ ╰────────┬────────╯
|
||||
│ │
|
||||
├────────────────────────╯
|
||||
▼
|
||||
╭──────────────────────╮
|
||||
│ OpenCode application │
|
||||
╰──────────────────────╯
|
||||
```
|
||||
|
||||
The lifecycle shell and application run in the same process. The distinction is
|
||||
|
|
@ -311,24 +358,39 @@ Windows, where Bun FFI is not available on every shipped architecture. It lives
|
|||
alongside the existing utility in `packages/core/src/util`. This primitive is
|
||||
the foundation of the design, so the delivery sequence spikes it first.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Contender process starts]
|
||||
B{Acquire service lock?}
|
||||
C[Exit successfully]
|
||||
D[Bind lifecycle shell]
|
||||
E[Write registration]
|
||||
F[Report starting]
|
||||
G[Initialize application]
|
||||
H[Report ready]
|
||||
I[Serve until shutdown]
|
||||
|
||||
A --> B
|
||||
B -->|No| C
|
||||
B -->|Yes| D
|
||||
D --> E --> F --> G
|
||||
G -->|Success| H --> I
|
||||
G -->|Failure| J[Report failed and stay bound]
|
||||
```text
|
||||
Contender Lock Lifecycle Application
|
||||
│ │ │ │
|
||||
├─ try acquire ───▶ │ │
|
||||
│ │ │ │
|
||||
╭─ alt: lock held ────────────────────────────────────────────────╮
|
||||
│ │ │ │ │ │
|
||||
│ ◀─ busy ──────────┤ │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ ├─────────╮ │ │ │ │
|
||||
│ │ exit │ │ │ │ │
|
||||
│ ◀─────────╯ │ │ │ │
|
||||
│ │ │ │ │ │
|
||||
├─ else: lock acquired ───────────────────────────────────────────┤
|
||||
│ │ │ │ │ │
|
||||
│ ◀─ owner ─────────┤ │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ ├─ bind, register, starting ────────▶ │ │
|
||||
│ │ │ │ │ │
|
||||
│ ├─ initialize ──────────────────────────────────────────────▶ │
|
||||
│ │ │ │ │ │
|
||||
│╭─ alt: boot succeeds ──────────────────────────────────────────╮│
|
||||
││ │ │ │ │ ││
|
||||
││ │ │ ◀─ ready ───────────────┤ ││
|
||||
││ │ │ │ │ ││
|
||||
│├─ else: boot fails ────────────────────────────────────────────┤│
|
||||
││ │ │ │ │ ││
|
||||
││ │ │ ◀─ failed, stay bound ──┤ ││
|
||||
││ │ │ │ │ ││
|
||||
│╰───────────────────────────────────────────────────────────────╯│
|
||||
│ │ │ │ │ │
|
||||
╰─────────────────────────────────────────────────────────────────╯
|
||||
│ │ │ │
|
||||
```
|
||||
|
||||
Lock acquisition by a contender is nonblocking or tightly bounded. A loser
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Effect, Option } from "effect"
|
|||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
import { Server } from "../../services/server"
|
||||
import { ServerConnection } from "../../services/server-connection"
|
||||
|
||||
const methods = new Set(["delete", "get", "head", "options", "patch", "post", "put"])
|
||||
|
||||
|
|
@ -18,7 +18,7 @@ type OpenApi = {
|
|||
export default Runtime.handler(
|
||||
Commands.commands.api,
|
||||
Effect.fn("cli.api")(function* (input) {
|
||||
const server = yield* Server.resolve({
|
||||
const server = yield* ServerConnection.resolve({
|
||||
server: Option.getOrUndefined(input.server),
|
||||
standalone: input.standalone,
|
||||
mismatch: "ignore",
|
||||
|
|
@ -62,11 +62,7 @@ export function rawRequest(input: readonly string[]) {
|
|||
return { method: input[0].toUpperCase(), path: input[1] }
|
||||
}
|
||||
|
||||
function resolveRequest(
|
||||
endpoint: Service.Endpoint,
|
||||
input: readonly string[],
|
||||
params: Record<string, string>,
|
||||
) {
|
||||
function resolveRequest(endpoint: Service.Endpoint, input: readonly string[], params: Record<string, string>) {
|
||||
const raw = rawRequest(input)
|
||||
if (raw) return Effect.succeed(raw)
|
||||
if (input.length !== 1) return Effect.fail(new Error("Expected an operation name or an HTTP method and path"))
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import { run } from "@opencode-ai/tui"
|
|||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { Config } from "../../config"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Server } from "../../services/server"
|
||||
import { Context, Effect, FileSystem, Option } from "effect"
|
||||
import { ServerConnection } from "../../services/server-connection"
|
||||
import { Updater } from "../../services/updater"
|
||||
import { UpdatePreflight } from "../../services/update-preflight"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
|
|
@ -18,7 +18,7 @@ export default Runtime.handler(Commands, (input) =>
|
|||
yield* updater.check().pipe(Effect.forkScoped)
|
||||
const preflight = UpdatePreflight.make()
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close()))
|
||||
const server = yield* Server.resolve({
|
||||
const server = yield* ServerConnection.resolve({
|
||||
server: Option.getOrUndefined(input.server),
|
||||
standalone: input.standalone,
|
||||
onStart: (reason, existing) => {
|
||||
|
|
@ -37,11 +37,22 @@ export default Runtime.handler(Commands, (input) =>
|
|||
preflight.loading()
|
||||
const config = yield* Config.Service
|
||||
const npm = yield* Npm.Service
|
||||
const context = yield* Effect.context()
|
||||
const fileSystem = yield* FileSystem.FileSystem
|
||||
const runServicePromise = Effect.runPromiseWith(Context.make(FileSystem.FileSystem, fileSystem))
|
||||
const context = yield* Effect.context<FileSystem.FileSystem>()
|
||||
const runFork = Effect.runForkWith(context)
|
||||
const runPromise = Effect.runPromiseWith(context)
|
||||
const service = server.service
|
||||
yield* run({
|
||||
server,
|
||||
server: {
|
||||
endpoint: server.endpoint,
|
||||
service: service
|
||||
? {
|
||||
reconnect: (onStatus, signal) => runServicePromise(service.reconnect(onStatus), { signal }),
|
||||
restart: () => runServicePromise(service.restart()),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
|
||||
config: {
|
||||
path: config.path,
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import { Effect, Option } from "effect"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { Server } from "../../services/server"
|
||||
import { ServerConnection } from "../../services/server-connection"
|
||||
|
||||
export default Runtime.handler(Commands.commands.mini, (input) =>
|
||||
Effect.gen(function* () {
|
||||
const { runMini, validateMiniTerminal } = yield* Effect.promise(() => import("../../mini"))
|
||||
yield* Effect.promise(async () => validateMiniTerminal())
|
||||
const serverURL = Option.getOrUndefined(input.server)
|
||||
const server = yield* Server.resolve({ server: serverURL, standalone: input.standalone })
|
||||
const server = yield* ServerConnection.resolve({ server: serverURL, standalone: input.standalone })
|
||||
yield* Effect.promise(() =>
|
||||
runMini({
|
||||
server,
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { Effect, Option } from "effect"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { Server } from "../../services/server"
|
||||
import { ServerConnection } from "../../services/server-connection"
|
||||
|
||||
export default Runtime.handler(Commands.commands.run, (input) =>
|
||||
Effect.gen(function* () {
|
||||
const { runNonInteractive } = yield* Effect.promise(() => import("../../mini"))
|
||||
const separator = process.argv.indexOf("--", 2)
|
||||
const server = yield* Server.resolve({
|
||||
const server = yield* ServerConnection.resolve({
|
||||
server: Option.getOrUndefined(input.server),
|
||||
standalone: input.standalone,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { Service } from "@opencode-ai/client/effect"
|
||||
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
||||
import { Server } from "../services/server"
|
||||
import { ServerConnection } from "../services/server-connection"
|
||||
import { waitForCatalogReady } from "./catalog.shared"
|
||||
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./runtime.stdin"
|
||||
import type { RunInput, RunTuiConfig } from "./types"
|
||||
|
||||
export type MiniCommandInput = {
|
||||
server: Server.Resolved
|
||||
server: ServerConnection.Resolved
|
||||
continue?: boolean
|
||||
session?: string
|
||||
fork?: boolean
|
||||
|
|
@ -38,10 +38,7 @@ export async function runMini(input: MiniCommandInput) {
|
|||
return agentTask
|
||||
}
|
||||
const resolveSession = async () => {
|
||||
const [agent, selected] = await Promise.all([
|
||||
resolveAgent(),
|
||||
selectSession(sdk, directory, input),
|
||||
])
|
||||
const [agent, selected] = await Promise.all([resolveAgent(), selectSession(sdk, directory, input)])
|
||||
const readyModel =
|
||||
model ?? (selected?.model ? { providerID: selected.model.providerID, modelID: selected.model.id } : undefined)
|
||||
if (readyModel) await waitForCatalogReady({ sdk, directory, model: readyModel })
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
|
|||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { open } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { Server } from "../services/server"
|
||||
import { ServerConnection } from "../services/server-connection"
|
||||
import { loadRunAgents, waitForCatalogReady } from "./catalog.shared"
|
||||
import { runNonInteractivePrompt } from "./noninteractive"
|
||||
import { toolInlineInfo } from "./tool"
|
||||
|
|
@ -12,7 +12,7 @@ import type { MiniToolPart } from "./types"
|
|||
import { UI } from "./ui"
|
||||
|
||||
export type RunCommandInput = {
|
||||
server: Server.Resolved
|
||||
server: ServerConnection.Resolved
|
||||
message: string[]
|
||||
continue?: boolean
|
||||
session?: string
|
||||
|
|
@ -73,8 +73,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Ser
|
|||
.then((result) => (result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined))
|
||||
: undefined
|
||||
const model = pickRunModel(explicitModel, variant, sessionModel, defaultModel)
|
||||
if (variant && !model)
|
||||
return reportError(input, "Cannot select a variant before selecting a model", session?.id)
|
||||
if (variant && !model) return reportError(input, "Cannot select a variant before selecting a model", session?.id)
|
||||
if (model) {
|
||||
await waitForCatalogReady({ sdk: client, directory: cwd, workspace, model })
|
||||
const available = await client.model.list({ location: { directory: cwd, workspace } })
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
|
|
@ -16,11 +15,10 @@ export type Args = {
|
|||
|
||||
export type Resolved = {
|
||||
readonly endpoint: Service.Endpoint
|
||||
readonly reconnect?: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise<Service.Endpoint>
|
||||
readonly reload?: () => Promise<void>
|
||||
readonly service?: ReturnType<typeof managedService>
|
||||
}
|
||||
|
||||
export const resolve = Effect.fn("cli.server.resolve")(function* (args: Args) {
|
||||
export const resolve = Effect.fn("cli.server-connection.resolve")(function* (args: Args) {
|
||||
if (args.server !== undefined && args.standalone)
|
||||
return yield* Effect.fail(new Error("--server and --standalone cannot be combined"))
|
||||
if (args.server !== undefined) {
|
||||
|
|
@ -45,24 +43,24 @@ export const resolve = Effect.fn("cli.server.resolve")(function* (args: Args) {
|
|||
}
|
||||
|
||||
const options = yield* ServiceConfig.options()
|
||||
const endpoint = yield* resolveManaged({ ...options, onStart: args.onStart }, args.mismatch ?? "replace")
|
||||
const reconnectOptions = { ...options, version: undefined }
|
||||
return {
|
||||
endpoint,
|
||||
reconnect: (onStatus, signal) =>
|
||||
Effect.runPromise(Service.start({ ...reconnectOptions, onStatus }).pipe(Effect.provide(NodeFileSystem.layer)), {
|
||||
signal,
|
||||
}),
|
||||
reload: () =>
|
||||
Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
yield* Service.stop(options, { targetVersion: options.version })
|
||||
yield* Service.start(options)
|
||||
}).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||
),
|
||||
endpoint: yield* resolveManaged({ ...options, onStart: args.onStart }, args.mismatch ?? "replace"),
|
||||
service: managedService(options),
|
||||
} satisfies Resolved
|
||||
})
|
||||
|
||||
function managedService(options: Service.StartOptions) {
|
||||
const reconnectOptions = { ...options, version: undefined }
|
||||
return {
|
||||
reconnect: (onStatus: (status: Service.Status) => void) => Service.start({ ...reconnectOptions, onStatus }),
|
||||
restart: () =>
|
||||
Effect.gen(function* () {
|
||||
yield* Service.stop(options, { targetVersion: options.version })
|
||||
yield* Service.start(options)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const resolveManaged = Effect.fnUntraced(function* (
|
||||
options: Service.StartOptions,
|
||||
mismatch: NonNullable<Args["mismatch"]>,
|
||||
|
|
@ -92,4 +90,4 @@ function connectError(endpoint: Service.Endpoint, cause: unknown) {
|
|||
return new Error(`Server at ${endpoint.url} did not provide a compatible V2 health response`, { cause })
|
||||
}
|
||||
|
||||
export * as Server from "./server"
|
||||
export * as ServerConnection from "./server-connection"
|
||||
59
packages/cli/test/server-connection.test.ts
Normal file
59
packages/cli/test/server-connection.test.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { expect, test } from "bun:test"
|
||||
import { Effect, FileSystem, Scope } from "effect"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { ServerConnection } from "../src/services/server-connection"
|
||||
import { ServiceConfig } from "../src/services/service-config"
|
||||
|
||||
test("resolution groups Effect-native lifecycle operations only for the managed service", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-server-resolution-"))
|
||||
const id = "server-resolution-test"
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch() {
|
||||
return Response.json({
|
||||
healthy: true,
|
||||
version: InstallationVersion,
|
||||
pid: process.pid,
|
||||
instanceID: id,
|
||||
status: { type: "ready" },
|
||||
})
|
||||
},
|
||||
})
|
||||
const registration = path.join(root, "state", ServiceConfig.filename())
|
||||
const layer = Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })
|
||||
const runPromise = <A, E>(effect: Effect.Effect<A, E, Global.Service | FileSystem.FileSystem | Scope.Scope>) =>
|
||||
Effect.runPromise(effect.pipe(Effect.provide(layer), Effect.provide(NodeFileSystem.layer), Effect.scoped))
|
||||
|
||||
try {
|
||||
await fs.mkdir(path.dirname(registration), { recursive: true })
|
||||
await fs.writeFile(
|
||||
registration,
|
||||
JSON.stringify({
|
||||
id,
|
||||
version: InstallationVersion,
|
||||
url: server.url.toString(),
|
||||
pid: process.pid,
|
||||
}),
|
||||
)
|
||||
const resolved = await runPromise(ServerConnection.resolve({}))
|
||||
|
||||
expect(resolved.endpoint.url).toBe(server.url.toString())
|
||||
expect(resolved.service).toBeDefined()
|
||||
if (!resolved.service) throw new Error("Expected managed service capabilities")
|
||||
expect(Effect.isEffect(resolved.service.reconnect(() => {}))).toBe(true)
|
||||
expect(Effect.isEffect(resolved.service.restart())).toBe(true)
|
||||
expect(await runPromise(resolved.service.reconnect(() => {}))).toEqual(resolved.endpoint)
|
||||
|
||||
const explicit = await runPromise(ServerConnection.resolve({ server: server.url.toString() }))
|
||||
expect(explicit.endpoint.url).toBe(server.url.toString())
|
||||
expect(explicit.service).toBeUndefined()
|
||||
} finally {
|
||||
await server.stop(true)
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
|
@ -4,8 +4,8 @@ description: "Diagnose OpenCode startup, server, and session issues."
|
|||
---
|
||||
|
||||
<Tip>
|
||||
You can ask OpenCode to debug itself. Describe the problem and ask it to use this troubleshooting page; it can read the
|
||||
steps below, inspect its service and logs, and help identify the issue.
|
||||
You can ask OpenCode to debug itself. Describe the problem and ask it to use this troubleshooting page; it can read
|
||||
the steps below, inspect its service and logs, and help identify the issue.
|
||||
</Tip>
|
||||
|
||||
OpenCode runs as two processes: the TUI is a client, while a background server owns sessions, plugins, permissions, and
|
||||
|
|
@ -31,10 +31,10 @@ If the service is stuck or unhealthy, restart it:
|
|||
opencode2 service restart
|
||||
```
|
||||
|
||||
From inside the TUI, run `/reload` to restart the managed service and reconnect:
|
||||
From inside the TUI, run `/restart` to restart the managed service and reconnect:
|
||||
|
||||
```text
|
||||
/reload
|
||||
/restart
|
||||
```
|
||||
|
||||
You can also stop and start it explicitly:
|
||||
|
|
@ -45,8 +45,8 @@ opencode2 service start
|
|||
```
|
||||
|
||||
<Note>
|
||||
OpenCode normally discovers or starts the shared background service automatically. The service commands are only needed
|
||||
when diagnosing its lifecycle.
|
||||
OpenCode normally discovers or starts the shared background service automatically. The service commands are only
|
||||
needed when diagnosing its lifecycle.
|
||||
</Note>
|
||||
|
||||
## Run an isolated session
|
||||
|
|
@ -125,8 +125,8 @@ The database normally lives at:
|
|||
`OPENCODE_DB` can override the database location.
|
||||
|
||||
<Warning>
|
||||
Do not delete or edit service files or the database while troubleshooting. Use the service commands to manage the daemon,
|
||||
and make a backup before inspecting persistent data with external tools.
|
||||
Do not delete or edit service files or the database while troubleshooting. Use the service commands to manage the
|
||||
daemon, and make a backup before inspecting persistent data with external tools.
|
||||
</Warning>
|
||||
|
||||
## Explicit servers
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ const appBindingCommands = [
|
|||
"provider.connect",
|
||||
"opencode.status",
|
||||
"server.pair",
|
||||
"service.restart",
|
||||
"opencode.debug",
|
||||
"theme.switch",
|
||||
"theme.switch_mode",
|
||||
|
|
@ -138,8 +139,10 @@ const appBindingCommands = [
|
|||
export type TuiInput = {
|
||||
server: {
|
||||
endpoint: Service.Endpoint
|
||||
reconnect?: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise<Service.Endpoint>
|
||||
reload?: () => Promise<void>
|
||||
service?: {
|
||||
reconnect: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise<Service.Endpoint>
|
||||
restart: () => Promise<void>
|
||||
}
|
||||
}
|
||||
args: Args
|
||||
config: Config.Interface
|
||||
|
|
@ -183,14 +186,15 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||
Effect.catch(() => Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory))),
|
||||
)
|
||||
const handoff = input.terminalHandoff ? yield* Effect.promise(input.terminalHandoff) : undefined
|
||||
const reconnectEndpoint = input.server.reconnect
|
||||
const reconnect = reconnectEndpoint
|
||||
? async (onStatus: (status: Service.Status) => void, signal: AbortSignal) => {
|
||||
const endpoint = await reconnectEndpoint(onStatus, signal)
|
||||
const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) }
|
||||
return {
|
||||
api: OpenCode.make(next),
|
||||
}
|
||||
const managed = input.server.service
|
||||
const service = managed
|
||||
? {
|
||||
reconnect: async (onStatus: (status: Service.Status) => void, signal: AbortSignal) => {
|
||||
const endpoint = await managed.reconnect(onStatus, signal)
|
||||
const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) }
|
||||
return { api: OpenCode.make(next) }
|
||||
},
|
||||
restart: managed.restart,
|
||||
}
|
||||
: undefined
|
||||
const exit = { epilogue: undefined as string | undefined, reason: undefined as unknown }
|
||||
|
|
@ -324,7 +328,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||
}
|
||||
>
|
||||
<PluginRuntimeProvider value={pluginRuntime}>
|
||||
<ClientProvider api={api} reconnect={reconnect} reload={input.server.reload}>
|
||||
<ClientProvider api={api} service={service}>
|
||||
<PermissionProvider>
|
||||
<DataProvider>
|
||||
<LocationProvider>
|
||||
|
|
@ -757,19 +761,21 @@ function App(props: { pair?: DialogPairCredentials }) {
|
|||
},
|
||||
category: "System",
|
||||
},
|
||||
...(client.reload
|
||||
...(client.restart
|
||||
? [
|
||||
{
|
||||
name: "server.reload",
|
||||
title: "Reload server",
|
||||
slash: { name: "reload" },
|
||||
name: "service.restart",
|
||||
title: "Restart service",
|
||||
slash: { name: "restart" },
|
||||
run: async () => {
|
||||
const restart = client.restart
|
||||
if (!restart) return
|
||||
dialog.clear()
|
||||
toast.show({ variant: "info", message: "Reloading server...", duration: 30000 })
|
||||
// reload resolves once the replacement service is healthy; the
|
||||
toast.show({ variant: "info", message: "Restarting service...", duration: 30000 })
|
||||
// restart resolves once the replacement service is healthy; the
|
||||
// event stream reattaches through the reconnect loop.
|
||||
await client.reload!()
|
||||
.then(() => toast.show({ variant: "success", message: "Server reloaded" }))
|
||||
await restart()
|
||||
.then(() => toast.show({ variant: "success", message: "Service restarted" }))
|
||||
.catch(toast.error)
|
||||
},
|
||||
category: "System",
|
||||
|
|
|
|||
|
|
@ -18,18 +18,18 @@ export type ClientConnectionEvent = {
|
|||
}
|
||||
}
|
||||
|
||||
type ManagedService = {
|
||||
reconnect: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise<{ api: OpenCodeClient }>
|
||||
restart: () => Promise<void>
|
||||
}
|
||||
|
||||
type ClientEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
|
||||
const connectTimeout = 2_000
|
||||
const connectionHistoryLimit = 50
|
||||
|
||||
export const { use: useClient, provider: ClientProvider } = createSimpleContext({
|
||||
name: "Client",
|
||||
init: (props: {
|
||||
api: OpenCodeClient
|
||||
reconnect?: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise<{ api: OpenCodeClient }>
|
||||
// Stops and starts the managed service; present only in service mode.
|
||||
reload?: () => Promise<void>
|
||||
}) => {
|
||||
init: (props: { api: OpenCodeClient; service?: ManagedService }) => {
|
||||
const log = useLog({ component: "client" })
|
||||
const abort = new AbortController()
|
||||
const history: ClientConnectionEvent[] = []
|
||||
|
|
@ -115,8 +115,8 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
|
|||
// Re-resolve the transport before retrying: the server may have
|
||||
// moved (service restarted on a new port) or need starting. Static
|
||||
// transports (--server, standalone) resolve to the same address.
|
||||
if (props.reconnect) {
|
||||
const next = await props.reconnect(setService, controller.signal).catch((error) => {
|
||||
if (props.service) {
|
||||
const next = await props.service.reconnect(setService, controller.signal).catch((error) => {
|
||||
if (!controller.signal.aborted)
|
||||
log.info("server resolution failed", {
|
||||
attempt,
|
||||
|
|
@ -168,7 +168,7 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
|
|||
},
|
||||
},
|
||||
},
|
||||
reload: props.reload,
|
||||
restart: props.service?.restart,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -65,10 +65,11 @@ async function mount(
|
|||
const ready = new Promise<void>((resolve) => {
|
||||
done = resolve
|
||||
})
|
||||
const service = reconnect ? { reconnect, restart: () => Promise.resolve() } : undefined
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts log={log}>
|
||||
<ClientProvider api={createApi(calls.fetch)} reconnect={reconnect}>
|
||||
<ClientProvider api={createApi(calls.fetch)} service={service}>
|
||||
<Probe
|
||||
onReady={(ctx) => {
|
||||
client = ctx.client
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ description: "Diagnose OpenCode startup, server, and session issues."
|
|||
---
|
||||
|
||||
<Tip>
|
||||
You can ask OpenCode to debug itself. Describe the problem and ask it to use this troubleshooting page; it can read the
|
||||
steps below, inspect its service and logs, and help identify the issue.
|
||||
You can ask OpenCode to debug itself. Describe the problem and ask it to use this troubleshooting page; it can read
|
||||
the steps below, inspect its service and logs, and help identify the issue.
|
||||
</Tip>
|
||||
|
||||
OpenCode runs as two processes: the TUI is a client, while a background server owns sessions, plugins, permissions, and
|
||||
|
|
@ -31,10 +31,10 @@ If the service is stuck or unhealthy, restart it:
|
|||
opencode2 service restart
|
||||
```
|
||||
|
||||
From inside the TUI, run `/reload` to restart the managed service and reconnect:
|
||||
From inside the TUI, run `/restart` to restart the managed service and reconnect:
|
||||
|
||||
```text
|
||||
/reload
|
||||
/restart
|
||||
```
|
||||
|
||||
You can also stop and start it explicitly:
|
||||
|
|
@ -45,8 +45,8 @@ opencode2 service start
|
|||
```
|
||||
|
||||
<Note>
|
||||
OpenCode normally discovers or starts the shared background service automatically. The service commands are only needed
|
||||
when diagnosing its lifecycle.
|
||||
OpenCode normally discovers or starts the shared background service automatically. The service commands are only
|
||||
needed when diagnosing its lifecycle.
|
||||
</Note>
|
||||
|
||||
## Run an isolated session
|
||||
|
|
@ -125,8 +125,8 @@ The database normally lives at:
|
|||
`OPENCODE_DB` can override the database location.
|
||||
|
||||
<Warning>
|
||||
Do not delete or edit service files or the database while troubleshooting. Use the service commands to manage the daemon,
|
||||
and make a backup before inspecting persistent data with external tools.
|
||||
Do not delete or edit service files or the database while troubleshooting. Use the service commands to manage the
|
||||
daemon, and make a backup before inspecting persistent data with external tools.
|
||||
</Warning>
|
||||
|
||||
## Explicit servers
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue