feat: expose background service lifecycle (#36895)

This commit is contained in:
Kit Langton 2026-07-14 16:38:22 -04:00 committed by GitHub
commit ece2b16cdf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 2421 additions and 293 deletions

View file

@ -16,7 +16,7 @@ export type Args = {
export type Resolved = {
readonly endpoint: Service.Endpoint
readonly reconnect?: (attempt: number) => Promise<Service.Endpoint>
readonly reconnect?: (onStatus: (status: Service.Status) => void, signal: AbortSignal) => Promise<Service.Endpoint>
readonly reload?: () => Promise<void>
}
@ -27,9 +27,7 @@ export const resolve = Effect.fn("cli.server.resolve")(function* (args: Args) {
const password = yield* Env.password
const endpoint = {
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
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const health = yield* Effect.tryPromise({
@ -51,19 +49,14 @@ export const resolve = Effect.fn("cli.server.resolve")(function* (args: Args) {
const reconnectOptions = { ...options, version: undefined }
return {
endpoint,
reconnect: (attempt) =>
Effect.runPromise(
Effect.gen(function* () {
if (attempt > 3) return yield* Service.start(reconnectOptions)
const endpoint = yield* Service.discover(reconnectOptions)
if (endpoint !== undefined) return endpoint
return yield* Effect.fail(new Error("Background server is unavailable"))
}).pipe(Effect.provide(NodeFileSystem.layer)),
),
reconnect: (onStatus, signal) =>
Effect.runPromise(Service.start({ ...reconnectOptions, onStatus }).pipe(Effect.provide(NodeFileSystem.layer)), {
signal,
}),
reload: () =>
Effect.runPromise(
Effect.gen(function* () {
yield* Service.stop(options)
yield* Service.stop(options, { targetVersion: options.version })
yield* Service.start(options)
}).pipe(Effect.provide(NodeFileSystem.layer)),
),
@ -80,7 +73,8 @@ const resolveManaged = Effect.fnUntraced(function* (
const compatible = yield* Service.discover(options)
if (compatible !== undefined) return compatible
const existing = yield* Service.discover({ ...options, version: undefined })
if (existing !== undefined) return yield* Effect.fail(new Error("Background server version does not match this client"))
if (existing !== undefined)
return yield* Effect.fail(new Error("Background server version does not match this client"))
return yield* Service.start(options)
})

View file

@ -1,7 +1,8 @@
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 { Effect, FileSystem, Schema } from "effect"
import { Effect, FileSystem, Option, Schema } from "effect"
import { randomBytes } from "crypto"
import path from "path"
@ -20,25 +21,63 @@ const keys = ["hostname", "port", "password"] as const
type Key = (typeof keys)[number]
const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Service.Info))
export function filename(channel = InstallationChannel) {
if (channel === "latest") return "service.json"
if (channel === "local") return "service-local.json"
return `service-${Hash.fast(channel)}.json`
}
export function versionBelongsToChannel(
version: string | undefined,
channel = InstallationChannel,
installedVersion = InstallationVersion,
) {
if (version === undefined) return false
if (version === installedVersion) return true
const prefix = `0.0.0-${channel}-`
if (!version.startsWith(prefix)) return false
return /^\d+(?:\.\d+)?$/.test(version.slice(prefix.length))
}
export const migrateRegistration = Effect.fnUntraced(function* (
legacy: string,
file: string,
channel = InstallationChannel,
installedVersion = InstallationVersion,
) {
if (channel === "latest" || channel === "local") return
const fs = yield* FileSystem.FileSystem
const text = yield* fs.readFileString(legacy).pipe(Effect.option)
if (Option.isNone(text)) return
const registration = yield* decodeRegistration(text.value).pipe(Effect.option)
if (Option.isNone(registration)) return
if (!versionBelongsToChannel(registration.value.version, channel, installedVersion)) return
yield* fs.writeFileString(file, text.value, { flag: "wx", mode: 0o600 }).pipe(Effect.ignore)
})
function configKey(key: string): Key {
if (keys.includes(key as Key)) return key as Key
if (key === "hostname" || key === "port" || key === "password") return key
throw new Error(`Unknown service config key: ${key}`)
}
const env = Effect.gen(function* () {
const paths = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const filename = InstallationChannel === "local" ? "service-local.json" : "service.json"
const name = filename()
const file = path.join(global.state, name)
return {
fs,
file: path.join(global.state, filename),
configFile: path.join(global.config, filename),
file,
legacyFile: path.join(global.state, "service.json"),
configFile: path.join(global.config, name),
}
})
export const options = Effect.fnUntraced(function* () {
const { file } = yield* env
const { file, legacyFile } = yield* paths
yield* migrateRegistration(legacyFile, file)
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
const entrypoint = compiled ? undefined : process.argv[1]
if (!compiled && entrypoint === undefined) return yield* Effect.fail(new Error("Failed to resolve CLI entrypoint"))
@ -50,7 +89,7 @@ export const options = Effect.fnUntraced(function* () {
})
export const read = Effect.fn("cli.service-config.read")(function* () {
const { fs, configFile } = yield* env
const { fs, configFile } = yield* paths
return yield* fs.readFileString(configFile).pipe(
Effect.flatMap(decodeInfo),
Effect.catch(() => Effect.succeed({} as Info)),
@ -58,7 +97,7 @@ export const read = Effect.fn("cli.service-config.read")(function* () {
})
const write = Effect.fn("cli.service-config.write")(function* (value: Info) {
const { fs, configFile } = yield* env
const { fs, configFile } = yield* paths
const temp = configFile + ".tmp"
yield* fs.makeDirectory(path.dirname(configFile), { recursive: true })
yield* fs.writeFileString(temp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 })
@ -93,6 +132,7 @@ export const get = Effect.fn("cli.service-config.get")(function* (key?: string)
return yield* password()
}
}
throw new Error(`Unknown service config key: ${key}`)
})
export const set = Effect.fn("cli.service-config.set")(function* (key: string, value: string) {