diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index ba3454b6b97f..26c5d46cd91d 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -472,7 +472,15 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME description: "Manage the background server", commands: [ Spec.make("start", { description: "Start the background server" }), - Spec.make("restart", { description: "Restart the background server" }), + Spec.make("restart", { + description: "Restart the background server", + params: { + force: Flag.Boolean("force").pipe( + Flag.withDescription("Restart immediately without asking running sessions for approval"), + Flag.withDefault(false), + ), + }, + }), Spec.make("status", { description: "Show background server status" }), Spec.make("stop", { description: "Stop the background server" }), Spec.make("get", { diff --git a/packages/cli/src/commands/handlers/service/restart.ts b/packages/cli/src/commands/handlers/service/restart.ts index afc7bb457c17..59af107699f9 100644 --- a/packages/cli/src/commands/handlers/service/restart.ts +++ b/packages/cli/src/commands/handlers/service/restart.ts @@ -1,15 +1,37 @@ -import { EOL } from "os" +import { select } from "@clack/prompts" +import { EOL } from "node:os" import { Effect } from "effect" import { Service } from "@opencode/client/effect/service" +import { OpenCode, isInvalidRequestError, type OpenCodeClient } from "@opencode/client/promise" import { Commands } from "../../commands" import { Runtime } from "../../../framework/runtime" import { ServiceConfig } from "../../../services/service-config" import { ServerConnection } from "../../../services/server-connection" +import { prompt } from "../../../ui/prompt" + +// Waits longer than the server's own request TTL so expiry, not this local +// timer, is what ends an unanswered wait. The timer only covers a stream that +// goes silent without the server releasing the request. +const ApprovalWaitMs = 150_000 +const ApprovalTimeoutMessage = "Timed out waiting for restart approval with sessions still running." export default Runtime.handler( Commands.commands.service.commands.restart, - Effect.fn("cli.service.restart")(function* () { + Effect.fn("cli.service.restart")(function* (input: Runtime.Input) { const options = yield* ServiceConfig.options() + const endpoint = yield* Service.discover(options) + if (endpoint !== undefined && !input.force) { + const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) + yield* resolveRestartApproval(client, { + force: input.force, + interactive: process.stdin.isTTY === true && process.stdout.isTTY === true, + ask: askRestartDecision, + resolveTimeoutMs: ApprovalWaitMs, + idlePollMs: 2_000, + onWaiting: (active) => + process.stdout.write(`Waiting for ${active.length} active session(s) to finish...${EOL}`), + }) + } // Keep this explicit: automatic service replacement must preserve terminals. yield* ServerConnection.shutdownPersistentPty(options).pipe(Effect.ignore) yield* Service.stop(options) @@ -17,3 +39,193 @@ export default Runtime.handler( process.stdout.write(transport.url + EOL) }), ) + +export type RestartApprovalDecision = "now" | "idle" | "cancel" + +export type RestartApprovalDeps = { + readonly force: boolean + readonly interactive: boolean + readonly ask: (active: ReadonlyArray) => Effect.Effect + readonly resolveTimeoutMs: number + readonly idlePollMs: number + readonly onWaiting: (active: ReadonlyArray) => void +} + +// Approval gate for restarting a managed server with running sessions. The event +// subscription starts before the intent request so a resolution cannot slip +// through between them; only decisions recorded after the request count. The +// server owns release: it expires an unanswered request, so a requester that +// dies without unwinding still closes approver dialogs. Resolves to the winning +// decision ("now" when nothing needs approval); "cancel", expiry and a dead +// stream fail instead of restarting. +export const resolveRestartApproval = Effect.fn("cli.service.restart.approval")(function* ( + client: OpenCodeClient, + deps: RestartApprovalDeps, +) { + const active = yield* Effect.tryPromise({ + try: () => client.session.active(), + catch: (cause) => new Error("Could not reach server to check active sessions", { cause }), + }) + const ids = Object.keys(active) + if (ids.length === 0) return "now" + if (deps.force) return "now" + const watcher = watchResolved(client) + const approval = Effect.gen(function* () { + yield* Effect.tryPromise({ + try: () => client.server.restart.request(), + catch: (cause) => new Error("Could not reach server to request restart approval", { cause }), + }) + watcher.arm() + const winner = deps.interactive + ? yield* resolveInteractive(client, deps, watcher, ids) + : yield* awaitWinner(watcher, deps) + if (winner === "cancel") return yield* Effect.fail(restartAborted(ids, deps.interactive)) + if (winner === "idle") yield* waitForIdle(client, deps) + return winner + }) + return yield* approval.pipe(Effect.ensuring(Effect.sync(() => watcher.dispose()))) +}) + +const resolveInteractive = Effect.fn("cli.service.restart.approval.interactive")(function* ( + client: OpenCodeClient, + deps: RestartApprovalDeps, + watcher: ResolvedWatcher, + active: ReadonlyArray, +) { + const picked = yield* deps.ask(active) + return yield* Effect.tryPromise({ + // Pass the rejection through unchanged so catchIf can read its `_tag`. + try: () => client.server.restart.resolve({ decision: picked }), + catch: (cause) => cause, + }).pipe( + Effect.map((resolved) => resolved.decision), + // Another surface won the race; adopt the broadcast winner instead. + Effect.catchIf(isInvalidRequestError, () => awaitWinner(watcher, deps)), + Effect.mapError((cause) => new Error("Could not reach server to resolve restart approval", { cause })), + ) +}) + +const awaitWinner = Effect.fn("cli.service.restart.approval.wait")(function* ( + watcher: ResolvedWatcher, + deps: RestartApprovalDeps, +) { + const outcome = yield* Effect.tryPromise({ + try: () => watcher.wait(deps.resolveTimeoutMs), + catch: (cause) => + cause instanceof RestartApprovalTimeout + ? new Error(ApprovalTimeoutMessage) + : new Error("Lost connection to the server while waiting for restart approval", { cause }), + }) + // The server expired the request without a decision; nothing was approved. + if (outcome === "expired") return yield* Effect.fail(new Error(ApprovalTimeoutMessage)) + return outcome +}) + +// The requesting session blocks on this command, so it can never go idle while +// watched. Exclude it from the drain set; approval itself still required it. +const waitForIdle = Effect.fn("cli.service.restart.approval.idle")(function* ( + client: OpenCodeClient, + deps: RestartApprovalDeps, +) { + while (true) { + yield* Effect.sleep(deps.idlePollMs) + const active = yield* Effect.tryPromise({ + try: () => client.session.active(), + catch: (cause) => + new Error("Lost connection to the server while waiting for sessions to finish", { cause }), + }) + const ids = Object.keys(active).filter((id) => id !== process.env.OPENCODE_SESSION_ID) + if (ids.length === 0) return + deps.onWaiting(ids) + } +}) + +function restartAborted(active: ReadonlyArray, interactive: boolean) { + return interactive + ? new Error( + `Restart cancelled with ${active.length} active session(s); re-run with --force to restart immediately.`, + ) + : new Error("Restart cancelled with active session(s) still running.") +} + +class RestartApprovalTimeout extends Error { + constructor() { + super("restart approval timed out") + this.name = "RestartApprovalTimeout" + } +} + +export type RestartResolution = RestartApprovalDecision | "expired" + +export type ResolvedWatcher = { + readonly arm: () => void + readonly wait: (timeoutMs: number) => Promise + readonly dispose: () => void +} + +// Subscribes immediately so a decision broadcast during the intent request is +// buffered rather than missed; consumers only count outcomes seen after arm(). +export function watchResolved(client: OpenCodeClient): ResolvedWatcher { + const controller = new AbortController() + let armed = false + let pending: RestartResolution | undefined + let settle: ((outcome: RestartResolution) => void) | undefined + let failure: unknown | undefined + const fail = (cause: unknown) => { + failure ??= cause + settle = undefined + } + void (async () => { + try { + const stream = client.event.subscribe({ signal: controller.signal })[Symbol.asyncIterator]() + for (;;) { + const next = await stream.next() + if (next.done) break + if (!armed) continue + const outcome: RestartResolution | undefined = + next.value.type === "server.restart.resolved" + ? next.value.data.decision + : next.value.type === "server.restart.expired" + ? "expired" + : undefined + if (outcome === undefined) continue + pending ??= outcome + settle?.(outcome) + } + fail(new Error("Event stream disconnected while waiting for restart approval")) + } catch (cause) { + fail(cause) + } + })() + return { + arm() { + armed = true + }, + wait(timeoutMs: number) { + return new Promise((resolve, reject) => { + if (pending !== undefined) return resolve(pending) + if (failure !== undefined) return reject(failure) + const timer = setTimeout(() => reject(new RestartApprovalTimeout()), timeoutMs) + settle = (outcome) => { + clearTimeout(timer) + resolve(outcome) + } + }) + }, + dispose() { + controller.abort() + }, + } +} + +const askRestartDecision = (active: ReadonlyArray) => + prompt(() => + select({ + message: `${active.length} session(s) are still running. Restart the background server?`, + options: [ + { value: "now", label: "Restart now", hint: "Interrupt running sessions" }, + { value: "idle", label: "Wait for idle", hint: "Restart when sessions finish" }, + { value: "cancel", label: "Cancel", hint: "Keep the server running" }, + ], + }), + ) diff --git a/packages/cli/test/service-restart-approval.test.ts b/packages/cli/test/service-restart-approval.test.ts new file mode 100644 index 000000000000..74e44df9f843 --- /dev/null +++ b/packages/cli/test/service-restart-approval.test.ts @@ -0,0 +1,327 @@ +import { OpenCode, type OpenCodeClient } from "@opencode/client/promise" +import { expect, test } from "bun:test" +import { Effect } from "effect" +import { + resolveRestartApproval, + type RestartApprovalDecision, + type RestartApprovalDeps, +} from "../src/commands/handlers/service/restart" + +type StubState = { + active: Record + activeCalls: number + drainAfterActiveCalls?: number + activeFails: boolean + requests: number + resolves: Array + resolveFirstFails: boolean + winner: RestartApprovalDecision | undefined + sseConnections: number + order: Array +} + +function startStub(initial: Partial = {}) { + const state: StubState = { + active: {}, + activeCalls: 0, + activeFails: false, + requests: 0, + resolves: [], + resolveFirstFails: false, + winner: undefined, + sseConnections: 0, + order: [], + ...initial, + } + const controllers = new Set>() + const send = (controller: ReadableStreamDefaultController, event: unknown) => { + try { + controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`)) + } catch { + controllers.delete(controller) + } + } + const broadcast = (event: unknown) => { + for (const controller of [...controllers]) send(controller, event) + } + const resolve = (decision: RestartApprovalDecision) => { + if (state.winner === undefined) { + state.winner = decision + broadcast({ id: "evt_resolved", type: "server.restart.resolved", created: 1, data: { decision } }) + } + return { decision: state.winner } + } + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch: async (request) => { + const url = new URL(request.url) + if (request.method === "GET" && url.pathname === "/api/session/active") { + state.activeCalls += 1 + if (state.activeFails) return new Response("gone", { status: 500 }) + if (state.drainAfterActiveCalls !== undefined && state.activeCalls > state.drainAfterActiveCalls) + state.active = {} + return Response.json({ data: state.active }) + } + if (request.method === "POST" && url.pathname === "/api/server/restart/request") { + state.requests += 1 + state.order.push("request") + return Response.json({ active: Object.keys(state.active) }) + } + if (request.method === "POST" && url.pathname === "/api/server/restart/resolve") { + const body = (await request.json()) as { decision: RestartApprovalDecision } + state.resolves.push(body.decision) + if (state.resolveFirstFails && state.resolves.length === 1) { + return Response.json({ _tag: "InvalidRequestError", message: "No pending service restart request" }, { status: 400 }) + } + return Response.json(resolve(body.decision)) + } + if (request.method === "GET" && url.pathname === "/api/event") { + const stream = new ReadableStream({ + start: (controller) => { + state.sseConnections += 1 + state.order.push("sse") + controllers.add(controller) + send(controller, { id: "evt_connected", type: "server.connected", created: 0, data: {} }) + }, + cancel: (controller) => { + controllers.delete(controller) + }, + }) + return new Response(stream, { + headers: { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }, + }) + } + return new Response("not found", { status: 404 }) + }, + }) + const client = OpenCode.make({ baseUrl: `http://127.0.0.1:${server.port}`, headers: {} }) + return { + state, + client, + url: `http://127.0.0.1:${server.port}`, + broadcastExpired: () => broadcast({ id: "evt_expired", type: "server.restart.expired", created: 2, data: {} }), + stop: () => server.stop(true), + } +} + +const deps = (overrides: Partial = {}): RestartApprovalDeps => ({ + force: false, + interactive: false, + ask: () => Effect.succeed("now"), + resolveTimeoutMs: 5_000, + idlePollMs: 10, + onWaiting: () => {}, + ...overrides, +}) + +const run = (client: OpenCodeClient, input: RestartApprovalDeps) => + Effect.runPromise(resolveRestartApproval(client, input)) + +async function until(condition: () => boolean, message: string) { + const deadline = Date.now() + 5_000 + while (!condition()) { + if (Date.now() >= deadline) throw new Error(message) + await Bun.sleep(10) + } +} + +async function tuiResolve(url: string, decision: RestartApprovalDecision) { + const response = await fetch(`${url}/api/server/restart/resolve`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ decision }), + }) + if (!response.ok) throw new Error(`TUI resolve failed: ${response.status}`) +} + +test("no active sessions restarts without requesting approval", async () => { + const stub = startStub() + try { + expect(await run(stub.client, deps())).toBe("now") + expect(stub.state.requests).toBe(0) + } finally { + await stub.stop() + } +}) + +test("active sessions with --force restart without requesting approval", async () => { + const stub = startStub({ active: { ses_1: { type: "running" } } }) + try { + expect(await run(stub.client, deps({ force: true }))).toBe("now") + expect(stub.state.requests).toBe(0) + expect(stub.state.resolves).toEqual([]) + } finally { + await stub.stop() + } +}) + +test("the event subscription connects before the intent request", async () => { + const stub = startStub({ active: { ses_1: { type: "running" } } }) + try { + const pending = run(stub.client, deps({ resolveTimeoutMs: 300 })) + await pending.then( + () => undefined, + () => undefined, + ) + expect(stub.state.order[0]).toBe("sse") + expect(stub.state.order).toContain("request") + } finally { + await stub.stop() + } +}) + +test("non-TTY waits for a TUI decision and obeys the winner", async () => { + const stub = startStub({ active: { ses_1: { type: "running" } } }) + try { + const pending = run(stub.client, deps()) + await until(() => stub.state.requests > 0, "CLI did not request approval") + await tuiResolve(stub.url, "now") + expect(await pending).toBe("now") + expect(stub.state.requests).toBe(1) + // The CLI never resolves itself off-terminal; the only resolve is the TUI's. + expect(stub.state.resolves).toEqual(["now"]) + } finally { + await stub.stop() + } +}) + +test("non-TTY timeout aborts without naming --force and leaves release to the server", async () => { + const stub = startStub({ active: { ses_1: { type: "running" } } }) + try { + const error = await run(stub.client, deps({ resolveTimeoutMs: 200 })).then( + () => undefined, + (cause: Error) => cause, + ) + expect(error?.message).toContain("Timed out") + expect(error?.message).not.toContain("--force") + expect(stub.state.requests).toBe(1) + // Release is the server's job; the CLI does not post a cancel of its own. + expect(stub.state.resolves).toEqual([]) + } finally { + await stub.stop() + } +}) + +test("an expired request aborts the wait", async () => { + const stub = startStub({ active: { ses_1: { type: "running" } } }) + try { + const pending = run(stub.client, deps()) + await until(() => stub.state.requests > 0, "CLI did not request approval") + stub.broadcastExpired() + const error = await pending.then( + () => undefined, + (cause: Error) => cause, + ) + expect(error?.message).toContain("Timed out") + expect(error?.message).not.toContain("--force") + } finally { + await stub.stop() + } +}) + +test("a TUI cancel aborts the restart without naming --force", async () => { + const stub = startStub({ active: { ses_1: { type: "running" } } }) + try { + const pending = run(stub.client, deps()) + await until(() => stub.state.requests > 0, "CLI did not request approval") + await tuiResolve(stub.url, "cancel") + const error = await pending.then( + () => undefined, + (cause: Error) => cause, + ) + expect(error?.message).toContain("still running") + expect(error?.message).not.toContain("--force") + } finally { + await stub.stop() + } +}) + +test("an idle winner waits until sessions finish", async () => { + const stub = startStub({ active: { ses_1: { type: "running" } }, drainAfterActiveCalls: 3 }) + try { + const waited: Array> = [] + const pending = run( + stub.client, + deps({ onWaiting: (active) => waited.push(active) }), + ) + await until(() => stub.state.requests > 0, "CLI did not request approval") + await tuiResolve(stub.url, "idle") + expect(await pending).toBe("idle") + expect(waited.length).toBeGreaterThan(0) + } finally { + await stub.stop() + } +}) + +test("the idle wait excludes the requesting session itself", async () => { + const previous = process.env.OPENCODE_SESSION_ID + process.env.OPENCODE_SESSION_ID = "ses_1" + const stub = startStub({ active: { ses_1: { type: "running" } } }) + try { + const pending = run(stub.client, deps()) + await until(() => stub.state.requests > 0, "CLI did not request approval") + await tuiResolve(stub.url, "idle") + expect(await pending).toBe("idle") + // One check plus one drain poll; without exclusion this would never settle. + expect(stub.state.activeCalls).toBeLessThanOrEqual(2) + } finally { + if (previous === undefined) delete process.env.OPENCODE_SESSION_ID + else process.env.OPENCODE_SESSION_ID = previous + await stub.stop() + } +}) + +test("an idle wait aborts when the server connection drops", async () => { + const stub = startStub({ active: { ses_1: { type: "running" } } }) + try { + const pending = run(stub.client, deps()) + await until(() => stub.state.requests > 0, "CLI did not request approval") + await tuiResolve(stub.url, "idle") + await until(() => stub.state.activeCalls > 1, "CLI did not poll for idle") + stub.state.activeFails = true + const error = await pending.then( + () => undefined, + (cause: Error) => cause, + ) + expect(error?.message).toContain("Lost connection") + } finally { + await stub.stop() + } +}) + +test("a TTY pick adopts the first-wins winner", async () => { + const stub = startStub({ active: { ses_1: { type: "running" } } }) + try { + // A TUI decides first; the later local pick loses and the winner stands. + await tuiResolve(stub.url, "cancel") + const error = await run(stub.client, deps({ interactive: true })).then( + () => undefined, + (cause: Error) => cause, + ) + expect(error?.message).toContain("--force") + expect(stub.state.resolves).toEqual(["cancel", "now"]) + } finally { + await stub.stop() + } +}) + +test("a lost resolve race falls back to the broadcast winner", async () => { + const stub = startStub({ + active: { ses_1: { type: "running" } }, + drainAfterActiveCalls: 4, + resolveFirstFails: true, + }) + try { + const pending = run(stub.client, deps({ interactive: true })) + await until(() => stub.state.requests > 0, "CLI did not request approval") + await tuiResolve(stub.url, "idle") + expect(await pending).toBe("idle") + } finally { + await stub.stop() + } +}) diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 5310d67cdd20..1867b5bf8568 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -1,9 +1,9 @@ // Generated by @opencode/httpapi-codegen. Do not edit. import type { Effect, Stream } from "effect" +import type { Session } from "@opencode/schema/session" import type { Location } from "@opencode/schema/location" import type { Agent } from "@opencode/schema/agent" import type { Plugin } from "@opencode/schema/plugin" -import type { Session } from "@opencode/schema/session" import type { AbsolutePath } from "@opencode/schema/schema" import type { Project } from "@opencode/schema/project" import type { RelativePath } from "@opencode/schema/schema" @@ -59,10 +59,23 @@ export type ServerConnectInput = { readonly code: string } export type ServerConnectOutput = { readonly token: string } export type ServerConnectOperation = (input: ServerConnectInput) => Effect.Effect +export type ServerRestartRequestOutput = { readonly active: ReadonlyArray } +export type ServerRestartRequestOperation = () => Effect.Effect + +export type ServerRestartResolveInput = { readonly decision: "now" | "idle" | "cancel" } +export type ServerRestartResolveOutput = { readonly decision: "now" | "idle" | "cancel" } +export type ServerRestartResolveOperation = ( + input: ServerRestartResolveInput, +) => Effect.Effect + export interface ServerApi { readonly info: ServerInfoOperation readonly pair: ServerPairOperation readonly connect: ServerConnectOperation + readonly restart: { + readonly request: ServerRestartRequestOperation + readonly resolve: ServerRestartResolveOperation + } } export type LocationGetInput = { readonly location?: { readonly directory?: string | undefined } | undefined } diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index c407bd2fcc85..952d2a018def 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -9,6 +9,9 @@ import type { ServerPairOutput, ServerConnectInput, ServerConnectOutput, + ServerRestartRequestOutput, + ServerRestartResolveInput, + ServerRestartResolveOutput, LocationGetInput, LocationGetOutput, LocationReloadOutput, @@ -310,10 +313,19 @@ const EndpointServerConnect = (raw: RawClient["server.server"]) => (input: Serve raw["server.connect"]({ params: { code: input["code"] } }).pipe(Effect.mapError(mapClientError)), ) +const EndpointServerRestartRequest = (raw: RawClient["server.server"]) => () => + preserveEffect()(raw["server.restart.request"]({}).pipe(Effect.mapError(mapClientError))) + +const EndpointServerRestartResolve = (raw: RawClient["server.server"]) => (input: ServerRestartResolveInput) => + preserveEffect()( + raw["server.restart.resolve"]({ payload: { decision: input["decision"] } }).pipe(Effect.mapError(mapClientError)), + ) + const adaptGroupServer = (raw: RawClient["server.server"]) => ({ info: EndpointServerInfo(raw), pair: EndpointServerPair(raw), connect: EndpointServerConnect(raw), + restart: { request: EndpointServerRestartRequest(raw), resolve: EndpointServerRestartResolve(raw) }, }) const EndpointLocationGet = (raw: RawClient["server.location"]) => (input?: LocationGetInput) => diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 50e154849745..72ad16ef4ccf 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -3,6 +3,9 @@ import type { ServerPairOutput, ServerConnectInput, ServerConnectOutput, + ServerRestartRequestOutput, + ServerRestartResolveInput, + ServerRestartResolveOutput, LocationGetInput, LocationGetOutput, LocationReloadOutput, @@ -443,6 +446,31 @@ export function make(options: ClientOptions) { }, requestOptions, ), + restart: { + request: (requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/server/restart/request`, + successStatus: 200, + declaredStatuses: [400, 401], + empty: false, + }, + requestOptions, + ), + resolve: (input: ServerRestartResolveInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/server/restart/resolve`, + body: { decision: input["decision"] }, + successStatus: 200, + declaredStatuses: [400, 401], + empty: false, + }, + requestOptions, + ), + }, }, location: { get: (input?: LocationGetInput, requestOptions?: RequestOptions) => diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 1d27265ccdf3..21efd2c4ce77 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -12,6 +12,8 @@ export type PairingCode = { code: string; expires_in: number } export type PairingSession = { token: string } +export type SessionID = string + export type AbsolutePath = string export type ProjectID = string @@ -40,8 +42,6 @@ export type PluginFeatures = { server?: true; tui?: true; rpc?: true } export type PluginState = { status: "active" } | { status: "failed"; error: string; ref?: string } -export type SessionID = string - export type SessionMessageID = string export type MoneyUSD = number @@ -1741,6 +1741,33 @@ export type SessionIdle = { data: { sessionID: SessionID } } +export type ServerRestartRequested = { + id: EventID + created: number + metadata?: { [x: string]: any } + type: "server.restart.requested" + location?: LocationRef + data: { active: Array } +} + +export type ServerRestartResolved = { + id: EventID + created: number + metadata?: { [x: string]: any } + type: "server.restart.resolved" + location?: LocationRef + data: { decision: "now" | "idle" | "cancel" } +} + +export type ServerRestartExpired = { + id: EventID + created: number + metadata?: { [x: string]: any } + type: "server.restart.expired" + location?: LocationRef + data: {} +} + export type TuiPromptAppend = { id: EventID created: number @@ -2627,6 +2654,9 @@ export type V2Event = | WebsearchUpdated | SessionStatusUpdated | SessionIdle + | ServerRestartRequested + | ServerRestartResolved + | ServerRestartExpired | TuiPromptAppend | TuiCommandExecute | TuiToastShow @@ -2895,6 +2925,14 @@ export type ServerConnectInput = { readonly code: { readonly code: string }["cod export type ServerConnectOutput = PairingSession +export type ServerRestartRequestOutput = { active: Array } + +export type ServerRestartResolveInput = { + readonly decision: { readonly decision: "now" | "idle" | "cancel" }["decision"] +} + +export type ServerRestartResolveOutput = { decision: "now" | "idle" | "cancel" } + export type LocationGetInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"] } diff --git a/packages/protocol/src/groups/server.ts b/packages/protocol/src/groups/server.ts index 4b4cab189bf2..a9220f5c5398 100644 --- a/packages/protocol/src/groups/server.ts +++ b/packages/protocol/src/groups/server.ts @@ -1,6 +1,8 @@ import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/http-api" -import { UnauthorizedError } from "../errors.js" +import { ServiceEvent } from "@opencode/schema/service-event" +import { Session } from "@opencode/schema/session" +import { InvalidRequestError, UnauthorizedError } from "../errors.js" export const ServerInfo = Schema.Struct({ version: Schema.String, @@ -75,4 +77,36 @@ export const ServerGroup = HttpApiGroup.make("server.server") }), ), ) + .add( + HttpApiEndpoint.post("server.restart.request", "/api/server/restart/request", { + success: Schema.Struct({ + active: Schema.Array(Session.ID), + }), + }).annotateMerge( + OpenApi.annotations({ + identifier: "server.restart.request", + summary: "Request service restart approval", + description: + "Register a pending service restart. Returns the sessions with running agents so the requester can seek approval. Resolving the request is reported through server.restart events.", + }), + ), + ) + .add( + HttpApiEndpoint.post("server.restart.resolve", "/api/server/restart/resolve", { + payload: Schema.Struct({ + decision: ServiceEvent.Decision, + }), + success: Schema.Struct({ + decision: ServiceEvent.Decision, + }), + error: InvalidRequestError, + }).annotateMerge( + OpenApi.annotations({ + identifier: "server.restart.resolve", + summary: "Resolve pending service restart", + description: + "Approve the pending service restart (now or once sessions go idle) or cancel it. Only the first resolution wins.", + }), + ), + ) .annotateMerge(OpenApi.annotations({ title: "server" })) diff --git a/packages/schema/src/event-manifest.ts b/packages/schema/src/event-manifest.ts index 341cc1675d53..afb753f8342d 100644 --- a/packages/schema/src/event-manifest.ts +++ b/packages/schema/src/event-manifest.ts @@ -27,6 +27,7 @@ import { Worktree } from "./worktree.js" import { Pty } from "./pty.js" import { Reference } from "./reference.js" import { ServerEvent } from "./server-event.js" +import { ServiceEvent } from "./service-event.js" import { Shell } from "./shell.js" import { Skill } from "./skill.js" import { SessionCompactionEvent } from "./session-compaction-event.js" @@ -73,6 +74,7 @@ export const ServerDefinitions = Event.inventory( ...featureDefinitions, // Current events the TUI consumes from the public stream. ...SessionStatusEvent.Definitions, + ...ServiceEvent.Definitions, ...TuiEvent.Definitions, ...InstallationEvent.Definitions, ...VcsEvent.Definitions, @@ -94,6 +96,7 @@ export const Definitions = Event.inventory( ...FileSystemV1.Event.Definitions, ...SessionStatusEvent.Definitions, ...SessionCompactionEvent.Definitions, + ...ServiceEvent.Definitions, ...VcsEvent.Definitions, ...WorkspaceEvent.Definitions, ...WorktreeEvent.Definitions, diff --git a/packages/schema/src/service-event.ts b/packages/schema/src/service-event.ts new file mode 100644 index 000000000000..8ce84beb234a --- /dev/null +++ b/packages/schema/src/service-event.ts @@ -0,0 +1,35 @@ +export * as ServiceEvent from "./service-event.js" + +import { Schema } from "effect" +import { Event } from "./event.js" +import { SessionID } from "./session-id.js" + +export const Requested = Event.ephemeral({ + type: "server.restart.requested", + schema: { + active: Schema.Array(SessionID), + }, +}) + +export const Decision = Schema.Union([ + Schema.Literal("now"), + Schema.Literal("idle"), + Schema.Literal("cancel"), +]) +export type Decision = typeof Decision.Type + +export const Resolved = Event.ephemeral({ + type: "server.restart.resolved", + schema: { + decision: Decision, + }, +}) + +// Emitted when a pending request passes its deadline without being resolved, so +// approvers can dismiss it even if the requester died without releasing. +export const Expired = Event.ephemeral({ + type: "server.restart.expired", + schema: {}, +}) + +export const Definitions = Event.inventory(Requested, Resolved, Expired) diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts index 5dccd1d847ae..5bb277c5d2cd 100644 --- a/packages/server/src/handlers.ts +++ b/packages/server/src/handlers.ts @@ -14,6 +14,7 @@ import { EventHandler } from "./handlers/event" import { AgentHandler } from "./handlers/agent" import { PluginHandler } from "./handlers/plugin" import { ServerHandler } from "./handlers/server" +import { RestartIntent } from "./restart-intent" import { DebugHandler } from "./handlers/debug" import { PtyHandler } from "./handlers/pty" import { PersistentPtyHandler } from "./handlers/persistent-pty" @@ -32,7 +33,7 @@ import { MigrationHandler } from "./handlers/migration" import { ConfigHandler } from "./handlers/config" export const handlers = Layer.mergeAll( - ServerHandler, + ServerHandler.pipe(Layer.provide(RestartIntent.layer)), DebugHandler, MigrationHandler, LocationHandler, diff --git a/packages/server/src/handlers/server.ts b/packages/server/src/handlers/server.ts index d85594b38d42..e03f25ebdfa0 100644 --- a/packages/server/src/handlers/server.ts +++ b/packages/server/src/handlers/server.ts @@ -2,16 +2,27 @@ import { Duration, Effect } from "effect" import { HttpServerRequest, HttpServerResponse } from "effect/http" import { HttpApiBuilder } from "effect/http-api" import { PersistentPty } from "@opencode/core/persistent-pty" -import { UnauthorizedError } from "@opencode/protocol/errors" +import { Session } from "@opencode/core/session" +import { Bus } from "@opencode/core/bus" +import { ServiceEvent } from "@opencode/schema/service-event" +import { InvalidRequestError, UnauthorizedError } from "@opencode/protocol/errors" import { Api } from "../api" import { ServerAuth } from "../auth" import { ServerInfo } from "../server-info" import { ServerPairing } from "../pairing" +import { RestartIntent } from "../restart-intent" + +// Deadline for a pending restart request. Kept in step with the CLI's own +// approval wait; the requester releases first when it is alive. +const RestartApprovalTtl = Duration.minutes(2) export const ServerHandler = HttpApiBuilder.group(Api, "server.server", (handlers) => Effect.gen(function* () { const pairing = yield* ServerPairing.Service const auth = yield* ServerAuth.Config + const session = yield* Session.Service + const intent = yield* RestartIntent.Service + const bus = yield* Bus.Service return handlers .handle("server.info", () => @@ -27,6 +38,38 @@ export const ServerHandler = HttpApiBuilder.group(Api, "server.server", (handler }), ) .handle("server.pair", () => pairing.issue()) + .handle( + "server.restart.request", + Effect.fn(function* () { + const active = Array.from(yield* session.active) + const generation = yield* intent.request() + yield* bus.publish(ServiceEvent.Requested, { active }, { global: true }) + // Backstop for a requester that dies without releasing: the requester + // covers its own timeout, this covers SIGKILL and shell-tool kills. + // Detached so it outlives this request's scope. + yield* Effect.sleep(RestartApprovalTtl).pipe( + Effect.andThen(intent.expire(generation)), + Effect.flatMap((expired) => + expired ? bus.publish(ServiceEvent.Expired, {}, { global: true }) : Effect.void, + ), + Effect.forkDetach, + ) + return { active } + }), + ) + .handle( + "server.restart.resolve", + Effect.fn(function* (ctx) { + const settled = yield* intent.resolve(ctx.payload.decision) + if (settled === undefined) { + return yield* new InvalidRequestError({ message: "No pending service restart request" }) + } + if (settled.fresh) { + yield* bus.publish(ServiceEvent.Resolved, { decision: settled.winner }, { global: true }) + } + return { decision: settled.winner } + }), + ) .handle( "server.connect", Effect.fn(function* (ctx) { diff --git a/packages/server/src/restart-intent.ts b/packages/server/src/restart-intent.ts new file mode 100644 index 000000000000..c6f6c369d3a2 --- /dev/null +++ b/packages/server/src/restart-intent.ts @@ -0,0 +1,62 @@ +export * as RestartIntent from "./restart-intent" + +import { ServiceEvent } from "@opencode/schema/service-event" +import { Context, Effect, Layer, Ref } from "effect" + +export type Settlement = { readonly winner: ServiceEvent.Decision; readonly fresh: boolean } | undefined + +type IntentState = { + readonly generation: number + readonly pending: boolean + readonly last?: ServiceEvent.Decision +} + +export class Service extends Context.Service Effect.Effect + /** + * Resolve the pending intent with first-wins semantics. Returns the winning + * decision and whether this call settled it; late resolvers replay the + * winner without rebroadcasting. Returns undefined when no intent exists. + */ + readonly resolve: (decision: ServiceEvent.Decision) => Effect.Effect + /** + * Drop the pending intent when it is still the given generation. Returns + * whether this call expired it; a stale token or a settled intent is a no-op. + */ + readonly expire: (generation: number) => Effect.Effect +}>()("@opencode/server/RestartIntent") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const state = yield* Ref.make({ generation: 0, pending: false }) + return Service.of({ + request: () => + Ref.modify( + state, + (current): readonly [number, IntentState] => { + const generation = current.generation + 1 + return [generation, current.pending ? { ...current, generation } : { generation, pending: true }] + }, + ), + resolve: (decision) => + Ref.modify(state, (current): readonly [Settlement, IntentState] => { + if (!current.pending) { + return current.last === undefined + ? [undefined, current] + : [{ winner: current.last, fresh: false }, current] + } + return [{ winner: decision, fresh: true }, { ...current, pending: false, last: decision }] + }), + expire: (generation) => + Ref.modify(state, (current): readonly [boolean, IntentState] => { + if (!current.pending || current.generation !== generation) return [false, current] + return [true, { ...current, pending: false }] + }), + }) + }), +) diff --git a/packages/server/test/restart-intent.test.ts b/packages/server/test/restart-intent.test.ts new file mode 100644 index 000000000000..9b43b34e80cf --- /dev/null +++ b/packages/server/test/restart-intent.test.ts @@ -0,0 +1,82 @@ +import { expect, test } from "bun:test" +import { Effect } from "effect" +import { RestartIntent } from "../src/restart-intent" + +const run = (effect: Effect.Effect) => + Effect.runPromise(Effect.provide(effect, RestartIntent.layer)) + +test("first resolve wins and late resolvers replay it", async () => { + const winner = await run( + Effect.gen(function* () { + const intent = yield* RestartIntent.Service + yield* intent.request() + const first = yield* intent.resolve("now") + const late = yield* intent.resolve("cancel") + return { first, late } + }), + ) + expect(winner.first).toEqual({ winner: "now", fresh: true }) + expect(winner.late).toEqual({ winner: "now", fresh: false }) +}) + +test("resolve with no pending intent returns undefined", async () => { + const winner = await run( + Effect.gen(function* () { + const intent = yield* RestartIntent.Service + return yield* intent.resolve("now") + }), + ) + expect(winner).toBeUndefined() +}) + +test("a second request does not clobber the pending intent", async () => { + const winner = await run( + Effect.gen(function* () { + const intent = yield* RestartIntent.Service + yield* intent.request() + yield* intent.request() + return yield* intent.resolve("idle") + }), + ) + expect(winner).toEqual({ winner: "idle", fresh: true }) +}) + +test("expiring the current generation releases the intent", async () => { + const outcome = await run( + Effect.gen(function* () { + const intent = yield* RestartIntent.Service + const generation = yield* intent.request() + const expired = yield* intent.expire(generation) + return { expired, settled: yield* intent.resolve("now") } + }), + ) + expect(outcome.expired).toBe(true) + expect(outcome.settled).toBeUndefined() +}) + +test("a stale expiry token leaves a newer request pending", async () => { + const outcome = await run( + Effect.gen(function* () { + const intent = yield* RestartIntent.Service + const stale = yield* intent.request() + yield* intent.request() + const expired = yield* intent.expire(stale) + return { expired, settled: yield* intent.resolve("idle") } + }), + ) + // Without the token guard the timer for the first request would free the second. + expect(outcome.expired).toBe(false) + expect(outcome.settled).toEqual({ winner: "idle", fresh: true }) +}) + +test("expiring a settled intent is a no-op", async () => { + const outcome = await run( + Effect.gen(function* () { + const intent = yield* RestartIntent.Service + const generation = yield* intent.request() + yield* intent.resolve("cancel") + return yield* intent.expire(generation) + }), + ) + expect(outcome).toBe(false) +}) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 88939ebc8528..69dca61c3b92 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -94,6 +94,7 @@ import { PromptRefProvider, usePromptRef } from "./context/prompt" import { Config, ConfigProvider, useConfig } from "./config" import { newSessionLocation } from "./config/new-session-location" import { UpdateNotificationProvider, useUpdateNotification, type UpdateSource } from "./context/update-notification" +import { RestartApprovalProvider } from "./context/restart-approval" import { PluginProvider, usePlugin, type PackageSource } from "./plugin/context" import { localPluginDirectories } from "./plugin/discovery" import { PluginRoute, Slot } from "./plugin/render" @@ -398,7 +399,8 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { - + + + @@ -1046,12 +1049,93 @@ function App() { const restart = client.restart if (!restart) return dialog.clear() - toast.show({ variant: "info", message: "Restarting service…", duration: 30000 }) - // restart resolves once the replacement service is healthy; the - // event stream reattaches through the reconnect loop. - await restart() - .then(() => toast.show({ variant: "success", message: "Service restarted" })) - .catch(toast.error) + const restartNow = () => { + toast.show({ variant: "info", message: "Restarting service…", duration: 30000 }) + // restart resolves once the replacement service is healthy; the + // event stream reattaches through the reconnect loop. + return restart() + .then(() => toast.show({ variant: "success", message: "Service restarted" })) + .catch(toast.error) + } + const waitForIdle = async () => { + for (;;) { + const active = await client.api.session.active().catch(() => undefined) + if (!active || Object.keys(active).length === 0) return + await new Promise((resolve) => setTimeout(resolve, 2000)) + } + } + const watchDecision = () => { + let armed = false + let pending: "now" | "idle" | "cancel" | undefined + let settle: ((decision: "now" | "idle" | "cancel") => void) | undefined + const dispose = event.on("server.restart.resolved", (evt) => { + if (!armed) return + pending ??= evt.data.decision + settle?.(evt.data.decision) + }) + return { + arm() { + armed = true + }, + wait() { + return new Promise<"now" | "idle" | "cancel">((resolve, reject) => { + if (pending !== undefined) return resolve(pending) + const timer = setTimeout( + () => reject(new Error("Restart request expired with no approver")), + 120000, + ) + settle = (decision) => { + clearTimeout(timer) + resolve(decision) + } + }) + }, + dispose, + } + } + const active = await client.api.session.active().catch(() => undefined) + if (!active || Object.keys(active).length === 0) { + await restartNow() + return + } + let requested + const watch = watchDecision() + try { + requested = await client.api.server.restart.request() + } catch { + // Servers older than restart intents cannot approve; fall back. + toast.show({ + variant: "warning", + message: "Server does not support restart approval; restarting directly…", + }) + await restartNow() + return + } + watch.arm() + if (Object.keys(requested.active).length === 0) { + await restartNow() + return + } + toast.show({ + variant: "info", + message: `Waiting for restart approval (${Object.keys(requested.active).length} running)…`, + duration: 120000, + }) + let winner: "now" | "idle" | "cancel" + try { + winner = await watch.wait() + } catch { + await client.api.server.restart.resolve({ decision: "cancel" }).catch(() => undefined) + winner = "cancel" + } finally { + watch.dispose() + } + if (winner === "now") await restartNow() + else if (winner === "idle") { + toast.show({ variant: "info", message: "Restart queued; waiting for sessions to finish…" }) + await waitForIdle() + await restartNow() + } else toast.show({ variant: "info", message: "Restart cancelled" }) }, category: "System", }, diff --git a/packages/tui/src/context/location.tsx b/packages/tui/src/context/location.tsx index 8c668757e75d..212e68e8633a 100644 --- a/packages/tui/src/context/location.tsx +++ b/packages/tui/src/context/location.tsx @@ -1,4 +1,5 @@ import type { LocationGetOutput, LocationRef } from "@opencode/client" +import { isLocationNotFoundError } from "@opencode/client" import { createContext, createMemo, createSignal, onCleanup, useContext, type ParentProps } from "solid-js" import { useClient } from "./client" import { useData } from "./data" @@ -36,6 +37,9 @@ export function LocationProvider(props: ParentProps) { current.workspaceID !== location.workspaceID ) return + // Only the server's typed LocationNotFoundError proves the folder is gone. + // Transient sync failures (reconnect, timeouts) must not mount recovery. + if (!isLocationNotFoundError(cause)) return setError({ location, cause }) }) } diff --git a/packages/tui/src/context/restart-approval.tsx b/packages/tui/src/context/restart-approval.tsx new file mode 100644 index 000000000000..c159a8cceae7 --- /dev/null +++ b/packages/tui/src/context/restart-approval.tsx @@ -0,0 +1,79 @@ +import { For, createSignal } from "solid-js" +import type { ServiceEvent } from "@opencode/schema/service-event" +import { createSimpleContext } from "./helper" +import { useClient } from "./client" +import { useData } from "./data" +import { useDialog } from "../ui/dialog" +import { useEvent } from "./event" +import { useToast } from "../ui/toast" +import { errorMessage } from "../util/error" +import { SessionQuestion } from "../routes/session/permission" + +export const { use: useRestartApproval, provider: RestartApprovalProvider } = createSimpleContext({ + name: "RestartApproval", + init: () => { + const client = useClient() + const dialog = useDialog() + const event = useEvent() + const toast = useToast() + const [active, setActive] = createSignal>([]) + + function open(sessions: ReadonlyArray) { + setActive(sessions) + dialog.replace(() => void decide(decision)} />) + } + + function close() { + setActive([]) + dialog.clear() + } + + async function decide(decision: ServiceEvent.Decision) { + try { + await client.api.server.restart.resolve({ decision }) + } catch (error) { + // Someone else settled the intent first; their resolved event dismisses us. + if (!(error instanceof Error && error.name === "InvalidRequestError")) { + toast.show({ title: "Failed to resolve restart", message: errorMessage(error), variant: "error" }) + } + } + close() + } + + event.on("server.restart.requested", (evt) => open(evt.data.active)) + event.on("server.restart.resolved", () => close()) + event.on("server.restart.expired", () => close()) + + return { open, close, decide } + }, +}) + +function RestartApprovalDialog(props: { + active: ReadonlyArray + onSelect: (decision: ServiceEvent.Decision) => void +}) { + const data = useData() + + return ( + + The following sessions have running agents and would be interrupted: + + + {(sessionID) => - {data.session.get(sessionID)?.title ?? sessionID}} + + + + } + options={{ now: "Restart now", idle: "When idle", cancel: "Cancel restart" }} + escapeKey="cancel" + onSelect={(option) => props.onSelect(option)} + /> + ) +} diff --git a/packages/tui/test/cli/tui/location-error.test.tsx b/packages/tui/test/cli/tui/location-error.test.tsx new file mode 100644 index 000000000000..b9141af8d959 --- /dev/null +++ b/packages/tui/test/cli/tui/location-error.test.tsx @@ -0,0 +1,93 @@ +/** @jsxImportSource @opentui/solid */ +import { expect, test } from "bun:test" +import { testRender } from "@opentui/solid" +import { ConfigProvider } from "../../../src/config" +import { ClientProvider } from "../../../src/context/client" +import { DataProvider, useData } from "../../../src/context/data" +import { LocationProvider, useLocation } from "../../../src/context/location" +import { Keymap } from "../../../src/context/keymap" +import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client" +import { TestTuiContexts } from "../../fixture/tui-environment" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" + +const directory = "/tmp/opencode/missing-clone" + +test("transient location sync failure does not flag the location as missing", async () => { + const fixture = await renderLocation("transient") + try { + fixture.location.set({ directory }) + await fixture.app.waitFor(() => fixture.reads.includes(directory)) + // Let the sync rejection settle through LocationProvider's catch. + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(fixture.location.error).toBeUndefined() + } finally { + fixture.app.renderer.destroy() + } +}) + +test("typed LocationNotFoundError flags the location as missing", async () => { + const fixture = await renderLocation("not-found") + try { + fixture.location.set({ directory }) + await fixture.app.waitFor(() => fixture.location.error !== undefined) + + expect(fixture.location.error?.location.directory).toBe(directory) + } finally { + fixture.app.renderer.destroy() + } +}) + +async function renderLocation(mode: "transient" | "not-found") { + const reads: string[] = [] + const calls = createFetch(async (url) => { + if (url.pathname === "/api/location") { + const requested = url.searchParams.get("location[directory]") ?? "" + reads.push(requested) + if (requested === directory) { + if (mode === "not-found") + return json( + { + _tag: "LocationNotFoundError", + location: { directory }, + message: `Location not found: ${directory}`, + }, + { status: 404 }, + ) + return json({ message: "Location unavailable" }, { status: 503 }) + } + } + return undefined + }, createEventStream()) + let data!: ReturnType + let location!: ReturnType + + function Probe() { + data = useData() + location = useLocation() + return null + } + + const app = await testRender( + () => ( + + + + + + + + + + + + + + ), + { width: 100, height: 30, kittyKeyboard: true }, + ) + app.renderer.start() + await app.waitFor(() => location !== undefined) + + return { app, data, location, reads } +} diff --git a/packages/tui/test/cli/tui/restart-approval.test.tsx b/packages/tui/test/cli/tui/restart-approval.test.tsx new file mode 100644 index 000000000000..f27d0620b5e9 --- /dev/null +++ b/packages/tui/test/cli/tui/restart-approval.test.tsx @@ -0,0 +1,172 @@ +/** @jsxImportSource @opentui/solid */ +import { expect, test } from "bun:test" +import { testRender } from "@opentui/solid" +import { ConfigProvider } from "../../../src/config" +import { ClientProvider } from "../../../src/context/client" +import { DataProvider, useData } from "../../../src/context/data" +import { Keymap } from "../../../src/context/keymap" +import { RestartApprovalProvider, useRestartApproval } from "../../../src/context/restart-approval" +import { ThemeProvider } from "../../../src/context/theme" +import { DialogProvider } from "../../../src/ui/dialog" +import { ToastProvider } from "../../../src/ui/toast" +import { emptyThemeSource } from "../../fixture/fixture" +import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client" +import { TestTuiContexts } from "../../fixture/tui-environment" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" + +const directory = "/tmp/opencode/restart-approval" +const title = "Restart service with running agents?" + +test("restart request opens approval dialog and deciding resolves the intent", async () => { + const fixture = await renderApproval() + try { + await fixture.data.session.sync("ses_1") + fixture.events.emit({ + id: "evt_restart_requested", + type: "server.restart.requested", + created: 1, + data: { active: ["ses_1", "ses_2"] }, + }) + const frame = await fixture.app.waitForFrame((frame) => frame.includes(title) && frame.includes("Lane one")) + expect(frame).toContain("ses_2") + + await fixture.approval.decide("idle") + + await fixture.app.waitFor(() => fixture.resolves.length === 1) + expect(fixture.resolves).toEqual([{ decision: "idle" }]) + } finally { + fixture.app.renderer.destroy() + } +}) + +test("resolution from another surface dismisses the dialog", async () => { + const fixture = await renderApproval() + try { + fixture.events.emit({ + id: "evt_restart_requested", + type: "server.restart.requested", + created: 1, + data: { active: ["ses_1"] }, + }) + await fixture.app.waitForFrame((frame) => frame.includes(title)) + fixture.events.emit({ + id: "evt_restart_resolved", + type: "server.restart.resolved", + created: 2, + data: { decision: "now" }, + }) + await fixture.app.waitForFrame((frame) => !frame.includes(title)) + expect(fixture.resolves).toEqual([]) + } finally { + fixture.app.renderer.destroy() + } +}) + +test("an expired request dismisses the dialog", async () => { + const fixture = await renderApproval() + try { + fixture.events.emit({ + id: "evt_restart_requested", + type: "server.restart.requested", + created: 1, + data: { active: ["ses_1"] }, + }) + await fixture.app.waitForFrame((frame) => frame.includes(title)) + fixture.events.emit({ + id: "evt_restart_expired", + type: "server.restart.expired", + created: 2, + data: {}, + }) + await fixture.app.waitForFrame((frame) => !frame.includes(title)) + expect(fixture.resolves).toEqual([]) + } finally { + fixture.app.renderer.destroy() + } +}) + +test("a stale resolve dismisses silently without an error toast", async () => { + const fixture = await renderApproval({ resolveFails: true }) + try { + fixture.events.emit({ + id: "evt_restart_requested", + type: "server.restart.requested", + created: 1, + data: { active: ["ses_1"] }, + }) + await fixture.app.waitForFrame((frame) => frame.includes(title)) + + await fixture.approval.decide("now") + + await fixture.app.waitFor(() => fixture.resolves.length === 1) + const frame = await fixture.app.waitForFrame((frame) => !frame.includes(title)) + expect(frame).not.toContain("Failed to resolve restart") + } finally { + fixture.app.renderer.destroy() + } +}) + +async function renderApproval(input: { resolveFails?: boolean } = {}) { + const resolves: unknown[] = [] + const events = createEventStream() + const calls = createFetch(async (url, request) => { + if (url.pathname === "/api/server/restart/resolve") { + const payload = await request.json() + resolves.push(payload) + if (input.resolveFails) + return json({ _tag: "InvalidRequestError", message: "No pending service restart request" }, { status: 400 }) + return json({ decision: payload.decision }) + } + if (url.pathname === "/api/session/ses_1") { + return json({ + data: { + id: "ses_1", + projectID: "proj_test", + location: { directory }, + title: "Lane one", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + }, + }) + } + return undefined + }, events) + let data!: ReturnType + let approval!: ReturnType + + function Probe() { + data = useData() + approval = useRestartApproval() + return null + } + + const app = await testRender( + () => ( + + + + + + + + + + + + + + + + + + + + ), + { width: 100, height: 30, kittyKeyboard: true }, + ) + app.renderer.start() + await app.waitFor(() => approval !== undefined) + + return { app, data, approval, events, resolves } +}