refactor(client): simplify local service lifecycle
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
import { Service } from "@opencode-ai/client/effect"
|
import { Service } from "@opencode-ai/client/effect/service"
|
||||||
import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
|
import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import fs from "node:fs/promises"
|
import fs from "node:fs/promises"
|
||||||
@@ -37,8 +37,7 @@ try {
|
|||||||
const headers = { authorization: "Basic " + credential }
|
const headers = { authorization: "Basic " + credential }
|
||||||
const token = encodeURIComponent(credential)
|
const token = encodeURIComponent(credential)
|
||||||
const health = await waitForReady(info.url, headers)
|
const health = await waitForReady(info.url, headers)
|
||||||
if (health.pid !== info.pid || health.instanceID !== info.id)
|
if (health.pid !== info.pid) throw new Error("Health process does not match registration")
|
||||||
throw new Error("Health identity does not match registration")
|
|
||||||
const tokenHealth = await fetch(
|
const tokenHealth = await fetch(
|
||||||
new URL(`/api/health?auth_token=${token}`, info.url),
|
new URL(`/api/health?auth_token=${token}`, info.url),
|
||||||
{ signal: AbortSignal.timeout(5_000) },
|
{ signal: AbortSignal.timeout(5_000) },
|
||||||
@@ -75,7 +74,7 @@ try {
|
|||||||
await fetch(new URL("/api/service/stop", info.url), {
|
await fetch(new URL("/api/service/stop", info.url), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { ...headers, "content-type": "application/json" },
|
headers: { ...headers, "content-type": "application/json" },
|
||||||
body: JSON.stringify({ instanceID: info.id, targetVersion: "smoke-next" }),
|
body: JSON.stringify({ instanceID: info.id }),
|
||||||
signal: AbortSignal.timeout(5_000),
|
signal: AbortSignal.timeout(5_000),
|
||||||
}).then((response) => response.json()),
|
}).then((response) => response.json()),
|
||||||
)
|
)
|
||||||
@@ -120,19 +119,11 @@ async function waitForRegistration() {
|
|||||||
async function waitForReady(url: string, headers: HeadersInit) {
|
async function waitForReady(url: string, headers: HeadersInit) {
|
||||||
const deadline = Date.now() + 20_000
|
const deadline = Date.now() + 20_000
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
const health = await fetch(new URL("/api/health", url), {
|
const response = await fetch(new URL("/api/health", url), {
|
||||||
headers,
|
headers,
|
||||||
signal: AbortSignal.timeout(1_000),
|
signal: AbortSignal.timeout(1_000),
|
||||||
})
|
}).catch(() => undefined)
|
||||||
.then((response) => response.json())
|
if (response?.ok) return Schema.decodeUnknownPromise(ServiceStatus.Health)(await response.json())
|
||||||
.then(Schema.decodeUnknownPromise(ServiceStatus.Health))
|
|
||||||
.catch(() => undefined)
|
|
||||||
if (health === undefined) {
|
|
||||||
await Bun.sleep(25)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (health.status.type === "ready") return health
|
|
||||||
if (health.status.type === "failed") throw new Error(health.status.message)
|
|
||||||
await Bun.sleep(25)
|
await Bun.sleep(25)
|
||||||
}
|
}
|
||||||
throw new Error("Compiled service did not become ready")
|
throw new Error("Compiled service did not become ready")
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { EOL } from "node:os"
|
|||||||
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 { Service } from "@opencode-ai/client/effect"
|
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
|
||||||
import { ServerConnection } from "../../services/server-connection"
|
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"])
|
||||||
@@ -62,7 +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(endpoint: Service.Endpoint, input: readonly string[], params: Record<string, string>) {
|
function resolveRequest(endpoint: 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"))
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Cause, Effect, Exit, Option } from "effect"
|
import { Cause, Effect, Exit, Option } from "effect"
|
||||||
import { Service } from "@opencode-ai/client/effect"
|
import { Service } from "@opencode-ai/client/effect/service"
|
||||||
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
||||||
import { AppProcess } from "@opencode-ai/core/process"
|
import { AppProcess } from "@opencode-ai/core/process"
|
||||||
import { Commands } from "../../commands"
|
import { Commands } from "../../commands"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Effect } from "effect"
|
|||||||
import { OpenCode } from "@opencode-ai/client"
|
import { OpenCode } from "@opencode-ai/client"
|
||||||
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/service"
|
||||||
import { ServiceConfig } from "../../../services/service-config"
|
import { ServiceConfig } from "../../../services/service-config"
|
||||||
|
|
||||||
export default Runtime.handler(
|
export default Runtime.handler(
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ export default Runtime.handler(Commands, (input) =>
|
|||||||
const server = yield* ServerConnection.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, previousVersion) => {
|
||||||
if (reason === "version-mismatch" && preflight.begin(existing?.version)) return
|
if (reason === "version-mismatch" && preflight.begin(previousVersion)) return
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
reason === "version-mismatch"
|
reason === "version-mismatch"
|
||||||
? "Restarting background server (version mismatch)...\n"
|
? "Restarting background server (version mismatch)...\n"
|
||||||
@@ -48,7 +48,7 @@ export default Runtime.handler(Commands, (input) =>
|
|||||||
endpoint: server.endpoint,
|
endpoint: server.endpoint,
|
||||||
service: service
|
service: service
|
||||||
? {
|
? {
|
||||||
reconnect: (onStatus, signal) => runServicePromise(service.reconnect(onStatus), { signal }),
|
reconnect: (signal) => runServicePromise(service.reconnect(), { signal }),
|
||||||
restart: () => runServicePromise(service.restart()),
|
restart: () => runServicePromise(service.restart()),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from "@opencode-ai/client"
|
} from "@opencode-ai/client"
|
||||||
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/service"
|
||||||
import { ServiceConfig } from "../../../services/service-config"
|
import { ServiceConfig } from "../../../services/service-config"
|
||||||
import { resolveIntegration } from "./resolve"
|
import { resolveIntegration } from "./resolve"
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Effect } from "effect"
|
|||||||
import { OpenCode, type McpServer } from "@opencode-ai/client"
|
import { OpenCode, type McpServer } from "@opencode-ai/client"
|
||||||
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/service"
|
||||||
import { ServiceConfig } from "../../../services/service-config"
|
import { ServiceConfig } from "../../../services/service-config"
|
||||||
|
|
||||||
export default Runtime.handler(
|
export default Runtime.handler(
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Effect } from "effect"
|
|||||||
import { OpenCode } from "@opencode-ai/client"
|
import { OpenCode } from "@opencode-ai/client"
|
||||||
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/service"
|
||||||
import { ServiceConfig } from "../../../services/service-config"
|
import { ServiceConfig } from "../../../services/service-config"
|
||||||
import { resolveIntegration } from "./resolve"
|
import { resolveIntegration } from "./resolve"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { EOL } from "os"
|
import { EOL } from "os"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Service } from "@opencode-ai/client/effect"
|
import { Service } from "@opencode-ai/client/effect/service"
|
||||||
import { OpenCode } from "@opencode-ai/client/promise"
|
import { OpenCode } from "@opencode-ai/client/promise"
|
||||||
import { renderUnicodeCompact } from "uqr"
|
import { renderUnicodeCompact } from "uqr"
|
||||||
import { Commands } from "../commands"
|
import { Commands } from "../commands"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { EOL } from "os"
|
import { EOL } from "os"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Service } from "@opencode-ai/client/effect"
|
import { Service } from "@opencode-ai/client/effect/service"
|
||||||
import { Commands } from "../../commands"
|
import { Commands } from "../../commands"
|
||||||
import { Runtime } from "../../../framework/runtime"
|
import { Runtime } from "../../../framework/runtime"
|
||||||
import { ServiceConfig } from "../../../services/service-config"
|
import { ServiceConfig } from "../../../services/service-config"
|
||||||
@@ -9,7 +9,7 @@ export default Runtime.handler(
|
|||||||
Commands.commands.service.commands.restart,
|
Commands.commands.service.commands.restart,
|
||||||
Effect.fn("cli.service.restart")(function* () {
|
Effect.fn("cli.service.restart")(function* () {
|
||||||
const options = yield* ServiceConfig.options()
|
const options = yield* ServiceConfig.options()
|
||||||
yield* Service.stop(options, { targetVersion: options.version })
|
yield* Service.stop(options)
|
||||||
const transport = yield* Service.start(options)
|
const transport = yield* Service.start(options)
|
||||||
process.stdout.write(transport.url + EOL)
|
process.stdout.write(transport.url + EOL)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { EOL } from "os"
|
import { EOL } from "os"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Service } from "@opencode-ai/client/effect"
|
import { Service } from "@opencode-ai/client/effect/service"
|
||||||
import { Commands } from "../../commands"
|
import { Commands } from "../../commands"
|
||||||
import { Runtime } from "../../../framework/runtime"
|
import { Runtime } from "../../../framework/runtime"
|
||||||
import { ServiceConfig } from "../../../services/service-config"
|
import { ServiceConfig } from "../../../services/service-config"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { EOL } from "os"
|
import { EOL } from "os"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Service } from "@opencode-ai/client/effect"
|
import { Service } from "@opencode-ai/client/effect/service"
|
||||||
import { Commands } from "../../commands"
|
import { Commands } from "../../commands"
|
||||||
import { Runtime } from "../../../framework/runtime"
|
import { Runtime } from "../../../framework/runtime"
|
||||||
import { ServiceConfig } from "../../../services/service-config"
|
import { ServiceConfig } from "../../../services/service-config"
|
||||||
@@ -9,12 +9,7 @@ export default Runtime.handler(
|
|||||||
Commands.commands.service.commands.status,
|
Commands.commands.service.commands.status,
|
||||||
Effect.fn("cli.service.status")(function* () {
|
Effect.fn("cli.service.status")(function* () {
|
||||||
const options = yield* ServiceConfig.options()
|
const options = yield* ServiceConfig.options()
|
||||||
const status = yield* Service.status(options)
|
|
||||||
if (status.type !== "ready") {
|
|
||||||
process.stdout.write(status.type + EOL)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const found = yield* Service.discover({ ...options, version: undefined })
|
const found = yield* Service.discover({ ...options, version: undefined })
|
||||||
process.stdout.write((found?.url ?? status.type) + EOL)
|
process.stdout.write((found?.url ?? "stopped") + EOL)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Service } from "@opencode-ai/client/effect"
|
import { Service } from "@opencode-ai/client/effect/service"
|
||||||
import { Commands } from "../../commands"
|
import { Commands } from "../../commands"
|
||||||
import { Runtime } from "../../../framework/runtime"
|
import { Runtime } from "../../../framework/runtime"
|
||||||
import { ServiceConfig } from "../../../services/service-config"
|
import { ServiceConfig } from "../../../services/service-config"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Service } from "@opencode-ai/client/effect"
|
import { Service } from "@opencode-ai/client/effect/service"
|
||||||
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
||||||
import { ServerConnection } from "../services/server-connection"
|
import { ServerConnection } from "../services/server-connection"
|
||||||
import { waitForCatalogReady } from "./catalog.shared"
|
import { waitForCatalogReady } from "./catalog.shared"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Service } from "@opencode-ai/client/effect"
|
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
|
||||||
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { Model } from "@opencode-ai/schema/model"
|
import { Model } from "@opencode-ai/schema/model"
|
||||||
@@ -55,7 +55,7 @@ async function run(input: RunCommandInput) {
|
|||||||
return execute(input, prepared, input.server.endpoint)
|
return execute(input, prepared, input.server.endpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Service.Endpoint) {
|
async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Endpoint) {
|
||||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||||
const requestedDirectory = prepared.directory ?? (await client.location.get()).directory
|
const requestedDirectory = prepared.directory ?? (await client.location.get()).directory
|
||||||
if (!requestedDirectory) fail("Failed to resolve server directory")
|
if (!requestedDirectory) fail("Failed to resolve server directory")
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export * as ServerProcess from "./server-process"
|
export * as ServerProcess from "./server-process"
|
||||||
|
|
||||||
import { NodeServices } from "@effect/platform-node"
|
import { NodeServices } from "@effect/platform-node"
|
||||||
import { Service } from "@opencode-ai/client/effect"
|
import { Service } from "@opencode-ai/client/effect/service"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Service } from "@opencode-ai/client/effect"
|
import { Service, type Endpoint, type StartOptions } from "@opencode-ai/client/effect/service"
|
||||||
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"
|
||||||
import { Effect, Redacted } from "effect"
|
import { Effect, Redacted } from "effect"
|
||||||
@@ -10,11 +10,11 @@ export type Args = {
|
|||||||
readonly server?: string
|
readonly server?: string
|
||||||
readonly standalone?: boolean
|
readonly standalone?: boolean
|
||||||
readonly mismatch?: "replace" | "ignore" | "error"
|
readonly mismatch?: "replace" | "ignore" | "error"
|
||||||
readonly onStart?: Service.StartOptions["onStart"]
|
readonly onStart?: StartOptions["onStart"]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Resolved = {
|
export type Resolved = {
|
||||||
readonly endpoint: Service.Endpoint
|
readonly endpoint: Endpoint
|
||||||
readonly service?: ReturnType<typeof managedService>
|
readonly service?: ReturnType<typeof managedService>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg
|
|||||||
const endpoint = {
|
const endpoint = {
|
||||||
url: args.server,
|
url: args.server,
|
||||||
auth: password ? { type: "basic" as const, username: "opencode", password: Redacted.value(password) } : undefined,
|
auth: password ? { type: "basic" as const, username: "opencode", password: Redacted.value(password) } : undefined,
|
||||||
} satisfies Service.Endpoint
|
} satisfies Endpoint
|
||||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||||
const health = yield* Effect.tryPromise({
|
const health = yield* Effect.tryPromise({
|
||||||
try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }),
|
try: () => client.health.get({ signal: AbortSignal.timeout(5_000) }),
|
||||||
@@ -49,20 +49,20 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg
|
|||||||
} satisfies Resolved
|
} satisfies Resolved
|
||||||
})
|
})
|
||||||
|
|
||||||
function managedService(options: Service.StartOptions) {
|
function managedService(options: StartOptions) {
|
||||||
const reconnectOptions = { ...options, version: undefined }
|
const reconnectOptions = { ...options, version: undefined }
|
||||||
return {
|
return {
|
||||||
reconnect: (onStatus: (status: Service.Status) => void) => Service.start({ ...reconnectOptions, onStatus }),
|
reconnect: () => Service.start(reconnectOptions),
|
||||||
restart: () =>
|
restart: () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* Service.stop(options, { targetVersion: options.version })
|
yield* Service.stop(options)
|
||||||
yield* Service.start(options)
|
yield* Service.start(options)
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveManaged = Effect.fnUntraced(function* (
|
const resolveManaged = Effect.fnUntraced(function* (
|
||||||
options: Service.StartOptions,
|
options: StartOptions,
|
||||||
mismatch: NonNullable<Args["mismatch"]>,
|
mismatch: NonNullable<Args["mismatch"]>,
|
||||||
) {
|
) {
|
||||||
if (mismatch === "replace") return yield* Service.start(options)
|
if (mismatch === "replace") return yield* Service.start(options)
|
||||||
@@ -76,7 +76,7 @@ const resolveManaged = Effect.fnUntraced(function* (
|
|||||||
return yield* Service.start(options)
|
return yield* Service.start(options)
|
||||||
})
|
})
|
||||||
|
|
||||||
function connectError(endpoint: Service.Endpoint, cause: unknown) {
|
function connectError(endpoint: Endpoint, cause: unknown) {
|
||||||
if (isUnauthorizedError(cause)) {
|
if (isUnauthorizedError(cause)) {
|
||||||
return new Error(
|
return new Error(
|
||||||
endpoint.auth === undefined
|
endpoint.auth === undefined
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
|
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||||
import { Hash } from "@opencode-ai/core/util/hash"
|
import { Hash } from "@opencode-ai/core/util/hash"
|
||||||
import { Service } from "@opencode-ai/client/effect"
|
import { Service } from "@opencode-ai/client/effect/service"
|
||||||
import { Effect, FileSystem, Option, Schema } from "effect"
|
import { Effect, FileSystem, Option, Schema } from "effect"
|
||||||
import { randomBytes } from "crypto"
|
import { randomBytes } from "crypto"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
|
||||||
// The CLI's service configuration file, plus the Service.Options binding that
|
// The CLI's service configuration file, plus the Service.StartOptions binding that
|
||||||
// points the client package's service operations at this CLI: which
|
// points the client package's service operations at this CLI: which
|
||||||
// registration file (by channel), which version, and how to spawn opencode.
|
// registration file (by channel), which version, and how to spawn opencode.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Service } from "@opencode-ai/client/effect"
|
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
|
||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { Effect, Schema, Stream } from "effect"
|
import { Effect, Schema, Stream } from "effect"
|
||||||
@@ -46,7 +46,7 @@ const makeEndpoint = Effect.fn("cli.standalone.endpoint")(
|
|||||||
url: ready.url,
|
url: ready.url,
|
||||||
auth: { type: "basic" as const, username: "opencode", password },
|
auth: { type: "basic" as const, username: "opencode", password },
|
||||||
pid: proc.pid,
|
pid: proc.pid,
|
||||||
} satisfies Service.Endpoint & { readonly pid: number }
|
} satisfies Endpoint & { readonly pid: number }
|
||||||
},
|
},
|
||||||
Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node)),
|
Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node)),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Service } from "@opencode-ai/client/effect"
|
import { Service } from "@opencode-ai/client/effect/service"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { Standalone } from "../../src/services/standalone"
|
import { Standalone } from "../../src/services/standalone"
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ test("resolution groups Effect-native lifecycle operations only for the managed
|
|||||||
healthy: true,
|
healthy: true,
|
||||||
version: InstallationVersion,
|
version: InstallationVersion,
|
||||||
pid: process.pid,
|
pid: process.pid,
|
||||||
instanceID: id,
|
|
||||||
status: { type: "ready" },
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -45,9 +43,9 @@ test("resolution groups Effect-native lifecycle operations only for the managed
|
|||||||
expect(resolved.endpoint.url).toBe(server.url.toString())
|
expect(resolved.endpoint.url).toBe(server.url.toString())
|
||||||
expect(resolved.service).toBeDefined()
|
expect(resolved.service).toBeDefined()
|
||||||
if (!resolved.service) throw new Error("Expected managed service capabilities")
|
if (!resolved.service) throw new Error("Expected managed service capabilities")
|
||||||
expect(Effect.isEffect(resolved.service.reconnect(() => {}))).toBe(true)
|
expect(Effect.isEffect(resolved.service.reconnect())).toBe(true)
|
||||||
expect(Effect.isEffect(resolved.service.restart())).toBe(true)
|
expect(Effect.isEffect(resolved.service.restart())).toBe(true)
|
||||||
expect(await runPromise(resolved.service.reconnect(() => {}))).toEqual(resolved.endpoint)
|
expect(await runPromise(resolved.service.reconnect())).toEqual(resolved.endpoint)
|
||||||
|
|
||||||
const explicit = await runPromise(ServerConnection.resolve({ server: server.url.toString() }))
|
const explicit = await runPromise(ServerConnection.resolve({ server: server.url.toString() }))
|
||||||
expect(explicit.endpoint.url).toBe(server.url.toString())
|
expect(explicit.endpoint.url).toBe(server.url.toString())
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NodeFileSystem } from "@effect/platform-node"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
import { Service } from "@opencode-ai/client/effect"
|
import { Service } from "@opencode-ai/client/effect/service"
|
||||||
import { Database } from "@opencode-ai/core/database/database"
|
import { Database } from "@opencode-ai/core/database/database"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
import { EventTable } from "@opencode-ai/core/event/sql"
|
import { EventTable } from "@opencode-ai/core/event/sql"
|
||||||
@@ -146,11 +146,10 @@ test("concurrent service processes elect one server", async () => {
|
|||||||
await fetch(new URL("/api/health", info.url), {
|
await fetch(new URL("/api/health", info.url), {
|
||||||
headers: { authorization: "Basic " + btoa(`opencode:${info.password}`) },
|
headers: { authorization: "Basic " + btoa(`opencode:${info.password}`) },
|
||||||
}).then((response) => response.json()),
|
}).then((response) => response.json()),
|
||||||
).toMatchObject({
|
).toEqual({
|
||||||
healthy: true,
|
healthy: true,
|
||||||
|
version: info.version,
|
||||||
pid: info.pid,
|
pid: info.pid,
|
||||||
instanceID: info.id,
|
|
||||||
status: { type: "ready" },
|
|
||||||
})
|
})
|
||||||
const blockedTemp = registration + "." + info.id + ".tmp"
|
const blockedTemp = registration + "." + info.id + ".tmp"
|
||||||
await fs.mkdir(blockedTemp)
|
await fs.mkdir(blockedTemp)
|
||||||
@@ -193,7 +192,7 @@ test("concurrent service processes elect one server", async () => {
|
|||||||
).toEqual({ timeSuspended: null })
|
).toEqual({ timeSuspended: null })
|
||||||
expect(await waitForExecutionStart(database, sessionID)).toBe(1)
|
expect(await waitForExecutionStart(database, sessionID)).toBe(1)
|
||||||
await Effect.runPromise(
|
await Effect.runPromise(
|
||||||
Service.stop({ file: registration }, { targetVersion: "next" }).pipe(Effect.provide(NodeFileSystem.layer)),
|
Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||||
)
|
)
|
||||||
await winner?.exited
|
await winner?.exited
|
||||||
} finally {
|
} finally {
|
||||||
@@ -227,19 +226,6 @@ test("a failed service stays registered and owns the lock until stopped", async
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const info = await waitForInfo(registration)
|
const info = await waitForInfo(registration)
|
||||||
const status = await Effect.runPromise(
|
|
||||||
Service.status({ file: registration }).pipe(
|
|
||||||
Effect.filterOrFail((status) => status.type === "failed"),
|
|
||||||
Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(200)))),
|
|
||||||
Effect.provide(NodeFileSystem.layer),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
expect(status).toEqual({
|
|
||||||
type: "failed",
|
|
||||||
version: info.version,
|
|
||||||
message: "The background service could not start.",
|
|
||||||
action: "Run `opencode service restart` after checking the service logs.",
|
|
||||||
})
|
|
||||||
expect(owner.exitCode).toBe(null)
|
expect(owner.exitCode).toBe(null)
|
||||||
|
|
||||||
const contender = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
|
const contender = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
|
||||||
|
|||||||
@@ -19,8 +19,10 @@
|
|||||||
".": "./src/promise/index.ts",
|
".": "./src/promise/index.ts",
|
||||||
"./promise": "./src/promise/index.ts",
|
"./promise": "./src/promise/index.ts",
|
||||||
"./promise/api": "./src/promise/api.ts",
|
"./promise/api": "./src/promise/api.ts",
|
||||||
|
"./service": "./src/promise/service.ts",
|
||||||
"./effect": "./src/effect/index.ts",
|
"./effect": "./src/effect/index.ts",
|
||||||
"./effect/api": "./src/effect/api.ts"
|
"./effect/api": "./src/effect/api.ts",
|
||||||
|
"./effect/service": "./src/effect/service.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "bun run script/build-package.ts",
|
"build": "bun run script/build-package.ts",
|
||||||
|
|||||||
@@ -11,10 +11,7 @@ export type Endpoint0_0Output = EffectValue<ReturnType<RawClient["server.health"
|
|||||||
export type HealthGetOperation<E = never> = () => Effect.Effect<Endpoint0_0Output, E>
|
export type HealthGetOperation<E = never> = () => Effect.Effect<Endpoint0_0Output, E>
|
||||||
|
|
||||||
type Endpoint0_1Request = Parameters<RawClient["server.health"]["health.stop"]>[0]
|
type Endpoint0_1Request = Parameters<RawClient["server.health"]["health.stop"]>[0]
|
||||||
export type Endpoint0_1Input = {
|
export type Endpoint0_1Input = { readonly instanceID: Endpoint0_1Request["payload"]["instanceID"] }
|
||||||
readonly instanceID: Endpoint0_1Request["payload"]["instanceID"]
|
|
||||||
readonly targetVersion?: Endpoint0_1Request["payload"]["targetVersion"]
|
|
||||||
}
|
|
||||||
export type Endpoint0_1Output = EffectValue<ReturnType<RawClient["server.health"]["health.stop"]>>
|
export type Endpoint0_1Output = EffectValue<ReturnType<RawClient["server.health"]["health.stop"]>>
|
||||||
export type HealthStopOperation<E = never> = (input: Endpoint0_1Input) => Effect.Effect<Endpoint0_1Output, E>
|
export type HealthStopOperation<E = never> = (input: Endpoint0_1Input) => Effect.Effect<Endpoint0_1Output, E>
|
||||||
|
|
||||||
|
|||||||
@@ -17,14 +17,9 @@ const Endpoint0_0 = (raw: RawClient["server.health"]) => () =>
|
|||||||
raw["health.get"]({}).pipe(Effect.mapError(mapClientError))
|
raw["health.get"]({}).pipe(Effect.mapError(mapClientError))
|
||||||
|
|
||||||
type Endpoint0_1Request = Parameters<RawClient["server.health"]["health.stop"]>[0]
|
type Endpoint0_1Request = Parameters<RawClient["server.health"]["health.stop"]>[0]
|
||||||
type Endpoint0_1Input = {
|
type Endpoint0_1Input = { readonly instanceID: Endpoint0_1Request["payload"]["instanceID"] }
|
||||||
readonly instanceID: Endpoint0_1Request["payload"]["instanceID"]
|
|
||||||
readonly targetVersion?: Endpoint0_1Request["payload"]["targetVersion"]
|
|
||||||
}
|
|
||||||
const Endpoint0_1 = (raw: RawClient["server.health"]) => (input: Endpoint0_1Input) =>
|
const Endpoint0_1 = (raw: RawClient["server.health"]) => (input: Endpoint0_1Input) =>
|
||||||
raw["health.stop"]({ payload: { instanceID: input["instanceID"], targetVersion: input["targetVersion"] } }).pipe(
|
raw["health.stop"]({ payload: { instanceID: input["instanceID"] } }).pipe(Effect.mapError(mapClientError))
|
||||||
Effect.mapError(mapClientError),
|
|
||||||
)
|
|
||||||
|
|
||||||
const adaptGroup0 = (raw: RawClient["server.health"]) => ({ get: Endpoint0_0(raw), stop: Endpoint0_1(raw) })
|
const adaptGroup0 = (raw: RawClient["server.health"]) => ({ get: Endpoint0_0(raw), stop: Endpoint0_1(raw) })
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ export type {
|
|||||||
SessionApi,
|
SessionApi,
|
||||||
SkillApi,
|
SkillApi,
|
||||||
} from "./api.js"
|
} from "./api.js"
|
||||||
export { Service } from "./service.js"
|
|
||||||
export { Agent } from "@opencode-ai/schema/agent"
|
export { Agent } from "@opencode-ai/schema/agent"
|
||||||
export { Command } from "@opencode-ai/schema/command"
|
export { Command } from "@opencode-ai/schema/command"
|
||||||
export { Credential } from "@opencode-ai/schema/credential"
|
export { Credential } from "@opencode-ai/schema/credential"
|
||||||
|
|||||||
@@ -3,6 +3,16 @@ import { Effect, FileSystem, Option, Schedule, Schema } from "effect"
|
|||||||
import { spawn, type ChildProcess } from "node:child_process"
|
import { spawn, type ChildProcess } from "node:child_process"
|
||||||
import { homedir } from "node:os"
|
import { homedir } from "node:os"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
|
import type {
|
||||||
|
DiscoverOptions,
|
||||||
|
Endpoint,
|
||||||
|
StartOptions,
|
||||||
|
StopOptions,
|
||||||
|
} from "../service.js"
|
||||||
|
|
||||||
|
export * from "../service.js"
|
||||||
|
/** Contents of the local service registration file. */
|
||||||
|
export type Info = import("../service.js").Info
|
||||||
|
|
||||||
// Find, start, and stop the local opencode background service.
|
// Find, start, and stop the local opencode background service.
|
||||||
//
|
//
|
||||||
@@ -12,48 +22,6 @@ import { join } from "node:path"
|
|||||||
// is all a client needs to connect. The daemon's own configuration (port,
|
// is all a client needs to connect. The daemon's own configuration (port,
|
||||||
// persisted password) is CLI-owned and never read here.
|
// persisted password) is CLI-owned and never read here.
|
||||||
|
|
||||||
export type Endpoint = {
|
|
||||||
readonly url: string
|
|
||||||
readonly auth?: {
|
|
||||||
readonly type: "basic"
|
|
||||||
readonly username: string
|
|
||||||
readonly password: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Options = {
|
|
||||||
// Absolute path to the service registration file. Defaults to
|
|
||||||
// opencode/service.json in the XDG state directory.
|
|
||||||
readonly file?: string
|
|
||||||
// When set, discovery only returns a server reporting this exact version,
|
|
||||||
// and start() replaces a healthy server whose version differs.
|
|
||||||
readonly version?: string
|
|
||||||
// Argv used to spawn the service. Defaults to ["opencode", "serve",
|
|
||||||
// "--service"] resolved from PATH.
|
|
||||||
readonly command?: ReadonlyArray<string>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type StartReason = "missing" | "version-mismatch"
|
|
||||||
|
|
||||||
export type StartOptions = Options & {
|
|
||||||
// Called once when start() decides it must spawn: either no service was
|
|
||||||
// found, or a healthy service with a different version is being replaced.
|
|
||||||
// `existing` carries the registration of the service being replaced.
|
|
||||||
readonly onStart?: (reason: StartReason, existing?: Info) => void
|
|
||||||
readonly onStatus?: (status: Status) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Status =
|
|
||||||
| { readonly type: "missing" }
|
|
||||||
| { readonly type: "unreachable" }
|
|
||||||
| { readonly type: "unresponsive" }
|
|
||||||
| (ServiceStatus.State & { readonly version?: string })
|
|
||||||
|
|
||||||
export class FailedError extends Schema.TaggedErrorClass<FailedError>()("ServiceFailedError", {
|
|
||||||
message: Schema.String,
|
|
||||||
action: Schema.String,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
type Contender = {
|
type Contender = {
|
||||||
readonly child: ChildProcess
|
readonly child: ChildProcess
|
||||||
readonly error: () => Error | undefined
|
readonly error: () => Error | undefined
|
||||||
@@ -61,24 +29,14 @@ type Contender = {
|
|||||||
|
|
||||||
// Read-only lookup: registration file plus health check and version gate.
|
// Read-only lookup: registration file plus health check and version gate.
|
||||||
// Never spawns; escalation to start() is the caller's policy.
|
// Never spawns; escalation to start() is the caller's policy.
|
||||||
export const discover = Effect.fn("service.discover")(function* (options: Options = {}) {
|
/** Discover a healthy, compatible local service without starting one. */
|
||||||
|
export const discover = Effect.fn("service.discover")(function* (options: DiscoverOptions = {}) {
|
||||||
return (yield* discoverLocal(options))?.endpoint
|
return (yield* discoverLocal(options))?.endpoint
|
||||||
})
|
})
|
||||||
|
|
||||||
export const status = Effect.fn("service.status")(function* (options: Options = {}) {
|
const discoverLocal = Effect.fnUntraced(function* (options: DiscoverOptions) {
|
||||||
const result = yield* registered(options.file, true)
|
|
||||||
if (result.info === undefined) return { type: "missing" } satisfies Status
|
|
||||||
if (result.service === undefined) return { type: "unreachable" } satisfies Status
|
|
||||||
return publicStatus(result.service)
|
|
||||||
})
|
|
||||||
|
|
||||||
function publicStatus(service: LocalService): Status {
|
|
||||||
return { ...service.status, version: service.version }
|
|
||||||
}
|
|
||||||
|
|
||||||
const discoverLocal = Effect.fnUntraced(function* (options: Options) {
|
|
||||||
const found = (yield* registered(options.file)).service
|
const found = (yield* registered(options.file)).service
|
||||||
if (found?.status.type !== "ready") return undefined
|
if (found?.state !== "ready") return undefined
|
||||||
if (options.version !== undefined && found.version !== options.version) return undefined
|
if (options.version !== undefined && found.version !== options.version) return undefined
|
||||||
return found
|
return found
|
||||||
})
|
})
|
||||||
@@ -86,18 +44,18 @@ const discoverLocal = Effect.fnUntraced(function* (options: Options) {
|
|||||||
// Idempotent ensure-running: reuses a healthy compatible server, replaces a
|
// Idempotent ensure-running: reuses a healthy compatible server, replaces a
|
||||||
// version-mismatched one, and otherwise spawns small contenders until a server
|
// version-mismatched one, and otherwise spawns small contenders until a server
|
||||||
// becomes discoverable. A contender is never killed merely for slow startup.
|
// becomes discoverable. A contender is never killed merely for slow startup.
|
||||||
|
/** Ensure a healthy, compatible local service is running. */
|
||||||
export const start = Effect.fn("service.start")(function* (options: StartOptions = {}) {
|
export const start = Effect.fn("service.start")(function* (options: StartOptions = {}) {
|
||||||
const contenders = new Set<Contender>()
|
const contenders = new Set<Contender>()
|
||||||
let announced = false
|
let announced = false
|
||||||
let reported: Status | undefined
|
|
||||||
let lastSpawn = 0
|
let lastSpawn = 0
|
||||||
let spawnDelay = 5_000
|
let spawnDelay = 5_000
|
||||||
let ownerHeld = false
|
let ownerHeld = false
|
||||||
const announce = (reason: StartReason, existing?: Info) =>
|
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
if (announced) return
|
if (announced) return
|
||||||
announced = true
|
announced = true
|
||||||
options.onStart?.(reason, existing)
|
options.onStart?.(reason, previousVersion)
|
||||||
})
|
})
|
||||||
const spawnContender = Effect.gen(function* () {
|
const spawnContender = Effect.gen(function* () {
|
||||||
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
|
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
|
||||||
@@ -119,24 +77,15 @@ export const start = Effect.fn("service.start")(function* (options: StartOptions
|
|||||||
const registration = yield* registered(options.file, true)
|
const registration = yield* registered(options.file, true)
|
||||||
const info = registration.info
|
const info = registration.info
|
||||||
const service = registration.service
|
const service = registration.service
|
||||||
const current: Status =
|
|
||||||
service === undefined ? { type: info === undefined ? "missing" : "unreachable" } : publicStatus(service)
|
|
||||||
const next = ownerHeld && service === undefined ? ({ type: "unresponsive" } satisfies Status) : current
|
|
||||||
yield* Effect.sync(() => {
|
|
||||||
if (sameStatus(reported, next)) return
|
|
||||||
reported = next
|
|
||||||
options.onStatus?.(next)
|
|
||||||
})
|
|
||||||
if (service !== undefined) {
|
if (service !== undefined) {
|
||||||
ownerHeld = false
|
ownerHeld = false
|
||||||
spawnDelay = 5_000
|
spawnDelay = 5_000
|
||||||
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
|
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
|
||||||
if (compatible && service.status.type === "ready") return Option.some(service)
|
if (compatible && service.state === "ready") return Option.some(service)
|
||||||
if (compatible && service.status.type === "failed")
|
if (compatible && service.state === "failed") return yield* Effect.fail(new Error("Background service failed to start"))
|
||||||
return yield* new FailedError({ message: service.status.message, action: service.status.action })
|
if (compatible) return Option.none<LocalService>()
|
||||||
if (compatible || service.status.type === "stopping") return Option.none<LocalService>()
|
yield* announce("version-mismatch", service.version)
|
||||||
yield* announce("version-mismatch", service.info)
|
yield* kill(service, options).pipe(Effect.ignore)
|
||||||
yield* kill(service, options, options.version).pipe(Effect.ignore)
|
|
||||||
lastSpawn = 0
|
lastSpawn = 0
|
||||||
return Option.none<LocalService>()
|
return Option.none<LocalService>()
|
||||||
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
|
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
|
||||||
@@ -160,22 +109,6 @@ export const start = Effect.fn("service.start")(function* (options: StartOptions
|
|||||||
return Option.getOrThrow(found).endpoint
|
return Option.getOrThrow(found).endpoint
|
||||||
})
|
})
|
||||||
|
|
||||||
function sameStatus(left: Status | undefined, right: Status) {
|
|
||||||
if (left?.type !== right.type) return false
|
|
||||||
if (right.type === "failed")
|
|
||||||
return (
|
|
||||||
left.type === "failed" &&
|
|
||||||
left.version === right.version &&
|
|
||||||
left.message === right.message &&
|
|
||||||
left.action === right.action
|
|
||||||
)
|
|
||||||
if (right.type === "stopping")
|
|
||||||
return left.type === "stopping" && left.version === right.version && left.targetVersion === right.targetVersion
|
|
||||||
if (right.type === "starting" || right.type === "ready")
|
|
||||||
return left.type === right.type && left.version === right.version
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
function contenderFailure(contender: Contender) {
|
function contenderFailure(contender: Contender) {
|
||||||
const error = contender.error()
|
const error = contender.error()
|
||||||
if (error !== undefined) return error
|
if (error !== undefined) return error
|
||||||
@@ -190,13 +123,10 @@ function contenderFinished(contender: Contender) {
|
|||||||
return contender.error() !== undefined || contender.child.exitCode !== null || contender.child.signalCode !== null
|
return contender.error() !== undefined || contender.child.exitCode !== null || contender.child.signalCode !== null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StopMetadata = {
|
/** Stop the registered local service. */
|
||||||
readonly targetVersion?: string
|
export const stop = Effect.fn("service.stop")(function* (options: StopOptions = {}) {
|
||||||
}
|
|
||||||
|
|
||||||
export const stop = Effect.fn("service.stop")(function* (options: Options = {}, metadata: StopMetadata = {}) {
|
|
||||||
const existing = yield* find(options)
|
const existing = yield* find(options)
|
||||||
if (existing !== undefined) yield* kill(existing, options, metadata.targetVersion)
|
if (existing !== undefined) yield* kill(existing, options)
|
||||||
})
|
})
|
||||||
|
|
||||||
function fallback() {
|
function fallback() {
|
||||||
@@ -204,11 +134,13 @@ function fallback() {
|
|||||||
return join(state, "opencode", "service.json")
|
return join(state, "opencode", "service.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Create HTTP authentication headers for a service endpoint. */
|
||||||
export function headers(endpoint: Endpoint) {
|
export function headers(endpoint: Endpoint) {
|
||||||
if (endpoint.auth === undefined) return undefined
|
if (endpoint.auth === undefined) return undefined
|
||||||
return { authorization: "Basic " + btoa(endpoint.auth.username + ":" + endpoint.auth.password) }
|
return { authorization: "Basic " + btoa(endpoint.auth.username + ":" + endpoint.auth.password) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Schema for the local service registration file. */
|
||||||
export const Info = Schema.Struct({
|
export const Info = Schema.Struct({
|
||||||
id: Schema.optional(Schema.String),
|
id: Schema.optional(Schema.String),
|
||||||
version: Schema.optional(Schema.String),
|
version: Schema.optional(Schema.String),
|
||||||
@@ -216,7 +148,6 @@ export const Info = Schema.Struct({
|
|||||||
pid: Schema.Int.check(Schema.isGreaterThan(0)),
|
pid: Schema.Int.check(Schema.isGreaterThan(0)),
|
||||||
password: Schema.optional(Schema.String),
|
password: Schema.optional(Schema.String),
|
||||||
})
|
})
|
||||||
export type Info = typeof Info.Type
|
|
||||||
|
|
||||||
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
|
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
|
||||||
const decodeHealth = Schema.decodeUnknownOption(ServiceStatus.Health)
|
const decodeHealth = Schema.decodeUnknownOption(ServiceStatus.Health)
|
||||||
@@ -235,7 +166,7 @@ type LocalService = {
|
|||||||
readonly info: Info
|
readonly info: Info
|
||||||
readonly endpoint: Endpoint
|
readonly endpoint: Endpoint
|
||||||
readonly version?: string
|
readonly version?: string
|
||||||
readonly status: ServiceStatus.State
|
readonly state: "ready" | "waiting" | "failed"
|
||||||
readonly legacy: boolean
|
readonly legacy: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,13 +190,11 @@ const probe = Effect.fnUntraced(function* (info: Info, allowLegacy = false) {
|
|||||||
if (Option.isSome(health)) {
|
if (Option.isSome(health)) {
|
||||||
if (health.value.pid !== info.pid) return undefined
|
if (health.value.pid !== info.pid) return undefined
|
||||||
if (info.version !== undefined && health.value.version !== info.version) return undefined
|
if (info.version !== undefined && health.value.version !== info.version) return undefined
|
||||||
if (info.id !== undefined && health.value.instanceID !== undefined && health.value.instanceID !== info.id)
|
|
||||||
return undefined
|
|
||||||
return {
|
return {
|
||||||
info,
|
info,
|
||||||
endpoint,
|
endpoint,
|
||||||
version: health.value.version,
|
version: health.value.version,
|
||||||
status: health.value.status,
|
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
|
||||||
legacy: false,
|
legacy: false,
|
||||||
} satisfies LocalService
|
} satisfies LocalService
|
||||||
}
|
}
|
||||||
@@ -275,7 +204,7 @@ const probe = Effect.fnUntraced(function* (info: Info, allowLegacy = false) {
|
|||||||
(typeof body === "object" && body !== null && ("version" in body || "pid" in body))
|
(typeof body === "object" && body !== null && ("version" in body || "pid" in body))
|
||||||
)
|
)
|
||||||
return undefined
|
return undefined
|
||||||
return { info, endpoint, status: { type: "ready" }, legacy: true } satisfies LocalService
|
return { info, endpoint, state: "ready", legacy: true } satisfies LocalService
|
||||||
})
|
})
|
||||||
|
|
||||||
const registered = Effect.fnUntraced(function* (file?: string, allowLegacy = false) {
|
const registered = Effect.fnUntraced(function* (file?: string, allowLegacy = false) {
|
||||||
@@ -284,9 +213,9 @@ const registered = Effect.fnUntraced(function* (file?: string, allowLegacy = fal
|
|||||||
return { info, service: yield* probe(info, allowLegacy) }
|
return { info, service: yield* probe(info, allowLegacy) }
|
||||||
})
|
})
|
||||||
|
|
||||||
// Health-checked lookup without the version gate: status operations must be
|
// Health-checked lookup without the version gate: lifecycle operations must be
|
||||||
// able to see (and replace or stop) a server from a different version.
|
// able to see (and replace or stop) a server from a different version.
|
||||||
const find = Effect.fnUntraced(function* (options: Options) {
|
const find = Effect.fnUntraced(function* (options: { readonly file?: string }) {
|
||||||
return (yield* registered(options.file, true)).service
|
return (yield* registered(options.file, true)).service
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -309,8 +238,11 @@ function same(left: Info, right: Info) {
|
|||||||
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
||||||
}
|
}
|
||||||
|
|
||||||
const kill = Effect.fnUntraced(function* (service: LocalService, options: Options, targetVersion?: string) {
|
const kill = Effect.fnUntraced(function* (
|
||||||
const requested = yield* requestStop(service, targetVersion)
|
service: LocalService,
|
||||||
|
options: { readonly file?: string },
|
||||||
|
) {
|
||||||
|
const requested = yield* requestStop(service)
|
||||||
if (requested === "rejected") return
|
if (requested === "rejected") return
|
||||||
if (requested === "unsupported") {
|
if (requested === "unsupported") {
|
||||||
// A stale registration may point at a reused PID. Authenticate again
|
// A stale registration may point at a reused PID. Authenticate again
|
||||||
@@ -330,13 +262,13 @@ const kill = Effect.fnUntraced(function* (service: LocalService, options: Option
|
|||||||
|
|
||||||
const decodeStopResponse = Schema.decodeUnknownOption(ServiceStatus.StopResponse)
|
const decodeStopResponse = Schema.decodeUnknownOption(ServiceStatus.StopResponse)
|
||||||
|
|
||||||
const requestStop = Effect.fnUntraced(function* (service: LocalService, targetVersion?: string) {
|
const requestStop = Effect.fnUntraced(function* (service: LocalService) {
|
||||||
if (service.info.id === undefined || service.legacy) return "unsupported" as const
|
if (service.info.id === undefined || service.legacy) return "unsupported" as const
|
||||||
const response = yield* Effect.tryPromise(() =>
|
const response = yield* Effect.tryPromise(() =>
|
||||||
fetch(new URL("/api/service/stop", service.info.url), {
|
fetch(new URL("/api/service/stop", service.info.url), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { ...headers(service.endpoint), "content-type": "application/json" },
|
headers: { ...headers(service.endpoint), "content-type": "application/json" },
|
||||||
body: JSON.stringify({ instanceID: service.info.id, targetVersion }),
|
body: JSON.stringify({ instanceID: service.info.id }),
|
||||||
signal: AbortSignal.timeout(2_000),
|
signal: AbortSignal.timeout(2_000),
|
||||||
}),
|
}),
|
||||||
).pipe(Effect.option, Effect.map(Option.getOrUndefined))
|
).pipe(Effect.option, Effect.map(Option.getOrUndefined))
|
||||||
@@ -347,4 +279,5 @@ const requestStop = Effect.fnUntraced(function* (service: LocalService, targetVe
|
|||||||
return "accepted" as const
|
return "accepted" as const
|
||||||
})
|
})
|
||||||
|
|
||||||
export * as Service from "./service.js"
|
/** Effect-based local service lifecycle operations. */
|
||||||
|
export const Service = { discover, start, stop, headers, Info }
|
||||||
|
|||||||
@@ -339,7 +339,7 @@ export function make(options: ClientOptions) {
|
|||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: `/api/service/stop`,
|
path: `/api/service/stop`,
|
||||||
body: { instanceID: input["instanceID"], targetVersion: input["targetVersion"] },
|
body: { instanceID: input["instanceID"] },
|
||||||
successStatus: 200,
|
successStatus: 200,
|
||||||
declaredStatuses: [401, 400],
|
declaredStatuses: [401, 400],
|
||||||
empty: false,
|
empty: false,
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
export type JsonValue = null | boolean | number | string | Array<JsonValue> | { [key: string]: JsonValue }
|
export type JsonValue = null | boolean | number | string | Array<JsonValue> | { [key: string]: JsonValue }
|
||||||
|
|
||||||
export type ServiceStatus =
|
export type ServiceHealth = { healthy: true; version: string; pid: number }
|
||||||
| { type: "starting" }
|
|
||||||
| { type: "ready" }
|
|
||||||
| { type: "stopping"; targetVersion?: string | null }
|
|
||||||
| { type: "failed"; message: string; action: string }
|
|
||||||
|
|
||||||
export type ServiceStopResponse = { accepted: boolean }
|
export type ServiceStopResponse = { accepted: boolean }
|
||||||
|
|
||||||
@@ -506,14 +502,6 @@ export type VcsFileStatus = {
|
|||||||
status: "added" | "deleted" | "modified"
|
status: "added" | "deleted" | "modified"
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ServiceHealth = {
|
|
||||||
healthy: true
|
|
||||||
version: string
|
|
||||||
pid: number
|
|
||||||
instanceID?: string | null
|
|
||||||
status?: ServiceStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SessionMessageModelSelected = {
|
export type SessionMessageModelSelected = {
|
||||||
id: string
|
id: string
|
||||||
metadata?: { [x: string]: JsonValue }
|
metadata?: { [x: string]: JsonValue }
|
||||||
@@ -2501,10 +2489,7 @@ export const isProjectCopyError = (value: unknown): value is ProjectCopyError =>
|
|||||||
|
|
||||||
export type HealthGetOutput = ServiceHealth
|
export type HealthGetOutput = ServiceHealth
|
||||||
|
|
||||||
export type HealthStopInput = {
|
export type HealthStopInput = { readonly instanceID: { readonly instanceID: string }["instanceID"] }
|
||||||
readonly instanceID: { readonly instanceID: string; readonly targetVersion?: string | undefined }["instanceID"]
|
|
||||||
readonly targetVersion?: { readonly instanceID: string; readonly targetVersion?: string | undefined }["targetVersion"]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type HealthStopOutput = ServiceStopResponse
|
export type HealthStopOutput = ServiceStopResponse
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import { readFile } from "node:fs/promises"
|
||||||
|
import { spawn, type ChildProcess } from "node:child_process"
|
||||||
|
import { homedir } from "node:os"
|
||||||
|
import { join } from "node:path"
|
||||||
|
import type {
|
||||||
|
DiscoverOptions,
|
||||||
|
Endpoint,
|
||||||
|
Info,
|
||||||
|
StartOptions,
|
||||||
|
StopOptions,
|
||||||
|
} from "../service.js"
|
||||||
|
import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js"
|
||||||
|
|
||||||
|
export * from "../service.js"
|
||||||
|
|
||||||
|
// Find, start, and stop the local opencode background service.
|
||||||
|
//
|
||||||
|
// The registration file is the complete discovery contract. This module is
|
||||||
|
// intentionally implemented with Node APIs so Promise clients do not need
|
||||||
|
// Effect or @effect/platform-node at runtime.
|
||||||
|
|
||||||
|
type Contender = {
|
||||||
|
readonly child: ChildProcess
|
||||||
|
readonly error: () => Error | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Discover a healthy, compatible local service without starting one. */
|
||||||
|
export async function discover(options: DiscoverOptions = {}) {
|
||||||
|
return (await discoverLocal(options))?.endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
async function discoverLocal(options: DiscoverOptions) {
|
||||||
|
const found = (await registered(options.file)).service
|
||||||
|
if (found?.state !== "ready") return undefined
|
||||||
|
if (options.version !== undefined && found.version !== options.version) return undefined
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ensure a healthy, compatible local service is running. */
|
||||||
|
export async function start(options: StartOptions = {}): Promise<Endpoint> {
|
||||||
|
const contenders = new Set<Contender>()
|
||||||
|
let announced = false
|
||||||
|
let lastSpawn = 0
|
||||||
|
let spawnDelay = 5_000
|
||||||
|
let ownerHeld = false
|
||||||
|
|
||||||
|
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) => {
|
||||||
|
if (announced) return
|
||||||
|
announced = true
|
||||||
|
options.onStart?.(reason, previousVersion)
|
||||||
|
}
|
||||||
|
const spawnContender = () => {
|
||||||
|
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
|
||||||
|
if (command === undefined) throw new Error("Missing service command")
|
||||||
|
try {
|
||||||
|
const child = spawn(command, args, { detached: true, stdio: "ignore" })
|
||||||
|
let error: Error | undefined
|
||||||
|
child.once("error", (cause) => {
|
||||||
|
error = new Error("Failed to start server", { cause })
|
||||||
|
})
|
||||||
|
child.unref()
|
||||||
|
return { child, error: () => error }
|
||||||
|
} catch (cause) {
|
||||||
|
throw new Error("Failed to start server", { cause })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const registration = await registered(options.file, true)
|
||||||
|
|
||||||
|
if (registration.service !== undefined) {
|
||||||
|
ownerHeld = false
|
||||||
|
spawnDelay = 5_000
|
||||||
|
const service = registration.service
|
||||||
|
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
|
||||||
|
if (compatible && service.state === "ready") return service.endpoint
|
||||||
|
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
|
||||||
|
if (!compatible) {
|
||||||
|
announce("version-mismatch", service.version)
|
||||||
|
await kill(service, options).catch(() => undefined)
|
||||||
|
lastSpawn = 0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (lastSpawn === 0 && registration.info !== undefined) lastSpawn = Date.now()
|
||||||
|
const failure = [...contenders].map(contenderFailure).find((error) => error !== undefined)
|
||||||
|
if (failure !== undefined) throw failure
|
||||||
|
const finished = [...contenders].filter(contenderFinished)
|
||||||
|
if (finished.some((item) => item.child.exitCode === 0)) {
|
||||||
|
ownerHeld = true
|
||||||
|
spawnDelay = Math.min(spawnDelay * 2, 30_000)
|
||||||
|
}
|
||||||
|
finished.forEach((item) => contenders.delete(item))
|
||||||
|
// Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
|
||||||
|
if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
|
||||||
|
announce("missing")
|
||||||
|
contenders.add(spawnContender())
|
||||||
|
lastSpawn = Date.now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await delay(1_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function contenderFailure(contender: Contender) {
|
||||||
|
const error = contender.error()
|
||||||
|
if (error !== undefined) return error
|
||||||
|
if (contender.child.exitCode !== null && contender.child.exitCode !== 0)
|
||||||
|
return new Error(`Server process exited with code ${contender.child.exitCode}`)
|
||||||
|
if (contender.child.signalCode !== null)
|
||||||
|
return new Error(`Server process terminated by ${contender.child.signalCode}`)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function contenderFinished(contender: Contender) {
|
||||||
|
return contender.error() !== undefined || contender.child.exitCode !== null || contender.child.signalCode !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop the registered local service. */
|
||||||
|
export async function stop(options: StopOptions = {}) {
|
||||||
|
const existing = await find(options)
|
||||||
|
if (existing !== undefined) await kill(existing, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallback() {
|
||||||
|
return join(process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state"), "opencode", "service.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create HTTP authentication headers for a service endpoint. */
|
||||||
|
export function headers(endpoint: Endpoint) {
|
||||||
|
if (endpoint.auth === undefined) return undefined
|
||||||
|
return { authorization: "Basic " + Buffer.from(endpoint.auth.username + ":" + endpoint.auth.password).toString("base64") }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function read(file?: string) {
|
||||||
|
const text = await readFile(file ?? fallback(), "utf8").catch(() => undefined)
|
||||||
|
if (text === undefined) return undefined
|
||||||
|
try {
|
||||||
|
return JSON.parse(text) as Info
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type LocalService = {
|
||||||
|
readonly info: Info
|
||||||
|
readonly endpoint: Endpoint
|
||||||
|
readonly version?: string
|
||||||
|
readonly state: "ready" | "waiting" | "failed"
|
||||||
|
readonly legacy: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probe(info: Info, allowLegacy = false): Promise<LocalService | undefined> {
|
||||||
|
const endpoint = {
|
||||||
|
url: info.url,
|
||||||
|
auth:
|
||||||
|
info.password === undefined
|
||||||
|
? undefined
|
||||||
|
: { type: "basic" as const, username: "opencode", password: info.password },
|
||||||
|
} satisfies Endpoint
|
||||||
|
const response = await fetch(new URL("/api/health", info.url), {
|
||||||
|
headers: headers(endpoint),
|
||||||
|
signal: AbortSignal.timeout(2_000),
|
||||||
|
}).catch(() => undefined)
|
||||||
|
const body = (await response?.json().catch(() => undefined)) as ServiceHealth | { readonly healthy: true } | undefined
|
||||||
|
if (body !== undefined && "version" in body && "pid" in body) {
|
||||||
|
if (body.pid !== info.pid) return undefined
|
||||||
|
if (info.version !== undefined && body.version !== info.version) return undefined
|
||||||
|
return {
|
||||||
|
info,
|
||||||
|
endpoint,
|
||||||
|
version: body.version,
|
||||||
|
state: response?.ok ? "ready" : response?.status === 500 ? "failed" : "waiting",
|
||||||
|
legacy: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!allowLegacy || body?.healthy !== true) return undefined
|
||||||
|
return { info, endpoint, state: "ready", legacy: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registered(file?: string, allowLegacy = false) {
|
||||||
|
const info = await read(file)
|
||||||
|
if (info === undefined) return { info: undefined, service: undefined }
|
||||||
|
return { info, service: await probe(info, allowLegacy) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function find(options: { readonly file?: string }) {
|
||||||
|
return (await registered(options.file, true)).service
|
||||||
|
}
|
||||||
|
|
||||||
|
function signal(pid: number, name: NodeJS.Signals) {
|
||||||
|
try {
|
||||||
|
process.kill(pid, name)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopped(pid: number) {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0)
|
||||||
|
return false
|
||||||
|
} catch {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitUntilStopped(pid: number) {
|
||||||
|
for (let attempt = 0; attempt <= 100; attempt++) {
|
||||||
|
if (stopped(pid)) return true
|
||||||
|
if (attempt < 100) await delay(50)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function same(left: Info, right: Info) {
|
||||||
|
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
||||||
|
}
|
||||||
|
|
||||||
|
async function kill(service: LocalService, options: { readonly file?: string }) {
|
||||||
|
const requested = await requestStop(service)
|
||||||
|
if (requested === "rejected") return
|
||||||
|
if (requested === "unsupported") {
|
||||||
|
const current = await find(options)
|
||||||
|
if (current === undefined || !same(current.info, service.info)) return
|
||||||
|
signal(service.info.pid, "SIGTERM")
|
||||||
|
}
|
||||||
|
if (await waitUntilStopped(service.info.pid)) return
|
||||||
|
|
||||||
|
const latest = await find(options)
|
||||||
|
if (latest === undefined || !same(latest.info, service.info)) return
|
||||||
|
signal(service.info.pid, "SIGKILL")
|
||||||
|
if (!(await waitUntilStopped(service.info.pid))) throw new Error(`Server process ${service.info.pid} is still running`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestStop(service: LocalService) {
|
||||||
|
if (service.info.id === undefined || service.legacy) return "unsupported" as const
|
||||||
|
const response = await fetch(new URL("/api/service/stop", service.info.url), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...headers(service.endpoint), "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ instanceID: service.info.id }),
|
||||||
|
signal: AbortSignal.timeout(2_000),
|
||||||
|
}).catch(() => undefined)
|
||||||
|
if (response === undefined || response.status === 404 || response.status === 405) return "unsupported" as const
|
||||||
|
const body = (await response.json().catch(() => undefined)) as ServiceStopResponse | undefined
|
||||||
|
if (!response.ok || body?.accepted !== true) return "rejected" as const
|
||||||
|
return "accepted" as const
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(milliseconds: number) {
|
||||||
|
return new Promise<void>((resolve) => setTimeout(resolve, milliseconds))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Promise-based local service lifecycle operations. */
|
||||||
|
export const Service = { discover, start, stop, headers }
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/** Connection details for a local OpenCode service. */
|
||||||
|
export type Endpoint = {
|
||||||
|
/** Base URL of the service. */
|
||||||
|
readonly url: string
|
||||||
|
/** Authentication required by the service, when configured. */
|
||||||
|
readonly auth?: {
|
||||||
|
/** HTTP authentication scheme. */
|
||||||
|
readonly type: "basic"
|
||||||
|
/** Basic authentication username. */
|
||||||
|
readonly username: string
|
||||||
|
/** Basic authentication password. */
|
||||||
|
readonly password: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options used to discover the local OpenCode service. */
|
||||||
|
export type DiscoverOptions = {
|
||||||
|
/** Absolute registration file path. Defaults to the XDG state directory. */
|
||||||
|
readonly file?: string
|
||||||
|
/** Required service version. */
|
||||||
|
readonly version?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reason a new service process must be started. */
|
||||||
|
export type StartReason = "missing" | "version-mismatch"
|
||||||
|
|
||||||
|
/** Options used to ensure the local OpenCode service is running. */
|
||||||
|
export type StartOptions = DiscoverOptions & {
|
||||||
|
/** Service command and arguments. Defaults to `opencode serve --service`. */
|
||||||
|
readonly command?: ReadonlyArray<string>
|
||||||
|
/** Called once before spawning a new service process. */
|
||||||
|
readonly onStart?: (reason: StartReason, previousVersion?: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options used to stop the local OpenCode service. */
|
||||||
|
export type StopOptions = {
|
||||||
|
/** Absolute registration file path. Defaults to the XDG state directory. */
|
||||||
|
readonly file?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Contents of the local service registration file. */
|
||||||
|
export type Info = {
|
||||||
|
/** Unique service instance identifier. */
|
||||||
|
readonly id?: string
|
||||||
|
/** OpenCode version served by the process. */
|
||||||
|
readonly version?: string
|
||||||
|
/** Base URL advertised by the service. */
|
||||||
|
readonly url: string
|
||||||
|
/** Operating system process identifier. */
|
||||||
|
readonly pid: number
|
||||||
|
/** Private service password, when authentication is enabled. */
|
||||||
|
readonly password?: string
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
|
|
||||||
const synced = { type: "log.synced" as const, aggregateID: "ses_test", seq: Event.Seq.make(1) }
|
const synced = { type: "log.synced" as const, aggregateID: "ses_test", seq: Event.Seq.make(1) }
|
||||||
|
|
||||||
test("health.get treats an old server as ready", async () => {
|
test("health.get decodes the readiness response", async () => {
|
||||||
const httpClient = HttpClient.make((request) =>
|
const httpClient = HttpClient.make((request) =>
|
||||||
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ healthy: true, version: "old", pid: 123 }))),
|
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ healthy: true, version: "old", pid: 123 }))),
|
||||||
)
|
)
|
||||||
@@ -24,7 +24,7 @@ test("health.get treats an old server as ready", async () => {
|
|||||||
return yield* client.health.get()
|
return yield* client.health.get()
|
||||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||||
|
|
||||||
expect(result.status).toEqual({ type: "ready" })
|
expect(result).toEqual({ healthy: true, version: "old", pid: 123 })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("session.get returns the decoded Effect projection", async () => {
|
test("session.get returns the decoded Effect projection", async () => {
|
||||||
|
|||||||
@@ -48,23 +48,11 @@ const server = Bun.serve({
|
|||||||
}
|
}
|
||||||
if (mode === "legacy") return Response.json({ healthy: true })
|
if (mode === "legacy") return Response.json({ healthy: true })
|
||||||
if (mode === "starting" && !(await Bun.file(registration + ".release").exists()))
|
if (mode === "starting" && !(await Bun.file(registration + ".release").exists()))
|
||||||
return Response.json(
|
return Response.json({ healthy: true, version, pid: process.pid }, { status: 503 })
|
||||||
{ healthy: true, version, pid: process.pid, instanceID: id, status: { type: "starting" } },
|
|
||||||
{ status: 503 },
|
|
||||||
)
|
|
||||||
if (mode === "failed-owner")
|
if (mode === "failed-owner")
|
||||||
return Response.json(
|
return Response.json({ healthy: true, version, pid: process.pid }, { status: 500 })
|
||||||
{
|
|
||||||
healthy: true,
|
|
||||||
version,
|
|
||||||
pid: process.pid,
|
|
||||||
instanceID: id,
|
|
||||||
status: { type: "failed", message: "Could not open the database.", action: "Check the service logs." },
|
|
||||||
},
|
|
||||||
{ status: 503 },
|
|
||||||
)
|
|
||||||
if (mode === "starting" || mode === "graceful" || mode === "reject-stop")
|
if (mode === "starting" || mode === "graceful" || mode === "reject-stop")
|
||||||
return Response.json({ healthy: true, version, pid: process.pid, instanceID: id, status: { type: "ready" } })
|
return Response.json({ healthy: true, version, pid: process.pid })
|
||||||
return Response.json({ healthy: true, version, pid: process.pid })
|
return Response.json({ healthy: true, version, pid: process.pid })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -20,15 +20,28 @@ describe("public import boundaries", () => {
|
|||||||
expect(within(root, core)).toEqual([])
|
expect(within(root, core)).toEqual([])
|
||||||
expect(within(root, server)).toEqual([])
|
expect(within(root, server)).toEqual([])
|
||||||
|
|
||||||
// The effect entry includes local service lifecycle (node spawn/fs), so it
|
const network = await bundleInputs("@opencode-ai/client/effect", "browser")
|
||||||
// bundles for bun; the boundary assertions below are what matter.
|
|
||||||
const network = await bundleInputs("@opencode-ai/client/effect", "bun")
|
|
||||||
|
|
||||||
expect(within(network, effect).length).toBeGreaterThan(0)
|
expect(within(network, effect).length).toBeGreaterThan(0)
|
||||||
expect(within(network, schema).length).toBeGreaterThan(0)
|
expect(within(network, schema).length).toBeGreaterThan(0)
|
||||||
expect(within(network, protocol).length).toBeGreaterThan(0)
|
expect(within(network, protocol).length).toBeGreaterThan(0)
|
||||||
expect(within(network, core)).toEqual([])
|
expect(within(network, core)).toEqual([])
|
||||||
expect(within(network, server)).toEqual([])
|
expect(within(network, server)).toEqual([])
|
||||||
|
|
||||||
|
const promiseService = await bundleInputs("@opencode-ai/client/service", "bun")
|
||||||
|
|
||||||
|
expect(within(promiseService, effect)).toEqual([])
|
||||||
|
expect(within(promiseService, schema)).toEqual([])
|
||||||
|
expect(within(promiseService, protocol)).toEqual([])
|
||||||
|
expect(within(promiseService, core)).toEqual([])
|
||||||
|
expect(within(promiseService, server)).toEqual([])
|
||||||
|
|
||||||
|
const effectService = await bundleInputs("@opencode-ai/client/effect/service", "bun")
|
||||||
|
|
||||||
|
expect(within(effectService, effect).length).toBeGreaterThan(0)
|
||||||
|
expect(within(effectService, protocol).length).toBeGreaterThan(0)
|
||||||
|
expect(within(effectService, core)).toEqual([])
|
||||||
|
expect(within(effectService, server)).toEqual([])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { afterEach, expect, test } from "bun:test"
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
import { join } from "node:path"
|
||||||
|
import { Service, type StartReason } from "../src/promise/service"
|
||||||
|
|
||||||
|
const fixture = join(import.meta.dir, "fixture/service.ts")
|
||||||
|
const processes: Bun.Subprocess[] = []
|
||||||
|
const directories: string[] = []
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
processes.forEach((process) => process.kill("SIGTERM"))
|
||||||
|
await Promise.all(processes.splice(0).map((process) => process.exited))
|
||||||
|
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("discovers a registered service", async () => {
|
||||||
|
const registration = await setup("graceful")
|
||||||
|
|
||||||
|
expect(await Service.discover({ file: registration, version: "test" })).toEqual(
|
||||||
|
expect.objectContaining({ url: expect.stringMatching(/^http:\/\//) }),
|
||||||
|
)
|
||||||
|
expect(await Service.discover({ file: registration, version: "other" })).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("starts a missing service with native promises", async () => {
|
||||||
|
const directory = await temp()
|
||||||
|
const registration = join(directory, "service.json")
|
||||||
|
const starts: StartReason[] = []
|
||||||
|
|
||||||
|
const endpoint = await Service.start({
|
||||||
|
file: registration,
|
||||||
|
version: "test",
|
||||||
|
command: [process.execPath, fixture, registration, "coordinated"],
|
||||||
|
onStart: (reason) => starts.push(reason),
|
||||||
|
})
|
||||||
|
const info = await Bun.file(registration).json()
|
||||||
|
try {
|
||||||
|
expect(endpoint.url).toBe(info.url)
|
||||||
|
expect(starts).toEqual(["missing"])
|
||||||
|
} finally {
|
||||||
|
process.kill(info.pid, "SIGTERM")
|
||||||
|
await waitForExit(info.pid)
|
||||||
|
}
|
||||||
|
}, 15_000)
|
||||||
|
|
||||||
|
test("reports a failed registered service", async () => {
|
||||||
|
const registration = await setup("failed-owner")
|
||||||
|
|
||||||
|
await expect(Service.start({ file: registration, version: "test", command: [] })).rejects.toThrow(
|
||||||
|
"Background service failed to start",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("requests graceful stop of the exact service instance", async () => {
|
||||||
|
const registration = await setup("graceful")
|
||||||
|
const info = await Bun.file(registration).json()
|
||||||
|
|
||||||
|
await Service.stop({ file: registration })
|
||||||
|
|
||||||
|
expect(await Bun.file(registration + ".stop").json()).toEqual({ instanceID: info.id })
|
||||||
|
})
|
||||||
|
|
||||||
|
async function setup(mode: string) {
|
||||||
|
const directory = await temp()
|
||||||
|
const registration = join(directory, "service.json")
|
||||||
|
processes.push(Bun.spawn([process.execPath, fixture, registration, mode], { stdout: "ignore", stderr: "inherit" }))
|
||||||
|
await waitForFile(registration)
|
||||||
|
return registration
|
||||||
|
}
|
||||||
|
|
||||||
|
async function temp() {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), "opencode-promise-service-"))
|
||||||
|
directories.push(directory)
|
||||||
|
return directory
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForFile(file: string) {
|
||||||
|
for (let attempt = 0; attempt < 600; attempt++) {
|
||||||
|
if (await Bun.file(file).exists()) return
|
||||||
|
await Bun.sleep(5)
|
||||||
|
}
|
||||||
|
throw new Error(`Timed out waiting for ${file}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForExit(pid: number) {
|
||||||
|
for (let attempt = 0; attempt < 600; attempt++) {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await Bun.sleep(5)
|
||||||
|
}
|
||||||
|
throw new Error(`Timed out waiting for process ${pid}`)
|
||||||
|
}
|
||||||
@@ -71,10 +71,10 @@ test("health.stop sends exact replacement identity", async () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(await client.health.stop({ instanceID: "instance", targetVersion: "next" })).toEqual({ accepted: true })
|
expect(await client.health.stop({ instanceID: "instance" })).toEqual({ accepted: true })
|
||||||
expect(request?.method).toBe("POST")
|
expect(request?.method).toBe("POST")
|
||||||
expect(request?.url).toBe("http://localhost:3000/api/service/stop")
|
expect(request?.url).toBe("http://localhost:3000/api/service/stop")
|
||||||
expect(await request?.json()).toEqual({ instanceID: "instance", targetVersion: "next" })
|
expect(await request?.json()).toEqual({ instanceID: "instance" })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("MCP resource catalog uses the public HTTP contract", async () => {
|
test("MCP resource catalog uses the public HTTP contract", async () => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Effect } from "effect"
|
|||||||
import { mkdtemp, rm, writeFile } from "node:fs/promises"
|
import { mkdtemp, rm, writeFile } from "node:fs/promises"
|
||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { Service } from "../src/effect/index"
|
import { Service, type StartReason } from "../src/effect/service"
|
||||||
|
|
||||||
const fixture = join(import.meta.dir, "fixture/service.ts")
|
const fixture = join(import.meta.dir, "fixture/service.ts")
|
||||||
const processes: Bun.Subprocess[] = []
|
const processes: Bun.Subprocess[] = []
|
||||||
@@ -23,7 +23,7 @@ test("a concurrent same-version start cannot invalidate a resolved endpoint", as
|
|||||||
await waitForFile(registration)
|
await waitForFile(registration)
|
||||||
const original = await Bun.file(registration).json()
|
const original = await Bun.file(registration).json()
|
||||||
|
|
||||||
const starts: Service.StartReason[] = []
|
const starts: StartReason[] = []
|
||||||
const first = run(
|
const first = run(
|
||||||
Service.start({
|
Service.start({
|
||||||
file: registration,
|
file: registration,
|
||||||
@@ -43,7 +43,6 @@ test("a concurrent same-version start cannot invalidate a resolved endpoint", as
|
|||||||
expect(starts).toEqual([])
|
expect(starts).toEqual([])
|
||||||
expect(await Bun.file(registration).json()).toEqual(original)
|
expect(await Bun.file(registration).json()).toEqual(original)
|
||||||
expect(await health(resolved.url)).toEqual({ healthy: true, version: "test", pid: original.pid })
|
expect(await health(resolved.url)).toEqual({ healthy: true, version: "test", pid: original.pid })
|
||||||
expect(await run(Service.status({ file: registration }))).toEqual({ type: "ready", version: "test" })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("waits for a registered service to finish starting", async () => {
|
test("waits for a registered service to finish starting", async () => {
|
||||||
@@ -51,15 +50,10 @@ test("waits for a registered service to finish starting", async () => {
|
|||||||
const registration = join(directory, "service.json")
|
const registration = join(directory, "service.json")
|
||||||
const process = spawn(registration, "starting")
|
const process = spawn(registration, "starting")
|
||||||
await waitForFile(registration)
|
await waitForFile(registration)
|
||||||
const statuses: Service.Status[] = []
|
const result = run(Service.start({ file: registration, version: "test", command: [] }))
|
||||||
const result = run(
|
|
||||||
Service.start({ file: registration, version: "test", command: [], onStatus: (status) => statuses.push(status) }),
|
|
||||||
)
|
|
||||||
|
|
||||||
await Bun.sleep(500)
|
await Bun.sleep(500)
|
||||||
expect(process.exitCode).toBe(null)
|
expect(process.exitCode).toBe(null)
|
||||||
expect(statuses).toContainEqual({ type: "starting", version: "test" })
|
|
||||||
expect(statuses.filter((status) => status.type === "starting")).toHaveLength(1)
|
|
||||||
await writeFile(registration + ".release", "")
|
await writeFile(registration + ".release", "")
|
||||||
expect((await result).url).toBe((await Bun.file(registration).json()).url)
|
expect((await result).url).toBe((await Bun.file(registration).json()).url)
|
||||||
})
|
})
|
||||||
@@ -70,23 +64,22 @@ test("reports a failed registered service without spawning", async () => {
|
|||||||
const process = spawn(registration, "failed-owner")
|
const process = spawn(registration, "failed-owner")
|
||||||
await waitForFile(registration)
|
await waitForFile(registration)
|
||||||
|
|
||||||
await expect(run(Service.start({ file: registration, version: "test", command: [] }))).rejects.toMatchObject({
|
await expect(run(Service.start({ file: registration, version: "test", command: [] }))).rejects.toThrow(
|
||||||
message: "Could not open the database.",
|
"Background service failed to start",
|
||||||
action: "Check the service logs.",
|
)
|
||||||
})
|
|
||||||
expect(process.exitCode).toBe(null)
|
expect(process.exitCode).toBe(null)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("requests graceful replacement of the exact service instance", async () => {
|
test("requests graceful stop of the exact service instance", async () => {
|
||||||
const directory = await temp()
|
const directory = await temp()
|
||||||
const registration = join(directory, "service.json")
|
const registration = join(directory, "service.json")
|
||||||
const process = spawn(registration, "graceful")
|
const process = spawn(registration, "graceful")
|
||||||
await waitForFile(registration)
|
await waitForFile(registration)
|
||||||
const info = await Bun.file(registration).json()
|
const info = await Bun.file(registration).json()
|
||||||
|
|
||||||
await run(Service.stop({ file: registration }, { targetVersion: "next" }))
|
await run(Service.stop({ file: registration }))
|
||||||
await process.exited
|
await process.exited
|
||||||
expect(await Bun.file(registration + ".stop").json()).toEqual({ instanceID: info.id, targetVersion: "next" })
|
expect(await Bun.file(registration + ".stop").json()).toEqual({ instanceID: info.id })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("does not spawn contenders while an incompatible service rejects replacement", async () => {
|
test("does not spawn contenders while an incompatible service rejects replacement", async () => {
|
||||||
@@ -120,7 +113,7 @@ test("a legacy health response is still replaced", async () => {
|
|||||||
const existing = spawn(registration, "legacy")
|
const existing = spawn(registration, "legacy")
|
||||||
await waitForFile(registration)
|
await waitForFile(registration)
|
||||||
|
|
||||||
const starts: Service.StartReason[] = []
|
const starts: StartReason[] = []
|
||||||
const result = run(Service.start({ file: registration, command: [], onStart: (reason) => starts.push(reason) }))
|
const result = run(Service.start({ file: registration, command: [], onStart: (reason) => starts.push(reason) }))
|
||||||
|
|
||||||
await expect(result).rejects.toThrow("Missing service command")
|
await expect(result).rejects.toThrow("Missing service command")
|
||||||
|
|||||||
Vendored
+33
-10
@@ -70,6 +70,33 @@ for await (const event of client.event.subscribe()) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Local service
|
||||||
|
|
||||||
|
`Service` discovers and manages the local OpenCode background service from a
|
||||||
|
Node application. The Promise API uses Node APIs directly and does not require
|
||||||
|
Effect or `@effect/platform-node`.
|
||||||
|
|
||||||
|
- `Service.discover()` returns a healthy registered endpoint without starting
|
||||||
|
a process.
|
||||||
|
- `Service.start()` reuses a compatible service or starts one when needed.
|
||||||
|
- `Service.stop()` stops the registered service.
|
||||||
|
- `Service.headers(endpoint)` creates the authentication headers for a client.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { OpenCode } from "@opencode-ai/client"
|
||||||
|
import { Service } from "@opencode-ai/client/service"
|
||||||
|
|
||||||
|
const endpoint = await Service.start()
|
||||||
|
const client = OpenCode.make({
|
||||||
|
baseUrl: endpoint.url,
|
||||||
|
headers: Service.headers(endpoint),
|
||||||
|
})
|
||||||
|
|
||||||
|
const health = await client.health.get()
|
||||||
|
```
|
||||||
|
|
||||||
|
Import the native Promise service API from `@opencode-ai/client/service`.
|
||||||
|
|
||||||
## Effect
|
## Effect
|
||||||
|
|
||||||
OpenCode provides a first-class Effect client through the
|
OpenCode provides a first-class Effect client through the
|
||||||
@@ -106,16 +133,11 @@ const session = await Effect.runPromise(
|
|||||||
Streaming operations, including `client.event.subscribe()` and
|
Streaming operations, including `client.event.subscribe()` and
|
||||||
`client.session.log(...)`, return Effect `Stream` values.
|
`client.session.log(...)`, return Effect `Stream` values.
|
||||||
|
|
||||||
### Service
|
### Local service
|
||||||
|
|
||||||
`Service` discovers and manages the local OpenCode background service from a
|
The Effect entrypoint exposes the same service lifecycle operations as Effect
|
||||||
Node application:
|
values. Add `@effect/platform-node` and provide its filesystem layer when
|
||||||
|
running service operations.
|
||||||
- `Service.discover()` returns a healthy registered endpoint without starting
|
|
||||||
a process.
|
|
||||||
- `Service.start()` reuses a compatible service or starts one when needed.
|
|
||||||
- `Service.stop()` stops the registered service.
|
|
||||||
- `Service.headers(endpoint)` creates the authentication headers for a client.
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
bun add @effect/platform-node
|
bun add @effect/platform-node
|
||||||
@@ -123,7 +145,8 @@ bun add @effect/platform-node
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { NodeFileSystem } from "@effect/platform-node"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
import { OpenCode, Service } from "@opencode-ai/client/effect"
|
import { OpenCode } from "@opencode-ai/client/effect"
|
||||||
|
import { Service } from "@opencode-ai/client/effect/service"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { FetchHttpClient } from "effect/unstable/http"
|
import { FetchHttpClient } from "effect/unstable/http"
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +1,16 @@
|
|||||||
import { Effect, Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
|
|
||||||
export namespace ServiceStatus {
|
export namespace ServiceStatus {
|
||||||
export const State = Schema.Union([
|
|
||||||
Schema.Struct({ type: Schema.Literal("starting") }),
|
|
||||||
Schema.Struct({ type: Schema.Literal("ready") }),
|
|
||||||
Schema.Struct({
|
|
||||||
type: Schema.Literal("stopping"),
|
|
||||||
targetVersion: Schema.String.pipe(Schema.optional),
|
|
||||||
}),
|
|
||||||
Schema.Struct({
|
|
||||||
type: Schema.Literal("failed"),
|
|
||||||
message: Schema.String,
|
|
||||||
action: Schema.String,
|
|
||||||
}),
|
|
||||||
]).annotate({ identifier: "ServiceStatus" })
|
|
||||||
export type State = typeof State.Type
|
|
||||||
|
|
||||||
export const Health = Schema.Struct({
|
export const Health = Schema.Struct({
|
||||||
healthy: Schema.Literal(true),
|
healthy: Schema.Literal(true),
|
||||||
version: Schema.String,
|
version: Schema.String,
|
||||||
pid: Schema.Int.check(Schema.isGreaterThan(0)),
|
pid: Schema.Int.check(Schema.isGreaterThan(0)),
|
||||||
instanceID: Schema.String.pipe(Schema.optional),
|
|
||||||
status: State.pipe(Schema.withDecodingDefaultKey(Effect.succeed({ type: "ready" as const }))),
|
|
||||||
}).annotate({ identifier: "ServiceHealth" })
|
}).annotate({ identifier: "ServiceHealth" })
|
||||||
export type Health = typeof Health.Type
|
export type Health = typeof Health.Type
|
||||||
|
|
||||||
export const StopRequest = Schema.Struct({
|
export const StopRequest = Schema.Struct({
|
||||||
instanceID: Schema.String,
|
instanceID: Schema.String,
|
||||||
targetVersion: Schema.String.pipe(Schema.optional),
|
|
||||||
}).annotate({ identifier: "ServiceStopRequest" })
|
}).annotate({ identifier: "ServiceStopRequest" })
|
||||||
export type StopRequest = typeof StopRequest.Type
|
export type StopRequest = typeof StopRequest.Type
|
||||||
|
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ import type {
|
|||||||
QuestionReplyErrors,
|
QuestionReplyErrors,
|
||||||
QuestionReplyResponses,
|
QuestionReplyResponses,
|
||||||
QuestionV2Reply,
|
QuestionV2Reply,
|
||||||
ServiceStopRequestV2,
|
ServiceStopRequest,
|
||||||
SessionAbortErrors,
|
SessionAbortErrors,
|
||||||
SessionAbortResponses,
|
SessionAbortResponses,
|
||||||
SessionChildrenErrors,
|
SessionChildrenErrors,
|
||||||
@@ -5091,11 +5091,11 @@ export class Health extends HeyApiClient {
|
|||||||
*/
|
*/
|
||||||
public stop<ThrowOnError extends boolean = false>(
|
public stop<ThrowOnError extends boolean = false>(
|
||||||
parameters: {
|
parameters: {
|
||||||
serviceStopRequestV2: ServiceStopRequestV2
|
serviceStopRequest: ServiceStopRequest
|
||||||
},
|
},
|
||||||
options?: Options<never, ThrowOnError>,
|
options?: Options<never, ThrowOnError>,
|
||||||
) {
|
) {
|
||||||
const params = buildClientParams([parameters], [{ args: [{ key: "serviceStopRequestV2", map: "body" }] }])
|
const params = buildClientParams([parameters], [{ args: [{ key: "serviceStopRequest", map: "body" }] }])
|
||||||
return (options?.client ?? this.client).post<V2HealthStopResponses, V2HealthStopErrors, ThrowOnError>({
|
return (options?.client ?? this.client).post<V2HealthStopResponses, V2HealthStopErrors, ThrowOnError>({
|
||||||
url: "/api/service/stop",
|
url: "/api/service/stop",
|
||||||
...options,
|
...options,
|
||||||
|
|||||||
@@ -2795,29 +2795,10 @@ export type WorkspaceWarpError = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ServiceStatus =
|
|
||||||
| {
|
|
||||||
type: "starting"
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "ready"
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "stopping"
|
|
||||||
targetVersion?: string
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "failed"
|
|
||||||
message: string
|
|
||||||
action: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ServiceHealth = {
|
export type ServiceHealth = {
|
||||||
healthy: true
|
healthy: true
|
||||||
version: string
|
version: string
|
||||||
pid: number
|
pid: number
|
||||||
instanceID?: string
|
|
||||||
status?: ServiceStatus
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UnauthorizedError = {
|
export type UnauthorizedError = {
|
||||||
@@ -2827,7 +2808,6 @@ export type UnauthorizedError = {
|
|||||||
|
|
||||||
export type ServiceStopRequest = {
|
export type ServiceStopRequest = {
|
||||||
instanceID: string
|
instanceID: string
|
||||||
targetVersion?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ServiceStopResponse = {
|
export type ServiceStopResponse = {
|
||||||
@@ -8141,29 +8121,10 @@ export type BadRequestError = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ServiceStatusV2 =
|
|
||||||
| {
|
|
||||||
type: "starting"
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "ready"
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "stopping"
|
|
||||||
targetVersion?: string | null
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "failed"
|
|
||||||
message: string
|
|
||||||
action: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ServiceHealthV2 = {
|
export type ServiceHealthV2 = {
|
||||||
healthy: true
|
healthy: true
|
||||||
version: string
|
version: string
|
||||||
pid: number
|
pid: number
|
||||||
instanceID?: string | null
|
|
||||||
status?: ServiceStatusV2
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type InvalidRequestErrorV2 = {
|
export type InvalidRequestErrorV2 = {
|
||||||
@@ -8173,11 +8134,6 @@ export type InvalidRequestErrorV2 = {
|
|||||||
field?: string | null
|
field?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ServiceStopRequestV2 = {
|
|
||||||
instanceID: string
|
|
||||||
targetVersion?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SessionsResponseV2 = {
|
export type SessionsResponseV2 = {
|
||||||
data: Array<SessionInfoV2>
|
data: Array<SessionInfoV2>
|
||||||
cursor: {
|
cursor: {
|
||||||
@@ -15183,7 +15139,7 @@ export type V2HealthGetResponses = {
|
|||||||
export type V2HealthGetResponse = V2HealthGetResponses[keyof V2HealthGetResponses]
|
export type V2HealthGetResponse = V2HealthGetResponses[keyof V2HealthGetResponses]
|
||||||
|
|
||||||
export type V2HealthStopData = {
|
export type V2HealthStopData = {
|
||||||
body: ServiceStopRequestV2
|
body: ServiceStopRequest
|
||||||
path?: never
|
path?: never
|
||||||
query?: never
|
query?: never
|
||||||
url: "/api/service/stop"
|
url: "/api/service/stop"
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ export const HealthHandler = HttpApiBuilder.group(Api, "server.health", (handler
|
|||||||
healthy: true as const,
|
healthy: true as const,
|
||||||
version: InstallationVersion,
|
version: InstallationVersion,
|
||||||
pid: process.pid,
|
pid: process.pid,
|
||||||
status: { type: "ready" },
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.handle("health.stop", () => Effect.succeed({ accepted: false })),
|
.handle("health.stop", () => Effect.succeed({ accepted: false })),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export * as ServerProcess from "./process"
|
export * as ServerProcess from "./process"
|
||||||
|
|
||||||
import { NodeHttpServer, NodeHttpServerRequest } from "@effect/platform-node"
|
import { NodeHttpServer, NodeHttpServerRequest } from "@effect/platform-node"
|
||||||
|
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||||
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
|
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
|
||||||
import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
|
import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
|
||||||
import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
|
import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
|
||||||
@@ -45,7 +46,7 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(options:
|
|||||||
const applicationScope = yield* Scope.fork(parentScope)
|
const applicationScope = yield* Scope.fork(parentScope)
|
||||||
yield* Effect.addFinalizer(() =>
|
yield* Effect.addFinalizer(() =>
|
||||||
status
|
status
|
||||||
.beginStopping()
|
.beginStopping
|
||||||
.pipe(
|
.pipe(
|
||||||
Effect.andThen(Ref.set(application, Option.none())),
|
Effect.andThen(Ref.set(application, Option.none())),
|
||||||
Effect.andThen(Effect.sync(() => bound.server.closeAllConnections())),
|
Effect.andThen(Effect.sync(() => bound.server.closeAllConnections())),
|
||||||
@@ -73,22 +74,17 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(options:
|
|||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.catchCause((cause) => {
|
Effect.catchCause((cause) => {
|
||||||
if (!options.service || Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause)
|
if (!options.service || Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause)
|
||||||
return status
|
return status.fail.pipe(
|
||||||
.fail({
|
Effect.andThen(
|
||||||
message: "The background service could not start.",
|
Scope.close(applicationScope, Exit.failCause(cause)).pipe(
|
||||||
action: "Run `opencode service restart` after checking the service logs.",
|
Effect.catchCause((cleanupCause) =>
|
||||||
})
|
Effect.logError("failed to clean up background service boot", { cause: cleanupCause }),
|
||||||
.pipe(
|
|
||||||
Effect.andThen(
|
|
||||||
Scope.close(applicationScope, Exit.failCause(cause)).pipe(
|
|
||||||
Effect.catchCause((cleanupCause) =>
|
|
||||||
Effect.logError("failed to clean up background service boot", { cause: cleanupCause }),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Effect.andThen(Effect.logError("background service boot failed", { cause })),
|
),
|
||||||
Effect.andThen(Effect.never),
|
Effect.andThen(Effect.logError("background service boot failed", { cause })),
|
||||||
)
|
Effect.andThen(Effect.never),
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if (!options.service) return yield* boot
|
if (!options.service) return yield* boot
|
||||||
@@ -189,18 +185,21 @@ const control = Effect.fnUntraced(function* (
|
|||||||
})
|
})
|
||||||
|
|
||||||
const healthResponse = Effect.fnUntraced(function* (status: Status.Interface) {
|
const healthResponse = Effect.fnUntraced(function* (status: Status.Interface) {
|
||||||
const health = yield* status.health
|
const state = yield* status.current
|
||||||
return HttpServerResponse.jsonUnsafe(health, {
|
return HttpServerResponse.jsonUnsafe({ healthy: true, version: InstallationVersion, pid: process.pid }, {
|
||||||
status: health.status.type === "ready" ? 200 : 503,
|
status: state.type === "ready" ? 200 : state.type === "failed" ? 500 : 503,
|
||||||
headers:
|
headers: state.type === "starting" || state.type === "stopping" ? { "retry-after": "1" } : undefined,
|
||||||
health.status.type === "starting" || health.status.type === "stopping" ? { "retry-after": "1" } : undefined,
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
function unavailable(status: ServiceStatus.State) {
|
function unavailable(status: Status.State) {
|
||||||
if (status.type === "failed")
|
if (status.type === "failed")
|
||||||
return HttpServerResponse.jsonUnsafe(
|
return HttpServerResponse.jsonUnsafe(
|
||||||
{ code: "service_failed", message: status.message, action: status.action },
|
{
|
||||||
|
code: "service_failed",
|
||||||
|
message: "The background service could not start.",
|
||||||
|
action: "Run `opencode service restart` after checking the service logs.",
|
||||||
|
},
|
||||||
{ status: 503 },
|
{ status: 503 },
|
||||||
)
|
)
|
||||||
return HttpServerResponse.jsonUnsafe(
|
return HttpServerResponse.jsonUnsafe(
|
||||||
|
|||||||
@@ -1,57 +1,45 @@
|
|||||||
export * as Status from "./service-status"
|
export * as Status from "./service-status"
|
||||||
|
|
||||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
|
||||||
import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
|
import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
|
||||||
import { Effect, Ref } from "effect"
|
import { Effect, Ref } from "effect"
|
||||||
|
|
||||||
|
export type State =
|
||||||
|
| { readonly type: "starting" }
|
||||||
|
| { readonly type: "ready" }
|
||||||
|
| { readonly type: "stopping" }
|
||||||
|
| { readonly type: "failed" }
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly health: Effect.Effect<ServiceStatus.Health>
|
readonly current: Effect.Effect<State>
|
||||||
readonly current: Effect.Effect<ServiceStatus.State>
|
|
||||||
readonly ready: Effect.Effect<void>
|
readonly ready: Effect.Effect<void>
|
||||||
readonly fail: (failure: { readonly message: string; readonly action: string }) => Effect.Effect<void>
|
readonly fail: Effect.Effect<void>
|
||||||
readonly beginStopping: (targetVersion?: string) => Effect.Effect<void>
|
readonly beginStopping: Effect.Effect<void>
|
||||||
readonly requestStop: (request: ServiceStatus.StopRequest) => Effect.Effect<boolean>
|
readonly requestStop: (request: ServiceStatus.StopRequest) => Effect.Effect<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const make = Effect.fnUntraced(function* (options: {
|
export const make = Effect.fnUntraced(function* (options: {
|
||||||
readonly instanceID: string
|
readonly instanceID: string
|
||||||
readonly managed: boolean
|
readonly managed: boolean
|
||||||
readonly initial?: ServiceStatus.State
|
readonly initial?: State
|
||||||
}) {
|
}) {
|
||||||
const current = yield* Ref.make(options.initial ?? ({ type: "starting" } satisfies ServiceStatus.State))
|
const current = yield* Ref.make(options.initial ?? ({ type: "starting" } satisfies State))
|
||||||
const transitionToStopping = (targetVersion?: string) =>
|
const beginStopping = Ref.update(current, (status) =>
|
||||||
Ref.update(current, (status) => {
|
status.type === "stopping" ? status : ({ type: "stopping" } satisfies State),
|
||||||
if (status.type === "stopping") return status
|
)
|
||||||
return (
|
|
||||||
targetVersion === undefined || targetVersion === InstallationVersion
|
|
||||||
? { type: "stopping" }
|
|
||||||
: { type: "stopping", targetVersion }
|
|
||||||
) satisfies ServiceStatus.State
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
current: Ref.get(current),
|
current: Ref.get(current),
|
||||||
health: Effect.gen(function* () {
|
|
||||||
return {
|
|
||||||
healthy: true as const,
|
|
||||||
version: InstallationVersion,
|
|
||||||
pid: process.pid,
|
|
||||||
instanceID: options.instanceID,
|
|
||||||
status: yield* Ref.get(current),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
ready: Ref.update(current, (status) =>
|
ready: Ref.update(current, (status) =>
|
||||||
status.type === "starting" ? ({ type: "ready" } satisfies ServiceStatus.State) : status,
|
status.type === "starting" ? ({ type: "ready" } satisfies State) : status,
|
||||||
),
|
),
|
||||||
fail: (failure) =>
|
fail: Ref.update(current, (status) =>
|
||||||
Ref.update(current, (status) =>
|
status.type === "starting" ? ({ type: "failed" } satisfies State) : status,
|
||||||
status.type === "starting" ? ({ type: "failed", ...failure } satisfies ServiceStatus.State) : status,
|
),
|
||||||
),
|
beginStopping,
|
||||||
beginStopping: transitionToStopping,
|
|
||||||
requestStop: (request) => {
|
requestStop: (request) => {
|
||||||
if (!options.managed || request.instanceID !== options.instanceID)
|
if (!options.managed || request.instanceID !== options.instanceID)
|
||||||
return Effect.succeed(false)
|
return Effect.succeed(false)
|
||||||
return transitionToStopping(request.targetVersion).pipe(Effect.as(true))
|
return beginStopping.pipe(Effect.as(true))
|
||||||
},
|
},
|
||||||
} satisfies Interface
|
} satisfies Interface
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
|
||||||
import { expect } from "bun:test"
|
import { expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { it } from "../../core/test/lib/effect"
|
import { it } from "../../core/test/lib/effect"
|
||||||
@@ -16,14 +15,10 @@ it.effect("moves from starting to ready", () =>
|
|||||||
it.effect("keeps a startup failure until shutdown", () =>
|
it.effect("keeps a startup failure until shutdown", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const status = yield* Status.make({ instanceID: "one", managed: true })
|
const status = yield* Status.make({ instanceID: "one", managed: true })
|
||||||
yield* status.fail({ message: "Could not open the database.", action: "Check the database path." })
|
yield* status.fail
|
||||||
yield* status.ready
|
yield* status.ready
|
||||||
yield* status.fail({ message: "Different failure.", action: "Different action." })
|
yield* status.fail
|
||||||
expect(yield* status.current).toEqual({
|
expect(yield* status.current).toEqual({ type: "failed" })
|
||||||
type: "failed",
|
|
||||||
message: "Could not open the database.",
|
|
||||||
action: "Check the database path.",
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,21 +26,20 @@ it.effect("stops only the addressed managed instance", () =>
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const status = yield* Status.make({ instanceID: "one", managed: true })
|
const status = yield* Status.make({ instanceID: "one", managed: true })
|
||||||
|
|
||||||
expect(yield* status.requestStop({ instanceID: "other", targetVersion: "next" })).toBe(false)
|
expect(yield* status.requestStop({ instanceID: "other" })).toBe(false)
|
||||||
expect(yield* status.current).toEqual({ type: "starting" })
|
expect(yield* status.current).toEqual({ type: "starting" })
|
||||||
expect(yield* status.requestStop({ instanceID: "one", targetVersion: "next" })).toBe(true)
|
expect(yield* status.requestStop({ instanceID: "one" })).toBe(true)
|
||||||
expect(yield* status.requestStop({ instanceID: "one", targetVersion: InstallationVersion })).toBe(true)
|
expect(yield* status.current).toEqual({ type: "stopping" })
|
||||||
expect(yield* status.current).toEqual({ type: "stopping", targetVersion: "next" })
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("preserves the original stopping target after shutdown begins", () =>
|
it.effect("keeps stopping after shutdown begins", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const status = yield* Status.make({ instanceID: "one", managed: true })
|
const status = yield* Status.make({ instanceID: "one", managed: true })
|
||||||
|
|
||||||
yield* status.beginStopping("next")
|
yield* status.beginStopping
|
||||||
expect(yield* status.current).toEqual({ type: "stopping", targetVersion: "next" })
|
expect(yield* status.current).toEqual({ type: "stopping" })
|
||||||
expect(yield* status.requestStop({ instanceID: "one" })).toBe(true)
|
expect(yield* status.requestStop({ instanceID: "one" })).toBe(true)
|
||||||
expect(yield* status.current).toEqual({ type: "stopping", targetVersion: "next" })
|
expect(yield* status.current).toEqual({ type: "stopping" })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { render, TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
import { render, TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||||
import { registerOpencodeSpinner } from "./component/register-spinner"
|
import { registerOpencodeSpinner } from "./component/register-spinner"
|
||||||
import { Deferred, Effect } from "effect"
|
import { Deferred, Effect } from "effect"
|
||||||
import { Service } from "@opencode-ai/client/effect"
|
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
|
||||||
import { OpenCode } from "@opencode-ai/client"
|
import { OpenCode } from "@opencode-ai/client"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
@@ -138,9 +138,9 @@ const appBindingCommands = [
|
|||||||
|
|
||||||
export type TuiInput = {
|
export type TuiInput = {
|
||||||
server: {
|
server: {
|
||||||
endpoint: Service.Endpoint
|
endpoint: Endpoint
|
||||||
service?: {
|
service?: {
|
||||||
reconnect: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise<Service.Endpoint>
|
reconnect: (signal: AbortSignal) => Promise<Endpoint>
|
||||||
restart: () => Promise<void>
|
restart: () => Promise<void>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -189,8 +189,8 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
|||||||
const managed = input.server.service
|
const managed = input.server.service
|
||||||
const service = managed
|
const service = managed
|
||||||
? {
|
? {
|
||||||
reconnect: async (onStatus: (status: Service.Status) => void, signal: AbortSignal) => {
|
reconnect: async (signal: AbortSignal) => {
|
||||||
const endpoint = await managed.reconnect(onStatus, signal)
|
const endpoint = await managed.reconnect(signal)
|
||||||
const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) }
|
const next = { baseUrl: endpoint.url, headers: Service.headers(endpoint) }
|
||||||
return { api: OpenCode.make(next) }
|
return { api: OpenCode.make(next) }
|
||||||
},
|
},
|
||||||
@@ -1114,7 +1114,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
|||||||
<StartupLoading ready={plugins.ready} />
|
<StartupLoading ready={plugins.ready} />
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={showReconnecting()}>
|
<Show when={showReconnecting()}>
|
||||||
<Reconnecting status={client.connection.service()} />
|
<Reconnecting />
|
||||||
</Show>
|
</Show>
|
||||||
<Toast />
|
<Toast />
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import type { Service } from "@opencode-ai/client/effect"
|
|
||||||
import { Show } from "solid-js"
|
|
||||||
import { useTheme } from "../context/theme"
|
import { useTheme } from "../context/theme"
|
||||||
import { Spinner } from "./spinner"
|
import { Spinner } from "./spinner"
|
||||||
|
|
||||||
export function Reconnecting(props: { status?: Service.Status }) {
|
export function Reconnecting() {
|
||||||
const theme = useTheme().theme
|
const theme = useTheme().theme
|
||||||
const copy = () => reconnectingCopy(props.status)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
@@ -20,46 +17,8 @@ export function Reconnecting(props: { status?: Service.Status }) {
|
|||||||
justifyContent="center"
|
justifyContent="center"
|
||||||
>
|
>
|
||||||
<box width={62} maxWidth="90%" flexDirection="column" alignItems="center" gap={1}>
|
<box width={62} maxWidth="90%" flexDirection="column" alignItems="center" gap={1}>
|
||||||
<Show when={!copy().loading} fallback={<Spinner color={theme.textMuted}>{copy().message}</Spinner>}>
|
<Spinner color={theme.textMuted}>Waiting for background service...</Spinner>
|
||||||
<text fg={theme.error}>{copy().message}</text>
|
|
||||||
<Show when={copy().detail}>
|
|
||||||
{(detail) => (
|
|
||||||
<text fg={theme.textMuted} wrapMode="word">
|
|
||||||
{detail()}
|
|
||||||
</text>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
<Show when={copy().action}>
|
|
||||||
{(action) => (
|
|
||||||
<text fg={theme.text} wrapMode="word">
|
|
||||||
{action()}
|
|
||||||
</text>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</Show>
|
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function reconnectingCopy(status?: Service.Status) {
|
|
||||||
if (status?.type === "starting")
|
|
||||||
return {
|
|
||||||
loading: true,
|
|
||||||
message: status.version ? `Starting OpenCode ${status.version}...` : "Starting background service...",
|
|
||||||
}
|
|
||||||
if (status?.type === "stopping")
|
|
||||||
return {
|
|
||||||
loading: true,
|
|
||||||
message: status.targetVersion ? `Updating to ${status.targetVersion}...` : "Restarting background service...",
|
|
||||||
}
|
|
||||||
if (status?.type === "failed")
|
|
||||||
return { loading: false, message: "Background service failed", detail: status.message, action: status.action }
|
|
||||||
if (status?.type === "unresponsive")
|
|
||||||
return {
|
|
||||||
loading: false,
|
|
||||||
message: "Background service is not responding",
|
|
||||||
action: "Run `opencode service restart` to recover it.",
|
|
||||||
}
|
|
||||||
return { loading: true, message: "Waiting for background service..." }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
|
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
|
||||||
import type { Service } from "@opencode-ai/client/effect"
|
|
||||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||||
import { createSignal, onCleanup, onMount } from "solid-js"
|
import { onCleanup, onMount } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { errorMessage } from "../util/error"
|
import { errorMessage } from "../util/error"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
@@ -19,7 +18,7 @@ export type ClientConnectionEvent = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ManagedService = {
|
type ManagedService = {
|
||||||
reconnect: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise<{ api: OpenCodeClient }>
|
reconnect: (signal: AbortSignal) => Promise<{ api: OpenCodeClient }>
|
||||||
restart: () => Promise<void>
|
restart: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +42,6 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
|
|||||||
status: "connecting",
|
status: "connecting",
|
||||||
attempt: 0,
|
attempt: 0,
|
||||||
})
|
})
|
||||||
const [service, setService] = createSignal<Service.Status>()
|
|
||||||
let stream: AbortController | undefined
|
let stream: AbortController | undefined
|
||||||
|
|
||||||
function record(status: ClientConnectionEvent["data"]["status"], attempt: number, error?: string) {
|
function record(status: ClientConnectionEvent["data"]["status"], attempt: number, error?: string) {
|
||||||
@@ -81,7 +79,6 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
|
|||||||
log.info("event stream connected")
|
log.info("event stream connected")
|
||||||
events.emit(first.value.type, first.value)
|
events.emit(first.value.type, first.value)
|
||||||
setConnection({ status: "connected", attempt: 0, error: undefined })
|
setConnection({ status: "connected", attempt: 0, error: undefined })
|
||||||
setService(undefined)
|
|
||||||
while (!abort.signal.aborted && !controller.signal.aborted) {
|
while (!abort.signal.aborted && !controller.signal.aborted) {
|
||||||
const event = await iterator.next()
|
const event = await iterator.next()
|
||||||
if (abort.signal.aborted || controller.signal.aborted) return undefined
|
if (abort.signal.aborted || controller.signal.aborted) return undefined
|
||||||
@@ -116,7 +113,7 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
|
|||||||
// 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.service) {
|
if (props.service) {
|
||||||
const next = await props.service.reconnect(setService, controller.signal).catch((error) => {
|
const next = await props.service.reconnect(controller.signal).catch((error) => {
|
||||||
if (!controller.signal.aborted)
|
if (!controller.signal.aborted)
|
||||||
log.info("server resolution failed", {
|
log.info("server resolution failed", {
|
||||||
attempt,
|
attempt,
|
||||||
@@ -159,9 +156,6 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
|
|||||||
error() {
|
error() {
|
||||||
return connection.error
|
return connection.error
|
||||||
},
|
},
|
||||||
service() {
|
|
||||||
return service()
|
|
||||||
},
|
|
||||||
internal: {
|
internal: {
|
||||||
history() {
|
history() {
|
||||||
return history.slice()
|
return history.slice()
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import { expect, test } from "bun:test"
|
|
||||||
import { reconnectingCopy } from "../../../src/component/reconnecting"
|
|
||||||
|
|
||||||
test("describes service status without transport diagnostics", () => {
|
|
||||||
expect(reconnectingCopy({ type: "starting", version: "2.0.0" })).toEqual({
|
|
||||||
loading: true,
|
|
||||||
message: "Starting OpenCode 2.0.0...",
|
|
||||||
})
|
|
||||||
expect(reconnectingCopy({ type: "stopping", targetVersion: "2.0.0" })).toEqual({
|
|
||||||
loading: true,
|
|
||||||
message: "Updating to 2.0.0...",
|
|
||||||
})
|
|
||||||
expect(
|
|
||||||
reconnectingCopy({
|
|
||||||
type: "failed",
|
|
||||||
message: "Could not open the database.",
|
|
||||||
action: "Check the service logs.",
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
loading: false,
|
|
||||||
message: "Background service failed",
|
|
||||||
detail: "Could not open the database.",
|
|
||||||
action: "Check the service logs.",
|
|
||||||
})
|
|
||||||
expect(reconnectingCopy({ type: "unresponsive" })).toEqual({
|
|
||||||
loading: false,
|
|
||||||
message: "Background service is not responding",
|
|
||||||
action: "Run `opencode service restart` to recover it.",
|
|
||||||
})
|
|
||||||
expect(JSON.stringify(reconnectingCopy())).not.toMatch(/Attempt|ECONNREFUSED|Event stream disconnected/)
|
|
||||||
})
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
/** @jsxImportSource @opentui/solid */
|
/** @jsxImportSource @opentui/solid */
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
|
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
|
||||||
import type { Service } from "@opencode-ai/client/effect"
|
|
||||||
import { testRender } from "@opentui/solid"
|
import { testRender } from "@opentui/solid"
|
||||||
import { onMount } from "solid-js"
|
import { onMount } from "solid-js"
|
||||||
import { ClientProvider, useClient } from "../../../src/context/client"
|
import { ClientProvider, useClient } from "../../../src/context/client"
|
||||||
@@ -53,7 +52,7 @@ function update(version: string): OpenCodeEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function mount(
|
async function mount(
|
||||||
reconnect?: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise<{ api: OpenCodeClient }>,
|
reconnect?: (signal: AbortSignal) => Promise<{ api: OpenCodeClient }>,
|
||||||
log?: LogSink,
|
log?: LogSink,
|
||||||
) {
|
) {
|
||||||
const events = createEventStream()
|
const events = createEventStream()
|
||||||
@@ -279,48 +278,10 @@ describe("useEvent", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("reports service status while endpoint resolution is pending", async () => {
|
|
||||||
const replacementEvents = createEventStream()
|
|
||||||
const replacement = { api: createApi(createFetch(undefined, replacementEvents).fetch) }
|
|
||||||
let report!: (status: Service.Status) => void
|
|
||||||
let resolve!: (value: typeof replacement) => void
|
|
||||||
const endpoint = new Promise<typeof replacement>((done) => {
|
|
||||||
resolve = done
|
|
||||||
})
|
|
||||||
const { app, events, client } = await mount(async (onStatus) => {
|
|
||||||
report = onStatus
|
|
||||||
onStatus({ type: "starting", version: "2.0.0" })
|
|
||||||
return endpoint
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
await wait(() => client.connection.status() === "connected")
|
|
||||||
events.disconnect()
|
|
||||||
await wait(
|
|
||||||
() => client.connection.status() === "reconnecting" && client.connection.service()?.type === "starting",
|
|
||||||
)
|
|
||||||
expect(client.connection.service()).toEqual({ type: "starting", version: "2.0.0" })
|
|
||||||
|
|
||||||
report({ type: "failed", message: "Could not open the database.", action: "Check the service logs." })
|
|
||||||
await wait(() => client.connection.service()?.type === "failed")
|
|
||||||
expect(client.connection.service()).toEqual({
|
|
||||||
type: "failed",
|
|
||||||
message: "Could not open the database.",
|
|
||||||
action: "Check the service logs.",
|
|
||||||
})
|
|
||||||
|
|
||||||
resolve(replacement)
|
|
||||||
await wait(() => client.connection.status() === "connected")
|
|
||||||
expect(client.connection.service()).toBeUndefined()
|
|
||||||
} finally {
|
|
||||||
app.renderer.destroy()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("cancels pending endpoint resolution on cleanup", async () => {
|
test("cancels pending endpoint resolution on cleanup", async () => {
|
||||||
let aborted = false
|
let aborted = false
|
||||||
const { app, events, client } = await mount(
|
const { app, events, client } = await mount(
|
||||||
(_onStatus, signal) =>
|
(signal) =>
|
||||||
new Promise((_, reject) => {
|
new Promise((_, reject) => {
|
||||||
signal.addEventListener(
|
signal.addEventListener(
|
||||||
"abort",
|
"abort",
|
||||||
|
|||||||
+2
-1
@@ -123,7 +123,8 @@ bun add @effect/platform-node
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { NodeFileSystem } from "@effect/platform-node"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
import { OpenCode, Service } from "@opencode-ai/client/effect"
|
import { OpenCode } from "@opencode-ai/client/effect"
|
||||||
|
import { Service } from "@opencode-ai/client/effect/service"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { FetchHttpClient } from "effect/unstable/http"
|
import { FetchHttpClient } from "effect/unstable/http"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user