From 01e2153758697aff938d1abe87e501960d3a2290 Mon Sep 17 00:00:00 2001 From: Ruslan Grinev Date: Wed, 28 Oct 2026 01:06:37 +0300 Subject: [PATCH] feat(OTB-164): connect to the registered OpenCode V2 server on another port When the configured address does not answer on V2 and OpenCode's registered background server runs on another port, /opencode_start and the auto-restart monitor connect the bot to it instead of refusing. The connected address lives in memory until the bot restarts, and /opencode_stop then stops that server. Co-Authored-By: Claude Opus 5.5 --- PRODUCT.md | 3 +- src/bot/commands/opencode-start-command.ts | 19 +- src/bot/commands/opencode-stop-command.ts | 5 +- src/i18n/ar.ts | 1 + src/i18n/de.ts | 1 + src/i18n/en.ts | 1 + src/i18n/es.ts | 1 + src/i18n/fr.ts | 2 + src/i18n/id.ts | 2 + src/i18n/it.ts | 1 + src/i18n/ko.ts | 1 + src/i18n/pt.ts | 1 + src/i18n/ru.ts | 1 + src/i18n/tr.ts | 1 + src/i18n/zh.ts | 1 + src/opencode/auto-restart.ts | 14 +- src/opencode/client.ts | 45 +++-- src/opencode/local-start.ts | 102 ++++++++--- src/opencode/server-health.ts | 40 ++-- src/opencode/server-target.ts | 55 ++++++ src/opencode/v2/client.ts | 28 ++- tests/bot/commands/opencode-start.test.ts | 46 ++++- tests/bot/commands/opencode-stop.test.ts | 19 ++ tests/helpers/reset-singleton-state.ts | 3 + tests/opencode/auto-restart.test.ts | 53 +++++- tests/opencode/client.test.ts | 89 +++++++++ tests/opencode/local-start.test.ts | 202 ++++++++++++++++++--- tests/opencode/server-health.test.ts | 10 +- tests/opencode/server-target.test.ts | 92 ++++++++++ tests/opencode/v2/client.test.ts | 50 +++++ 30 files changed, 775 insertions(+), 114 deletions(-) create mode 100644 src/opencode/server-target.ts create mode 100644 tests/opencode/client.test.ts create mode 100644 tests/opencode/server-target.test.ts diff --git a/PRODUCT.md b/PRODUCT.md index 389e1bfa3..090383187 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -31,7 +31,8 @@ No public inbound ports are required for normal usage. - Works with OpenCode V1 and OpenCode V2 servers; the API version is set in configuration (`OPENCODE_SERVER_VERSION`, default V1) and a server of the other version is reported in the log - Check OpenCode server status (running / not running) -- Start OpenCode server from the app: `opencode serve` on V1, the registered background server (`opencode serve --service`) on V2; no start while the configured address answers with the wrong password or as the other version, while the local `opencode` executable is the other version, or on V2 while a registered V2 server runs on another port — the reason goes to the log +- Start OpenCode server from the app: `opencode serve` on V1, the registered background server (`opencode serve --service`) on V2; no start while the configured address answers with the wrong password or as the other version, or while the local `opencode` executable is the other version — the reason goes to the log +- On V2, when the configured address does not answer and OpenCode's registered V2 server runs on another port, `/opencode_start` and auto-restart connect the bot to that server instead of starting one; the connection lasts until the bot restarts, and `/opencode_stop` then stops that server - Stop OpenCode server from the app - Optionally monitor and auto-restart a local OpenCode server, with the same start rules diff --git a/src/bot/commands/opencode-start-command.ts b/src/bot/commands/opencode-start-command.ts index 0d141ce02..591d5187f 100644 --- a/src/bot/commands/opencode-start-command.ts +++ b/src/bot/commands/opencode-start-command.ts @@ -2,7 +2,7 @@ import { CommandContext, Context } from "grammy"; import type { AppContainer } from "../../app/bootstrap/app-container.js"; import { config } from "../../config.js"; import { opencodeClient, opencodeServerVersion } from "../../opencode/client.js"; -import { canStartLocalOpencodeServer } from "../../opencode/local-start.js"; +import { resolveUnavailableOpencodeServer } from "../../opencode/local-start.js"; import { resolveLocalOpencodeTarget, startLocalOpencodeServer } from "../../opencode/process.js"; import { explainFailedHealthCheck } from "../../opencode/server-health.js"; import { logger } from "../../utils/logger.js"; @@ -116,11 +116,26 @@ export async function opencodeStartCommand( // would then skip the ready refresh (model catalog, selection check, session restore). deps.opencodeReadyLifecycle.notifyUnavailable("opencode_start_not_running"); - if (!(await canStartLocalOpencodeServer(localTarget, "always"))) { + const resolution = await resolveUnavailableOpencodeServer(localTarget, "always"); + if (resolution.kind === "refuse") { await ctx.reply(t("opencode_start.error")); return; } + if (resolution.kind === "answers") { + await ctx.reply(t("opencode_start.already_running", { version: resolution.version })); + await deps.opencodeReadyLifecycle.notifyReady("opencode_start_already_running"); + return; + } + + if (resolution.kind === "connected") { + await ctx.reply( + t("opencode_start.connected", { url: resolution.url, version: resolution.version }), + ); + await deps.opencodeReadyLifecycle.notifyReady("opencode_start_connected"); + return; + } + const statusMessage = await ctx.reply(t("opencode_start.starting")); const childProcess = startLocalOpencodeServer(localTarget, opencodeServerVersion); diff --git a/src/bot/commands/opencode-stop-command.ts b/src/bot/commands/opencode-stop-command.ts index 0b336126a..30e412fc3 100644 --- a/src/bot/commands/opencode-stop-command.ts +++ b/src/bot/commands/opencode-stop-command.ts @@ -1,6 +1,6 @@ import { CommandContext, Context } from "grammy"; import type { AppContainer } from "../../app/bootstrap/app-container.js"; -import { config } from "../../config.js"; +import { getActiveOpencodeServer } from "../../opencode/server-target.js"; import { findServerPid, killServerProcess, @@ -72,7 +72,8 @@ export async function opencodeStopCommand( return; } - const localTarget = resolveLocalOpencodeTarget(config.opencode.apiUrl); + // After a switch to another server, that server is the one stopped. + const localTarget = resolveLocalOpencodeTarget(getActiveOpencodeServer().url); if (!localTarget) { await ctx.reply(t("opencode_stop.remote_configured")); return; diff --git a/src/i18n/ar.ts b/src/i18n/ar.ts index 0ef23d9dc..58f7b0aa8 100644 --- a/src/i18n/ar.ts +++ b/src/i18n/ar.ts @@ -285,6 +285,7 @@ export const ar: I18nDictionary = { "opencode_start.already_running_external": "✅ خادم OpenCode يعمل بالفعل كعملية خارجية\n\nالإصدار: {version}\n\nلم يتم تشغيل هذا الخادم بواسطة البوت، لذلك لا يمكن إيقافه باستخدام /opencode-stop.", "opencode_start.already_running": "✅ خادم OpenCode يعمل بالفعل\n\nالإصدار: {version}", + "opencode_start.connected": "✅ تم الاتصال بخادم OpenCode قيد التشغيل\n{url} · v{version}", "opencode_start.remote_configured": "⚠️ يعمل /opencode_start فقط مع خادم OpenCode محلي.", "opencode_start.starting": "🔄 جارٍ تشغيل خادم OpenCode...", "opencode_start.start_error": diff --git a/src/i18n/de.ts b/src/i18n/de.ts index ef42750fd..cbae3e2c1 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -305,6 +305,7 @@ export const de: I18nDictionary = { "opencode_start.already_running_external": "✅ OpenCode-Server läuft bereits als externer Prozess\n\nVersion: {version}\n\nDieser Server wurde nicht vom Bot gestartet, daher kann /opencode-stop ihn nicht stoppen.", "opencode_start.already_running": "✅ OpenCode-Server läuft bereits\n\nVersion: {version}", + "opencode_start.connected": "✅ Mit einem laufenden OpenCode-Server verbunden\n{url} · v{version}", "opencode_start.remote_configured": "⚠️ /opencode_start funktioniert nur mit einem lokalen OpenCode-Server.", "opencode_start.starting": "🔄 Starte OpenCode-Server...", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 2272ddff4..5ab56a681 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -289,6 +289,7 @@ export const en = { "opencode_start.already_running_external": "✅ OpenCode Server is already running as an external process\n\nVersion: {version}\n\nThis server was not started by bot, so /opencode-stop cannot stop it.", "opencode_start.already_running": "✅ OpenCode Server is already running\n\nVersion: {version}", + "opencode_start.connected": "✅ Connected to a running OpenCode Server\n{url} · v{version}", "opencode_start.remote_configured": "⚠️ /opencode_start works only with a local OpenCode Server.", "opencode_start.starting": "🔄 Starting OpenCode Server...", "opencode_start.start_error": diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 2d0b43d05..fbe225767 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -302,6 +302,7 @@ export const es: I18nDictionary = { "opencode_start.already_running_external": "✅ OpenCode Server ya está en ejecución como un proceso externo\n\nVersión: {version}\n\nEste servidor no fue iniciado por el bot, por lo que /opencode-stop no puede detenerlo.", "opencode_start.already_running": "✅ OpenCode Server ya está en ejecución\n\nVersión: {version}", + "opencode_start.connected": "✅ Conectado a un OpenCode Server en ejecución\n{url} · v{version}", "opencode_start.remote_configured": "⚠️ /opencode_start solo funciona con un OpenCode Server local.", "opencode_start.starting": "🔄 Iniciando OpenCode Server...", diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts index cac30629c..92ab70f1b 100644 --- a/src/i18n/fr.ts +++ b/src/i18n/fr.ts @@ -306,6 +306,8 @@ export const fr: I18nDictionary = { "✅ Le serveur OpenCode est déjà en cours d'exécution en tant que processus externe\n\nVersion : {version}\n\nCe serveur n'a pas été démarré par le bot, donc /opencode-stop ne peut pas l'arrêter.", "opencode_start.already_running": "✅ Le serveur OpenCode est déjà en cours d'exécution\n\nVersion : {version}", + "opencode_start.connected": + "✅ Connecté à un serveur OpenCode en cours d'exécution\n{url} · v{version}", "opencode_start.remote_configured": "⚠️ /opencode_start fonctionne uniquement avec un serveur OpenCode local.", "opencode_start.starting": "🔄 Démarrage du serveur OpenCode...", diff --git a/src/i18n/id.ts b/src/i18n/id.ts index 37f808c48..108b20fdc 100644 --- a/src/i18n/id.ts +++ b/src/i18n/id.ts @@ -290,6 +290,8 @@ export const id: I18nDictionary = { "opencode_start.already_running_external": "✅ Server OpenCode sudah berjalan sebagai proses eksternal\n\nVersi: {version}\n\nServer ini tidak dimulai oleh bot, jadi /opencode-stop tidak bisa menghentikannya.", "opencode_start.already_running": "✅ Server OpenCode sudah berjalan\n\nVersi: {version}", + "opencode_start.connected": + "✅ Terhubung ke Server OpenCode yang sedang berjalan\n{url} · v{version}", "opencode_start.remote_configured": "⚠️ /opencode_start hanya berfungsi dengan Server OpenCode lokal.", "opencode_start.starting": "🔄 Memulai Server OpenCode...", diff --git a/src/i18n/it.ts b/src/i18n/it.ts index 4f9d907b9..c08a7d72a 100644 --- a/src/i18n/it.ts +++ b/src/i18n/it.ts @@ -303,6 +303,7 @@ export const it: I18nDictionary = { "opencode_start.already_running_external": "✅ Il server OpenCode è già in esecuzione come processo esterno\n\nVersione: {version}\n\nQuesto server non è stato avviato dal bot, quindi /opencode-stop non può fermarlo.", "opencode_start.already_running": "✅ Il server OpenCode è già in esecuzione\n\nVersione: {version}", + "opencode_start.connected": "✅ Connesso a un server OpenCode in esecuzione\n{url} · v{version}", "opencode_start.remote_configured": "⚠️ /opencode_start funziona solo con un server OpenCode locale.", "opencode_start.starting": "🔄 Avvio del server OpenCode...", "opencode_start.start_error": diff --git a/src/i18n/ko.ts b/src/i18n/ko.ts index 9afb0742f..d60c50898 100644 --- a/src/i18n/ko.ts +++ b/src/i18n/ko.ts @@ -298,6 +298,7 @@ export const ko: I18nDictionary = { "opencode_start.already_running_external": "✅ OpenCode 서버가 외부 프로세스로 이미 실행 중입니다\n\n버전: {version}\n\n이 서버는 봇이 시작한 것이 아니므로 /opencode-stop으로 중지할 수 없습니다.", "opencode_start.already_running": "✅ OpenCode 서버가 이미 실행 중입니다\n\n버전: {version}", + "opencode_start.connected": "✅ 실행 중인 OpenCode 서버에 연결했습니다\n{url} · v{version}", "opencode_start.remote_configured": "⚠️ /opencode_start는 로컬 OpenCode 서버에서만 동작합니다.", "opencode_start.starting": "🔄 OpenCode 서버를 시작하는 중...", "opencode_start.start_error": diff --git a/src/i18n/pt.ts b/src/i18n/pt.ts index 92ba41f57..25ea17e50 100644 --- a/src/i18n/pt.ts +++ b/src/i18n/pt.ts @@ -302,6 +302,7 @@ export const pt: I18nDictionary = { "opencode_start.already_running_external": "✅ O OpenCode Server já está em execução como um processo externo\n\nVersão: {version}\n\nEste servidor não foi iniciado pelo bot, então /opencode-stop não pode encerrá-lo.", "opencode_start.already_running": "✅ O OpenCode Server já está em execução\n\nVersão: {version}", + "opencode_start.connected": "✅ Conectado a um OpenCode Server em execução\n{url} · v{version}", "opencode_start.remote_configured": "⚠️ O /opencode_start funciona apenas com um OpenCode Server local.", "opencode_start.starting": "🔄 Iniciando o OpenCode Server...", diff --git a/src/i18n/ru.ts b/src/i18n/ru.ts index c43ff9826..92e6a78d9 100644 --- a/src/i18n/ru.ts +++ b/src/i18n/ru.ts @@ -292,6 +292,7 @@ export const ru: I18nDictionary = { "opencode_start.already_running_external": "✅ OpenCode Server уже запущен внешним процессом\n\nВерсия: {version}\n\nЭтот сервер не был запущен через бота, поэтому команда /opencode-stop не сможет его остановить.", "opencode_start.already_running": "✅ OpenCode Server уже запущен\n\nВерсия: {version}", + "opencode_start.connected": "✅ Подключено к запущенному OpenCode Server\n{url} · v{version}", "opencode_start.remote_configured": "⚠️ /opencode_start работает только с локальным OpenCode Server.", "opencode_start.starting": "🔄 Запускаю OpenCode Server...", diff --git a/src/i18n/tr.ts b/src/i18n/tr.ts index b6ce2fd2a..18c41133c 100644 --- a/src/i18n/tr.ts +++ b/src/i18n/tr.ts @@ -287,6 +287,7 @@ export const tr: I18nDictionary = { "opencode_start.already_running_external": "✅ OpenCode Sunucusu zaten harici bir süreç olarak çalışıyor\n\nSürüm: {version}\n\nBu sunucu bot tarafından başlatılmadı, bu yüzden /opencode-stop onu durduramaz.", "opencode_start.already_running": "✅ OpenCode Sunucusu zaten çalışıyor\n\nSürüm: {version}", + "opencode_start.connected": "✅ Çalışan bir OpenCode Sunucusuna bağlanıldı\n{url} · v{version}", "opencode_start.remote_configured": "⚠️ /opencode_start yalnızca yerel bir OpenCode Sunucusu ile çalışır.", "opencode_start.starting": "🔄 OpenCode Sunucusu başlatılıyor...", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 2e6fb4f90..2dd2e5481 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -263,6 +263,7 @@ export const zh: I18nDictionary = { "opencode_start.already_running_external": "✅ OpenCode 服务器正作为外部进程运行\n\n版本:{version}\n\n该服务器不是由机器人启动,因此 /opencode-stop 无法停止它。", "opencode_start.already_running": "✅ OpenCode 服务器已在运行\n\n版本:{version}", + "opencode_start.connected": "✅ 已连接到正在运行的 OpenCode 服务器\n{url} · v{version}", "opencode_start.remote_configured": "⚠️ /opencode_start 仅适用于本地 OpenCode 服务器。", "opencode_start.starting": "🔄 正在启动 OpenCode 服务器...", "opencode_start.start_error": diff --git a/src/opencode/auto-restart.ts b/src/opencode/auto-restart.ts index 369059994..6e1944c99 100644 --- a/src/opencode/auto-restart.ts +++ b/src/opencode/auto-restart.ts @@ -2,7 +2,7 @@ import { config } from "../config.js"; import { isContainerRuntime } from "../runtime/container.js"; import { logger } from "../utils/logger.js"; import { opencodeClient, opencodeServerVersion } from "./client.js"; -import { canStartLocalOpencodeServer } from "./local-start.js"; +import { resolveUnavailableOpencodeServer } from "./local-start.js"; import type { OpencodeReadyLifecycle } from "./ready-lifecycle.js"; import { explainFailedHealthCheck } from "./server-health.js"; import { @@ -154,13 +154,23 @@ export class OpencodeAutoRestartService { return; } - if (!(await canStartLocalOpencodeServer(this.localTarget, "once"))) { + const resolution = await resolveUnavailableOpencodeServer(this.localTarget, "once"); + if (resolution.kind === "refuse") { logger.debug( `[OpenCodeAutoRestart] OpenCode server is unavailable; not starting a local server: reason=${reason}`, ); return; } + if (resolution.kind === "answers" || resolution.kind === "connected") { + logger.info( + `[OpenCodeAutoRestart] OpenCode server answers: version=${resolution.version}, reason=${reason}`, + ); + this.serverWasHealthy = true; + await this.opencodeReadyLifecycle.notifyReady(`auto_restart_${reason}`); + return; + } + logger.warn( `[OpenCodeAutoRestart] OpenCode server is unavailable, starting local server: reason=${reason}, port=${this.localTarget.port}`, ); diff --git a/src/opencode/client.ts b/src/opencode/client.ts index 2e0de020e..ce8365687 100644 --- a/src/opencode/client.ts +++ b/src/opencode/client.ts @@ -1,23 +1,27 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2"; import { config, type OpencodeServerVersion } from "../config.js"; +import { getActiveOpencodeServer, type OpencodeServerTarget } from "./server-target.js"; import { createV2OpencodeClient, - findRegisteredV2ServerUrl, + findRegisteredV2Server, + type RegisteredV2Server, type V2ClientExtension, } from "./v2/client.js"; const PROBE_TIMEOUT_MS = 5000; -const getAuth = () => { - if (!config.opencode.password) { +const getAuth = (server: OpencodeServerTarget) => { + if (!server.password) { return undefined; } - const credentials = `${config.opencode.username}:${config.opencode.password}`; + const credentials = `${server.username ?? ""}:${server.password}`; return `Basic ${Buffer.from(credentials).toString("base64")}`; }; -const authHeaders = (): Record | undefined => { - const auth = getAuth(); +const authHeaders = ( + server: OpencodeServerTarget = getActiveOpencodeServer(), +): Record | undefined => { + const auth = getAuth(server); return auth ? { Authorization: auth } : undefined; }; @@ -26,16 +30,20 @@ export const opencodeServerVersion: OpencodeServerVersion = config.opencode.serverVersion === "v2" ? "v2" : "v1"; function createClient() { - const headers = authHeaders(); if (opencodeServerVersion === "v2") { + // Read on every request, so the client follows a switch to another server. return createV2OpencodeClient({ - baseUrl: config.opencode.apiUrl, - ...(headers ? { headers } : {}), + get baseUrl() { + return getActiveOpencodeServer().url; + }, + get headers() { + return authHeaders() ?? {}; + }, }); } return createOpencodeClient({ baseUrl: config.opencode.apiUrl, - headers, + headers: authHeaders(), }); } @@ -69,17 +77,16 @@ function readServerVersion(version: OpencodeServerVersion, body: unknown): strin } /** - * Asks the configured URL whether it answers the given API version, with the configured - * credentials. Used to explain a failed health check, never to switch clients. + * Asks a server (by default the active one) whether it answers the given API version, with + * that server's credentials. */ export async function probeOpencodeServer( version: OpencodeServerVersion, + server: OpencodeServerTarget = getActiveOpencodeServer(), ): Promise { - const baseUrl = config.opencode.apiUrl.endsWith("/") - ? config.opencode.apiUrl - : `${config.opencode.apiUrl}/`; + const baseUrl = server.url.endsWith("/") ? server.url : `${server.url}/`; try { - const headers = authHeaders(); + const headers = authHeaders(server); const response = await fetch(new URL(PROBE_ROUTES[version], baseUrl), { ...(headers ? { headers } : {}), signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), @@ -98,7 +105,7 @@ export async function probeOpencodeServer( } } -/** URL of the registered OpenCode V2 background server, or null when none answers. */ -export async function findRegisteredOpencodeServerUrl(): Promise { - return findRegisteredV2ServerUrl(); +/** The registered OpenCode V2 background server and its credentials, or null when none answers. */ +export async function findRegisteredOpencodeServer(): Promise { + return findRegisteredV2Server(); } diff --git a/src/opencode/local-start.ts b/src/opencode/local-start.ts index b257fdfae..f208ce4f9 100644 --- a/src/opencode/local-start.ts +++ b/src/opencode/local-start.ts @@ -1,5 +1,9 @@ import { logger } from "../utils/logger.js"; -import { findRegisteredOpencodeServerUrl, opencodeServerVersion } from "./client.js"; +import { + findRegisteredOpencodeServer, + opencodeServerVersion, + probeOpencodeServer, +} from "./client.js"; import { getOpencodeApiVersion, readLocalOpencodeVersion, @@ -12,6 +16,30 @@ import { reportOpencodeProblem, type ProblemReportMode, } from "./server-health.js"; +import { + getConfiguredOpencodeServer, + isOnConfiguredOpencodeServer, + switchOpencodeServer, + type OpencodeServerTarget, +} from "./server-target.js"; + +/** + * What to do about an active OpenCode server that failed its health check: + * - `answers`: the active server, or the configured one again, answers after all; + * - `connected`: the bot now talks to the registered V2 server at `url`; + * - `start`: start a local server for the configured URL; + * - `refuse`: neither; the reason is in the log. + */ +export type UnavailableOpencodeServerResolution = + | { kind: "answers"; version: string } + | { kind: "connected"; url: string; version: string } + | { kind: "start" } + | { kind: "refuse" }; + +type RegisteredServerLookup = + | { kind: "none" } + | { kind: "unusable" } + | { kind: "found"; server: OpencodeServerTarget; version: string }; function getUrlPort(url: string): number | null { try { @@ -23,26 +51,35 @@ function getUrlPort(url: string): number | null { } } -/** Refuses when OpenCode V2 already runs its one registered background server on another port. */ -async function isBlockedByRegisteredServer( +/** The OpenCode V2 background server registered on another port, checked with its own credentials. */ +async function findRegisteredServerOnAnotherPort( target: LocalOpencodeTarget, mode: ProblemReportMode, -): Promise { +): Promise { if (opencodeServerVersion !== "v2") { - return false; + return { kind: "none" }; } - const registeredUrl = await findRegisteredOpencodeServerUrl(); - if (!registeredUrl || getUrlPort(registeredUrl) === target.port) { - return false; + const registered = await findRegisteredOpencodeServer(); + if (!registered || getUrlPort(registered.url) === target.port) { + return { kind: "none" }; + } + + const probe = await probeOpencodeServer("v2", registered); + if (probe.kind === "found") { + return { kind: "found", server: registered, version: probe.serverVersion }; } - reportOpencodeProblem(`registered:${registeredUrl}`, mode, () => + const problem = + probe.kind === "unauthorized" + ? "rejected the credentials from the OpenCode registry" + : "did not answer as an OpenCode v2 server"; + reportOpencodeProblem(`registered:${registered.url}`, mode, () => logger.error( - `[OpenCode] Not starting a local OpenCode v2 server for ${describeServerUrl()}: a registered OpenCode v2 background server is already running at ${registeredUrl}, and starting another one would replace it. Set OPENCODE_API_URL=${registeredUrl} and restart the bot, or stop that server (opencode service stop).`, + `[OpenCode] Not connecting to the registered OpenCode v2 background server at ${describeServerUrl(registered)}: it ${problem}. Not starting another local server either, since that would replace it; stop that server (opencode service stop) or fix it.`, ), ); - return true; + return { kind: "unusable" }; } /** Refuses when the local `opencode` executable is a release of the other API version. */ @@ -66,22 +103,45 @@ async function isBlockedByExecutableVersion(mode: ProblemReportMode): Promise { - const cause = await classifyFailedHealthCheck(); +): Promise { + const active = await probeOpencodeServer(opencodeServerVersion); + if (active.kind === "found") { + return { kind: "answers", version: active.serverVersion }; + } + + const configured = getConfiguredOpencodeServer(); + if (!isOnConfiguredOpencodeServer()) { + const configuredProbe = await probeOpencodeServer(opencodeServerVersion, configured); + if (configuredProbe.kind === "found") { + switchOpencodeServer(null); + return { kind: "answers", version: configuredProbe.serverVersion }; + } + } + + const cause = await classifyFailedHealthCheck(configured); if (cause.kind !== "unknown") { - reportFailedHealthCause(cause, mode); - return false; + reportFailedHealthCause(cause, mode, configured); + return { kind: "refuse" }; } - if (await isBlockedByRegisteredServer(target, mode)) { - return false; + const registered = await findRegisteredServerOnAnotherPort(target, mode); + if (registered.kind === "found") { + switchOpencodeServer(registered.server); + return { kind: "connected", url: registered.server.url, version: registered.version }; + } + if (registered.kind === "unusable" || (await isBlockedByExecutableVersion(mode))) { + return { kind: "refuse" }; } - return !(await isBlockedByExecutableVersion(mode)); + switchOpencodeServer(null); + return { kind: "start" }; } diff --git a/src/opencode/server-health.ts b/src/opencode/server-health.ts index 96764671d..3e437a282 100644 --- a/src/opencode/server-health.ts +++ b/src/opencode/server-health.ts @@ -1,7 +1,12 @@ -import { config, type OpencodeServerVersion } from "../config.js"; +import type { OpencodeServerVersion } from "../config.js"; import { logger } from "../utils/logger.js"; import { isExpectedOpencodeUnavailableError } from "../utils/opencode-error.js"; import { opencodeClient, opencodeServerVersion, probeOpencodeServer } from "./client.js"; +import { + describeOpencodeServerUrl, + getActiveOpencodeServer, + type OpencodeServerTarget, +} from "./server-target.js"; export type OpencodeHealth = { healthy: true; version: string | undefined } | { healthy: false; error: unknown }; @@ -9,7 +14,7 @@ export type OpencodeHealth = /** "always" logs every time (a user asked); "once" logs a problem once until it changes. */ export type ProblemReportMode = "always" | "once"; -/** Why the configured URL did not answer as a healthy server of the configured version. */ +/** Why a server did not answer as a healthy server of the configured version. */ export type FailedHealthCause = | { kind: "unauthorized" } | { kind: "mismatch"; actual: string; otherVersion: OpencodeServerVersion } @@ -17,13 +22,10 @@ export type FailedHealthCause = let lastReportedProblem: string | null = null; -export function describeServerUrl(): string { - try { - const url = new URL(config.opencode.apiUrl); - return `${url.protocol}//${url.host}${url.pathname === "/" ? "" : url.pathname}`; - } catch { - return "the configured OPENCODE_API_URL"; - } +export function describeServerUrl( + server: OpencodeServerTarget = getActiveOpencodeServer(), +): string { + return describeOpencodeServerUrl(server.url); } /** Logs an OpenCode problem; in "once" mode the same problem is not logged again in a row. */ @@ -40,14 +42,16 @@ export function reportOpencodeProblem( } /** Tells wrong credentials and a server of the other version apart from anything else. */ -export async function classifyFailedHealthCheck(): Promise { - const configured = await probeOpencodeServer(opencodeServerVersion); +export async function classifyFailedHealthCheck( + server: OpencodeServerTarget = getActiveOpencodeServer(), +): Promise { + const configured = await probeOpencodeServer(opencodeServerVersion, server); if (configured.kind === "unauthorized") { return { kind: "unauthorized" }; } const otherVersion = opencodeServerVersion === "v1" ? "v2" : "v1"; - const other = await probeOpencodeServer(otherVersion); + const other = await probeOpencodeServer(otherVersion, server); // A server that answers the configured route without asking for credentials but locks // the other version's route is that other version: a password-protected server locks // every route. Its exact version stays unknown without the password. @@ -61,11 +65,15 @@ export async function classifyFailedHealthCheck(): Promise { } /** Writes the log line for a failed health check cause; an unknown cause logs nothing. */ -export function reportFailedHealthCause(cause: FailedHealthCause, mode: ProblemReportMode): void { +export function reportFailedHealthCause( + cause: FailedHealthCause, + mode: ProblemReportMode, + server: OpencodeServerTarget = getActiveOpencodeServer(), +): void { if (cause.kind === "unauthorized") { reportOpencodeProblem("unauthorized", mode, () => logger.warn( - `[OpenCode] Authentication failed at ${describeServerUrl()}: check OPENCODE_SERVER_USERNAME and OPENCODE_SERVER_PASSWORD`, + `[OpenCode] Authentication failed at ${describeServerUrl(server)}: check OPENCODE_SERVER_USERNAME and OPENCODE_SERVER_PASSWORD`, ), ); return; @@ -74,7 +82,7 @@ export function reportFailedHealthCause(cause: FailedHealthCause, mode: ProblemR if (cause.kind === "mismatch") { reportOpencodeProblem(`mismatch:${cause.actual}`, mode, () => logger.error( - `[OpenCode] Server version mismatch: OPENCODE_SERVER_VERSION=${opencodeServerVersion}, but the server at ${describeServerUrl()} is ${cause.actual} (API ${cause.otherVersion}). Set OPENCODE_SERVER_VERSION=${cause.otherVersion} and restart the bot, or run an OpenCode ${opencodeServerVersion} server at this address.`, + `[OpenCode] Server version mismatch: OPENCODE_SERVER_VERSION=${opencodeServerVersion}, but the server at ${describeServerUrl(server)} is ${cause.actual} (API ${cause.otherVersion}). Set OPENCODE_SERVER_VERSION=${cause.otherVersion} and restart the bot, or run an OpenCode ${opencodeServerVersion} server at this address.`, ), ); } @@ -86,7 +94,7 @@ export async function explainFailedHealthCheck(mode: ProblemReportMode): Promise } /** - * Health of the server at the configured URL through the configured API version. A server + * Health of the active server through the configured API version. A server * that answers with anything but the expected health shape is unhealthy. */ export async function checkOpencodeHealth(): Promise { diff --git a/src/opencode/server-target.ts b/src/opencode/server-target.ts new file mode 100644 index 000000000..83af5091a --- /dev/null +++ b/src/opencode/server-target.ts @@ -0,0 +1,55 @@ +import { config } from "../config.js"; +import { logger } from "../utils/logger.js"; + +/** An OpenCode server address with the basic-auth credentials it expects, if any. */ +export interface OpencodeServerTarget { + url: string; + username?: string; + password?: string; +} + +// The server the bot talks to when it is not the configured one; lives until the bot restarts. +let switchedServer: OpencodeServerTarget | null = null; + +export function getConfiguredOpencodeServer(): OpencodeServerTarget { + return { + url: config.opencode.apiUrl, + username: config.opencode.username, + password: config.opencode.password, + }; +} + +/** The server every OpenCode request goes to. */ +export function getActiveOpencodeServer(): OpencodeServerTarget { + return switchedServer ?? getConfiguredOpencodeServer(); +} + +export function isOnConfiguredOpencodeServer(): boolean { + return switchedServer === null; +} + +/** A server address for the log, without any credentials written into it. */ +export function describeOpencodeServerUrl(serverUrl: string): string { + try { + const url = new URL(serverUrl); + return `${url.protocol}//${url.host}${url.pathname === "/" ? "" : url.pathname}`; + } catch { + return "the configured OPENCODE_API_URL"; + } +} + +/** Points every later OpenCode request at the given server, or at the configured one for null. */ +export function switchOpencodeServer(server: OpencodeServerTarget | null): void { + const previousUrl = getActiveOpencodeServer().url; + switchedServer = server && server.url !== config.opencode.apiUrl ? server : null; + const nextUrl = getActiveOpencodeServer().url; + if (nextUrl !== previousUrl) { + logger.info( + `[OpenCode] Switched the OpenCode server from ${describeOpencodeServerUrl(previousUrl)} to ${describeOpencodeServerUrl(nextUrl)}`, + ); + } +} + +export function __resetOpencodeServerTargetForTests(): void { + switchedServer = null; +} diff --git a/src/opencode/v2/client.ts b/src/opencode/v2/client.ts index 4376548e9..4d205f7a0 100644 --- a/src/opencode/v2/client.ts +++ b/src/opencode/v2/client.ts @@ -146,10 +146,9 @@ const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, * `V2ClientExtension`; everything else is absent. */ export function createV2OpencodeClient(options: V2ClientOptions): OpencodeClient { - const client: OpenCodeClient = OpenCode.make({ - baseUrl: options.baseUrl, - ...(options.headers ? { headers: options.headers } : {}), - }); + // Handed over as is: the SDK reads the address and headers on every request and stream + // connect, so an options object with getters follows a server switch. + const client: OpenCodeClient = OpenCode.make(options); // Replies to V2 forms and permissions need the session and, for forms, the fields; // both are learned from the lists and the event stream. @@ -688,13 +687,26 @@ export function createV2OpencodeClient(options: V2ClientOptions): OpencodeClient return adapter as unknown as OpencodeClient; } +export interface RegisteredV2Server { + url: string; + username?: string; + password?: string; +} + /** - * URL of the V2 background server registered for this user, or null when no registered - * server answers. There is one per user: a new registered server replaces it. + * The V2 background server registered for this user, with the credentials it expects, or + * null when no registered server answers. There is one per user: a new registered server + * replaces it. */ -export async function findRegisteredV2ServerUrl(): Promise { +export async function findRegisteredV2Server(): Promise { try { - return (await Service.discover())?.url ?? null; + const endpoint = await Service.discover(); + if (!endpoint) { + return null; + } + return endpoint.auth + ? { url: endpoint.url, username: endpoint.auth.username, password: endpoint.auth.password } + : { url: endpoint.url }; } catch { return null; } diff --git a/tests/bot/commands/opencode-start.test.ts b/tests/bot/commands/opencode-start.test.ts index 35f7ecb55..555d10297 100644 --- a/tests/bot/commands/opencode-start.test.ts +++ b/tests/bot/commands/opencode-start.test.ts @@ -7,7 +7,7 @@ const mocked = vi.hoisted(() => ({ healthMock: vi.fn(), resolveLocalOpencodeTargetMock: vi.fn(), startLocalOpencodeServerMock: vi.fn(), - canStartLocalOpencodeServerMock: vi.fn(), + resolveUnavailableMock: vi.fn(), explainFailedHealthCheckMock: vi.fn(), notifyReadyMock: vi.fn(), notifyUnavailableMock: vi.fn(), @@ -37,7 +37,7 @@ vi.mock("../../../src/opencode/client.js", () => ({ })); vi.mock("../../../src/opencode/local-start.js", () => ({ - canStartLocalOpencodeServer: mocked.canStartLocalOpencodeServerMock, + resolveUnavailableOpencodeServer: mocked.resolveUnavailableMock, })); vi.mock("../../../src/opencode/server-health.js", () => ({ @@ -97,7 +97,7 @@ describe("bot/commands/opencode-start-command", () => { mocked.notifyUnavailableMock.mockReset(); mocked.resolveLocalOpencodeTargetMock.mockReset(); mocked.startLocalOpencodeServerMock.mockReset(); - mocked.canStartLocalOpencodeServerMock.mockReset(); + mocked.resolveUnavailableMock.mockReset(); mocked.explainFailedHealthCheckMock.mockReset(); mocked.notifyReadyMock.mockReset(); mocked.editBotTextMock.mockReset(); @@ -108,7 +108,7 @@ describe("bot/commands/opencode-start-command", () => { mocked.config.opencode.apiUrl = "http://localhost:4096"; mocked.resolveLocalOpencodeTargetMock.mockReturnValue({ host: "localhost", port: 4096 }); - mocked.canStartLocalOpencodeServerMock.mockResolvedValue(true); + mocked.resolveUnavailableMock.mockResolvedValue({ kind: "start" }); mocked.explainFailedHealthCheckMock.mockResolvedValue(undefined); mocked.notifyReadyMock.mockResolvedValue(true); mocked.editBotTextMock.mockResolvedValue(undefined); @@ -200,11 +200,11 @@ describe("bot/commands/opencode-start-command", () => { it("replies with the generic start failure and starts nothing when a start is refused", async () => { const ctx = createContext(); mocked.healthMock.mockRejectedValue(new Error("offline")); - mocked.canStartLocalOpencodeServerMock.mockResolvedValue(false); + mocked.resolveUnavailableMock.mockResolvedValue({ kind: "refuse" }); await opencodeStartCommand(ctx as never, createDeps()); - expect(mocked.canStartLocalOpencodeServerMock).toHaveBeenCalledWith( + expect(mocked.resolveUnavailableMock).toHaveBeenCalledWith( { host: "localhost", port: 4096 }, "always", ); @@ -214,6 +214,40 @@ describe("bot/commands/opencode-start-command", () => { expect(mocked.notifyReadyMock).not.toHaveBeenCalled(); }); + it("connects to a registered server on another port and names its address", async () => { + const ctx = createContext(); + mocked.healthMock.mockRejectedValue(new Error("offline")); + mocked.resolveUnavailableMock.mockResolvedValue({ + kind: "connected", + url: "http://127.0.0.1:28894", + version: "2.1.3", + }); + + await opencodeStartCommand(ctx as never, createDeps()); + + expect(ctx.reply).toHaveBeenCalledTimes(1); + expect(ctx.reply).toHaveBeenCalledWith( + t("opencode_start.connected", { url: "http://127.0.0.1:28894", version: "2.1.3" }), + ); + expect(mocked.startLocalOpencodeServerMock).not.toHaveBeenCalled(); + expect(mocked.notifyReadyMock).toHaveBeenCalledWith("opencode_start_connected"); + }); + + it("reports already running when a server answers after the failed health check", async () => { + const ctx = createContext(); + mocked.healthMock.mockRejectedValue(new Error("offline")); + mocked.resolveUnavailableMock.mockResolvedValue({ kind: "answers", version: "2.1.4" }); + + await opencodeStartCommand(ctx as never, createDeps()); + + expect(ctx.reply).toHaveBeenCalledTimes(1); + expect(ctx.reply).toHaveBeenCalledWith( + t("opencode_start.already_running", { version: "2.1.4" }), + ); + expect(mocked.startLocalOpencodeServerMock).not.toHaveBeenCalled(); + expect(mocked.notifyReadyMock).toHaveBeenCalledWith("opencode_start_already_running"); + }); + it("reports command error when ready lifecycle fails unexpectedly", async () => { const ctx = createContext(); const childProcess = createChildProcess(123); diff --git a/tests/bot/commands/opencode-stop.test.ts b/tests/bot/commands/opencode-stop.test.ts index 8a42eaf0a..78771e34a 100644 --- a/tests/bot/commands/opencode-stop.test.ts +++ b/tests/bot/commands/opencode-stop.test.ts @@ -67,6 +67,10 @@ vi.mock("../../../src/app/services/prompt-inbox-service.js", () => ({ import { opencodeStopCommand } from "../../../src/bot/commands/opencode-stop-command.js"; import { promptQueue } from "../../../src/app/managers/prompt-queue-manager.js"; +import { + __resetOpencodeServerTargetForTests, + switchOpencodeServer, +} from "../../../src/opencode/server-target.js"; import { createIncomingPrompt } from "../../../src/app/types/prompt.js"; import { startInteractionForTest } from "../../helpers/interaction.js"; import { createTestAppContainer } from "../../helpers/app-container.js"; @@ -115,6 +119,7 @@ describe("bot/commands/opencode-stop-command", () => { mocked.clearPromptResponseModeMock.mockReset(); mocked.notifyUnavailableMock.mockReset(); promptQueue.__resetForTests(); + __resetOpencodeServerTargetForTests(); container.interactionManager.clear("test_setup"); mocked.config.opencode.apiUrl = "http://localhost:4096"; @@ -164,6 +169,20 @@ describe("bot/commands/opencode-stop-command", () => { expect(mocked.clearRuntimeStateMock).not.toHaveBeenCalled(); }); + it("stops the server the bot switched to instead of the configured one", async () => { + const ctx = createContext(); + switchOpencodeServer({ url: "http://127.0.0.1:28894" }); + mocked.resolveLocalOpencodeTargetMock.mockReturnValue({ host: "127.0.0.1", port: 28894 }); + mocked.findServerPidMock.mockResolvedValue(789); + mocked.killServerProcessMock.mockResolvedValue(true); + + await opencodeStopCommand(ctx as never, createDeps()); + + expect(mocked.resolveLocalOpencodeTargetMock).toHaveBeenCalledWith("http://127.0.0.1:28894"); + expect(mocked.findServerPidMock).toHaveBeenCalledWith(28894); + expect(mocked.killServerProcessMock).toHaveBeenCalledWith(789, 5000); + }); + it("stops the process found on the configured port without a health check", async () => { const ctx = createContext(); mocked.findServerPidMock.mockResolvedValue(456); diff --git a/tests/helpers/reset-singleton-state.ts b/tests/helpers/reset-singleton-state.ts index 2156472eb..c1a7f1a71 100644 --- a/tests/helpers/reset-singleton-state.ts +++ b/tests/helpers/reset-singleton-state.ts @@ -10,6 +10,7 @@ export async function resetSingletonState(): Promise { { __resetStreamThrottleForTests }, { telegramOutageNoticeService }, { __resetServerHealthStateForTests }, + { __resetOpencodeServerTargetForTests }, modelSelectionModule, promptHandoverDeliveryModule, readyRefreshModule, @@ -27,6 +28,7 @@ export async function resetSingletonState(): Promise { import("../../src/bot/streaming/stream-throttle.js"), import("../../src/app/services/telegram-outage-notice-service.js"), import("../../src/opencode/server-health.js"), + import("../../src/opencode/server-target.js"), import("../../src/app/services/model-selection-service.js"), import("../../src/bot/handlers/prompt-handover.js"), import("../../src/opencode/ready-refresh.js"), @@ -45,6 +47,7 @@ export async function resetSingletonState(): Promise { telegramOutageNoticeService.__resetForTests(); __resetSessionDirectoryCacheForTests(); __resetServerHealthStateForTests(); + __resetOpencodeServerTargetForTests(); // Files that mock these modules may leave the reset out of the mock. if ( diff --git a/tests/opencode/auto-restart.test.ts b/tests/opencode/auto-restart.test.ts index a1c712235..e486f6e27 100644 --- a/tests/opencode/auto-restart.test.ts +++ b/tests/opencode/auto-restart.test.ts @@ -5,7 +5,7 @@ const mocked = vi.hoisted(() => ({ healthMock: vi.fn(), resolveLocalOpencodeTargetMock: vi.fn(), startLocalOpencodeServerMock: vi.fn(), - canStartLocalOpencodeServerMock: vi.fn(), + resolveUnavailableMock: vi.fn(), explainFailedHealthCheckMock: vi.fn(), notifyReadyMock: vi.fn(), notifyUnavailableMock: vi.fn(), @@ -36,7 +36,7 @@ vi.mock("../../src/opencode/client.js", () => ({ })); vi.mock("../../src/opencode/local-start.js", () => ({ - canStartLocalOpencodeServer: mocked.canStartLocalOpencodeServerMock, + resolveUnavailableOpencodeServer: mocked.resolveUnavailableMock, })); vi.mock("../../src/opencode/server-health.js", () => ({ @@ -89,7 +89,7 @@ describe("opencode/auto-restart", () => { mocked.healthMock.mockReset(); mocked.resolveLocalOpencodeTargetMock.mockReset(); mocked.startLocalOpencodeServerMock.mockReset(); - mocked.canStartLocalOpencodeServerMock.mockReset(); + mocked.resolveUnavailableMock.mockReset(); mocked.explainFailedHealthCheckMock.mockReset(); mocked.notifyReadyMock.mockReset(); mocked.notifyUnavailableMock.mockReset(); @@ -103,7 +103,7 @@ describe("opencode/auto-restart", () => { mocked.config.opencode.monitorIntervalSec = 300; mocked.resolveLocalOpencodeTargetMock.mockReturnValue({ host: "localhost", port: 4096 }); mocked.startLocalOpencodeServerMock.mockReturnValue(createChildProcess(123)); - mocked.canStartLocalOpencodeServerMock.mockResolvedValue(true); + mocked.resolveUnavailableMock.mockResolvedValue({ kind: "start" }); mocked.explainFailedHealthCheckMock.mockResolvedValue(undefined); mocked.notifyReadyMock.mockResolvedValue(true); }); @@ -214,14 +214,14 @@ describe("opencode/auto-restart", () => { it("starts nothing while a local start is refused, on every interval", async () => { mocked.config.opencode.autoRestartEnabled = true; mocked.healthMock.mockRejectedValue(new Error("offline")); - mocked.canStartLocalOpencodeServerMock.mockResolvedValue(false); + mocked.resolveUnavailableMock.mockResolvedValue({ kind: "refuse" }); const service = new OpencodeAutoRestartService(readyLifecycle); await service.start(); await vi.advanceTimersByTimeAsync(300_000); - expect(mocked.canStartLocalOpencodeServerMock).toHaveBeenCalledTimes(2); - expect(mocked.canStartLocalOpencodeServerMock).toHaveBeenCalledWith( + expect(mocked.resolveUnavailableMock).toHaveBeenCalledTimes(2); + expect(mocked.resolveUnavailableMock).toHaveBeenCalledWith( { host: "localhost", port: 4096 }, "once", ); @@ -231,6 +231,45 @@ describe("opencode/auto-restart", () => { service.stop(); }); + it("connects at startup to a registered server instead of starting one, without a chat message", async () => { + mocked.config.opencode.autoRestartEnabled = true; + mocked.healthMock.mockRejectedValue(new Error("offline")); + mocked.resolveUnavailableMock.mockResolvedValue({ + kind: "connected", + url: "http://127.0.0.1:28894", + version: "2.1.3", + }); + const service = new OpencodeAutoRestartService(readyLifecycle); + + await service.start(); + + expect(mocked.resolveUnavailableMock).toHaveBeenCalledWith( + { host: "localhost", port: 4096 }, + "once", + ); + expect(mocked.startLocalOpencodeServerMock).not.toHaveBeenCalled(); + expect(mocked.notifyReadyMock).toHaveBeenCalledWith("auto_restart_startup"); + + service.stop(); + }); + + it("does not notify ready again on the next tick after connecting", async () => { + mocked.config.opencode.autoRestartEnabled = true; + mocked.healthMock + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValue(healthyResponse()); + mocked.resolveUnavailableMock.mockResolvedValue({ kind: "answers", version: "2.1.3" }); + const service = new OpencodeAutoRestartService(readyLifecycle); + + await service.start(); + await vi.advanceTimersByTimeAsync(300_000); + + expect(mocked.notifyReadyMock).toHaveBeenCalledTimes(1); + expect(mocked.startLocalOpencodeServerMock).not.toHaveBeenCalled(); + + service.stop(); + }); + it("explains a started server that never becomes ready, once per problem", async () => { mocked.config.opencode.autoRestartEnabled = true; mocked.healthMock.mockRejectedValue(new Error("offline")); diff --git a/tests/opencode/client.test.ts b/tests/opencode/client.test.ts new file mode 100644 index 000000000..d81c20ac1 --- /dev/null +++ b/tests/opencode/client.test.ts @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocked = vi.hoisted(() => ({ + v2Options: null as { baseUrl: string; headers?: Record } | null, +})); + +vi.mock("../../src/config.js", () => ({ + config: { + opencode: { + serverVersion: "v2", + apiUrl: "http://127.0.0.1:49374", + username: "opencode", + password: "configured-secret", + }, + }, +})); + +vi.mock("../../src/opencode/v2/client.js", () => ({ + createV2OpencodeClient: (options: { baseUrl: string; headers?: Record }) => { + mocked.v2Options = options; + return {}; + }, + findRegisteredV2Server: vi.fn(), +})); + +vi.mock("../../src/utils/logger.js", () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { probeOpencodeServer } from "../../src/opencode/client.js"; +import { + __resetOpencodeServerTargetForTests, + switchOpencodeServer, +} from "../../src/opencode/server-target.js"; + +function basic(username: string, password: string): string { + return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`; +} + +describe("opencode/client", () => { + beforeEach(() => { + __resetOpencodeServerTargetForTests(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("points the V2 client at the active server on every request", () => { + const options = mocked.v2Options; + expect(options?.baseUrl).toBe("http://127.0.0.1:49374"); + expect(options?.headers).toEqual({ Authorization: basic("opencode", "configured-secret") }); + + switchOpencodeServer({ + url: "http://127.0.0.1:28894", + username: "opencode", + password: "registry-secret", + }); + + expect(options?.baseUrl).toBe("http://127.0.0.1:28894"); + expect(options?.headers).toEqual({ Authorization: basic("opencode", "registry-secret") }); + }); + + it("sends no Authorization header to a server without credentials", () => { + switchOpencodeServer({ url: "http://127.0.0.1:28894" }); + + expect(mocked.v2Options?.headers).toEqual({}); + }); + + it("probes a given server with that server's credentials", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ version: "2.1.3", pid: 7 }), { + headers: { "content-type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const probe = await probeOpencodeServer("v2", { + url: "http://127.0.0.1:28894", + username: "opencode", + password: "registry-secret", + }); + + expect(probe).toEqual({ kind: "found", version: "v2", serverVersion: "2.1.3" }); + const [url, init] = fetchMock.mock.calls[0] as [URL, RequestInit]; + expect(url.toString()).toBe("http://127.0.0.1:28894/api/info"); + expect(init.headers).toEqual({ Authorization: basic("opencode", "registry-secret") }); + }); +}); diff --git a/tests/opencode/local-start.test.ts b/tests/opencode/local-start.test.ts index b1bccc10a..728868a7e 100644 --- a/tests/opencode/local-start.test.ts +++ b/tests/opencode/local-start.test.ts @@ -15,7 +15,7 @@ vi.mock("../../src/opencode/client.js", () => ({ return mocked.serverVersion; }, probeOpencodeServer: mocked.probeMock, - findRegisteredOpencodeServerUrl: mocked.findRegisteredMock, + findRegisteredOpencodeServer: mocked.findRegisteredMock, })); vi.mock("../../src/opencode/process.js", async (importOriginal) => ({ @@ -38,10 +38,39 @@ vi.mock("../../src/utils/logger.js", () => ({ }, })); -import { canStartLocalOpencodeServer } from "../../src/opencode/local-start.js"; +import { resolveUnavailableOpencodeServer } from "../../src/opencode/local-start.js"; import { __resetServerHealthStateForTests } from "../../src/opencode/server-health.js"; +import { + __resetOpencodeServerTargetForTests, + getActiveOpencodeServer, + isOnConfiguredOpencodeServer, + switchOpencodeServer, + type OpencodeServerTarget, +} from "../../src/opencode/server-target.js"; const TARGET = { host: "127.0.0.1", port: 4097 }; +const CONFIGURED_URL = "http://127.0.0.1:4097"; +const REGISTERED = { url: "http://127.0.0.1:49374", username: "opencode", password: "secret" }; + +type Probe = + | { kind: "found"; version: "v1" | "v2"; serverVersion: string } + | { kind: "unauthorized" } + | { kind: "none" }; + +/** Answers probes per address; a probe without a server goes to the active one. */ +function answerProbes(answer: (version: string, url: string) => Probe): void { + mocked.probeMock.mockImplementation(async (version: string, server?: OpencodeServerTarget) => + answer(version, (server ?? getActiveOpencodeServer()).url), + ); +} + +/** A V2 server of the given release at `url`; every other probe finds nothing. */ +function v2At(url: string, serverVersion: string): (version: string, probed: string) => Probe { + return (version, probed) => + version === "v2" && probed === url + ? { kind: "found", version: "v2", serverVersion } + : { kind: "none" }; +} function errorMessage(): string { return mocked.loggerErrorMock.mock.calls[0]?.[0] as string; @@ -50,6 +79,7 @@ function errorMessage(): string { describe("opencode/local-start", () => { beforeEach(() => { __resetServerHealthStateForTests(); + __resetOpencodeServerTargetForTests(); mocked.serverVersion = "v2"; mocked.probeMock.mockReset(); mocked.probeMock.mockResolvedValue({ kind: "none" }); @@ -61,86 +91,202 @@ describe("opencode/local-start", () => { mocked.loggerErrorMock.mockReset(); }); - it("allows a start when nothing answers and the executable matches", async () => { - await expect(canStartLocalOpencodeServer(TARGET, "always")).resolves.toBe(true); + it("starts when nothing answers and the executable matches", async () => { + await expect(resolveUnavailableOpencodeServer(TARGET, "always")).resolves.toEqual({ + kind: "start", + }); expect(mocked.loggerErrorMock).not.toHaveBeenCalled(); expect(mocked.loggerWarnMock).not.toHaveBeenCalled(); }); + it("keeps a slow configured server that answers the probe and skips the registry", async () => { + answerProbes(v2At(CONFIGURED_URL, "2.1.3")); + mocked.findRegisteredMock.mockResolvedValue(REGISTERED); + + await expect(resolveUnavailableOpencodeServer(TARGET, "always")).resolves.toEqual({ + kind: "answers", + version: "2.1.3", + }); + expect(mocked.findRegisteredMock).not.toHaveBeenCalled(); + expect(isOnConfiguredOpencodeServer()).toBe(true); + }); + it("refuses when the configured address rejects the credentials", async () => { - mocked.probeMock.mockImplementation(async (version: string) => - version === "v2" ? { kind: "unauthorized" } : { kind: "none" }, - ); + answerProbes((version) => (version === "v2" ? { kind: "unauthorized" } : { kind: "none" })); + mocked.findRegisteredMock.mockResolvedValue(REGISTERED); - await expect(canStartLocalOpencodeServer(TARGET, "always")).resolves.toBe(false); + await expect(resolveUnavailableOpencodeServer(TARGET, "always")).resolves.toEqual({ + kind: "refuse", + }); expect(mocked.loggerWarnMock).toHaveBeenCalledWith( expect.stringContaining("Authentication failed"), ); - expect(mocked.loggerErrorMock).not.toHaveBeenCalled(); + expect(mocked.findRegisteredMock).not.toHaveBeenCalled(); expect(mocked.readLocalVersionMock).not.toHaveBeenCalled(); }); it("refuses when a server of the other version answers at the configured address", async () => { - mocked.probeMock.mockImplementation(async (version: string) => + answerProbes((version) => version === "v1" ? { kind: "found", version: "v1", serverVersion: "1.18.32" } : { kind: "none" }, ); + mocked.findRegisteredMock.mockResolvedValue(REGISTERED); - await expect(canStartLocalOpencodeServer(TARGET, "always")).resolves.toBe(false); + await expect(resolveUnavailableOpencodeServer(TARGET, "always")).resolves.toEqual({ + kind: "refuse", + }); expect(errorMessage()).toContain("OPENCODE_SERVER_VERSION=v2"); expect(errorMessage()).toContain("OpenCode 1.18.32 (API v1)"); + expect(mocked.findRegisteredMock).not.toHaveBeenCalled(); }); - it("refuses on V2 while a registered V2 server runs on another port", async () => { - mocked.findRegisteredMock.mockResolvedValue("http://127.0.0.1:49374"); + it("connects to a registered V2 server on another port that answers with its credentials", async () => { + mocked.findRegisteredMock.mockResolvedValue(REGISTERED); + answerProbes(v2At(REGISTERED.url, "2.1.3")); - await expect(canStartLocalOpencodeServer(TARGET, "always")).resolves.toBe(false); + await expect(resolveUnavailableOpencodeServer(TARGET, "always")).resolves.toEqual({ + kind: "connected", + url: REGISTERED.url, + version: "2.1.3", + }); + expect(mocked.probeMock).toHaveBeenCalledWith("v2", REGISTERED); + expect(getActiveOpencodeServer()).toEqual(REGISTERED); + expect(mocked.readLocalVersionMock).not.toHaveBeenCalled(); + expect(mocked.loggerErrorMock).not.toHaveBeenCalled(); + }); + + it("refuses without switching or starting when the registered server rejects its credentials", async () => { + mocked.findRegisteredMock.mockResolvedValue(REGISTERED); + answerProbes((_version, url) => + url === REGISTERED.url ? { kind: "unauthorized" } : { kind: "none" }, + ); + + await expect(resolveUnavailableOpencodeServer(TARGET, "always")).resolves.toEqual({ + kind: "refuse", + }); + expect(isOnConfiguredOpencodeServer()).toBe(true); expect(errorMessage()).toContain("http://127.0.0.1:49374"); - expect(errorMessage()).toContain("OPENCODE_API_URL=http://127.0.0.1:49374"); + expect(errorMessage()).toContain("rejected the credentials"); + expect(errorMessage()).not.toContain("secret"); expect(mocked.readLocalVersionMock).not.toHaveBeenCalled(); }); + it("refuses without switching when the registered server does not answer", async () => { + mocked.findRegisteredMock.mockResolvedValue(REGISTERED); + + await expect(resolveUnavailableOpencodeServer(TARGET, "always")).resolves.toEqual({ + kind: "refuse", + }); + expect(isOnConfiguredOpencodeServer()).toBe(true); + expect(errorMessage()).toContain("did not answer"); + }); + it("does not look for a registered server on V1", async () => { mocked.serverVersion = "v1"; mocked.readLocalVersionMock.mockResolvedValue("1.18.32"); - mocked.findRegisteredMock.mockResolvedValue("http://127.0.0.1:49374"); + mocked.findRegisteredMock.mockResolvedValue(REGISTERED); - await expect(canStartLocalOpencodeServer(TARGET, "always")).resolves.toBe(true); + await expect(resolveUnavailableOpencodeServer(TARGET, "always")).resolves.toEqual({ + kind: "start", + }); expect(mocked.findRegisteredMock).not.toHaveBeenCalled(); }); - it("allows a start when the registered V2 server is on the configured port", async () => { - mocked.findRegisteredMock.mockResolvedValue("http://localhost:4097"); + it("starts when the registered V2 server is on the configured port", async () => { + mocked.findRegisteredMock.mockResolvedValue({ url: "http://localhost:4097" }); - await expect(canStartLocalOpencodeServer(TARGET, "always")).resolves.toBe(true); + await expect(resolveUnavailableOpencodeServer(TARGET, "always")).resolves.toEqual({ + kind: "start", + }); + expect(isOnConfiguredOpencodeServer()).toBe(true); + }); + + describe("while switched to another server", () => { + beforeEach(() => { + switchOpencodeServer(REGISTERED); + }); + + it("stays on the switched server when it answers the probe", async () => { + answerProbes(v2At(REGISTERED.url, "2.1.3")); + + await expect(resolveUnavailableOpencodeServer(TARGET, "always")).resolves.toEqual({ + kind: "answers", + version: "2.1.3", + }); + expect(getActiveOpencodeServer()).toEqual(REGISTERED); + }); + + it("moves back to the configured address when it answers again", async () => { + answerProbes(v2At(CONFIGURED_URL, "2.1.4")); + mocked.findRegisteredMock.mockResolvedValue({ url: "http://127.0.0.1:50000" }); + + await expect(resolveUnavailableOpencodeServer(TARGET, "always")).resolves.toEqual({ + kind: "answers", + version: "2.1.4", + }); + expect(isOnConfiguredOpencodeServer()).toBe(true); + expect(mocked.findRegisteredMock).not.toHaveBeenCalled(); + }); + + it("moves to a new registered server when neither the switched nor the configured one answers", async () => { + const next = { url: "http://127.0.0.1:50000" }; + mocked.findRegisteredMock.mockResolvedValue(next); + answerProbes(v2At(next.url, "2.1.4")); + + await expect(resolveUnavailableOpencodeServer(TARGET, "always")).resolves.toEqual({ + kind: "connected", + url: next.url, + version: "2.1.4", + }); + expect(getActiveOpencodeServer()).toEqual(next); + }); + + it("moves back to the configured address before a start when nothing answers", async () => { + await expect(resolveUnavailableOpencodeServer(TARGET, "always")).resolves.toEqual({ + kind: "start", + }); + expect(isOnConfiguredOpencodeServer()).toBe(true); + }); + + it("stays on the switched server when the start is refused", async () => { + mocked.readLocalVersionMock.mockResolvedValue("1.18.32"); + + await expect(resolveUnavailableOpencodeServer(TARGET, "always")).resolves.toEqual({ + kind: "refuse", + }); + expect(getActiveOpencodeServer()).toEqual(REGISTERED); + }); }); it("refuses when the local opencode executable is the other version", async () => { mocked.serverVersion = "v1"; mocked.readLocalVersionMock.mockResolvedValue("2.0.16"); - await expect(canStartLocalOpencodeServer(TARGET, "always")).resolves.toBe(false); + await expect(resolveUnavailableOpencodeServer(TARGET, "always")).resolves.toEqual({ + kind: "refuse", + }); expect(errorMessage()).toContain("OPENCODE_SERVER_VERSION=v1"); expect(errorMessage()).toContain("local opencode executable is OpenCode 2.0.16 (API v2)"); expect(errorMessage()).toContain("Set OPENCODE_SERVER_VERSION=v2"); }); - it("allows a start when the executable version cannot be read", async () => { + it("starts when the executable version cannot be read", async () => { mocked.readLocalVersionMock.mockResolvedValue(null); - await expect(canStartLocalOpencodeServer(TARGET, "always")).resolves.toBe(true); + await expect(resolveUnavailableOpencodeServer(TARGET, "always")).resolves.toEqual({ + kind: "start", + }); }); it("logs a repeated refusal once in once mode and every time in always mode", async () => { - mocked.serverVersion = "v1"; - mocked.readLocalVersionMock.mockResolvedValue("2.0.16"); + mocked.findRegisteredMock.mockResolvedValue(REGISTERED); - await canStartLocalOpencodeServer(TARGET, "once"); - await canStartLocalOpencodeServer(TARGET, "once"); + await resolveUnavailableOpencodeServer(TARGET, "once"); + await resolveUnavailableOpencodeServer(TARGET, "once"); expect(mocked.loggerErrorMock).toHaveBeenCalledTimes(1); - await canStartLocalOpencodeServer(TARGET, "always"); + await resolveUnavailableOpencodeServer(TARGET, "always"); expect(mocked.loggerErrorMock).toHaveBeenCalledTimes(2); }); }); diff --git a/tests/opencode/server-health.test.ts b/tests/opencode/server-health.test.ts index 2b1995a26..517abbd32 100644 --- a/tests/opencode/server-health.test.ts +++ b/tests/opencode/server-health.test.ts @@ -151,8 +151,14 @@ describe("opencode/server-health", () => { const health = await checkOpencodeHealth(); expect(health.healthy).toBe(false); - expect(mocked.probeMock).toHaveBeenCalledWith("v1"); - expect(mocked.probeMock).toHaveBeenCalledWith("v2"); + expect(mocked.probeMock).toHaveBeenCalledWith( + "v1", + expect.objectContaining({ url: "http://user:secret@127.0.0.1:4096" }), + ); + expect(mocked.probeMock).toHaveBeenCalledWith( + "v2", + expect.objectContaining({ url: "http://user:secret@127.0.0.1:4096" }), + ); expect(mocked.loggerErrorMock).not.toHaveBeenCalled(); }); diff --git a/tests/opencode/server-target.test.ts b/tests/opencode/server-target.test.ts new file mode 100644 index 000000000..a32cabfdc --- /dev/null +++ b/tests/opencode/server-target.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocked = vi.hoisted(() => ({ + loggerInfoMock: vi.fn(), +})); + +vi.mock("../../src/config.js", () => ({ + config: { + opencode: { + apiUrl: "http://127.0.0.1:49374", + username: "opencode", + password: "configured-secret", + }, + }, +})); + +vi.mock("../../src/utils/logger.js", () => ({ + logger: { + debug: vi.fn(), + info: mocked.loggerInfoMock, + warn: vi.fn(), + error: vi.fn(), + }, +})); + +import { + __resetOpencodeServerTargetForTests, + describeOpencodeServerUrl, + getActiveOpencodeServer, + isOnConfiguredOpencodeServer, + switchOpencodeServer, +} from "../../src/opencode/server-target.js"; + +const CONFIGURED = { + url: "http://127.0.0.1:49374", + username: "opencode", + password: "configured-secret", +}; +const REGISTERED = { url: "http://127.0.0.1:28894", username: "opencode", password: "secret" }; + +describe("opencode/server-target", () => { + beforeEach(() => { + __resetOpencodeServerTargetForTests(); + mocked.loggerInfoMock.mockReset(); + }); + + it("talks to the configured server by default", () => { + expect(getActiveOpencodeServer()).toEqual(CONFIGURED); + expect(isOnConfiguredOpencodeServer()).toBe(true); + }); + + it("switches to another server with one INFO line naming both addresses and no password", () => { + switchOpencodeServer(REGISTERED); + + expect(getActiveOpencodeServer()).toEqual(REGISTERED); + expect(isOnConfiguredOpencodeServer()).toBe(false); + expect(mocked.loggerInfoMock).toHaveBeenCalledTimes(1); + const message = mocked.loggerInfoMock.mock.calls[0]?.[0] as string; + expect(message).toContain("http://127.0.0.1:49374"); + expect(message).toContain("http://127.0.0.1:28894"); + expect(message).not.toContain("secret"); + }); + + it("logs nothing when the server does not change", () => { + switchOpencodeServer(null); + switchOpencodeServer(REGISTERED); + switchOpencodeServer({ ...REGISTERED }); + + expect(mocked.loggerInfoMock).toHaveBeenCalledTimes(1); + }); + + it("switches back to the configured server", () => { + switchOpencodeServer(REGISTERED); + switchOpencodeServer(null); + + expect(getActiveOpencodeServer()).toEqual(CONFIGURED); + expect(isOnConfiguredOpencodeServer()).toBe(true); + expect(mocked.loggerInfoMock).toHaveBeenCalledTimes(2); + }); + + it("uses a server without credentials without the configured password", () => { + switchOpencodeServer({ url: "http://127.0.0.1:28894" }); + + expect(getActiveOpencodeServer()).toEqual({ url: "http://127.0.0.1:28894" }); + }); + + it("describes an address without credentials written into it", () => { + expect(describeOpencodeServerUrl("http://user:secret@127.0.0.1:4096")).toBe( + "http://127.0.0.1:4096", + ); + }); +}); diff --git a/tests/opencode/v2/client.test.ts b/tests/opencode/v2/client.test.ts index bad3c8845..34285a341 100644 --- a/tests/opencode/v2/client.test.ts +++ b/tests/opencode/v2/client.test.ts @@ -4,6 +4,7 @@ import { isExpectedOpencodeUnavailableError } from "../../../src/utils/opencode- const fake = vi.hoisted(() => ({ makeOptions: null as unknown, + discover: vi.fn(), events: [] as unknown[], client: { server: { info: vi.fn() }, @@ -38,8 +39,13 @@ vi.mock("@opencode/client", () => ({ }, })); +vi.mock("@opencode/client/service", () => ({ + Service: { discover: fake.discover }, +})); + import { createV2OpencodeClient, + findRegisteredV2Server, type V2ClientExtension, } from "../../../src/opencode/v2/client.js"; @@ -91,6 +97,50 @@ describe("opencode/v2/client", () => { }); }); + it("hands its options to the SDK as is, so getters on them are read per request", () => { + let baseUrl = "http://127.0.0.1:49374"; + const options = { + get baseUrl() { + return baseUrl; + }, + }; + createV2OpencodeClient(options); + + baseUrl = "http://127.0.0.1:28894"; + + expect(fake.makeOptions).toBe(options); + expect((fake.makeOptions as { baseUrl: string }).baseUrl).toBe("http://127.0.0.1:28894"); + }); + + describe("findRegisteredV2Server", () => { + it("returns the registered server with its registry credentials", async () => { + fake.discover.mockResolvedValue({ + url: "http://127.0.0.1:28894", + auth: { type: "basic", username: "opencode", password: "secret" }, + }); + + await expect(findRegisteredV2Server()).resolves.toEqual({ + url: "http://127.0.0.1:28894", + username: "opencode", + password: "secret", + }); + }); + + it("returns a registered server without authentication with no credentials", async () => { + fake.discover.mockResolvedValue({ url: "http://127.0.0.1:28894" }); + + await expect(findRegisteredV2Server()).resolves.toEqual({ url: "http://127.0.0.1:28894" }); + }); + + it("returns null when no registered server answers or the lookup fails", async () => { + fake.discover.mockResolvedValueOnce(undefined); + await expect(findRegisteredV2Server()).resolves.toBeNull(); + + fake.discover.mockRejectedValueOnce(new Error("no registry")); + await expect(findRegisteredV2Server()).resolves.toBeNull(); + }); + }); + it("reports health with the server version", async () => { fake.client.server.info.mockResolvedValue({ version: "2.0.16", pid: 1, urls: [], paths: {} });