refactor(client): simplify local service lifecycle
This commit is contained in:
parent
4f1298063b
commit
75bc611ef1
51 changed files with 635 additions and 534 deletions
|
|
@ -1,6 +1,6 @@
|
|||
#!/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 { Schema } from "effect"
|
||||
import fs from "node:fs/promises"
|
||||
|
|
@ -37,8 +37,7 @@ try {
|
|||
const headers = { authorization: "Basic " + credential }
|
||||
const token = encodeURIComponent(credential)
|
||||
const health = await waitForReady(info.url, headers)
|
||||
if (health.pid !== info.pid || health.instanceID !== info.id)
|
||||
throw new Error("Health identity does not match registration")
|
||||
if (health.pid !== info.pid) throw new Error("Health process does not match registration")
|
||||
const tokenHealth = await fetch(
|
||||
new URL(`/api/health?auth_token=${token}`, info.url),
|
||||
{ signal: AbortSignal.timeout(5_000) },
|
||||
|
|
@ -75,7 +74,7 @@ try {
|
|||
await fetch(new URL("/api/service/stop", info.url), {
|
||||
method: "POST",
|
||||
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),
|
||||
}).then((response) => response.json()),
|
||||
)
|
||||
|
|
@ -120,19 +119,11 @@ async function waitForRegistration() {
|
|||
async function waitForReady(url: string, headers: HeadersInit) {
|
||||
const deadline = Date.now() + 20_000
|
||||
while (Date.now() < deadline) {
|
||||
const health = await fetch(new URL("/api/health", url), {
|
||||
const response = await fetch(new URL("/api/health", url), {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(1_000),
|
||||
})
|
||||
.then((response) => 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)
|
||||
}).catch(() => undefined)
|
||||
if (response?.ok) return Schema.decodeUnknownPromise(ServiceStatus.Health)(await response.json())
|
||||
await Bun.sleep(25)
|
||||
}
|
||||
throw new Error("Compiled service did not become ready")
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { EOL } from "node:os"
|
|||
import { Effect, Option } from "effect"
|
||||
import { Commands } from "../commands"
|
||||
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"
|
||||
|
||||
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] }
|
||||
}
|
||||
|
||||
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)
|
||||
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"))
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { AppProcess } from "@opencode-ai/core/process"
|
||||
import { Commands } from "../../commands"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Effect } from "effect"
|
|||
import { OpenCode } from "@opencode-ai/client"
|
||||
import { Commands } from "../../commands"
|
||||
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"
|
||||
|
||||
export default Runtime.handler(
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ export default Runtime.handler(Commands, (input) =>
|
|||
const server = yield* ServerConnection.resolve({
|
||||
server: Option.getOrUndefined(input.server),
|
||||
standalone: input.standalone,
|
||||
onStart: (reason, existing) => {
|
||||
if (reason === "version-mismatch" && preflight.begin(existing?.version)) return
|
||||
onStart: (reason, previousVersion) => {
|
||||
if (reason === "version-mismatch" && preflight.begin(previousVersion)) return
|
||||
process.stderr.write(
|
||||
reason === "version-mismatch"
|
||||
? "Restarting background server (version mismatch)...\n"
|
||||
|
|
@ -48,7 +48,7 @@ export default Runtime.handler(Commands, (input) =>
|
|||
endpoint: server.endpoint,
|
||||
service: service
|
||||
? {
|
||||
reconnect: (onStatus, signal) => runServicePromise(service.reconnect(onStatus), { signal }),
|
||||
reconnect: (signal) => runServicePromise(service.reconnect(), { signal }),
|
||||
restart: () => runServicePromise(service.restart()),
|
||||
}
|
||||
: undefined,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
} from "@opencode-ai/client"
|
||||
import { Commands } from "../../commands"
|
||||
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 { resolveIntegration } from "./resolve"
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Effect } from "effect"
|
|||
import { OpenCode, type McpServer } from "@opencode-ai/client"
|
||||
import { Commands } from "../../commands"
|
||||
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"
|
||||
|
||||
export default Runtime.handler(
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Effect } from "effect"
|
|||
import { OpenCode } from "@opencode-ai/client"
|
||||
import { Commands } from "../../commands"
|
||||
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 { resolveIntegration } from "./resolve"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { EOL } from "os"
|
||||
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 { renderUnicodeCompact } from "uqr"
|
||||
import { Commands } from "../commands"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
|
|
@ -9,7 +9,7 @@ export default Runtime.handler(
|
|||
Commands.commands.service.commands.restart,
|
||||
Effect.fn("cli.service.restart")(function* () {
|
||||
const options = yield* ServiceConfig.options()
|
||||
yield* Service.stop(options, { targetVersion: options.version })
|
||||
yield* Service.stop(options)
|
||||
const transport = yield* Service.start(options)
|
||||
process.stdout.write(transport.url + EOL)
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
|
|
@ -9,12 +9,7 @@ export default Runtime.handler(
|
|||
Commands.commands.service.commands.status,
|
||||
Effect.fn("cli.service.status")(function* () {
|
||||
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 })
|
||||
process.stdout.write((found?.url ?? status.type) + EOL)
|
||||
process.stdout.write((found?.url ?? "stopped") + EOL)
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Effect } from "effect"
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
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 { ServerConnection } from "../services/server-connection"
|
||||
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 { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
|
|
@ -55,7 +55,7 @@ async function run(input: RunCommandInput) {
|
|||
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 requestedDirectory = prepared.directory ?? (await client.location.get()).directory
|
||||
if (!requestedDirectory) fail("Failed to resolve server directory")
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export * as ServerProcess from "./server-process"
|
||||
|
||||
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 { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
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 { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { Effect, Redacted } from "effect"
|
||||
|
|
@ -10,11 +10,11 @@ export type Args = {
|
|||
readonly server?: string
|
||||
readonly standalone?: boolean
|
||||
readonly mismatch?: "replace" | "ignore" | "error"
|
||||
readonly onStart?: Service.StartOptions["onStart"]
|
||||
readonly onStart?: StartOptions["onStart"]
|
||||
}
|
||||
|
||||
export type Resolved = {
|
||||
readonly endpoint: Service.Endpoint
|
||||
readonly endpoint: Endpoint
|
||||
readonly service?: ReturnType<typeof managedService>
|
||||
}
|
||||
|
||||
|
|
@ -26,7 +26,7 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg
|
|||
const endpoint = {
|
||||
url: args.server,
|
||||
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 health = yield* Effect.tryPromise({
|
||||
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
|
||||
})
|
||||
|
||||
function managedService(options: Service.StartOptions) {
|
||||
function managedService(options: StartOptions) {
|
||||
const reconnectOptions = { ...options, version: undefined }
|
||||
return {
|
||||
reconnect: (onStatus: (status: Service.Status) => void) => Service.start({ ...reconnectOptions, onStatus }),
|
||||
reconnect: () => Service.start(reconnectOptions),
|
||||
restart: () =>
|
||||
Effect.gen(function* () {
|
||||
yield* Service.stop(options, { targetVersion: options.version })
|
||||
yield* Service.stop(options)
|
||||
yield* Service.start(options)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const resolveManaged = Effect.fnUntraced(function* (
|
||||
options: Service.StartOptions,
|
||||
options: StartOptions,
|
||||
mismatch: NonNullable<Args["mismatch"]>,
|
||||
) {
|
||||
if (mismatch === "replace") return yield* Service.start(options)
|
||||
|
|
@ -76,7 +76,7 @@ const resolveManaged = Effect.fnUntraced(function* (
|
|||
return yield* Service.start(options)
|
||||
})
|
||||
|
||||
function connectError(endpoint: Service.Endpoint, cause: unknown) {
|
||||
function connectError(endpoint: Endpoint, cause: unknown) {
|
||||
if (isUnauthorizedError(cause)) {
|
||||
return new Error(
|
||||
endpoint.auth === undefined
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { Global } from "@opencode-ai/core/global"
|
||||
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
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 { randomBytes } from "crypto"
|
||||
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
|
||||
// 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 { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
|
|
@ -46,7 +46,7 @@ const makeEndpoint = Effect.fn("cli.standalone.endpoint")(
|
|||
url: ready.url,
|
||||
auth: { type: "basic" as const, username: "opencode", password },
|
||||
pid: proc.pid,
|
||||
} satisfies Service.Endpoint & { readonly pid: number }
|
||||
} satisfies Endpoint & { readonly pid: number }
|
||||
},
|
||||
Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node)),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { Standalone } from "../../src/services/standalone"
|
||||
|
||||
|
|
|
|||
|
|
@ -19,8 +19,6 @@ test("resolution groups Effect-native lifecycle operations only for the managed
|
|||
healthy: true,
|
||||
version: InstallationVersion,
|
||||
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.service).toBeDefined()
|
||||
if (!resolved.service) throw new Error("Expected managed service capabilities")
|
||||
expect(Effect.isEffect(resolved.service.reconnect(() => {}))).toBe(true)
|
||||
expect(Effect.isEffect(resolved.service.reconnect())).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() }))
|
||||
expect(explicit.endpoint.url).toBe(server.url.toString())
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { EventV2 } from "@opencode-ai/core/event"
|
||||
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), {
|
||||
headers: { authorization: "Basic " + btoa(`opencode:${info.password}`) },
|
||||
}).then((response) => response.json()),
|
||||
).toMatchObject({
|
||||
).toEqual({
|
||||
healthy: true,
|
||||
version: info.version,
|
||||
pid: info.pid,
|
||||
instanceID: info.id,
|
||||
status: { type: "ready" },
|
||||
})
|
||||
const blockedTemp = registration + "." + info.id + ".tmp"
|
||||
await fs.mkdir(blockedTemp)
|
||||
|
|
@ -193,7 +192,7 @@ test("concurrent service processes elect one server", async () => {
|
|||
).toEqual({ timeSuspended: null })
|
||||
expect(await waitForExecutionStart(database, sessionID)).toBe(1)
|
||||
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
|
||||
} finally {
|
||||
|
|
@ -227,19 +226,6 @@ test("a failed service stays registered and owns the lock until stopped", async
|
|||
|
||||
try {
|
||||
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)
|
||||
|
||||
const contender = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue