refactor(client): split package into promise and effect entrypoints
This commit is contained in:
parent
0087dd56d9
commit
af5eabcb26
46 changed files with 5211 additions and 2332 deletions
|
|
@ -3,7 +3,7 @@ import { Effect, Stream, Schema } from "effect"
|
|||
import { Sse } from "effect/unstable/encoding"
|
||||
import { HttpClientError } from "effect/unstable/http"
|
||||
import { HttpApiClient } from "effect/unstable/httpapi"
|
||||
import { ClientApi } from "../contract"
|
||||
import { ClientApi } from "../../contract"
|
||||
import { ClientError } from "./client-error"
|
||||
|
||||
type RawClient = HttpApiClient.ForApi<typeof ClientApi>
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
// TODO: Keep additional network capabilities inside Schema and Protocol as the client grows; /effect must never import
|
||||
// Core or Server. Preserve these datatype exports so internal model reorganizations do not require caller migrations.
|
||||
export * from "./generated-effect/index"
|
||||
export * from "./generated/index"
|
||||
export { Service } from "./service.js"
|
||||
export type { Transport, ServiceOptions } from "./service.js"
|
||||
export { Agent } from "@opencode-ai/schema/agent"
|
||||
export { Command } from "@opencode-ai/schema/command"
|
||||
export { Credential } from "@opencode-ai/schema/credential"
|
||||
165
packages/client/src/effect/service.ts
Normal file
165
packages/client/src/effect/service.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { Effect, FileSystem, Option, Schedule, Schema } from "effect"
|
||||
import { spawn } from "node:child_process"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
|
||||
// Find, start, and stop the local opencode background service.
|
||||
//
|
||||
// The service daemon advertises itself through a registration file in the
|
||||
// user's state directory: url, pid, version, and the private password, with
|
||||
// 0600 permissions. That file is the complete discovery contract — reading it
|
||||
// is all a client needs to connect. The daemon's own configuration (port,
|
||||
// persisted password) is CLI-owned and never read here.
|
||||
|
||||
export type Transport = {
|
||||
readonly url: string
|
||||
readonly headers?: RequestInit["headers"]
|
||||
}
|
||||
|
||||
export type ServiceOptions = {
|
||||
// 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>
|
||||
}
|
||||
|
||||
// Read-only lookup: registration file plus health check and version gate.
|
||||
// Never spawns; escalation to start() is the caller's policy.
|
||||
export const discover = Effect.fn("service.discover")(function* (options: ServiceOptions = {}) {
|
||||
const registration = yield* read(options.file)
|
||||
if (registration === undefined) return undefined
|
||||
if (options.version !== undefined && registration.version !== options.version) return undefined
|
||||
const found = yield* probe(registration)
|
||||
return found?.transport
|
||||
})
|
||||
|
||||
// Idempotent ensure-running: reuses a healthy compatible server, replaces a
|
||||
// version-mismatched one, and otherwise spawns the service command detached.
|
||||
export const start = Effect.fn("service.start")(function* (options: ServiceOptions = {}) {
|
||||
const compatible = yield* discover(options)
|
||||
if (compatible !== undefined) return compatible
|
||||
const mismatched = yield* find(options)
|
||||
if (mismatched !== undefined) yield* kill(mismatched.registration, options).pipe(Effect.ignore)
|
||||
|
||||
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
|
||||
if (command === undefined) return yield* Effect.fail(new Error("Missing service command"))
|
||||
yield* Effect.try({
|
||||
try: () => {
|
||||
spawn(command, args, { detached: true, stdio: "ignore" }).unref()
|
||||
},
|
||||
catch: (cause) => new Error("Failed to start server", { cause }),
|
||||
})
|
||||
|
||||
return yield* discover(options).pipe(
|
||||
Effect.flatMap((found) =>
|
||||
found === undefined ? Effect.fail(new Error("Server is not ready")) : Effect.succeed(found),
|
||||
),
|
||||
Effect.retry(poll),
|
||||
Effect.mapError(() => new Error("Failed to start server")),
|
||||
)
|
||||
})
|
||||
|
||||
export const stop = Effect.fn("service.stop")(function* (options: ServiceOptions = {}) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const existing = yield* find(options)
|
||||
if (existing !== undefined) yield* kill(existing.registration, options)
|
||||
yield* fs.remove(options.file ?? fallback()).pipe(Effect.ignore)
|
||||
})
|
||||
|
||||
function fallback() {
|
||||
const state = process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state")
|
||||
return join(state, "opencode", "service.json")
|
||||
}
|
||||
|
||||
function auth(password: string): RequestInit["headers"] {
|
||||
return { authorization: "Basic " + btoa("opencode:" + password) }
|
||||
}
|
||||
|
||||
const Registration = Schema.Struct({
|
||||
id: Schema.optional(Schema.String),
|
||||
version: Schema.optional(Schema.String),
|
||||
url: Schema.String,
|
||||
pid: Schema.Int.check(Schema.isGreaterThan(0)),
|
||||
password: Schema.optional(Schema.String),
|
||||
})
|
||||
type Registration = typeof Registration.Type
|
||||
|
||||
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Registration))
|
||||
|
||||
// A missing or corrupt file means no valid registration; callers treat both
|
||||
// the same (the registering server self-evicts, clients rediscover).
|
||||
const read = Effect.fnUntraced(function* (file?: string) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const text = yield* fs.readFileString(file ?? fallback()).pipe(Effect.option)
|
||||
if (Option.isNone(text)) return undefined
|
||||
return yield* decode(text.value).pipe(Effect.option, Effect.map(Option.getOrUndefined))
|
||||
})
|
||||
|
||||
type LocalService = {
|
||||
readonly registration: Registration
|
||||
readonly transport: Transport
|
||||
}
|
||||
|
||||
const probe = Effect.fnUntraced(function* (registration: Registration) {
|
||||
const headers = registration.password === undefined ? undefined : auth(registration.password)
|
||||
const healthy = yield* Effect.tryPromise(() =>
|
||||
fetch(new URL("/api/health", registration.url), {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
}),
|
||||
).pipe(
|
||||
Effect.map((response) => response.ok),
|
||||
Effect.orElseSucceed(() => false),
|
||||
)
|
||||
if (!healthy) return undefined
|
||||
return { registration, transport: { url: registration.url, headers } } satisfies LocalService
|
||||
})
|
||||
|
||||
// Health-checked lookup without the version gate: lifecycle operations must be
|
||||
// able to see (and replace or stop) a server from a different version.
|
||||
const find = Effect.fnUntraced(function* (options: ServiceOptions) {
|
||||
const registration = yield* read(options.file)
|
||||
if (registration === undefined) return undefined
|
||||
return yield* probe(registration)
|
||||
})
|
||||
|
||||
// 50ms cadence bounded at ~5s, shared by stop escalation and start readiness.
|
||||
const poll = Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))
|
||||
|
||||
const signal = (pid: number, name: NodeJS.Signals) =>
|
||||
Effect.try({ try: () => process.kill(pid, name), catch: (cause) => cause }).pipe(Effect.ignore)
|
||||
|
||||
const stopped = Effect.fnUntraced(function* (pid: number) {
|
||||
const running = yield* Effect.try({ try: () => process.kill(pid, 0), catch: () => false }).pipe(
|
||||
Effect.orElseSucceed(() => false),
|
||||
)
|
||||
if (!running) return true
|
||||
return yield* Effect.fail(new Error(`Server process ${pid} is still running`))
|
||||
})
|
||||
|
||||
function same(left: Registration, right: Registration) {
|
||||
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) {
|
||||
// A stale registration may point at a PID that has since been reused by
|
||||
// another process. Only signal the PID after authenticating the server.
|
||||
const current = yield* find(options)
|
||||
if (current === undefined || !same(current.registration, info)) return
|
||||
|
||||
yield* signal(info.pid, "SIGTERM")
|
||||
const done = yield* stopped(info.pid).pipe(Effect.retry(poll), Effect.option)
|
||||
if (Option.isSome(done)) return
|
||||
|
||||
const latest = yield* find(options)
|
||||
if (latest === undefined || !same(latest.registration, info)) return
|
||||
yield* signal(info.pid, "SIGKILL")
|
||||
yield* stopped(info.pid).pipe(Effect.retry(poll))
|
||||
})
|
||||
|
||||
export * as Service from "./service.js"
|
||||
|
|
@ -4030,6 +4030,239 @@ export type EventSubscribeOutput =
|
|||
readonly todos: ReadonlyArray<{ readonly content: string; readonly status: string; readonly priority: string }>
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.status"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly sessionID: string
|
||||
readonly status:
|
||||
| { readonly type: "idle" }
|
||||
| {
|
||||
readonly type: "retry"
|
||||
readonly attempt: number
|
||||
readonly message: string
|
||||
readonly action?: {
|
||||
readonly reason: string
|
||||
readonly provider: string
|
||||
readonly title: string
|
||||
readonly message: string
|
||||
readonly label: string
|
||||
readonly link?: string
|
||||
}
|
||||
readonly next: number
|
||||
}
|
||||
| { readonly type: "busy" }
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.idle"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly sessionID: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "tui.prompt.append"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly text: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "tui.command.execute"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly command:
|
||||
| "session.list"
|
||||
| "session.new"
|
||||
| "session.share"
|
||||
| "session.interrupt"
|
||||
| "session.background"
|
||||
| "session.compact"
|
||||
| "session.page.up"
|
||||
| "session.page.down"
|
||||
| "session.line.up"
|
||||
| "session.line.down"
|
||||
| "session.half.page.up"
|
||||
| "session.half.page.down"
|
||||
| "session.first"
|
||||
| "session.last"
|
||||
| "prompt.clear"
|
||||
| "prompt.submit"
|
||||
| "agent.cycle"
|
||||
| string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "tui.toast.show"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly title?: string
|
||||
readonly message: string
|
||||
readonly variant: "info" | "success" | "warning" | "error"
|
||||
readonly duration?: number | undefined
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "tui.session.select"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly sessionID: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "installation.updated"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly version: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "installation.update-available"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly version: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "vcs.branch.updated"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly branch?: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "mcp.status.changed"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly server: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "permission.asked"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly permission: string
|
||||
readonly patterns: ReadonlyArray<string>
|
||||
readonly metadata: { readonly [x: string]: unknown }
|
||||
readonly always: ReadonlyArray<string>
|
||||
readonly tool?: { readonly messageID: string; readonly callID: string } | undefined
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "permission.replied"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly sessionID: string
|
||||
readonly requestID: string
|
||||
readonly reply: "once" | "always" | "reject"
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "question.asked"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly id: string
|
||||
readonly sessionID: string
|
||||
readonly questions: ReadonlyArray<{
|
||||
readonly question: string
|
||||
readonly header: string
|
||||
readonly options: ReadonlyArray<{ readonly label: string; readonly description: string }>
|
||||
readonly multiple?: boolean | undefined
|
||||
readonly custom?: boolean | undefined
|
||||
}>
|
||||
readonly tool?: { readonly messageID: string; readonly callID: string } | undefined
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "question.replied"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly sessionID: string
|
||||
readonly requestID: string
|
||||
readonly answers: ReadonlyArray<ReadonlyArray<string>>
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "question.rejected"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: { readonly sessionID: string; readonly requestID: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown }
|
||||
readonly type: "session.error"
|
||||
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly data: {
|
||||
readonly sessionID?: string | undefined
|
||||
readonly error?:
|
||||
| {
|
||||
readonly name: "ProviderAuthError"
|
||||
readonly data: { readonly providerID: string; readonly message: string }
|
||||
}
|
||||
| {
|
||||
readonly name: "UnknownError"
|
||||
readonly data: { readonly message: string; readonly ref?: string | undefined }
|
||||
}
|
||||
| { readonly name: "MessageOutputLengthError"; readonly data: {} }
|
||||
| { readonly name: "MessageAbortedError"; readonly data: { readonly message: string } }
|
||||
| {
|
||||
readonly name: "StructuredOutputError"
|
||||
readonly data: { readonly message: string; readonly retries: number }
|
||||
}
|
||||
| {
|
||||
readonly name: "ContextOverflowError"
|
||||
readonly data: { readonly message: string; readonly responseBody?: string | undefined }
|
||||
}
|
||||
| { readonly name: "ContentFilterError"; readonly data: { readonly message: string } }
|
||||
| {
|
||||
readonly name: "APIError"
|
||||
readonly data: {
|
||||
readonly message: string
|
||||
readonly statusCode?: number | undefined
|
||||
readonly isRetryable: boolean
|
||||
readonly responseHeaders?: { readonly [x: string]: string } | undefined
|
||||
readonly responseBody?: string | undefined
|
||||
readonly metadata?: { readonly [x: string]: string } | undefined
|
||||
}
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export * from "./generated/index"
|
||||
export type { Transport } from "../effect/service.js"
|
||||
export type { EventSubscribeOutput as OpenCodeEvent } from "./generated/types"
|
||||
export type OpenCodeClient = ReturnType<typeof import("./generated/client").make>
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import { Effect } from "effect"
|
||||
import * as service from "./index.js"
|
||||
|
||||
export type { Transport, Discover, Registration, LocalService, ServiceOptions } from "./index.js"
|
||||
|
||||
export { basicAuth } from "./index.js"
|
||||
|
||||
export const readRegistration = (file?: string) => Effect.promise(() => service.readRegistration(file))
|
||||
export const discover = (options?: service.ServiceOptions) => Effect.promise(() => service.discover(options))
|
||||
export const stop = (options?: service.ServiceOptions) => Effect.promise(() => service.stop(options))
|
||||
export const start = (options?: service.ServiceOptions) =>
|
||||
Effect.tryPromise({
|
||||
try: () => service.start(options),
|
||||
catch: (cause) => new Error("Failed to start server", { cause }),
|
||||
})
|
||||
export const connect = (options?: service.ServiceOptions) =>
|
||||
Effect.tryPromise({
|
||||
try: () => service.connect(options),
|
||||
catch: (cause) => new Error("Failed to connect to server", { cause }),
|
||||
})
|
||||
|
||||
export * as ServiceEffect from "./effect.js"
|
||||
Loading…
Add table
Add a link
Reference in a new issue