refactor(client): namespace service exports and share the registration contract
This commit is contained in:
parent
de476aa51b
commit
dd768e30e2
8 changed files with 47 additions and 50 deletions
|
|
@ -4,7 +4,6 @@ import { Commands } from "../commands"
|
||||||
import { Runtime } from "../../framework/runtime"
|
import { Runtime } from "../../framework/runtime"
|
||||||
import { Service } from "@opencode-ai/client/effect"
|
import { Service } from "@opencode-ai/client/effect"
|
||||||
import { ServiceConfig } from "../../services/service-config"
|
import { ServiceConfig } from "../../services/service-config"
|
||||||
import type { Transport } from "@opencode-ai/client/effect"
|
|
||||||
|
|
||||||
const methods = new Set(["delete", "get", "head", "options", "patch", "post", "put"])
|
const methods = new Set(["delete", "get", "head", "options", "patch", "post", "put"])
|
||||||
|
|
||||||
|
|
@ -61,7 +60,7 @@ export function rawRequest(input: readonly string[]) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveRequest(
|
function resolveRequest(
|
||||||
transport: Transport,
|
transport: Service.Transport,
|
||||||
input: readonly string[],
|
input: readonly string[],
|
||||||
params: Record<string, string>,
|
params: Record<string, string>,
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { Commands } from "../commands"
|
||||||
import { Runtime } from "../../framework/runtime"
|
import { Runtime } from "../../framework/runtime"
|
||||||
import { Effect, Option } from "effect"
|
import { Effect, Option } from "effect"
|
||||||
import { Service } from "@opencode-ai/client/effect"
|
import { Service } from "@opencode-ai/client/effect"
|
||||||
import type { Transport } from "@opencode-ai/client/effect"
|
|
||||||
import { ServiceConfig } from "../../services/service-config"
|
import { ServiceConfig } from "../../services/service-config"
|
||||||
import { Standalone } from "../../services/standalone"
|
import { Standalone } from "../../services/standalone"
|
||||||
import { Updater } from "../../services/updater"
|
import { Updater } from "../../services/updater"
|
||||||
|
|
@ -23,7 +22,7 @@ export default Runtime.handler(Commands, (input) =>
|
||||||
return {
|
return {
|
||||||
url: server,
|
url: server,
|
||||||
headers: password ? { authorization: "Basic " + btoa("opencode:" + password) } : undefined,
|
headers: password ? { authorization: "Basic " + btoa("opencode:" + password) } : undefined,
|
||||||
} satisfies Transport
|
} satisfies Service.Transport
|
||||||
}
|
}
|
||||||
if (input.standalone) return yield* Standalone.transport()
|
if (input.standalone) return yield* Standalone.transport()
|
||||||
const options = yield* ServiceConfig.options()
|
const options = yield* ServiceConfig.options()
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2/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 { ServiceConfig } from "../../services/service-config"
|
import { ServiceConfig } from "../../services/service-config"
|
||||||
import { Updater } from "../../services/updater"
|
import { Updater } from "../../services/updater"
|
||||||
import { randomBytes, randomUUID } from "crypto"
|
import { randomBytes, randomUUID } from "crypto"
|
||||||
|
|
@ -63,8 +64,11 @@ export default Runtime.handler(
|
||||||
// not a startup lock: the atomic rename elects the latest writer, the watcher
|
// not a startup lock: the atomic rename elects the latest writer, the watcher
|
||||||
// self-evicts losers, and the finalizer id-guard keeps an exiting server from
|
// self-evicts losers, and the finalizer id-guard keeps an exiting server from
|
||||||
// deleting its successor's registration.
|
// deleting its successor's registration.
|
||||||
const RegistrationId = Schema.Struct({ id: Schema.optional(Schema.String) })
|
// Written and read through Service.Info so the file the server registers is
|
||||||
const decodeRegistrationId = Schema.decodeUnknownEffect(Schema.fromJsonString(RegistrationId))
|
// provably the contract clients discover with.
|
||||||
|
const infoJson = Schema.fromJsonString(Service.Info)
|
||||||
|
const encodeInfo = Schema.encodeEffect(infoJson)
|
||||||
|
const decodeInfo = Schema.decodeUnknownEffect(infoJson)
|
||||||
|
|
||||||
const register = Effect.fnUntraced(function* (address: HttpServer.Address) {
|
const register = Effect.fnUntraced(function* (address: HttpServer.Address) {
|
||||||
const fs = yield* FileSystem.FileSystem
|
const fs = yield* FileSystem.FileSystem
|
||||||
|
|
@ -73,20 +77,17 @@ const register = Effect.fnUntraced(function* (address: HttpServer.Address) {
|
||||||
const secret = yield* ServiceConfig.password()
|
const secret = yield* ServiceConfig.password()
|
||||||
const temp = file + "." + id + ".tmp"
|
const temp = file + "." + id + ".tmp"
|
||||||
yield* fs.makeDirectory(path.dirname(file), { recursive: true })
|
yield* fs.makeDirectory(path.dirname(file), { recursive: true })
|
||||||
yield* fs.writeFileString(
|
const encoded = yield* encodeInfo({
|
||||||
temp,
|
id,
|
||||||
JSON.stringify({
|
version: InstallationVersion,
|
||||||
id,
|
url: HttpServer.formatAddress(address),
|
||||||
version: InstallationVersion,
|
pid: process.pid,
|
||||||
url: HttpServer.formatAddress(address),
|
password: secret,
|
||||||
pid: process.pid,
|
})
|
||||||
password: secret,
|
yield* fs.writeFileString(temp, encoded, { mode: 0o600 })
|
||||||
}),
|
|
||||||
{ mode: 0o600 },
|
|
||||||
)
|
|
||||||
yield* fs.rename(temp, file)
|
yield* fs.rename(temp, file)
|
||||||
const currentID = fs.readFileString(file).pipe(
|
const currentID = fs.readFileString(file).pipe(
|
||||||
Effect.flatMap(decodeRegistrationId),
|
Effect.flatMap(decodeInfo),
|
||||||
Effect.map((info) => info.id),
|
Effect.map((info) => info.id),
|
||||||
Effect.orElseSucceed(() => undefined),
|
Effect.orElseSucceed(() => undefined),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { Effect, FileSystem, 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 ServiceOptions binding that
|
// The CLI's service configuration file, plus the Service.Options 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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,11 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
|
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
|
||||||
import { OpenCode } from "@opencode-ai/client/promise"
|
import { OpenCode } from "@opencode-ai/client/promise"
|
||||||
import type { Transport } from "@opencode-ai/client/effect"
|
import type { Service } from "@opencode-ai/client/effect"
|
||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||||
import type { Args } from "@opencode-ai/tui/context/args"
|
import type { Args } from "@opencode-ai/tui/context/args"
|
||||||
|
|
||||||
export function runTui(transport: Transport, args: Args, discover?: () => Promise<Transport>) {
|
export function runTui(transport: Service.Transport, args: Args, discover?: () => Promise<Service.Transport>) {
|
||||||
const config = TuiConfig.resolve({}, { terminalSuspend: false })
|
const config = TuiConfig.resolve({}, { terminalSuspend: false })
|
||||||
let disposeSlots: (() => void) | undefined
|
let disposeSlots: (() => void) | undefined
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
// Core or Server. Preserve these datatype exports so internal model reorganizations do not require caller migrations.
|
// Core or Server. Preserve these datatype exports so internal model reorganizations do not require caller migrations.
|
||||||
export * from "./generated/index"
|
export * from "./generated/index"
|
||||||
export { Service } from "./service.js"
|
export { Service } from "./service.js"
|
||||||
export type { Transport, ServiceOptions } 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"
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ export type Transport = {
|
||||||
readonly headers?: RequestInit["headers"]
|
readonly headers?: RequestInit["headers"]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ServiceOptions = {
|
export type Options = {
|
||||||
// Absolute path to the service registration file. Defaults to
|
// Absolute path to the service registration file. Defaults to
|
||||||
// opencode/service.json in the XDG state directory.
|
// opencode/service.json in the XDG state directory.
|
||||||
readonly file?: string
|
readonly file?: string
|
||||||
|
|
@ -30,21 +30,21 @@ export type ServiceOptions = {
|
||||||
|
|
||||||
// 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: ServiceOptions = {}) {
|
export const discover = Effect.fn("service.discover")(function* (options: Options = {}) {
|
||||||
const registration = yield* read(options.file)
|
const info = yield* read(options.file)
|
||||||
if (registration === undefined) return undefined
|
if (info === undefined) return undefined
|
||||||
if (options.version !== undefined && registration.version !== options.version) return undefined
|
if (options.version !== undefined && info.version !== options.version) return undefined
|
||||||
const found = yield* probe(registration)
|
const found = yield* probe(info)
|
||||||
return found?.transport
|
return found?.transport
|
||||||
})
|
})
|
||||||
|
|
||||||
// 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 the service command detached.
|
// version-mismatched one, and otherwise spawns the service command detached.
|
||||||
export const start = Effect.fn("service.start")(function* (options: ServiceOptions = {}) {
|
export const start = Effect.fn("service.start")(function* (options: Options = {}) {
|
||||||
const compatible = yield* discover(options)
|
const compatible = yield* discover(options)
|
||||||
if (compatible !== undefined) return compatible
|
if (compatible !== undefined) return compatible
|
||||||
const mismatched = yield* find(options)
|
const mismatched = yield* find(options)
|
||||||
if (mismatched !== undefined) yield* kill(mismatched.registration, options).pipe(Effect.ignore)
|
if (mismatched !== undefined) yield* kill(mismatched.info, options).pipe(Effect.ignore)
|
||||||
|
|
||||||
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
|
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
|
||||||
if (command === undefined) return yield* Effect.fail(new Error("Missing service command"))
|
if (command === undefined) return yield* Effect.fail(new Error("Missing service command"))
|
||||||
|
|
@ -64,10 +64,10 @@ export const start = Effect.fn("service.start")(function* (options: ServiceOptio
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const stop = Effect.fn("service.stop")(function* (options: ServiceOptions = {}) {
|
export const stop = Effect.fn("service.stop")(function* (options: Options = {}) {
|
||||||
const fs = yield* FileSystem.FileSystem
|
const fs = yield* FileSystem.FileSystem
|
||||||
const existing = yield* find(options)
|
const existing = yield* find(options)
|
||||||
if (existing !== undefined) yield* kill(existing.registration, options)
|
if (existing !== undefined) yield* kill(existing.info, options)
|
||||||
yield* fs.remove(options.file ?? fallback()).pipe(Effect.ignore)
|
yield* fs.remove(options.file ?? fallback()).pipe(Effect.ignore)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -80,18 +80,18 @@ function auth(password: string): RequestInit["headers"] {
|
||||||
return { authorization: "Basic " + btoa("opencode:" + password) }
|
return { authorization: "Basic " + btoa("opencode:" + password) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const Registration = 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),
|
||||||
url: Schema.String,
|
url: Schema.String,
|
||||||
pid: Schema.Int.check(Schema.isGreaterThan(0)),
|
pid: Schema.Int.check(Schema.isGreaterThan(0)),
|
||||||
password: Schema.optional(Schema.String),
|
password: Schema.optional(Schema.String),
|
||||||
})
|
})
|
||||||
type Registration = typeof Registration.Type
|
export type Info = typeof Info.Type
|
||||||
|
|
||||||
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Registration))
|
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
|
||||||
|
|
||||||
// A missing or corrupt file means no valid registration; callers treat both
|
// A missing or corrupt file means no valid info; callers treat both
|
||||||
// the same (the registering server self-evicts, clients rediscover).
|
// the same (the registering server self-evicts, clients rediscover).
|
||||||
const read = Effect.fnUntraced(function* (file?: string) {
|
const read = Effect.fnUntraced(function* (file?: string) {
|
||||||
const fs = yield* FileSystem.FileSystem
|
const fs = yield* FileSystem.FileSystem
|
||||||
|
|
@ -101,14 +101,14 @@ const read = Effect.fnUntraced(function* (file?: string) {
|
||||||
})
|
})
|
||||||
|
|
||||||
type LocalService = {
|
type LocalService = {
|
||||||
readonly registration: Registration
|
readonly info: Info
|
||||||
readonly transport: Transport
|
readonly transport: Transport
|
||||||
}
|
}
|
||||||
|
|
||||||
const probe = Effect.fnUntraced(function* (registration: Registration) {
|
const probe = Effect.fnUntraced(function* (info: Info) {
|
||||||
const headers = registration.password === undefined ? undefined : auth(registration.password)
|
const headers = info.password === undefined ? undefined : auth(info.password)
|
||||||
const healthy = yield* Effect.tryPromise(() =>
|
const healthy = yield* Effect.tryPromise(() =>
|
||||||
fetch(new URL("/api/health", registration.url), {
|
fetch(new URL("/api/health", info.url), {
|
||||||
headers,
|
headers,
|
||||||
signal: AbortSignal.timeout(2_000),
|
signal: AbortSignal.timeout(2_000),
|
||||||
}),
|
}),
|
||||||
|
|
@ -117,15 +117,15 @@ const probe = Effect.fnUntraced(function* (registration: Registration) {
|
||||||
Effect.orElseSucceed(() => false),
|
Effect.orElseSucceed(() => false),
|
||||||
)
|
)
|
||||||
if (!healthy) return undefined
|
if (!healthy) return undefined
|
||||||
return { registration, transport: { url: registration.url, headers } } satisfies LocalService
|
return { info, transport: { url: info.url, headers } } satisfies LocalService
|
||||||
})
|
})
|
||||||
|
|
||||||
// Health-checked lookup without the version gate: lifecycle 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: ServiceOptions) {
|
const find = Effect.fnUntraced(function* (options: Options) {
|
||||||
const registration = yield* read(options.file)
|
const info = yield* read(options.file)
|
||||||
if (registration === undefined) return undefined
|
if (info === undefined) return undefined
|
||||||
return yield* probe(registration)
|
return yield* probe(info)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 50ms cadence bounded at ~5s, shared by stop escalation and start readiness.
|
// 50ms cadence bounded at ~5s, shared by stop escalation and start readiness.
|
||||||
|
|
@ -142,22 +142,22 @@ const stopped = Effect.fnUntraced(function* (pid: number) {
|
||||||
return yield* Effect.fail(new Error(`Server process ${pid} is still running`))
|
return yield* Effect.fail(new Error(`Server process ${pid} is still running`))
|
||||||
})
|
})
|
||||||
|
|
||||||
function same(left: Registration, right: Registration) {
|
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* (info: Registration, options: ServiceOptions) {
|
const kill = Effect.fnUntraced(function* (info: Info, options: Options) {
|
||||||
// A stale registration may point at a PID that has since been reused by
|
// A stale registration may point at a PID that has since been reused by
|
||||||
// another process. Only signal the PID after authenticating the server.
|
// another process. Only signal the PID after authenticating the server.
|
||||||
const current = yield* find(options)
|
const current = yield* find(options)
|
||||||
if (current === undefined || !same(current.registration, info)) return
|
if (current === undefined || !same(current.info, info)) return
|
||||||
|
|
||||||
yield* signal(info.pid, "SIGTERM")
|
yield* signal(info.pid, "SIGTERM")
|
||||||
const done = yield* stopped(info.pid).pipe(Effect.retry(poll), Effect.option)
|
const done = yield* stopped(info.pid).pipe(Effect.retry(poll), Effect.option)
|
||||||
if (Option.isSome(done)) return
|
if (Option.isSome(done)) return
|
||||||
|
|
||||||
const latest = yield* find(options)
|
const latest = yield* find(options)
|
||||||
if (latest === undefined || !same(latest.registration, info)) return
|
if (latest === undefined || !same(latest.info, info)) return
|
||||||
yield* signal(info.pid, "SIGKILL")
|
yield* signal(info.pid, "SIGKILL")
|
||||||
yield* stopped(info.pid).pipe(Effect.retry(poll))
|
yield* stopped(info.pid).pipe(Effect.retry(poll))
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
export * from "./generated/index"
|
export * from "./generated/index"
|
||||||
export type { Transport } from "../effect/service.js"
|
|
||||||
export type { EventSubscribeOutput as OpenCodeEvent } from "./generated/types"
|
export type { EventSubscribeOutput as OpenCodeEvent } from "./generated/types"
|
||||||
export type OpenCodeClient = ReturnType<typeof import("./generated/client").make>
|
export type OpenCodeClient = ReturnType<typeof import("./generated/client").make>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue