refactor(client): simplify local service lifecycle

This commit is contained in:
Dax Raad 2026-07-15 13:16:49 -04:00
commit 75bc611ef1
51 changed files with 635 additions and 534 deletions

View file

@ -15,7 +15,7 @@ import {
const synced = { type: "log.synced" as const, aggregateID: "ses_test", seq: Event.Seq.make(1) }
test("health.get treats an old server as ready", async () => {
test("health.get decodes the readiness response", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ healthy: true, version: "old", pid: 123 }))),
)
@ -24,7 +24,7 @@ test("health.get treats an old server as ready", async () => {
return yield* client.health.get()
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(result.status).toEqual({ type: "ready" })
expect(result).toEqual({ healthy: true, version: "old", pid: 123 })
})
test("session.get returns the decoded Effect projection", async () => {

View file

@ -48,23 +48,11 @@ const server = Bun.serve({
}
if (mode === "legacy") return Response.json({ healthy: true })
if (mode === "starting" && !(await Bun.file(registration + ".release").exists()))
return Response.json(
{ healthy: true, version, pid: process.pid, instanceID: id, status: { type: "starting" } },
{ status: 503 },
)
return Response.json({ healthy: true, version, pid: process.pid }, { status: 503 })
if (mode === "failed-owner")
return Response.json(
{
healthy: true,
version,
pid: process.pid,
instanceID: id,
status: { type: "failed", message: "Could not open the database.", action: "Check the service logs." },
},
{ status: 503 },
)
return Response.json({ healthy: true, version, pid: process.pid }, { status: 500 })
if (mode === "starting" || mode === "graceful" || mode === "reject-stop")
return Response.json({ healthy: true, version, pid: process.pid, instanceID: id, status: { type: "ready" } })
return Response.json({ healthy: true, version, pid: process.pid })
return Response.json({ healthy: true, version, pid: process.pid })
},
})

View file

@ -20,15 +20,28 @@ describe("public import boundaries", () => {
expect(within(root, core)).toEqual([])
expect(within(root, server)).toEqual([])
// The effect entry includes local service lifecycle (node spawn/fs), so it
// bundles for bun; the boundary assertions below are what matter.
const network = await bundleInputs("@opencode-ai/client/effect", "bun")
const network = await bundleInputs("@opencode-ai/client/effect", "browser")
expect(within(network, effect).length).toBeGreaterThan(0)
expect(within(network, schema).length).toBeGreaterThan(0)
expect(within(network, protocol).length).toBeGreaterThan(0)
expect(within(network, core)).toEqual([])
expect(within(network, server)).toEqual([])
const promiseService = await bundleInputs("@opencode-ai/client/service", "bun")
expect(within(promiseService, effect)).toEqual([])
expect(within(promiseService, schema)).toEqual([])
expect(within(promiseService, protocol)).toEqual([])
expect(within(promiseService, core)).toEqual([])
expect(within(promiseService, server)).toEqual([])
const effectService = await bundleInputs("@opencode-ai/client/effect/service", "bun")
expect(within(effectService, effect).length).toBeGreaterThan(0)
expect(within(effectService, protocol).length).toBeGreaterThan(0)
expect(within(effectService, core)).toEqual([])
expect(within(effectService, server)).toEqual([])
})
})

View file

@ -0,0 +1,96 @@
import { afterEach, expect, test } from "bun:test"
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { Service, type StartReason } from "../src/promise/service"
const fixture = join(import.meta.dir, "fixture/service.ts")
const processes: Bun.Subprocess[] = []
const directories: string[] = []
afterEach(async () => {
processes.forEach((process) => process.kill("SIGTERM"))
await Promise.all(processes.splice(0).map((process) => process.exited))
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))
})
test("discovers a registered service", async () => {
const registration = await setup("graceful")
expect(await Service.discover({ file: registration, version: "test" })).toEqual(
expect.objectContaining({ url: expect.stringMatching(/^http:\/\//) }),
)
expect(await Service.discover({ file: registration, version: "other" })).toBeUndefined()
})
test("starts a missing service with native promises", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const starts: StartReason[] = []
const endpoint = await Service.start({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "coordinated"],
onStart: (reason) => starts.push(reason),
})
const info = await Bun.file(registration).json()
try {
expect(endpoint.url).toBe(info.url)
expect(starts).toEqual(["missing"])
} finally {
process.kill(info.pid, "SIGTERM")
await waitForExit(info.pid)
}
}, 15_000)
test("reports a failed registered service", async () => {
const registration = await setup("failed-owner")
await expect(Service.start({ file: registration, version: "test", command: [] })).rejects.toThrow(
"Background service failed to start",
)
})
test("requests graceful stop of the exact service instance", async () => {
const registration = await setup("graceful")
const info = await Bun.file(registration).json()
await Service.stop({ file: registration })
expect(await Bun.file(registration + ".stop").json()).toEqual({ instanceID: info.id })
})
async function setup(mode: string) {
const directory = await temp()
const registration = join(directory, "service.json")
processes.push(Bun.spawn([process.execPath, fixture, registration, mode], { stdout: "ignore", stderr: "inherit" }))
await waitForFile(registration)
return registration
}
async function temp() {
const directory = await mkdtemp(join(tmpdir(), "opencode-promise-service-"))
directories.push(directory)
return directory
}
async function waitForFile(file: string) {
for (let attempt = 0; attempt < 600; attempt++) {
if (await Bun.file(file).exists()) return
await Bun.sleep(5)
}
throw new Error(`Timed out waiting for ${file}`)
}
async function waitForExit(pid: number) {
for (let attempt = 0; attempt < 600; attempt++) {
try {
process.kill(pid, 0)
} catch {
return
}
await Bun.sleep(5)
}
throw new Error(`Timed out waiting for process ${pid}`)
}

View file

@ -71,10 +71,10 @@ test("health.stop sends exact replacement identity", async () => {
},
})
expect(await client.health.stop({ instanceID: "instance", targetVersion: "next" })).toEqual({ accepted: true })
expect(await client.health.stop({ instanceID: "instance" })).toEqual({ accepted: true })
expect(request?.method).toBe("POST")
expect(request?.url).toBe("http://localhost:3000/api/service/stop")
expect(await request?.json()).toEqual({ instanceID: "instance", targetVersion: "next" })
expect(await request?.json()).toEqual({ instanceID: "instance" })
})
test("MCP resource catalog uses the public HTTP contract", async () => {

View file

@ -4,7 +4,7 @@ import { Effect } from "effect"
import { mkdtemp, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { Service } from "../src/effect/index"
import { Service, type StartReason } from "../src/effect/service"
const fixture = join(import.meta.dir, "fixture/service.ts")
const processes: Bun.Subprocess[] = []
@ -23,7 +23,7 @@ test("a concurrent same-version start cannot invalidate a resolved endpoint", as
await waitForFile(registration)
const original = await Bun.file(registration).json()
const starts: Service.StartReason[] = []
const starts: StartReason[] = []
const first = run(
Service.start({
file: registration,
@ -43,7 +43,6 @@ test("a concurrent same-version start cannot invalidate a resolved endpoint", as
expect(starts).toEqual([])
expect(await Bun.file(registration).json()).toEqual(original)
expect(await health(resolved.url)).toEqual({ healthy: true, version: "test", pid: original.pid })
expect(await run(Service.status({ file: registration }))).toEqual({ type: "ready", version: "test" })
})
test("waits for a registered service to finish starting", async () => {
@ -51,15 +50,10 @@ test("waits for a registered service to finish starting", async () => {
const registration = join(directory, "service.json")
const process = spawn(registration, "starting")
await waitForFile(registration)
const statuses: Service.Status[] = []
const result = run(
Service.start({ file: registration, version: "test", command: [], onStatus: (status) => statuses.push(status) }),
)
const result = run(Service.start({ file: registration, version: "test", command: [] }))
await Bun.sleep(500)
expect(process.exitCode).toBe(null)
expect(statuses).toContainEqual({ type: "starting", version: "test" })
expect(statuses.filter((status) => status.type === "starting")).toHaveLength(1)
await writeFile(registration + ".release", "")
expect((await result).url).toBe((await Bun.file(registration).json()).url)
})
@ -70,23 +64,22 @@ test("reports a failed registered service without spawning", async () => {
const process = spawn(registration, "failed-owner")
await waitForFile(registration)
await expect(run(Service.start({ file: registration, version: "test", command: [] }))).rejects.toMatchObject({
message: "Could not open the database.",
action: "Check the service logs.",
})
await expect(run(Service.start({ file: registration, version: "test", command: [] }))).rejects.toThrow(
"Background service failed to start",
)
expect(process.exitCode).toBe(null)
})
test("requests graceful replacement of the exact service instance", async () => {
test("requests graceful stop of the exact service instance", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const process = spawn(registration, "graceful")
await waitForFile(registration)
const info = await Bun.file(registration).json()
await run(Service.stop({ file: registration }, { targetVersion: "next" }))
await run(Service.stop({ file: registration }))
await process.exited
expect(await Bun.file(registration + ".stop").json()).toEqual({ instanceID: info.id, targetVersion: "next" })
expect(await Bun.file(registration + ".stop").json()).toEqual({ instanceID: info.id })
})
test("does not spawn contenders while an incompatible service rejects replacement", async () => {
@ -120,7 +113,7 @@ test("a legacy health response is still replaced", async () => {
const existing = spawn(registration, "legacy")
await waitForFile(registration)
const starts: Service.StartReason[] = []
const starts: StartReason[] = []
const result = run(Service.start({ file: registration, command: [], onStart: (reason) => starts.push(reason) }))
await expect(result).rejects.toThrow("Missing service command")