refactor(cli): define server connection boundary (#37133)
This commit is contained in:
@@ -30,6 +30,50 @@ This proposal does not introduce a supervisor process, warm candidate server,
|
|||||||
protocol negotiation, idle background restart, or general execution-recovery
|
protocol negotiation, idle background restart, or general execution-recovery
|
||||||
framework.
|
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
|
## Implementation Status
|
||||||
|
|
||||||
| Area | State |
|
| Area | State |
|
||||||
@@ -164,19 +208,22 @@ This design gives each concept one authority.
|
|||||||
|
|
||||||
## System Model
|
## System Model
|
||||||
|
|
||||||
```mermaid
|
```text
|
||||||
flowchart LR
|
╭───────────────────────╮ ╭──────────────────────────────╮
|
||||||
TUI[Fresh or existing TUI]
|
│ Fresh or existing TUI │ │ Process-held OS service lock │
|
||||||
REG[Registration file]
|
╰───────────┬───────────╯ ╰───────────────┬──────────────╯
|
||||||
LOCK[Process-held OS service lock]
|
╰─────┬ normal requests observe ───────────────────────╮ │
|
||||||
SHELL[Lifecycle shell]
|
│ discover │ ├──╯ authorizes one owner
|
||||||
APP[OpenCode application]
|
▼ │ ▼
|
||||||
|
╭───────────────────╮ │ ╭─────────────────╮
|
||||||
TUI -->|discover| REG
|
│ Registration file │ │ │ Lifecycle shell │
|
||||||
TUI -->|observe| SHELL
|
╰───────────────────╯ │ ╰────────┬────────╯
|
||||||
TUI -->|normal requests| APP
|
│ │
|
||||||
SHELL --> APP
|
├────────────────────────╯
|
||||||
LOCK -->|authorizes one owner| SHELL
|
▼
|
||||||
|
╭──────────────────────╮
|
||||||
|
│ OpenCode application │
|
||||||
|
╰──────────────────────╯
|
||||||
```
|
```
|
||||||
|
|
||||||
The lifecycle shell and application run in the same process. The distinction is
|
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
|
alongside the existing utility in `packages/core/src/util`. This primitive is
|
||||||
the foundation of the design, so the delivery sequence spikes it first.
|
the foundation of the design, so the delivery sequence spikes it first.
|
||||||
|
|
||||||
```mermaid
|
```text
|
||||||
flowchart TD
|
Contender Lock Lifecycle Application
|
||||||
A[Contender process starts]
|
│ │ │ │
|
||||||
B{Acquire service lock?}
|
├─ try acquire ───▶ │ │
|
||||||
C[Exit successfully]
|
│ │ │ │
|
||||||
D[Bind lifecycle shell]
|
╭─ alt: lock held ────────────────────────────────────────────────╮
|
||||||
E[Write registration]
|
│ │ │ │ │ │
|
||||||
F[Report starting]
|
│ ◀─ busy ──────────┤ │ │ │
|
||||||
G[Initialize application]
|
│ │ │ │ │ │
|
||||||
H[Report ready]
|
│ ├─────────╮ │ │ │ │
|
||||||
I[Serve until shutdown]
|
│ │ exit │ │ │ │ │
|
||||||
|
│ ◀─────────╯ │ │ │ │
|
||||||
A --> B
|
│ │ │ │ │ │
|
||||||
B -->|No| C
|
├─ else: lock acquired ───────────────────────────────────────────┤
|
||||||
B -->|Yes| D
|
│ │ │ │ │ │
|
||||||
D --> E --> F --> G
|
│ ◀─ owner ─────────┤ │ │ │
|
||||||
G -->|Success| H --> I
|
│ │ │ │ │ │
|
||||||
G -->|Failure| J[Report failed and stay bound]
|
│ ├─ 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
|
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 { 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 { Server } from "../../services/server"
|
import { ServerConnection } from "../../services/server-connection"
|
||||||
|
|
||||||
const methods = new Set(["delete", "get", "head", "options", "patch", "post", "put"])
|
const methods = new Set(["delete", "get", "head", "options", "patch", "post", "put"])
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ 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 server = yield* Server.resolve({
|
const server = yield* ServerConnection.resolve({
|
||||||
server: Option.getOrUndefined(input.server),
|
server: Option.getOrUndefined(input.server),
|
||||||
standalone: input.standalone,
|
standalone: input.standalone,
|
||||||
mismatch: "ignore",
|
mismatch: "ignore",
|
||||||
@@ -62,11 +62,7 @@ export function rawRequest(input: readonly string[]) {
|
|||||||
return { method: input[0].toUpperCase(), path: input[1] }
|
return { method: input[0].toUpperCase(), path: input[1] }
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveRequest(
|
function resolveRequest(endpoint: Service.Endpoint, input: readonly string[], params: Record<string, string>) {
|
||||||
endpoint: Service.Endpoint,
|
|
||||||
input: readonly string[],
|
|
||||||
params: Record<string, string>,
|
|
||||||
) {
|
|
||||||
const raw = rawRequest(input)
|
const raw = rawRequest(input)
|
||||||
if (raw) return Effect.succeed(raw)
|
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"))
|
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 { Commands } from "../commands"
|
||||||
import { Runtime } from "../../framework/runtime"
|
import { Runtime } from "../../framework/runtime"
|
||||||
import { Config } from "../../config"
|
import { Config } from "../../config"
|
||||||
import { Effect, Option } from "effect"
|
import { Context, Effect, FileSystem, Option } from "effect"
|
||||||
import { Server } from "../../services/server"
|
import { ServerConnection } from "../../services/server-connection"
|
||||||
import { Updater } from "../../services/updater"
|
import { Updater } from "../../services/updater"
|
||||||
import { UpdatePreflight } from "../../services/update-preflight"
|
import { UpdatePreflight } from "../../services/update-preflight"
|
||||||
import { Npm } from "@opencode-ai/core/npm"
|
import { Npm } from "@opencode-ai/core/npm"
|
||||||
@@ -18,7 +18,7 @@ export default Runtime.handler(Commands, (input) =>
|
|||||||
yield* updater.check().pipe(Effect.forkScoped)
|
yield* updater.check().pipe(Effect.forkScoped)
|
||||||
const preflight = UpdatePreflight.make()
|
const preflight = UpdatePreflight.make()
|
||||||
yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close()))
|
yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close()))
|
||||||
const server = yield* Server.resolve({
|
const server = yield* ServerConnection.resolve({
|
||||||
server: Option.getOrUndefined(input.server),
|
server: Option.getOrUndefined(input.server),
|
||||||
standalone: input.standalone,
|
standalone: input.standalone,
|
||||||
onStart: (reason, existing) => {
|
onStart: (reason, existing) => {
|
||||||
@@ -37,11 +37,22 @@ export default Runtime.handler(Commands, (input) =>
|
|||||||
preflight.loading()
|
preflight.loading()
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const npm = yield* Npm.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 runFork = Effect.runForkWith(context)
|
||||||
const runPromise = Effect.runPromiseWith(context)
|
const runPromise = Effect.runPromiseWith(context)
|
||||||
|
const service = server.service
|
||||||
yield* run({
|
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) },
|
args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
|
||||||
config: {
|
config: {
|
||||||
path: config.path,
|
path: config.path,
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { Effect, Option } from "effect"
|
import { Effect, Option } from "effect"
|
||||||
import { Commands } from "../commands"
|
import { Commands } from "../commands"
|
||||||
import { Runtime } from "../../framework/runtime"
|
import { Runtime } from "../../framework/runtime"
|
||||||
import { Server } from "../../services/server"
|
import { ServerConnection } from "../../services/server-connection"
|
||||||
|
|
||||||
export default Runtime.handler(Commands.commands.mini, (input) =>
|
export default Runtime.handler(Commands.commands.mini, (input) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { runMini, validateMiniTerminal } = yield* Effect.promise(() => import("../../mini"))
|
const { runMini, validateMiniTerminal } = yield* Effect.promise(() => import("../../mini"))
|
||||||
yield* Effect.promise(async () => validateMiniTerminal())
|
yield* Effect.promise(async () => validateMiniTerminal())
|
||||||
const serverURL = Option.getOrUndefined(input.server)
|
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(() =>
|
yield* Effect.promise(() =>
|
||||||
runMini({
|
runMini({
|
||||||
server,
|
server,
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { Effect, Option } from "effect"
|
import { Effect, Option } from "effect"
|
||||||
import { Commands } from "../commands"
|
import { Commands } from "../commands"
|
||||||
import { Runtime } from "../../framework/runtime"
|
import { Runtime } from "../../framework/runtime"
|
||||||
import { Server } from "../../services/server"
|
import { ServerConnection } from "../../services/server-connection"
|
||||||
|
|
||||||
export default Runtime.handler(Commands.commands.run, (input) =>
|
export default Runtime.handler(Commands.commands.run, (input) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { runNonInteractive } = yield* Effect.promise(() => import("../../mini"))
|
const { runNonInteractive } = yield* Effect.promise(() => import("../../mini"))
|
||||||
const separator = process.argv.indexOf("--", 2)
|
const separator = process.argv.indexOf("--", 2)
|
||||||
const server = yield* Server.resolve({
|
const server = yield* ServerConnection.resolve({
|
||||||
server: Option.getOrUndefined(input.server),
|
server: Option.getOrUndefined(input.server),
|
||||||
standalone: input.standalone,
|
standalone: input.standalone,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Service } from "@opencode-ai/client/effect"
|
import { Service } from "@opencode-ai/client/effect"
|
||||||
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
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 { waitForCatalogReady } from "./catalog.shared"
|
||||||
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./runtime.stdin"
|
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./runtime.stdin"
|
||||||
import type { RunInput, RunTuiConfig } from "./types"
|
import type { RunInput, RunTuiConfig } from "./types"
|
||||||
|
|
||||||
export type MiniCommandInput = {
|
export type MiniCommandInput = {
|
||||||
server: Server.Resolved
|
server: ServerConnection.Resolved
|
||||||
continue?: boolean
|
continue?: boolean
|
||||||
session?: string
|
session?: string
|
||||||
fork?: boolean
|
fork?: boolean
|
||||||
@@ -38,10 +38,7 @@ export async function runMini(input: MiniCommandInput) {
|
|||||||
return agentTask
|
return agentTask
|
||||||
}
|
}
|
||||||
const resolveSession = async () => {
|
const resolveSession = async () => {
|
||||||
const [agent, selected] = await Promise.all([
|
const [agent, selected] = await Promise.all([resolveAgent(), selectSession(sdk, directory, input)])
|
||||||
resolveAgent(),
|
|
||||||
selectSession(sdk, directory, input),
|
|
||||||
])
|
|
||||||
const readyModel =
|
const readyModel =
|
||||||
model ?? (selected?.model ? { providerID: selected.model.providerID, modelID: selected.model.id } : undefined)
|
model ?? (selected?.model ? { providerID: selected.model.providerID, modelID: selected.model.id } : undefined)
|
||||||
if (readyModel) await waitForCatalogReady({ sdk, directory, model: readyModel })
|
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 { Model } from "@opencode-ai/schema/model"
|
||||||
import { open } from "node:fs/promises"
|
import { open } from "node:fs/promises"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { Server } from "../services/server"
|
import { ServerConnection } from "../services/server-connection"
|
||||||
import { loadRunAgents, waitForCatalogReady } from "./catalog.shared"
|
import { loadRunAgents, waitForCatalogReady } from "./catalog.shared"
|
||||||
import { runNonInteractivePrompt } from "./noninteractive"
|
import { runNonInteractivePrompt } from "./noninteractive"
|
||||||
import { toolInlineInfo } from "./tool"
|
import { toolInlineInfo } from "./tool"
|
||||||
@@ -12,7 +12,7 @@ import type { MiniToolPart } from "./types"
|
|||||||
import { UI } from "./ui"
|
import { UI } from "./ui"
|
||||||
|
|
||||||
export type RunCommandInput = {
|
export type RunCommandInput = {
|
||||||
server: Server.Resolved
|
server: ServerConnection.Resolved
|
||||||
message: string[]
|
message: string[]
|
||||||
continue?: boolean
|
continue?: boolean
|
||||||
session?: string
|
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))
|
.then((result) => (result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined))
|
||||||
: undefined
|
: undefined
|
||||||
const model = pickRunModel(explicitModel, variant, sessionModel, defaultModel)
|
const model = pickRunModel(explicitModel, variant, sessionModel, defaultModel)
|
||||||
if (variant && !model)
|
if (variant && !model) return reportError(input, "Cannot select a variant before selecting a model", session?.id)
|
||||||
return reportError(input, "Cannot select a variant before selecting a model", session?.id)
|
|
||||||
if (model) {
|
if (model) {
|
||||||
await waitForCatalogReady({ sdk: client, directory: cwd, workspace, model })
|
await waitForCatalogReady({ sdk: client, directory: cwd, workspace, model })
|
||||||
const available = await client.model.list({ location: { directory: cwd, workspace } })
|
const available = await client.model.list({ location: { directory: cwd, workspace } })
|
||||||
|
|||||||
+17
-19
@@ -1,4 +1,3 @@
|
|||||||
import { NodeFileSystem } from "@effect/platform-node"
|
|
||||||
import { Service } from "@opencode-ai/client/effect"
|
import { Service } from "@opencode-ai/client/effect"
|
||||||
import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise"
|
import { ClientError, isUnauthorizedError, OpenCode } from "@opencode-ai/client/promise"
|
||||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||||
@@ -16,11 +15,10 @@ export type Args = {
|
|||||||
|
|
||||||
export type Resolved = {
|
export type Resolved = {
|
||||||
readonly endpoint: Service.Endpoint
|
readonly endpoint: Service.Endpoint
|
||||||
readonly reconnect?: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise<Service.Endpoint>
|
readonly service?: ReturnType<typeof managedService>
|
||||||
readonly reload?: () => Promise<void>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
if (args.server !== undefined && args.standalone)
|
||||||
return yield* Effect.fail(new Error("--server and --standalone cannot be combined"))
|
return yield* Effect.fail(new Error("--server and --standalone cannot be combined"))
|
||||||
if (args.server !== undefined) {
|
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 options = yield* ServiceConfig.options()
|
||||||
const endpoint = yield* resolveManaged({ ...options, onStart: args.onStart }, args.mismatch ?? "replace")
|
|
||||||
const reconnectOptions = { ...options, version: undefined }
|
|
||||||
return {
|
return {
|
||||||
endpoint,
|
endpoint: yield* resolveManaged({ ...options, onStart: args.onStart }, args.mismatch ?? "replace"),
|
||||||
reconnect: (onStatus, signal) =>
|
service: managedService(options),
|
||||||
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)),
|
|
||||||
),
|
|
||||||
} satisfies Resolved
|
} 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* (
|
const resolveManaged = Effect.fnUntraced(function* (
|
||||||
options: Service.StartOptions,
|
options: Service.StartOptions,
|
||||||
mismatch: NonNullable<Args["mismatch"]>,
|
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 })
|
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"
|
||||||
@@ -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>
|
<Tip>
|
||||||
You can ask OpenCode to debug itself. Describe the problem and ask it to use this troubleshooting page; it can read the
|
You can ask OpenCode to debug itself. Describe the problem and ask it to use this troubleshooting page; it can read
|
||||||
steps below, inspect its service and logs, and help identify the issue.
|
the steps below, inspect its service and logs, and help identify the issue.
|
||||||
</Tip>
|
</Tip>
|
||||||
|
|
||||||
OpenCode runs as two processes: the TUI is a client, while a background server owns sessions, plugins, permissions, and
|
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
|
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
|
```text
|
||||||
/reload
|
/restart
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also stop and start it explicitly:
|
You can also stop and start it explicitly:
|
||||||
@@ -45,8 +45,8 @@ opencode2 service start
|
|||||||
```
|
```
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
OpenCode normally discovers or starts the shared background service automatically. The service commands are only needed
|
OpenCode normally discovers or starts the shared background service automatically. The service commands are only
|
||||||
when diagnosing its lifecycle.
|
needed when diagnosing its lifecycle.
|
||||||
</Note>
|
</Note>
|
||||||
|
|
||||||
## Run an isolated session
|
## Run an isolated session
|
||||||
@@ -125,8 +125,8 @@ The database normally lives at:
|
|||||||
`OPENCODE_DB` can override the database location.
|
`OPENCODE_DB` can override the database location.
|
||||||
|
|
||||||
<Warning>
|
<Warning>
|
||||||
Do not delete or edit service files or the database while troubleshooting. Use the service commands to manage the daemon,
|
Do not delete or edit service files or the database while troubleshooting. Use the service commands to manage the
|
||||||
and make a backup before inspecting persistent data with external tools.
|
daemon, and make a backup before inspecting persistent data with external tools.
|
||||||
</Warning>
|
</Warning>
|
||||||
|
|
||||||
## Explicit servers
|
## Explicit servers
|
||||||
|
|||||||
+25
-19
@@ -118,6 +118,7 @@ const appBindingCommands = [
|
|||||||
"provider.connect",
|
"provider.connect",
|
||||||
"opencode.status",
|
"opencode.status",
|
||||||
"server.pair",
|
"server.pair",
|
||||||
|
"service.restart",
|
||||||
"opencode.debug",
|
"opencode.debug",
|
||||||
"theme.switch",
|
"theme.switch",
|
||||||
"theme.switch_mode",
|
"theme.switch_mode",
|
||||||
@@ -138,8 +139,10 @@ const appBindingCommands = [
|
|||||||
export type TuiInput = {
|
export type TuiInput = {
|
||||||
server: {
|
server: {
|
||||||
endpoint: Service.Endpoint
|
endpoint: Service.Endpoint
|
||||||
reconnect?: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise<Service.Endpoint>
|
service?: {
|
||||||
reload?: () => Promise<void>
|
reconnect: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise<Service.Endpoint>
|
||||||
|
restart: () => Promise<void>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
args: Args
|
args: Args
|
||||||
config: Config.Interface
|
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))),
|
Effect.catch(() => Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory))),
|
||||||
)
|
)
|
||||||
const handoff = input.terminalHandoff ? yield* Effect.promise(input.terminalHandoff) : undefined
|
const handoff = input.terminalHandoff ? yield* Effect.promise(input.terminalHandoff) : undefined
|
||||||
const reconnectEndpoint = input.server.reconnect
|
const managed = input.server.service
|
||||||
const reconnect = reconnectEndpoint
|
const service = managed
|
||||||
? async (onStatus: (status: Service.Status) => void, signal: AbortSignal) => {
|
? {
|
||||||
const endpoint = await reconnectEndpoint(onStatus, signal)
|
reconnect: async (onStatus: (status: Service.Status) => void, signal: AbortSignal) => {
|
||||||
const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) }
|
const endpoint = await managed.reconnect(onStatus, signal)
|
||||||
return {
|
const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) }
|
||||||
api: OpenCode.make(next),
|
return { api: OpenCode.make(next) }
|
||||||
}
|
},
|
||||||
|
restart: managed.restart,
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
const exit = { epilogue: undefined as string | undefined, reason: undefined as unknown }
|
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}>
|
<PluginRuntimeProvider value={pluginRuntime}>
|
||||||
<ClientProvider api={api} reconnect={reconnect} reload={input.server.reload}>
|
<ClientProvider api={api} service={service}>
|
||||||
<PermissionProvider>
|
<PermissionProvider>
|
||||||
<DataProvider>
|
<DataProvider>
|
||||||
<LocationProvider>
|
<LocationProvider>
|
||||||
@@ -757,19 +761,21 @@ function App(props: { pair?: DialogPairCredentials }) {
|
|||||||
},
|
},
|
||||||
category: "System",
|
category: "System",
|
||||||
},
|
},
|
||||||
...(client.reload
|
...(client.restart
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
name: "server.reload",
|
name: "service.restart",
|
||||||
title: "Reload server",
|
title: "Restart service",
|
||||||
slash: { name: "reload" },
|
slash: { name: "restart" },
|
||||||
run: async () => {
|
run: async () => {
|
||||||
|
const restart = client.restart
|
||||||
|
if (!restart) return
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
toast.show({ variant: "info", message: "Reloading server...", duration: 30000 })
|
toast.show({ variant: "info", message: "Restarting service...", duration: 30000 })
|
||||||
// reload resolves once the replacement service is healthy; the
|
// restart resolves once the replacement service is healthy; the
|
||||||
// event stream reattaches through the reconnect loop.
|
// event stream reattaches through the reconnect loop.
|
||||||
await client.reload!()
|
await restart()
|
||||||
.then(() => toast.show({ variant: "success", message: "Server reloaded" }))
|
.then(() => toast.show({ variant: "success", message: "Service restarted" }))
|
||||||
.catch(toast.error)
|
.catch(toast.error)
|
||||||
},
|
},
|
||||||
category: "System",
|
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 }> }
|
type ClientEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
|
||||||
const connectTimeout = 2_000
|
const connectTimeout = 2_000
|
||||||
const connectionHistoryLimit = 50
|
const connectionHistoryLimit = 50
|
||||||
|
|
||||||
export const { use: useClient, provider: ClientProvider } = createSimpleContext({
|
export const { use: useClient, provider: ClientProvider } = createSimpleContext({
|
||||||
name: "Client",
|
name: "Client",
|
||||||
init: (props: {
|
init: (props: { api: OpenCodeClient; service?: ManagedService }) => {
|
||||||
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>
|
|
||||||
}) => {
|
|
||||||
const log = useLog({ component: "client" })
|
const log = useLog({ component: "client" })
|
||||||
const abort = new AbortController()
|
const abort = new AbortController()
|
||||||
const history: ClientConnectionEvent[] = []
|
const history: ClientConnectionEvent[] = []
|
||||||
@@ -115,8 +115,8 @@ export const { use: useClient, provider: ClientProvider } = 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.reconnect) {
|
if (props.service) {
|
||||||
const next = await props.reconnect(setService, controller.signal).catch((error) => {
|
const next = await props.service.reconnect(setService, controller.signal).catch((error) => {
|
||||||
if (!controller.signal.aborted)
|
if (!controller.signal.aborted)
|
||||||
log.info("server resolution failed", {
|
log.info("server resolution failed", {
|
||||||
attempt,
|
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) => {
|
const ready = new Promise<void>((resolve) => {
|
||||||
done = resolve
|
done = resolve
|
||||||
})
|
})
|
||||||
|
const service = reconnect ? { reconnect, restart: () => Promise.resolve() } : undefined
|
||||||
|
|
||||||
const app = await testRender(() => (
|
const app = await testRender(() => (
|
||||||
<TestTuiContexts log={log}>
|
<TestTuiContexts log={log}>
|
||||||
<ClientProvider api={createApi(calls.fetch)} reconnect={reconnect}>
|
<ClientProvider api={createApi(calls.fetch)} service={service}>
|
||||||
<Probe
|
<Probe
|
||||||
onReady={(ctx) => {
|
onReady={(ctx) => {
|
||||||
client = ctx.client
|
client = ctx.client
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ description: "Diagnose OpenCode startup, server, and session issues."
|
|||||||
---
|
---
|
||||||
|
|
||||||
<Tip>
|
<Tip>
|
||||||
You can ask OpenCode to debug itself. Describe the problem and ask it to use this troubleshooting page; it can read the
|
You can ask OpenCode to debug itself. Describe the problem and ask it to use this troubleshooting page; it can read
|
||||||
steps below, inspect its service and logs, and help identify the issue.
|
the steps below, inspect its service and logs, and help identify the issue.
|
||||||
</Tip>
|
</Tip>
|
||||||
|
|
||||||
OpenCode runs as two processes: the TUI is a client, while a background server owns sessions, plugins, permissions, and
|
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
|
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
|
```text
|
||||||
/reload
|
/restart
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also stop and start it explicitly:
|
You can also stop and start it explicitly:
|
||||||
@@ -45,8 +45,8 @@ opencode2 service start
|
|||||||
```
|
```
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
OpenCode normally discovers or starts the shared background service automatically. The service commands are only needed
|
OpenCode normally discovers or starts the shared background service automatically. The service commands are only
|
||||||
when diagnosing its lifecycle.
|
needed when diagnosing its lifecycle.
|
||||||
</Note>
|
</Note>
|
||||||
|
|
||||||
## Run an isolated session
|
## Run an isolated session
|
||||||
@@ -125,8 +125,8 @@ The database normally lives at:
|
|||||||
`OPENCODE_DB` can override the database location.
|
`OPENCODE_DB` can override the database location.
|
||||||
|
|
||||||
<Warning>
|
<Warning>
|
||||||
Do not delete or edit service files or the database while troubleshooting. Use the service commands to manage the daemon,
|
Do not delete or edit service files or the database while troubleshooting. Use the service commands to manage the
|
||||||
and make a backup before inspecting persistent data with external tools.
|
daemon, and make a backup before inspecting persistent data with external tools.
|
||||||
</Warning>
|
</Warning>
|
||||||
|
|
||||||
## Explicit servers
|
## Explicit servers
|
||||||
|
|||||||
Reference in New Issue
Block a user