refactor(client): rename service start to ensure

This commit is contained in:
Dax Raad 2026-07-15 13:34:32 -04:00
commit ec8ee60e0b
17 changed files with 79 additions and 64 deletions

View file

@ -6,7 +6,7 @@ import { join } from "node:path"
import type {
DiscoverOptions,
Endpoint,
StartOptions,
EnsureOptions,
StopOptions,
} from "../service.js"
@ -28,7 +28,7 @@ type Contender = {
}
// 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 ensure() is the caller's policy.
/** Discover a healthy, compatible local service without starting one. */
export const discover = Effect.fn("service.discover")(function* (options: DiscoverOptions = {}) {
return (yield* discoverLocal(options))?.endpoint
@ -45,7 +45,7 @@ const discoverLocal = Effect.fnUntraced(function* (options: DiscoverOptions) {
// version-mismatched one, and otherwise spawns small contenders until a server
// becomes discoverable. A contender is never killed merely for slow startup.
/** Ensure a healthy, compatible local service is running. */
export const start = Effect.fn("service.start")(function* (options: StartOptions = {}) {
export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOptions = {}) {
const contenders = new Set<Contender>()
let announced = false
let lastSpawn = 0
@ -219,7 +219,7 @@ const find = Effect.fnUntraced(function* (options: { readonly file?: string }) {
return (yield* registered(options.file, true)).service
})
// 50ms cadence bounded at ~5s, shared by stop escalation and each start
// 50ms cadence bounded at ~5s, shared by stop escalation and each ensure
// discovery window.
const poll = Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)))
@ -280,4 +280,4 @@ const requestStop = Effect.fnUntraced(function* (service: LocalService) {
})
/** Effect-based local service lifecycle operations. */
export const Service = { discover, start, stop, headers, Info }
export const Service = { discover, ensure, stop, headers, Info }

View file

@ -6,7 +6,7 @@ import type {
DiscoverOptions,
Endpoint,
Info,
StartOptions,
EnsureOptions,
StopOptions,
} from "../service.js"
import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js"
@ -37,7 +37,7 @@ async function discoverLocal(options: DiscoverOptions) {
}
/** Ensure a healthy, compatible local service is running. */
export async function start(options: StartOptions = {}): Promise<Endpoint> {
export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
const contenders = new Set<Contender>()
let announced = false
let lastSpawn = 0
@ -249,4 +249,4 @@ function delay(milliseconds: number) {
}
/** Promise-based local service lifecycle operations. */
export const Service = { discover, start, stop, headers }
export const Service = { discover, ensure, stop, headers }

View file

@ -21,15 +21,15 @@ export type DiscoverOptions = {
readonly version?: string
}
/** Reason a new service process must be started. */
export type StartReason = "missing" | "version-mismatch"
/** Reason ensuring the service requires a new process. */
export type EnsureReason = "missing" | "version-mismatch"
/** Options used to ensure the local OpenCode service is running. */
export type StartOptions = DiscoverOptions & {
export type EnsureOptions = DiscoverOptions & {
/** Service command and arguments. Defaults to `opencode serve --service`. */
readonly command?: ReadonlyArray<string>
/** Called once before spawning a new service process. */
readonly onStart?: (reason: StartReason, previousVersion?: string) => void
readonly onStart?: (reason: EnsureReason, previousVersion?: string) => void
}
/** Options used to stop the local OpenCode service. */

View file

@ -2,7 +2,7 @@ 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"
import { Service, type EnsureReason } from "../src/promise/service"
const fixture = join(import.meta.dir, "fixture/service.ts")
const processes: Bun.Subprocess[] = []
@ -23,12 +23,12 @@ test("discovers a registered service", async () => {
expect(await Service.discover({ file: registration, version: "other" })).toBeUndefined()
})
test("starts a missing service with native promises", async () => {
test("ensures a missing service with native promises", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const starts: StartReason[] = []
const starts: EnsureReason[] = []
const endpoint = await Service.start({
const endpoint = await Service.ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "coordinated"],
@ -47,7 +47,7 @@ test("starts a missing service with native promises", async () => {
test("reports a failed registered service", async () => {
const registration = await setup("failed-owner")
await expect(Service.start({ file: registration, version: "test", command: [] })).rejects.toThrow(
await expect(Service.ensure({ file: registration, version: "test", command: [] })).rejects.toThrow(
"Background service failed to start",
)
})

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, type StartReason } from "../src/effect/service"
import { Service, type EnsureReason } from "../src/effect/service"
const fixture = join(import.meta.dir, "fixture/service.ts")
const processes: Bun.Subprocess[] = []
@ -23,9 +23,9 @@ test("a concurrent same-version start cannot invalidate a resolved endpoint", as
await waitForFile(registration)
const original = await Bun.file(registration).json()
const starts: StartReason[] = []
const starts: EnsureReason[] = []
const first = run(
Service.start({
Service.ensure({
file: registration,
version: "test",
command: [],
@ -34,7 +34,7 @@ test("a concurrent same-version start cannot invalidate a resolved endpoint", as
)
await waitForFile(registration + ".first-request")
const resolved = await run(Service.start({ file: registration, version: "test" }))
const resolved = await run(Service.ensure({ file: registration, version: "test" }))
expect(resolved.url).toBe(original.url)
await writeFile(registration + ".release", "")
@ -50,7 +50,7 @@ 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 result = run(Service.start({ file: registration, version: "test", command: [] }))
const result = run(Service.ensure({ file: registration, version: "test", command: [] }))
await Bun.sleep(500)
expect(process.exitCode).toBe(null)
@ -64,7 +64,7 @@ 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.toThrow(
await expect(run(Service.ensure({ file: registration, version: "test", command: [] }))).rejects.toThrow(
"Background service failed to start",
)
expect(process.exitCode).toBe(null)
@ -90,7 +90,7 @@ test("does not spawn contenders while an incompatible service rejects replacemen
await waitForFile(registration)
const controller = new AbortController()
const starting = Effect.runPromise(
Service.start({
Service.ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, contender, "record-start"],
@ -113,8 +113,8 @@ test("a legacy health response is still replaced", async () => {
const existing = spawn(registration, "legacy")
await waitForFile(registration)
const starts: StartReason[] = []
const result = run(Service.start({ file: registration, command: [], onStart: (reason) => starts.push(reason) }))
const starts: EnsureReason[] = []
const result = run(Service.ensure({ file: registration, command: [], onStart: (reason) => starts.push(reason) }))
await expect(result).rejects.toThrow("Missing service command")
expect(starts).toEqual(["version-mismatch"])
@ -125,7 +125,7 @@ test("waits for a slow winner while bounding lock probes", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const endpoint = await run(
Service.start({
Service.ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "coordinated"],
@ -146,7 +146,7 @@ test("reports a contender that fails to start", async () => {
const registration = join(directory, "service.json")
await expect(
run(
Service.start({
Service.ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "failed"],
@ -160,7 +160,7 @@ test("reports a contender terminated by a signal", async () => {
const registration = join(directory, "service.json")
await expect(
run(
Service.start({
Service.ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "signal"],
@ -174,7 +174,7 @@ test("reports a slow contender that eventually fails", async () => {
const registration = join(directory, "service.json")
await expect(
run(
Service.start({
Service.ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "delayed-failed", "8000"],
@ -187,7 +187,7 @@ test("replaces an incompatible owner that appears during startup", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const starting = run(
Service.start({
Service.ensure({
file: registration,
version: "test",
command: [process.execPath, fixture, registration, "delayed", "8000"],