feat: expose background service lifecycle (#36895)
This commit is contained in:
parent
ea89a2f619
commit
ece2b16cdf
36 changed files with 2421 additions and 293 deletions
|
|
@ -55,6 +55,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/semver": "catalog:",
|
||||
|
|
|
|||
143
packages/cli/script/service-smoke.ts
Normal file
143
packages/cli/script/service-smoke.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
#!/usr/bin/env bun
|
||||
|
||||
import { Service } from "@opencode-ai/client/effect"
|
||||
import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
|
||||
import { Schema } from "effect"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
|
||||
const target = `cli-${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`
|
||||
const directory = path.join(import.meta.dir, "..", "dist", target, "bin")
|
||||
const binary = path.join(directory, `opencode2${process.platform === "win32" ? ".exe" : ""}`)
|
||||
if (!(await Bun.file(binary).exists())) throw new Error(`Missing compiled CLI in ${directory}`)
|
||||
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-smoke-"))
|
||||
const env = {
|
||||
...process.env,
|
||||
HOME: root,
|
||||
USERPROFILE: root,
|
||||
OPENCODE_DB: path.join(root, "opencode.db"),
|
||||
OPENCODE_TEST_HOME: root,
|
||||
XDG_CACHE_HOME: path.join(root, "cache"),
|
||||
XDG_CONFIG_HOME: path.join(root, "config"),
|
||||
XDG_DATA_HOME: path.join(root, "data"),
|
||||
XDG_STATE_HOME: path.join(root, "state"),
|
||||
}
|
||||
const processes: Array<ReturnType<typeof Bun.spawn>> = []
|
||||
const errors: Array<Promise<string>> = []
|
||||
let failure: unknown
|
||||
try {
|
||||
spawnService()
|
||||
spawnService()
|
||||
const registration = await waitForRegistration()
|
||||
const info = await Schema.decodeUnknownPromise(Service.Info)(await Bun.file(registration).json())
|
||||
if (info.id === undefined || info.password === undefined) throw new Error("Registration is missing service identity")
|
||||
const credential = btoa(`opencode:${info.password}`)
|
||||
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")
|
||||
const tokenHealth = await fetch(
|
||||
new URL(`/api/health?auth_token=${token}`, info.url),
|
||||
{ signal: AbortSignal.timeout(5_000) },
|
||||
)
|
||||
if (tokenHealth.status !== 200) throw new Error("Compiled service rejected query authentication")
|
||||
const tokenOpenApi = await fetch(
|
||||
new URL(`/openapi.json?auth_token=${token}`, info.url),
|
||||
{ signal: AbortSignal.timeout(5_000) },
|
||||
)
|
||||
if (tokenOpenApi.status !== 200) throw new Error("Compiled application rejected query authentication")
|
||||
|
||||
const unauthorizedHealth = await fetch(new URL("/api/health", info.url), {
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
})
|
||||
if (unauthorizedHealth.status !== 401) throw new Error("Compiled service exposed health without authentication")
|
||||
const unauthorizedOpenApi = await fetch(new URL("/openapi.json", info.url), {
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
})
|
||||
if (unauthorizedOpenApi.status !== 401) throw new Error("Compiled service exposed application routes without authentication")
|
||||
const unauthorizedStop = await fetch(new URL("/api/service/stop", info.url), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ instanceID: info.id }),
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
})
|
||||
if (unauthorizedStop.status !== 401) throw new Error("Compiled service accepted unauthenticated stop")
|
||||
|
||||
const winner = processes.find((process) => process.pid === info.pid)
|
||||
const loser = processes.find((process) => process.pid !== info.pid)
|
||||
if (!winner || !loser) throw new Error("Compiled contenders did not elect one registered owner")
|
||||
if (!(await exitsWithin(loser, 10_000))) throw new Error("Losing compiled contender did not exit")
|
||||
|
||||
const stopped = await Schema.decodeUnknownPromise(ServiceStatus.StopResponse)(
|
||||
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" }),
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
}).then((response) => response.json()),
|
||||
)
|
||||
if (!stopped.accepted) throw new Error("Compiled service rejected exact-instance stop")
|
||||
if (!(await exitsWithin(winner, 10_000))) throw new Error("Compiled service did not stop")
|
||||
for (let attempt = 0; attempt < 200 && (await Bun.file(registration).exists()); attempt++) await Bun.sleep(25)
|
||||
if (await Bun.file(registration).exists()) throw new Error("Compiled service registration was not removed")
|
||||
} catch (cause) {
|
||||
failure = cause
|
||||
} finally {
|
||||
processes.forEach((process) => process.kill())
|
||||
await Promise.all(processes.map((process) => process.exited))
|
||||
}
|
||||
|
||||
const output = await Promise.all(errors)
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
if (failure)
|
||||
throw new Error(output.filter(Boolean).join("\n") || "Compiled service lifecycle smoke test failed", {
|
||||
cause: failure,
|
||||
})
|
||||
|
||||
function spawnService() {
|
||||
const process = Bun.spawn([binary, "serve", "--service"], { env, stdout: "ignore", stderr: "pipe" })
|
||||
processes.push(process)
|
||||
errors.push(new Response(process.stderr).text())
|
||||
return process
|
||||
}
|
||||
|
||||
async function waitForRegistration() {
|
||||
const directory = path.join(root, "state", "opencode")
|
||||
for (let attempt = 0; attempt < 400; attempt++) {
|
||||
const files = await fs.readdir(directory).catch(() => [])
|
||||
const file = files.find(
|
||||
(file) => file === "service.json" || (file.startsWith("service-") && file.endsWith(".json")),
|
||||
)
|
||||
if (file) return path.join(directory, file)
|
||||
await Bun.sleep(25)
|
||||
}
|
||||
throw new Error("Compiled service did not publish registration")
|
||||
}
|
||||
|
||||
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), {
|
||||
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)
|
||||
await Bun.sleep(25)
|
||||
}
|
||||
throw new Error("Compiled service did not become ready")
|
||||
}
|
||||
|
||||
function exitsWithin(process: Bun.Subprocess, milliseconds: number) {
|
||||
return Promise.race([process.exited.then(() => true), Bun.sleep(milliseconds).then(() => false)])
|
||||
}
|
||||
|
|
@ -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)
|
||||
yield* Service.stop(options, { targetVersion: options.version })
|
||||
const transport = yield* Service.start(options)
|
||||
process.stdout.write(transport.url + EOL)
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -8,7 +8,13 @@ import { ServiceConfig } from "../../../services/service-config"
|
|||
export default Runtime.handler(
|
||||
Commands.commands.service.commands.status,
|
||||
Effect.fn("cli.service.status")(function* () {
|
||||
const found = yield* Service.discover(yield* ServiceConfig.options())
|
||||
process.stdout.write((found ? found.url : "stopped") + EOL)
|
||||
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)
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,11 +7,10 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
|||
import { Global } from "@opencode-ai/core/global"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
import { start } from "@opencode-ai/server/process"
|
||||
import { ProcessLock } from "@opencode-ai/core/util/process-lock"
|
||||
import { randomBytes, randomUUID } from "node:crypto"
|
||||
import path from "node:path"
|
||||
import { Effect, Exit, FileSystem, Logger, Option, Redacted, Schedule, Schema, Scope } from "effect"
|
||||
import { Effect, FileSystem, Logger, Option, Redacted, Schedule, Schema } from "effect"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { Env } from "./env"
|
||||
import { ServiceConfig } from "./services/service-config"
|
||||
|
|
@ -28,7 +27,7 @@ export type Options = {
|
|||
export const run = Effect.fn("cli.server-process.run")((options: Options) =>
|
||||
processEffect(options).pipe(
|
||||
Effect.provide(Updater.layer),
|
||||
Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node, EffectFlock.node]))),
|
||||
Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))),
|
||||
Effect.provide(NodeServices.layer),
|
||||
),
|
||||
)
|
||||
|
|
@ -38,15 +37,15 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
|||
return yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined
|
||||
const lockScope = serviceOptions === undefined ? undefined : yield* acquireServiceLock(serviceOptions.file)
|
||||
if (
|
||||
serviceOptions !== undefined &&
|
||||
lockScope !== undefined &&
|
||||
(yield* Service.discover(serviceOptions)) !== undefined
|
||||
) {
|
||||
yield* Scope.close(lockScope, Exit.void)
|
||||
return
|
||||
if (serviceOptions !== undefined) {
|
||||
const acquired = yield* ProcessLock.acquire(serviceOptions.file + ".lock").pipe(
|
||||
Effect.as(true),
|
||||
Effect.catchTag("ProcessLockHeldError", () => Effect.succeed(false)),
|
||||
)
|
||||
if (!acquired) return yield* Effect.void
|
||||
if ((yield* Service.discover(serviceOptions)) !== undefined) return yield* Effect.void
|
||||
}
|
||||
const { start } = yield* Effect.promise(() => import("@opencode-ai/server/process"))
|
||||
const environmentPassword = yield* Env.password
|
||||
// Keep the lease credential out of the environment inherited by tools.
|
||||
if (options.mode === "stdio") {
|
||||
|
|
@ -61,77 +60,82 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
|||
? Redacted.value(environmentPassword)
|
||||
: randomBytes(32).toString("base64url")
|
||||
if (!password) return yield* Effect.fail(new Error("Missing server password"))
|
||||
const address = yield* start({
|
||||
const instanceID = randomUUID()
|
||||
const server = yield* start({
|
||||
hostname: options.hostname ?? config.hostname ?? "127.0.0.1",
|
||||
port: Option.fromNullishOr(options.port ?? config.port),
|
||||
password,
|
||||
restartContinuity: options.mode === "service",
|
||||
instanceID,
|
||||
service:
|
||||
serviceOptions === undefined
|
||||
? undefined
|
||||
: { onListen: (address) => register(address, password, instanceID, serviceOptions.file) },
|
||||
}).pipe(Effect.provide(Logger.layer([], { mergeWithExisting: false })))
|
||||
if (lockScope !== undefined) {
|
||||
yield* register(address, password)
|
||||
yield* Scope.close(lockScope, Exit.void)
|
||||
}
|
||||
const url = HttpServer.formatAddress(address)
|
||||
const url = HttpServer.formatAddress(server.address)
|
||||
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
|
||||
if (options.mode === "default" && !environmentPassword) console.log(`server password ${password}`)
|
||||
const updater = yield* Updater.Service
|
||||
yield* updater.check().pipe(Effect.schedule(Schedule.spaced("10 minutes")), Effect.forkScoped)
|
||||
return yield* options.mode === "stdio" ? waitForStdinClose() : Effect.never
|
||||
return yield* options.mode === "service"
|
||||
? server.shutdown
|
||||
: options.mode === "stdio"
|
||||
? waitForStdinClose()
|
||||
: Effect.never
|
||||
}).pipe(Effect.annotateLogs({ role: "server" })),
|
||||
)
|
||||
})
|
||||
|
||||
const acquireServiceLock = Effect.fnUntraced(function* (file: string) {
|
||||
const flock = yield* EffectFlock.Service
|
||||
const scope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer((exit) => Scope.close(scope, exit))
|
||||
yield* flock
|
||||
.acquire(`service:${file}`, undefined, { staleMs: 3_000, timeoutMs: 3_000 })
|
||||
.pipe(Effect.provideService(Scope.Scope, scope))
|
||||
return scope
|
||||
})
|
||||
|
||||
// The latest atomic registration wins. A displaced process notices the new id,
|
||||
// exits, and cannot remove its successor's registration from its finalizer.
|
||||
const infoJson = Schema.fromJsonString(Service.Info)
|
||||
const encodeInfo = Schema.encodeEffect(infoJson)
|
||||
const decodeInfo = Schema.decodeUnknownEffect(infoJson)
|
||||
|
||||
const register = Effect.fnUntraced(function* (address: HttpServer.Address, password: string) {
|
||||
const register = Effect.fnUntraced(function* (
|
||||
address: HttpServer.Address,
|
||||
password: string,
|
||||
id: string,
|
||||
file: string,
|
||||
) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const options = yield* ServiceConfig.options()
|
||||
const id = randomUUID()
|
||||
const temp = options.file + "." + id + ".tmp"
|
||||
yield* fs.makeDirectory(path.dirname(options.file), { recursive: true })
|
||||
const encoded = yield* encodeInfo({
|
||||
const temp = file + "." + id + ".tmp"
|
||||
yield* fs.makeDirectory(path.dirname(file), { recursive: true })
|
||||
const info = {
|
||||
id,
|
||||
version: InstallationVersion,
|
||||
url: HttpServer.formatAddress(address),
|
||||
pid: process.pid,
|
||||
password,
|
||||
})
|
||||
yield* fs.writeFileString(temp, encoded, { mode: 0o600 })
|
||||
yield* fs.rename(temp, options.file)
|
||||
const currentID = fs.readFileString(options.file).pipe(
|
||||
}
|
||||
const encoded = yield* encodeInfo(info)
|
||||
const publish = fs.writeFileString(temp, encoded, { mode: 0o600 }).pipe(Effect.andThen(fs.rename(temp, file)))
|
||||
yield* publish
|
||||
const current = fs.readFileString(file).pipe(
|
||||
Effect.flatMap(decodeInfo),
|
||||
Effect.map((info) => info.id),
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
)
|
||||
yield* currentID.pipe(
|
||||
Effect.flatMap((current) =>
|
||||
current === id
|
||||
? Effect.void
|
||||
: Effect.try({ try: () => process.kill(process.pid, "SIGTERM"), catch: (cause) => cause }).pipe(Effect.ignore),
|
||||
),
|
||||
Effect.repeat(Schedule.spaced("10 seconds")),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
const assertRegistration = Effect.gen(function* () {
|
||||
const found = yield* current
|
||||
if (
|
||||
found !== undefined &&
|
||||
found.id === info.id &&
|
||||
found.version === info.version &&
|
||||
found.url === info.url &&
|
||||
found.pid === info.pid &&
|
||||
found.password === info.password
|
||||
)
|
||||
return
|
||||
yield* publish
|
||||
})
|
||||
yield* Effect.addFinalizer(() =>
|
||||
currentID.pipe(
|
||||
Effect.flatMap((current) => (current === id ? fs.remove(options.file) : Effect.void)),
|
||||
current.pipe(
|
||||
Effect.flatMap((current) => (current?.id === id ? fs.remove(file) : Effect.void)),
|
||||
Effect.ignore,
|
||||
),
|
||||
)
|
||||
yield* assertRegistration.pipe(
|
||||
Effect.catchCause((cause) => Effect.logWarning("failed to reassert service registration", { cause })),
|
||||
Effect.repeat(Schedule.spaced("5 seconds")),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
})
|
||||
|
||||
function waitForStdinClose() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,61 @@ test("local channel stores service config with the local service filename", asyn
|
|||
}
|
||||
})
|
||||
|
||||
test("service filenames isolate installation channels", () => {
|
||||
expect(ServiceConfig.filename("latest")).toBe("service.json")
|
||||
expect(ServiceConfig.filename("local")).toBe("service-local.json")
|
||||
expect(ServiceConfig.filename("preview-a")).not.toBe(ServiceConfig.filename("preview-b"))
|
||||
expect(ServiceConfig.filename("preview-a")).not.toBe(ServiceConfig.filename("latest"))
|
||||
expect(ServiceConfig.versionBelongsToChannel("0.0.0-preview-a-1234", "preview-a")).toBe(true)
|
||||
expect(ServiceConfig.versionBelongsToChannel("0.0.0-preview-a-1234.2", "preview-a")).toBe(true)
|
||||
expect(ServiceConfig.versionBelongsToChannel("0.0.0-preview-a-other-1234", "preview-a")).toBe(false)
|
||||
expect(ServiceConfig.versionBelongsToChannel("1.2.3", "preview-a")).toBe(false)
|
||||
})
|
||||
|
||||
test("preview registration migration never moves stable discovery", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-migration-"))
|
||||
const legacy = path.join(root, "service.json")
|
||||
const target = path.join(root, ServiceConfig.filename("preview-a"))
|
||||
try {
|
||||
await fs.writeFile(
|
||||
legacy,
|
||||
JSON.stringify({ id: "old-preview", version: "0.0.0-preview-a-1234", url: "http://localhost:4096", pid: 1 }),
|
||||
)
|
||||
await Effect.runPromise(
|
||||
ServiceConfig.migrateRegistration(legacy, target, "preview-a", "0.0.0-preview-a-5678").pipe(
|
||||
Effect.provide(NodeFileSystem.layer),
|
||||
),
|
||||
)
|
||||
expect(await Bun.file(legacy).exists()).toBe(true)
|
||||
expect(await Bun.file(target).json()).toMatchObject({ id: "old-preview" })
|
||||
|
||||
await fs.rm(target)
|
||||
await fs.writeFile(legacy, JSON.stringify({ id: "stable", version: "1.2.3", url: "http://localhost:4096", pid: 1 }))
|
||||
await Effect.runPromise(
|
||||
ServiceConfig.migrateRegistration(legacy, target, "preview-a", "0.0.0-preview-a-5678").pipe(
|
||||
Effect.provide(NodeFileSystem.layer),
|
||||
),
|
||||
)
|
||||
expect(await Bun.file(legacy).exists()).toBe(true)
|
||||
expect(await Bun.file(target).exists()).toBe(false)
|
||||
|
||||
await fs.writeFile(
|
||||
legacy,
|
||||
JSON.stringify({ id: "old-preview", version: "0.0.0-preview-a-1234", url: "http://localhost:4096", pid: 1 }),
|
||||
)
|
||||
await fs.writeFile(target, JSON.stringify({ id: "current-preview" }))
|
||||
await Effect.runPromise(
|
||||
ServiceConfig.migrateRegistration(legacy, target, "preview-a", "0.0.0-preview-a-5678").pipe(
|
||||
Effect.provide(NodeFileSystem.layer),
|
||||
),
|
||||
)
|
||||
expect(await Bun.file(legacy).exists()).toBe(true)
|
||||
expect(await Bun.file(target).json()).toMatchObject({ id: "current-preview" })
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("concurrent service processes elect one server", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-election-"))
|
||||
const database = path.join(root, "opencode.db")
|
||||
|
|
@ -74,18 +129,55 @@ test("concurrent service processes elect one server", async () => {
|
|||
}),
|
||||
)
|
||||
const command = [process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"]
|
||||
const first = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
|
||||
const second = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
|
||||
const registration = path.join(root, "state", "opencode", "service-local.json")
|
||||
const processes = Array.from({ length: 10 }, () => Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" }))
|
||||
|
||||
try {
|
||||
const registration = path.join(root, "state", "opencode", "service-local.json")
|
||||
const info = await waitForInfo(registration)
|
||||
const winner = info.pid === first.pid ? first : second
|
||||
const loser = info.pid === first.pid ? second : first
|
||||
const exited = await Promise.race([loser.exited.then(() => true), Bun.sleep(10_000).then(() => false)])
|
||||
const winner = processes.find((process) => process.pid === info.pid)
|
||||
const losers = processes.filter((process) => process.pid !== info.pid)
|
||||
const exited = await Promise.all(
|
||||
losers.map((process) => Promise.race([process.exited.then(() => true), Bun.sleep(10_000).then(() => false)])),
|
||||
)
|
||||
|
||||
expect(exited).toBe(true)
|
||||
expect(winner.exitCode).toBe(null)
|
||||
expect(exited).toEqual(losers.map(() => true))
|
||||
expect(winner?.exitCode).toBe(null)
|
||||
expect(
|
||||
await fetch(new URL("/api/health", info.url), {
|
||||
headers: { authorization: "Basic " + btoa(`opencode:${info.password}`) },
|
||||
}).then((response) => response.json()),
|
||||
).toMatchObject({
|
||||
healthy: true,
|
||||
pid: info.pid,
|
||||
instanceID: info.id,
|
||||
status: { type: "ready" },
|
||||
})
|
||||
const blockedTemp = registration + "." + info.id + ".tmp"
|
||||
await fs.mkdir(blockedTemp)
|
||||
await fs.rm(registration)
|
||||
await Bun.sleep(6_000)
|
||||
expect(await Bun.file(registration).exists()).toBe(false)
|
||||
await fs.rm(blockedTemp, { recursive: true })
|
||||
const restored = await waitForInfo(registration)
|
||||
expect(restored.id).toBe(info.id)
|
||||
expect(restored.pid).toBe(info.pid)
|
||||
await fs.writeFile(registration, "not-json")
|
||||
const repaired = await waitForInfo(registration)
|
||||
expect(repaired.id).toBe(info.id)
|
||||
expect(repaired.pid).toBe(info.pid)
|
||||
|
||||
const contender = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
|
||||
try {
|
||||
const contenderExited = await Promise.race([
|
||||
contender.exited.then(() => true),
|
||||
Bun.sleep(10_000).then(() => false),
|
||||
])
|
||||
expect(contenderExited).toBe(true)
|
||||
expect((await waitForInfo(registration)).id).toBe(info.id)
|
||||
} finally {
|
||||
contender.kill("SIGTERM")
|
||||
await contender.exited
|
||||
}
|
||||
expect(
|
||||
await withDatabase(
|
||||
database,
|
||||
|
|
@ -100,13 +192,70 @@ 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)),
|
||||
)
|
||||
await winner?.exited
|
||||
} finally {
|
||||
first.kill("SIGTERM")
|
||||
second.kill("SIGTERM")
|
||||
await Promise.all([first.exited, second.exited])
|
||||
processes.forEach((process) => process.kill("SIGTERM"))
|
||||
await Promise.all(processes.map((process) => process.exited))
|
||||
try {
|
||||
expect(await Bun.file(registration).exists()).toBe(false)
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
test("a failed service stays registered and owns the lock until stopped", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-failed-"))
|
||||
const database = path.join(root, "database")
|
||||
await fs.mkdir(database)
|
||||
const env = {
|
||||
...process.env,
|
||||
HOME: root,
|
||||
OPENCODE_DB: database,
|
||||
OPENCODE_TEST_HOME: root,
|
||||
XDG_CACHE_HOME: path.join(root, "cache"),
|
||||
XDG_CONFIG_HOME: path.join(root, "config"),
|
||||
XDG_DATA_HOME: path.join(root, "data"),
|
||||
XDG_STATE_HOME: path.join(root, "state"),
|
||||
}
|
||||
const command = [process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"]
|
||||
const registration = path.join(root, "state", "opencode", "service-local.json")
|
||||
const owner = Bun.spawn(command, { env, stderr: "pipe", stdout: "ignore" })
|
||||
|
||||
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" })
|
||||
expect(await Promise.race([contender.exited.then(() => true), Bun.sleep(10_000).then(() => false)])).toBe(true)
|
||||
expect((await waitForInfo(registration)).id).toBe(info.id)
|
||||
expect(owner.exitCode).toBe(null)
|
||||
|
||||
await Effect.runPromise(Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)))
|
||||
await owner.exited
|
||||
expect(await Bun.file(registration).exists()).toBe(false)
|
||||
} finally {
|
||||
owner.kill("SIGTERM")
|
||||
await owner.exited
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
}, 30_000)
|
||||
|
||||
function withDatabase<A, E>(file: string, effect: Effect.Effect<A, E, Database.Service>) {
|
||||
return Effect.runPromise(effect.pipe(Effect.provide(Database.layerFromPath(file)), Effect.scoped))
|
||||
|
|
@ -143,7 +292,7 @@ function waitForExecutionStart(file: string, sessionID: SessionV2.ID) {
|
|||
}
|
||||
|
||||
async function waitForInfo(file: string) {
|
||||
for (let attempt = 0; attempt < 200; attempt++) {
|
||||
for (let attempt = 0; attempt < 400; attempt++) {
|
||||
const value = await Bun.file(file)
|
||||
.json()
|
||||
.catch(() => undefined)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue