Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/cli/bin/opencode2.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ const path = require("path")
const os = require("os")

const forwardedSignals = ["SIGINT", "SIGTERM", "SIGHUP"]
const restartExitCode = 75

function run(target) {
function run(target, restarted = false) {
const child = childProcess.spawn(target, process.argv.slice(2), { stdio: "inherit" })
child.on("error", (error) => {
console.error(error.message)
Expand All @@ -25,6 +26,7 @@ function run(target) {
child.on("exit", (code, signal) => {
for (const forwardedSignal of forwardedSignals) process.removeListener(forwardedSignal, forwarders[forwardedSignal])
if (signal) return process.kill(process.pid, signal)
if (code === restartExitCode && !restarted) return run(target, true)
process.exit(typeof code === "number" ? code : 0)
})
}
Expand Down
37 changes: 31 additions & 6 deletions packages/cli/src/commands/handlers/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { ServiceConfig } from "../../services/service-config"
import { Standalone } from "../../services/standalone"
import { Updater } from "../../services/updater"

const restartExitCode = 75

export default Runtime.handler(Commands, (input) =>
Effect.gen(function* () {
const directory = Option.getOrUndefined(input.directory)
Expand All @@ -22,9 +24,7 @@ export default Runtime.handler(Commands, (input) =>
const password = yield* Env.password
const explicit = {
url: server,
headers: password
? { authorization: "Basic " + btoa("opencode:" + Redacted.value(password)) }
: undefined,
headers: password ? { authorization: "Basic " + btoa("opencode:" + Redacted.value(password)) } : undefined,
} satisfies Service.Transport
// Fail loudly before entering the TUI: an explicit server that is
// unreachable or rejects auth should not present as reconnect churn.
Expand Down Expand Up @@ -60,7 +60,7 @@ export default Runtime.handler(Commands, (input) =>
Effect.gen(function* () {
const found = yield* Service.discover(serviceOptions)
return found ?? (yield* Service.start(serviceOptions))
}).pipe(Effect.provide(NodeFileSystem.layer)),
}).pipe(Effect.catchIf(versionMismatch, restart), Effect.provide(NodeFileSystem.layer)),
)
: () => Promise.resolve(transport)
// Restart the managed service in place; start() resolves once the
Expand All @@ -76,11 +76,36 @@ export default Runtime.handler(Commands, (input) =>
}).pipe(Effect.provide(NodeFileSystem.layer)),
)
: undefined
yield* runTui(
return yield* runTui(
transport,
{ continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
discover,
reload,
() => {
process.exitCode = restartExitCode
},
)
}),
}).pipe(
Effect.catchIf(versionMismatch, (error) =>
restart(error).pipe(
Effect.tap(() =>
Effect.sync(() => {
process.exitCode = restartExitCode
}),
),
Effect.asVoid,
),
),
),
)

function restart(error: Service.VersionMismatchError) {
return Effect.logInfo("restarting stale client", {
clientVersion: error.clientVersion,
serverVersion: error.serverVersion,
}).pipe(Effect.as({ restart: true as const }))
}

function versionMismatch(error: unknown): error is Service.VersionMismatchError {
return error instanceof Service.VersionMismatchError
}
2 changes: 1 addition & 1 deletion packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,6 @@ Effect.logInfo("cli starting", {
Effect.provide(LoggingLayer),
Effect.provide(NodeServices.layer),
Effect.scoped,
Effect.tap(() => Effect.sync(() => process.exit(0))),
Effect.tap(() => Effect.sync(() => process.exit(process.exitCode ?? 0))),
NodeRuntime.runMain,
)
12 changes: 12 additions & 0 deletions packages/cli/src/services/service-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Service } from "@opencode-ai/client/effect"
import { Effect, FileSystem, Schema } from "effect"
import { randomBytes } from "crypto"
import path from "path"
import semver from "semver"

// The CLI's service configuration file, plus the Service.Options binding that
// points the client package's service operations at this CLI: which
Expand Down Expand Up @@ -45,10 +46,21 @@ export const options = Effect.fnUntraced(function* () {
return {
file,
version: InstallationVersion,
canReplace: (version: string | undefined) => canReplaceVersion(version, InstallationVersion),
command: [process.execPath, ...(entrypoint ? [entrypoint] : []), "serve", "--service"],
}
})

export function canReplaceVersion(serverVersion: string | undefined, clientVersion: string) {
if (serverVersion === undefined) return true
// Preview versions end in `<channel>-<build>[.<attempt>]`. Convert the build
// to a numeric semver identifier so next-15000 sorts after next-9999.
const server = serverVersion.replace(/-(\d+)(?=(?:\.\d+)?$)/, ".$1")
const client = clientVersion.replace(/-(\d+)(?=(?:\.\d+)?$)/, ".$1")
if (!semver.valid(server) || !semver.valid(client)) return true
return semver.lt(server, client)
}

export const read = Effect.fn("cli.service-config.read")(function* () {
const { fs, configFile } = yield* env
return yield* fs.readFileString(configFile).pipe(
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ import type { Args } from "@opencode-ai/tui/context/args"
export function runTui(
transport: Service.Transport,
args: Args,
discover?: () => Promise<Service.Transport>,
discover?: () => Promise<Service.Transport | { restart: true }>,
reload?: () => Promise<void>,
restart?: () => void,
) {
const config = TuiConfig.resolve({}, { terminalSuspend: false })
let disposeSlots: (() => void) | undefined
Expand All @@ -32,12 +33,14 @@ export function runTui(
discover: discover
? async () => {
const next = await discover()
if ("restart" in next) return next
return {
client: createOpencodeClient({ baseUrl: next.url, headers: next.headers, directory }),
api: OpenCode.make({ baseUrl: next.url, headers: next.headers }),
}
}
: undefined,
restart,
reload,
args,
config,
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/test/fixture/restart-child.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
const stateFile = process.argv[2]
const exit = Number(process.argv[3])
const count = (await Bun.file(stateFile).exists()) ? Number(await Bun.file(stateFile).text()) : 0

await Bun.write(stateFile, String(count + 1))
process.exit(exit || (count === 0 ? 75 : 0))
43 changes: 43 additions & 0 deletions packages/cli/test/launcher.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { expect, test } from "bun:test"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"

const launcher = path.join(import.meta.dir, "../bin/opencode2.cjs")
const fixture = path.join(import.meta.dir, "fixture/restart-child.ts")

test("restarts the installed binary once with the original arguments", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-launcher-"))
const state = path.join(root, "count")

try {
const child = Bun.spawn([process.execPath, launcher, fixture, state, "0"], {
env: { ...process.env, OPENCODE_BIN_PATH: process.execPath },
stdout: "pipe",
stderr: "pipe",
})

expect(await child.exited).toBe(0)
expect(await Bun.file(state).text()).toBe("2")
} finally {
await fs.rm(root, { recursive: true, force: true })
}
})

test("bounds automatic restarts", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-launcher-"))
const state = path.join(root, "count")

try {
const child = Bun.spawn([process.execPath, launcher, fixture, state, "75"], {
env: { ...process.env, OPENCODE_BIN_PATH: process.execPath },
stdout: "pipe",
stderr: "pipe",
})

expect(await child.exited).toBe(75)
expect(await Bun.file(state).text()).toBe("2")
} finally {
await fs.rm(root, { recursive: true, force: true })
}
})
12 changes: 12 additions & 0 deletions packages/cli/test/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ import os from "node:os"
import path from "node:path"
import { ServiceConfig } from "../src/services/service-config"

test("only replaces older semantic versions", () => {
expect(ServiceConfig.canReplaceVersion("1.0.0", "2.0.0")).toBe(true)
expect(ServiceConfig.canReplaceVersion("2.0.0", "1.0.0")).toBe(false)
expect(ServiceConfig.canReplaceVersion("1.0.0", "1.0.0")).toBe(false)
expect(ServiceConfig.canReplaceVersion("0.0.0-next-9999", "0.0.0-next-15000")).toBe(true)
expect(ServiceConfig.canReplaceVersion("0.0.0-next-15000", "0.0.0-next-9999")).toBe(false)
expect(ServiceConfig.canReplaceVersion("0.0.0-next-15000.1", "0.0.0-next-15000.2")).toBe(true)
expect(ServiceConfig.canReplaceVersion("0.0.0-next-15000.10", "0.0.0-next-15000.9")).toBe(false)
expect(ServiceConfig.canReplaceVersion("0.0.0-next-15000.10", "0.0.0-next-15000.10")).toBe(false)
expect(ServiceConfig.canReplaceVersion(undefined, "1.0.0")).toBe(true)
})

test("local channel stores service config with the local service filename", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-"))
try {
Expand Down
35 changes: 31 additions & 4 deletions packages/client/src/effect/service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Effect, FileSystem, Option, Schedule, Schema } from "effect"
import { Data, Effect, FileSystem, Option, Schedule, Schema } from "effect"
import { spawn } from "node:child_process"
import { homedir } from "node:os"
import { join } from "node:path"
Expand All @@ -23,6 +23,9 @@ export type Options = {
// When set, discovery only returns a server reporting this exact version,
// and start() replaces a healthy server whose version differs.
readonly version?: string
// Decides whether start() may terminate a healthy version-mismatched server.
// Defaults to true for callers that do not need directional version handling.
readonly canReplace?: (version: string | undefined) => boolean
// Argv used to spawn the service. Defaults to ["opencode", "serve",
// "--service"] resolved from PATH.
readonly command?: ReadonlyArray<string>
Expand All @@ -44,7 +47,11 @@ export const start = Effect.fn("service.start")(function* (options: Options = {}
const compatible = yield* discover(options)
if (compatible !== undefined) return compatible
const mismatched = yield* find(options)
if (mismatched !== undefined) yield* kill(mismatched.info, options).pipe(Effect.ignore)
if (mismatched !== undefined) {
const error = replacementError(mismatched.info, options)
if (error) return yield* Effect.fail(error)
yield* kill(mismatched.info, options).pipe(Effect.ignore)
}

const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
if (command === undefined) return yield* Effect.fail(new Error("Missing service command"))
Expand All @@ -67,8 +74,12 @@ export const start = Effect.fn("service.start")(function* (options: Options = {}
export const stop = Effect.fn("service.stop")(function* (options: Options = {}) {
const fs = yield* FileSystem.FileSystem
const existing = yield* find(options)
if (existing !== undefined) yield* kill(existing.info, options)
yield* fs.remove(options.file ?? fallback()).pipe(Effect.ignore)
if (existing !== undefined) {
const error = replacementError(existing.info, options)
if (error) return yield* Effect.fail(error)
yield* kill(existing.info, options)
}
return yield* fs.remove(options.file ?? fallback()).pipe(Effect.ignore)
})

function fallback() {
Expand All @@ -89,6 +100,22 @@ export const Info = Schema.Struct({
})
export type Info = typeof Info.Type

export class VersionMismatchError extends Data.TaggedError("ServiceVersionMismatchError")<{
readonly clientVersion: string | undefined
readonly serverVersion: string | undefined
readonly message: string
}> {}

function replacementError(info: Info, options: Options): VersionMismatchError | undefined {
if (options.version === undefined || info.version === options.version || options.canReplace?.(info.version) !== false)
return undefined
return new VersionMismatchError({
clientVersion: options.version,
serverVersion: info.version,
message: `Client version ${options.version} cannot replace server version ${info.version ?? "unknown"}`,
})
}

const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))

// A missing or corrupt file means no valid info; callers treat both
Expand Down
49 changes: 49 additions & 0 deletions packages/client/test/service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { NodeFileSystem } from "@effect/platform-node"
import { afterEach, expect, test } from "bun:test"
import { Effect } from "effect"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { Service } from "../src/effect/index"

const cleanup: Array<() => void | Promise<void>> = []

afterEach(async () => {
await Promise.all(cleanup.splice(0).map(async (run) => await run()))
})

test("does not start or stop a healthy service rejected by the version policy", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-client-service-"))
const child = Bun.spawn([process.execPath, "-e", "await new Promise(() => {})"])
const server = Bun.serve({ port: 0, fetch: () => Response.json({ healthy: true }) })
const file = path.join(root, "service.json")
cleanup.push(
() => child.kill(),
() => server.stop(true),
() => fs.rm(root, { recursive: true, force: true }),
)
await Bun.write(file, JSON.stringify({ id: "newer", version: "2.0.0", url: server.url.toString(), pid: child.pid }))

const options = {
file,
version: "1.0.0",
canReplace: () => false,
command: [process.execPath, "-e", "throw new Error('should not spawn')"],
}
const startError = await Service.start(options).pipe(
Effect.flip,
Effect.provide(NodeFileSystem.layer),
Effect.runPromise,
)
const stopError = await Service.stop(options).pipe(
Effect.flip,
Effect.provide(NodeFileSystem.layer),
Effect.runPromise,
)

expect(startError).toBeInstanceOf(Service.VersionMismatchError)
expect(startError).toMatchObject({ clientVersion: "1.0.0", serverVersion: "2.0.0" })
expect(stopError).toBeInstanceOf(Service.VersionMismatchError)
expect(process.kill(child.pid, 0)).toBe(true)
expect(await Bun.file(file).exists()).toBe(true)
})
19 changes: 14 additions & 5 deletions packages/tui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { PluginRouteMissing } from "./component/plugin-route-missing"
import { ProjectProvider, useProject } from "./context/project"
import { EditorContextProvider } from "./context/editor"
import { useEvent } from "./context/event"
import { SDKProvider, useSDK } from "./context/sdk"
import { SDKProvider, useSDK, type SDKDiscovery } from "./context/sdk"
import { StartupLoading } from "./component/startup-loading"
import { Reconnecting } from "./component/reconnecting"
import { SyncProvider, useSync } from "./context/sync"
Expand Down Expand Up @@ -138,7 +138,8 @@ const appBindingCommands = [
export type TuiInput = {
client: OpencodeClient
api: OpenCodeClient
discover?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }>
discover?: () => Promise<SDKDiscovery>
restart?: () => void
reload?: () => Promise<void>
args: Args
config: TuiConfig.Resolved
Expand Down Expand Up @@ -302,7 +303,16 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
>
<TuiConfigProvider config={input.config}>
<PluginRuntimeProvider value={pluginRuntime}>
<SDKProvider client={input.client} api={input.api} discover={input.discover} reload={input.reload}>
<SDKProvider
client={input.client}
api={input.api}
discover={input.discover}
restart={() => {
input.restart?.()
destroyRenderer(renderer)
}}
reload={input.reload}
>
<PermissionProvider>
<ProjectProvider>
<SyncProvider>
Expand Down Expand Up @@ -812,8 +822,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
toast.show({ variant: "info", message: "Reloading server...", duration: 30000 })
// reload resolves once the replacement service is healthy; the
// event stream reattaches through the reconnect loop.
await sdk
.reload!()
await sdk.reload!()
.then(() => toast.show({ variant: "success", message: "Server reloaded" }))
.catch(toast.error)
},
Expand Down
Loading
Loading