From 50ea617955493798c123a971f8f580fd21958b20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 4 Oct 2026 17:10:18 +0200 Subject: [PATCH 1/2] fix(opencode): expose a persistent troubleshooting service restart Keep an explicit Restart OpenCode service action in the shared settings and recovery Troubleshooting disclosure even when no update is pending. Confirm all-client interruption, preserve session history, fence changed selection and disposed views, and retain the separate inline post-update activation shortcut. Admit authenticated same-version daemon replacement through the existing host/WSL lifecycle without allowing downgrades, unknown-version replacement, missing restart capability or automatic retry. Configuration reload remains a distinct process-preserving action, and troubleshooting never resumes a pending workspace-open continuation. Add all ten locale translations and document the lifecycle distinction. Validate with 23 service/reconnect regressions, 28 real Solid/Chromium setup regressions, server/UI typechecks and a production UI build; native desktop packaging was not performed. --- AGENTS.md | 2 +- dev-docs/OPENCODE_V2_POST_BETA.md | 13 ++ .../src/opencode-update/service.test.ts | 86 ++++++++++- .../server/src/opencode-update/service.ts | 16 +- .../settings/opencode-setup-panel.tsx | 39 ++++- .../ui/src/lib/i18n/messages/de/settings.ts | 2 + .../ui/src/lib/i18n/messages/en/settings.ts | 2 + .../ui/src/lib/i18n/messages/es/settings.ts | 2 + .../ui/src/lib/i18n/messages/fr/settings.ts | 2 + .../ui/src/lib/i18n/messages/he/settings.ts | 2 + .../ui/src/lib/i18n/messages/ja/settings.ts | 2 + .../ui/src/lib/i18n/messages/ne/settings.ts | 2 + .../ui/src/lib/i18n/messages/ru/settings.ts | 2 + .../ui/src/lib/i18n/messages/tr/settings.ts | 2 + .../src/lib/i18n/messages/zh-Hans/settings.ts | 2 + .../tests/browser/fixtures/opencode-setup.tsx | 10 +- .../ui/tests/browser/opencode-setup.test.ts | 138 +++++++++++++++++- 17 files changed, 305 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a62df47e8..7c9fe3ef7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,7 @@ - Durable permission decisions render through `components/permission-receipts.tsx` outside native tool visibility, with bounded mounted-row reads and a paginated session disclosure for unanchored receipts. Keep receipts out of native message/copy/search/speech projections; styles live in `styles/messaging/permission-receipts.css`. - Completed question receipts share native answer decoding with text projection through `tool-call/renderers/question-data.ts`. Preserve selected-option descriptions and verbatim free text; keep remaining choices in a native disclosure. Receipt styles live in `styles/messaging/tool-call/question.css`. - Pending-request recovery uses the authenticated broker in `packages/server/src/server/routes/pending-requests.ts` when the daemon supports loaded-only snapshots. Validate directory ownership before querying and every returned placement before publishing; retain execution-host authority even for cold/empty coverage, including WSL and path aliases. Preserve idle/global Forms, per-kind mutation fences and settled-request tombstones. Errors or incomplete coverage never clear queues. The capability is negotiated per connection, not inferred from a version label; older daemons retain their existing discovery behavior and do not gain the native performance fix merely by updating CodeNomad. -- OpenCode settings keep executable selection first and runtime status, install/update and service actions directly inline. Only version details and troubleshooting are collapsed disclosures at the bottom of the runtime panel; log levels remain the final settings card. Share controls with the startup recovery dialog rather than routing settings through a separate management modal. Disclosure styles live in `styles/components/opencode-setup.css`. +- OpenCode settings keep executable selection first and runtime status, install/update and service actions directly inline. Only version details and troubleshooting are collapsed disclosures at the bottom of the runtime panel; log levels remain the final settings card. Troubleshooting always exposes an explicit, confirmed shared-service restart independently of updates; retain the inline activation shortcut after an update, and never downgrade a newer daemon through an older selected CLI. Configuration reload remains a separate process-preserving action. Share controls with the startup recovery dialog rather than routing settings through a separate management modal. Disclosure styles live in `styles/components/opencode-setup.css`. - Full-history search/counts use the bundled pruning plugin's bounded queries, outside the transcript store. Current-session results and the global timeline navigate through bounded anchor windows; other-session previews fetch one native message only. Search/results/progress styles live in `styles/components/history-search.css`. See `dev-docs/SESSION_HISTORY_QUERIES.md` for scope, snapshot and cleanup semantics. - Keep agent, model, and thinking controls in the composer footer via `PromptContextControls`; adapt that footer with the named `prompt-composer` container rather than viewport-only breakpoints. - Composer context selectors and actions always share one row on desktop and touch; truncate selector labels rather than wrapping the footer. Dense conversation toolbar/footer controls use `--touch-target-size-compact`; timeline markers use the compact control height rather than the global touch target. Toolbar touch spacing lives in `styles/panels/session-toolbar-touch.css`. Transcript filters join the header action overflow and anchor/focus back to that menu when collapsed. diff --git a/dev-docs/OPENCODE_V2_POST_BETA.md b/dev-docs/OPENCODE_V2_POST_BETA.md index 8ee4d6083..cb3b27ffc 100644 --- a/dev-docs/OPENCODE_V2_POST_BETA.md +++ b/dev-docs/OPENCODE_V2_POST_BETA.md @@ -142,6 +142,19 @@ non-disruptive reconnect updates backend executable ownership so opening additio workspaces remains possible while restart is deferred. Executable selection and workspace execution-host eligibility are rechecked before service mutation. +Troubleshooting also keeps **Restart OpenCode service** available without an +installation or version change. The existing lifecycle may replace an authenticated +same-version daemon, but an older selected CLI still cannot replace a newer daemon +and unknown versions remain fail-closed. The control remains visible but disabled +when restart authority is unavailable or a fresh status check fails. It shares the +host/WSL service path and serialization with update activation; opening settings, +checking status, reconnecting and reloading configuration never restart implicitly. +The troubleshooting action confirms the all-client interruption, fences a changed +executable or unmounted view, and does not resume a pending workspace-open callback. +The inline post-update activation shortcut remains separate. Session history is not +deleted by either action; configuration reload preserves the daemon process and +must not be presented as a process restart or a guarantee of memory reclamation. + Mounted Windows discovery directories and Linux aliases are canonicalized through the selected distro, then translated for host filesystem access. Native import URLs and lease paths stay Linux-native; canonical outside-root storage is retained. diff --git a/packages/server/src/opencode-update/service.test.ts b/packages/server/src/opencode-update/service.test.ts index 0c038efac..c6516dc63 100644 --- a/packages/server/src/opencode-update/service.test.ts +++ b/packages/server/src/opencode-update/service.test.ts @@ -188,7 +188,7 @@ test("an unverified newer major is not blocked but cannot be downgraded by resta assert.equal((await service.getStatus()).canRestart, false) assert.equal((await service.start()).serviceState, "ready") assert.equal((await service.getStatus()).versionAssessment, "untested") - await assert.rejects(service.start(true), /not an older runtime/) + await assert.rejects(service.start(true), /cannot safely replace/) }) test("optional upgrades retain explicit activation for an admitted but older daemon", async () => { @@ -214,6 +214,90 @@ test("optional upgrades retain explicit activation for an admitted but older dae assert.equal(restarts, 1) }) +test("same-version troubleshooting restart is explicit for host and WSL; reload never restarts", async () => { + for (const binaryPath of ["/fixture/opencode", "\\\\wsl.localhost\\Ubuntu\\usr\\bin\\opencode"]) { + let pid = 123, restarts = 0, reloads = 0, reconnects = 0 + const endpoint = () => { + const value: Endpoint = { url: "http://127.0.0.1:9876" } + rememberRuntime(value, { version: "2.0.22", pid, discovery: "info" }) + return value + } + const service = new OpenCodeUpdateService(deps({ + resolveBinary: () => ({ path: binaryPath, label: "Fixture" }), + probeBinary: () => ({ valid: true, version: "2.0.22" }), resolveLatestVersion: async () => "2.0.22", + lifecycle: async () => ({ discover: async () => endpoint(), ensure: async () => { assert.fail("retain the running daemon") }, + restart: async () => { restarts++; pid++; return endpoint() } }), + upgradeBinary: async () => { assert.fail("restart must not install a version") }, + reload: async () => { reloads++ }, reconnect: async () => { reconnects++ }, + })) + const status = await service.getStatus() + assert.equal(status.serviceState, "ready") + assert.equal(status.updateAvailable, false) + assert.equal(status.canRestart, true) + assert.equal(status.canReload, true) + await service.start() + await service.reload() + assert.equal(restarts, 0) + assert.equal(pid, 123, "configuration reload preserves the process") + assert.equal(reloads, 1) + const restarted = await service.start(true) + assert.equal(restarts, 1) + assert.equal(pid, 124) + assert.equal(reconnects, 2) + assert.equal(restarted.serviceState, "ready") + assert.equal(restarted.canRestart, true, "troubleshooting stays available after a restart") + } +}) + +test("troubleshooting restart refuses unknown identity/builds and a newer daemon", async () => { + for (const version of [undefined, "custom-build", "2.0.23"]) { + const endpoint: Endpoint = { url: "http://127.0.0.1:9876" } + if (version) rememberRuntime(endpoint, { version, pid: 123, discovery: "info" }) + const service = new OpenCodeUpdateService(deps({ + probeBinary: () => ({ valid: true, version: "2.0.22" }), + lifecycle: async () => ({ discover: async () => endpoint, ensure: async () => { assert.fail("no fallback start") }, + restart: async () => { assert.fail("must not stop an unverified or newer daemon") } }), + })) + assert.equal((await service.getStatus()).canRestart, false) + await assert.rejects(service.start(true), /cannot safely replace/) + } +}) + +test("same-version restart coalesces, excludes reload, and never retries a failed mutation", async () => { + let restarts = 0, reconnects = 0 + let release!: () => void, entered!: () => void + const gate = new Promise(resolve => { release = resolve }) + const reached = new Promise(resolve => { entered = resolve }) + const endpoint: Endpoint = { url: "http://127.0.0.1:9876" } + rememberRuntime(endpoint, { version: "2.0.22", pid: 123, discovery: "info" }) + const service = new OpenCodeUpdateService(deps({ + probeBinary: () => ({ valid: true, version: "2.0.22" }), + lifecycle: async () => ({ discover: async () => endpoint, ensure: async () => { assert.fail("no fallback start") }, + restart: async () => { restarts++; entered(); await gate; throw new Error("restart failed") } }), + reconnect: async () => { reconnects++ }, + })) + const first = service.start(true) + assert.equal(service.start(true), first) + await reached + await assert.rejects(service.reload(), /action is in progress/) + assert.equal(restarts, 1) + release() + await assert.rejects(first, /restart failed/) + assert.equal(restarts, 1) + assert.equal(reconnects, 0) +}) + +test("a lifecycle without restart cannot expose or silently emulate it", async () => { + const endpoint: Endpoint = { url: "http://127.0.0.1:9876" } + rememberRuntime(endpoint, { version: "2.0.22", pid: 123, discovery: "info" }) + const service = new OpenCodeUpdateService(deps({ + probeBinary: () => ({ valid: true, version: "2.0.22" }), + lifecycle: async () => ({ discover: async () => endpoint, ensure: async () => { assert.fail("no fallback start") } }), + })) + assert.equal((await service.getStatus()).canRestart, false) + await assert.rejects(service.start(true), /restart unavailable/) +}) + test("explicit configuration reload is admitted, fenced and serialized with service actions", async () => { let selected = "/fixture/opencode", version = "2.0.11", reloads = 0 let release!: () => void diff --git a/packages/server/src/opencode-update/service.ts b/packages/server/src/opencode-update/service.ts index 907736dfe..41f6be45a 100644 --- a/packages/server/src/opencode-update/service.ts +++ b/packages/server/src/opencode-update/service.ts @@ -102,7 +102,8 @@ export class OpenCodeUpdateService { } } status.canRestart = Boolean(lifecycle.restart) && state === "ready" - && (status.serviceState === "restart_required" || status.serviceState === "restart_available") + && (status.serviceState === "ready" || status.serviceState === "restart_required" || status.serviceState === "restart_available") + && canReplaceDaemon(currentVersion, identity?.version) } catch { status.serviceState = "error"; status.serviceError = "service_check_failed" } } const effectiveVersion = status.daemonVersion ?? currentVersion @@ -148,13 +149,13 @@ export class OpenCodeUpdateService { const installedVersion = await this.readCurrentVersion(binary.path) const lifecycle = await this.deps.lifecycle?.(binary) if (!lifecycle) throw new Error("OpenCode service lifecycle unavailable") + if (restart && !lifecycle.restart) throw new Error("OpenCode service restart unavailable") const previous = await lifecycle.discover() if (!previous || restart) assertSupportedOpenCode(installedVersion) if (restart && previous) { const identity = runtimeIdentity(previous) - if (!identity || !comparableVersion(identity.version) || !comparableVersion(installedVersion) - || compareOpenCodeVersionStrings(installedVersion, identity.version) <= 0) { - throw new Error("The shared daemon is not an older runtime eligible for this update") + if (!canReplaceDaemon(installedVersion, identity?.version)) { + throw new Error("The selected OpenCode executable cannot safely replace the running daemon") } } const assertCurrent = () => { @@ -320,6 +321,13 @@ export function createOpenCodeUpdateService( }) } +// Explicit troubleshooting can replace the same release, but must retain the +// update path's authenticated-version fence against downgrades/unknown builds. +function canReplaceDaemon(installed: string | null, running?: string): boolean { + return Boolean(installed && running && comparableVersion(installed) && comparableVersion(running) + && compareOpenCodeVersionStrings(installed, running) >= 0) +} + function comparableVersion(version: string): boolean { return /^\d+\.\d+\.\d+$/.test(version) || /^0\.0\.0-beta-\d+$/.test(version) } diff --git a/packages/ui/src/components/settings/opencode-setup-panel.tsx b/packages/ui/src/components/settings/opencode-setup-panel.tsx index 5ecf8682d..0d96468a9 100644 --- a/packages/ui/src/components/settings/opencode-setup-panel.tsx +++ b/packages/ui/src/components/settings/opencode-setup-panel.tsx @@ -1,5 +1,7 @@ -import { Show, type ParentProps } from "solid-js" +import { createSignal, onCleanup, Show, type ParentProps } from "solid-js" import { useI18n } from "../../lib/i18n" +import { showConfirmDialog } from "../../stores/alerts" +import { serverSettings } from "../../stores/preferences" import { OpenCodeSetupProgress } from "./opencode-setup-progress" import { openCodeSetupStatus as status, openCodeSetupBusy as busy, openCodeSetupError, openCodeSetupCheckError, openCodeSetupChecking as checking, openCodeSetupAction as action, openCodeSetupFeedback, openCodeInstallationError, @@ -8,7 +10,26 @@ import { openCodeSetupStatus as status, openCodeSetupBusy as busy, openCodeSetup export function OpenCodeSetupPanel(props: ParentProps = {}) { const { t } = useI18n() - const disabled = () => busy() || checking() + const [restartPending, setRestartPending] = createSignal(false) + let disposed = false + onCleanup(() => { disposed = true }) + const disabled = () => busy() || checking() || restartPending() + const restartForTroubleshooting = async () => { + if (disabled() || openCodeSetupCheckError() || !status()?.canRestart) return + const binary = serverSettings().opencodeBinary + setRestartPending(true) + try { + const confirmed = await showConfirmDialog(t("settings.opencode.setup.restartDescription"), { + title: t("settings.opencode.setup.restartTroubleshooting"), variant: "warning", + confirmLabel: t("settings.opencode.setup.restartTroubleshooting"), cancelLabel: t("alertDialog.actions.cancel"), + }) + if (!confirmed || disposed || binary !== serverSettings().opencodeBinary || busy() || checking() + || openCodeSetupCheckError() || !status()?.canRestart) return + await runOpenCodeSetup("restart", { resumeWorkspace: false }) + } finally { + if (!disposed) setRestartPending(false) + } + } const progress = () => checking() ? "settings.opencode.update.checking" : action() === "install" ? "settings.opencode.update.updating" : action() ? `settings.opencode.setup.progress.${action()}` : undefined @@ -53,7 +74,7 @@ export function OpenCodeSetupPanel(props: ParentProps = {}) { - +

{t("settings.opencode.setup.sharedInstallDescription")}

@@ -80,13 +101,15 @@ export function OpenCodeSetupPanel(props: ParentProps = {}) {

{t(`settings.opencode.setup.${data().incompatibilityReason}`)}

- -
- {t("settings.opencode.setup.troubleshooting")} +
+ {t("settings.opencode.setup.troubleshooting")} +

{t("settings.opencode.setup.restartDescription")}

+
+

{t("settings.opencode.setup.reloadDescription")}

-
- + +
}
diff --git a/packages/ui/src/lib/i18n/messages/de/settings.ts b/packages/ui/src/lib/i18n/messages/de/settings.ts index 02b397636..64d67c8db 100644 --- a/packages/ui/src/lib/i18n/messages/de/settings.ts +++ b/packages/ui/src/lib/i18n/messages/de/settings.ts @@ -41,6 +41,8 @@ export const settingsMessages = { "settings.opencode.setup.continue": "Weiter", "settings.opencode.setup.diagnostics": "Versionsdetails", "settings.opencode.setup.troubleshooting": "Fehlerbehebung", + "settings.opencode.setup.restartTroubleshooting": "OpenCode-Dienst neu starten", + "settings.opencode.setup.restartDescription": "Starten Sie den OpenCode-Prozess neu, ohne ein Update zu installieren. Anders als beim Neuladen der Konfiguration wird der Prozess ersetzt und aktive Arbeit in allen verbundenen Clients unterbrochen. Der Sitzungsverlauf bleibt erhalten.", "settings.opencode.setup.check": "Status und Updates prüfen", "settings.opencode.setup.checked": "Status- und Updateprüfung abgeschlossen.", "settings.opencode.setup.reloaded": "OpenCode-Konfiguration neu geladen.", diff --git a/packages/ui/src/lib/i18n/messages/en/settings.ts b/packages/ui/src/lib/i18n/messages/en/settings.ts index 26862e9f8..ccfd466e3 100644 --- a/packages/ui/src/lib/i18n/messages/en/settings.ts +++ b/packages/ui/src/lib/i18n/messages/en/settings.ts @@ -41,6 +41,8 @@ export const settingsMessages = { "settings.opencode.setup.continue": "Continue", "settings.opencode.setup.diagnostics": "Version details", "settings.opencode.setup.troubleshooting": "Troubleshooting", + "settings.opencode.setup.restartTroubleshooting": "Restart OpenCode service", + "settings.opencode.setup.restartDescription": "Restart the OpenCode process without installing an update. Unlike configuration reload, this replaces the process and interrupts active work in all connected clients. Session history is kept.", "settings.opencode.setup.check": "Check status and updates", "settings.opencode.setup.checked": "Status and update check complete.", "settings.opencode.setup.reloaded": "OpenCode configuration reloaded.", diff --git a/packages/ui/src/lib/i18n/messages/es/settings.ts b/packages/ui/src/lib/i18n/messages/es/settings.ts index 961c4158e..e33c87878 100644 --- a/packages/ui/src/lib/i18n/messages/es/settings.ts +++ b/packages/ui/src/lib/i18n/messages/es/settings.ts @@ -41,6 +41,8 @@ export const settingsMessages = { "settings.opencode.setup.continue": "Continuar", "settings.opencode.setup.diagnostics": "Detalles de versiones", "settings.opencode.setup.troubleshooting": "Solución de problemas", + "settings.opencode.setup.restartTroubleshooting": "Reiniciar servicio OpenCode", + "settings.opencode.setup.restartDescription": "Reinicia el proceso OpenCode sin instalar una actualización. A diferencia de recargar la configuración, esto reemplaza el proceso e interrumpe el trabajo activo de todos los clientes conectados. El historial de sesiones se conserva.", "settings.opencode.setup.check": "Comprobar estado y actualizaciones", "settings.opencode.setup.checked": "Comprobación de estado y actualizaciones completada.", "settings.opencode.setup.reloaded": "Configuración de OpenCode recargada.", diff --git a/packages/ui/src/lib/i18n/messages/fr/settings.ts b/packages/ui/src/lib/i18n/messages/fr/settings.ts index 29d0c0cd8..b9274c6da 100644 --- a/packages/ui/src/lib/i18n/messages/fr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr/settings.ts @@ -41,6 +41,8 @@ export const settingsMessages = { "settings.opencode.setup.continue": "Continuer", "settings.opencode.setup.diagnostics": "Détails des versions", "settings.opencode.setup.troubleshooting": "Dépannage", + "settings.opencode.setup.restartTroubleshooting": "Redémarrer le service OpenCode", + "settings.opencode.setup.restartDescription": "Redémarrez le processus OpenCode sans installer de mise à jour. Contrairement au rechargement de la configuration, cette action remplace le processus et interrompt le travail actif de tous les clients connectés. L’historique des sessions est conservé.", "settings.opencode.setup.check": "Vérifier l’état et les mises à jour", "settings.opencode.setup.checked": "Vérification de l’état et des mises à jour terminée.", "settings.opencode.setup.reloaded": "La configuration OpenCode a été rechargée.", diff --git a/packages/ui/src/lib/i18n/messages/he/settings.ts b/packages/ui/src/lib/i18n/messages/he/settings.ts index 7b74b1f73..f1ac44213 100644 --- a/packages/ui/src/lib/i18n/messages/he/settings.ts +++ b/packages/ui/src/lib/i18n/messages/he/settings.ts @@ -41,6 +41,8 @@ export const settingsMessages = { "settings.opencode.setup.continue": "המשך", "settings.opencode.setup.diagnostics": "פרטי גרסאות", "settings.opencode.setup.troubleshooting": "פתרון בעיות", + "settings.opencode.setup.restartTroubleshooting": "הפעל מחדש את שירות OpenCode", + "settings.opencode.setup.restartDescription": "הפעל מחדש את תהליך OpenCode ללא התקנת עדכון. בניגוד לטעינה מחדש של התצורה, פעולה זו מחליפה את התהליך ומפסיקה עבודה פעילה בכל הלקוחות המחוברים. היסטוריית הסשנים נשמרת.", "settings.opencode.setup.check": "בדיקת מצב ועדכונים", "settings.opencode.setup.checked": "בדיקת המצב והעדכונים הושלמה.", "settings.opencode.setup.reloaded": "תצורת OpenCode נטענה מחדש.", diff --git a/packages/ui/src/lib/i18n/messages/ja/settings.ts b/packages/ui/src/lib/i18n/messages/ja/settings.ts index f9d63cb6e..d465f8601 100644 --- a/packages/ui/src/lib/i18n/messages/ja/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja/settings.ts @@ -41,6 +41,8 @@ export const settingsMessages = { "settings.opencode.setup.continue": "続行", "settings.opencode.setup.diagnostics": "バージョンの詳細", "settings.opencode.setup.troubleshooting": "トラブルシューティング", + "settings.opencode.setup.restartTroubleshooting": "OpenCode サービスを再起動", + "settings.opencode.setup.restartDescription": "アップデートをインストールせずに OpenCode プロセスを再起動します。設定の再読み込みとは異なり、プロセスを置き換え、接続中のすべてのクライアントの作業を中断します。セッション履歴は保持されます。", "settings.opencode.setup.check": "状態と更新を確認", "settings.opencode.setup.checked": "状態と更新の確認が完了しました。", "settings.opencode.setup.reloaded": "OpenCode の設定を再読み込みしました。", diff --git a/packages/ui/src/lib/i18n/messages/ne/settings.ts b/packages/ui/src/lib/i18n/messages/ne/settings.ts index 1f5d3a8e6..10016dd23 100644 --- a/packages/ui/src/lib/i18n/messages/ne/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ne/settings.ts @@ -41,6 +41,8 @@ export const settingsMessages = { "settings.opencode.setup.continue": "जारी राख्नुहोस्", "settings.opencode.setup.diagnostics": "संस्करणका विवरण", "settings.opencode.setup.troubleshooting": "समस्या समाधान", + "settings.opencode.setup.restartTroubleshooting": "OpenCode सेवा पुनः सुरु गर्नुहोस्", + "settings.opencode.setup.restartDescription": "अद्यावधिक स्थापना नगरी OpenCode प्रक्रिया पुनः सुरु गर्नुहोस्। कन्फिगरेसन पुनः लोड गर्ने कार्यभन्दा फरक, यसले प्रक्रिया प्रतिस्थापन गर्छ र सबै जडित क्लाइन्टको सक्रिय काम रोक्छ। सत्रको इतिहास सुरक्षित रहन्छ।", "settings.opencode.setup.check": "स्थिति र अपडेट जाँच गर्नुहोस्", "settings.opencode.setup.checked": "स्थिति र अपडेट जाँच पूरा भयो।", "settings.opencode.setup.reloaded": "OpenCode कन्फिगरेसन पुनः लोड भयो।", diff --git a/packages/ui/src/lib/i18n/messages/ru/settings.ts b/packages/ui/src/lib/i18n/messages/ru/settings.ts index 49b991374..50db12081 100644 --- a/packages/ui/src/lib/i18n/messages/ru/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ru/settings.ts @@ -41,6 +41,8 @@ export const settingsMessages = { "settings.opencode.setup.continue": "Продолжить", "settings.opencode.setup.diagnostics": "Сведения о версиях", "settings.opencode.setup.troubleshooting": "Устранение неполадок", + "settings.opencode.setup.restartTroubleshooting": "Перезапустить службу OpenCode", + "settings.opencode.setup.restartDescription": "Перезапустите процесс OpenCode без установки обновления. В отличие от перезагрузки конфигурации, это заменяет процесс и прерывает активную работу всех подключённых клиентов. История сессий сохраняется.", "settings.opencode.setup.check": "Проверить состояние и обновления", "settings.opencode.setup.checked": "Проверка состояния и обновлений завершена.", "settings.opencode.setup.reloaded": "Конфигурация OpenCode перезагружена.", diff --git a/packages/ui/src/lib/i18n/messages/tr/settings.ts b/packages/ui/src/lib/i18n/messages/tr/settings.ts index cd4d7901d..bfca1bd34 100644 --- a/packages/ui/src/lib/i18n/messages/tr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/tr/settings.ts @@ -41,6 +41,8 @@ export const settingsMessages = { "settings.opencode.setup.continue": "Devam et", "settings.opencode.setup.diagnostics": "Sürüm ayrıntıları", "settings.opencode.setup.troubleshooting": "Sorun giderme", + "settings.opencode.setup.restartTroubleshooting": "OpenCode hizmetini yeniden başlat", + "settings.opencode.setup.restartDescription": "Güncelleme yüklemeden OpenCode sürecini yeniden başlatın. Yapılandırmayı yeniden yüklemenin aksine bu işlem süreci değiştirir ve bağlı tüm istemcilerdeki etkin çalışmayı keser. Oturum geçmişi korunur.", "settings.opencode.setup.check": "Durumu ve güncellemeleri denetle", "settings.opencode.setup.checked": "Durum ve güncelleme denetimi tamamlandı.", "settings.opencode.setup.reloaded": "OpenCode yapılandırması yeniden yüklendi.", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts index e44191fda..2cca9dd78 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts @@ -41,6 +41,8 @@ export const settingsMessages = { "settings.opencode.setup.continue": "继续", "settings.opencode.setup.diagnostics": "版本详情", "settings.opencode.setup.troubleshooting": "故障排查", + "settings.opencode.setup.restartTroubleshooting": "重启 OpenCode 服务", + "settings.opencode.setup.restartDescription": "无需安装更新即可重启 OpenCode 进程。与重新加载配置不同,此操作会替换进程并中断所有已连接客户端的当前工作。会话历史将保留。", "settings.opencode.setup.check": "检查状态和更新", "settings.opencode.setup.checked": "状态和更新检查已完成。", "settings.opencode.setup.reloaded": "OpenCode 配置已重新加载。", diff --git a/packages/ui/tests/browser/fixtures/opencode-setup.tsx b/packages/ui/tests/browser/fixtures/opencode-setup.tsx index 69f881a3b..162886fed 100644 --- a/packages/ui/tests/browser/fixtures/opencode-setup.tsx +++ b/packages/ui/tests/browser/fixtures/opencode-setup.tsx @@ -1,7 +1,7 @@ import { render } from "solid-js/web" import OpenCodeSetup from "../../../src/components/opencode-setup" import { OpenCodeSettingsSection } from "../../../src/components/settings/opencode-settings-section" -import { ConfigProvider, serverSettings, setThemePreference, updatePreferences } from "../../../src/stores/preferences" +import { ConfigProvider, serverSettings, setThemePreference, updatePreferences, updateLastUsedBinary } from "../../../src/stores/preferences" import { ThemeProvider } from "../../../src/lib/theme" import { I18nProvider } from "../../../src/lib/i18n" import { serverApi } from "../../../src/lib/api-client" @@ -23,15 +23,17 @@ render(() => {info &&
} - + } as any} />} + +
, document.getElementById("root")!) await updatePreferences({ locale: params.get("locale") === "fr" ? "fr" : "en" }) await setThemePreference(params.get("theme") === "dark" ? "dark" : "light") ;(window as any).fixture = { open: () => openOpenCodeSetup(async () => { resumed++ }), resumed: () => resumed, reopen: () => openOpenCodeSetup(), - selectedBinary: () => serverSettings().opencodeBinary, + selectedBinary: () => serverSettings().opencodeBinary, + selectBinary: updateLastUsedBinary, refresh: refreshOpenCodeSetup, invalidate: invalidateOpenCodeSetup, notifications: getToastHistory, diff --git a/packages/ui/tests/browser/opencode-setup.test.ts b/packages/ui/tests/browser/opencode-setup.test.ts index 58234e01f..75af65d4d 100644 --- a/packages/ui/tests/browser/opencode-setup.test.ts +++ b/packages/ui/tests/browser/opencode-setup.test.ts @@ -426,6 +426,142 @@ const healthyStatus = { state: "ready", currentVersion: "2.0.16", latestVersion: recommendedVersion: "2.0.15", binaryPath: "opencode2", target: "host", canUpgrade: false, canRestart: false, serviceState: "ready", daemonVersion: "2.0.16" } +for (const surface of ["settings", "recovery"]) test(`${surface} keeps same-version restart in troubleshooting, confirmed and separate from reload`, async () => { + const page = await browser.newPage() + const mutations: unknown[] = [], errors: string[] = [] + let release: (() => void) | undefined + page.on("pageerror", error => errors.push(error.message)) + await page.route("**/api/**", async route => { + const request = route.request() + if (request.method() === "POST" && /\/api\/opencode\/(service|update)$/.test(request.url())) { + assert.ok(request.url().endsWith("/api/opencode/service"), "maintenance must not install an update") + mutations.push(request.postDataJSON()) + if (request.postDataJSON().restart) await new Promise(resolve => { release = resolve }) + } + return route.fulfill({ json: { ...healthyStatus, canRestart: true, canReload: true, + latestVersion: null, checkError: "update_check_failed", target: surface === "recovery" ? "wsl" : "host" } }) + }) + try { + await page.goto(`${url}${surface === "settings" ? "?settings=1" : ""}`, { waitUntil: "domcontentloaded", timeout: 90_000 }) + await page.waitForFunction(() => Boolean((window as any).fixture)) + if (surface === "recovery") await page.evaluate(() => (window as any).fixture.open()) + const panel = page.locator(".opencode-setup-panel") + await panel.getByText("OpenCode is connected.", { exact: true }).waitFor() + assert.equal(await panel.getByRole("button", { name: "Restart shared service", exact: true }).count(), 0, "no update-only shortcut on a healthy same-version service") + await panel.getByText("Troubleshooting", { exact: true }).click() + const restart = panel.getByRole("button", { name: "Restart OpenCode service", exact: true }) + assert.equal(await restart.isEnabled(), true, "registry availability is not restart authority") + await restart.click() + const confirm = page.getByRole("dialog", { name: "Restart OpenCode service", exact: true }) + await confirm.getByText(/interrupts active work in all connected clients/).waitFor() + assert.deepEqual(mutations, []) + await confirm.getByRole("button", { name: "Cancel", exact: true }).click() + assert.deepEqual(mutations, []) + await restart.evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() }) + assert.equal(await confirm.count(), 1) + await confirm.getByRole("button", { name: "Restart OpenCode service", exact: true }).click() + await page.waitForFunction(() => document.querySelector(".opencode-setup-progress")) + assert.equal(await restart.isDisabled(), true) + for (let count = 0; count < 100 && !release; count++) await page.waitForTimeout(10) + assert.ok(release) + release() + await page.waitForFunction(() => !document.querySelector(".opencode-setup-progress")) + assert.deepEqual(mutations, [{ restart: true }]) + assert.equal(await restart.isEnabled(), true, "restart remains available without another update") + assert.equal(await page.evaluate(() => (window as any).fixture.resumed()), 0, "troubleshooting never resumes a workspace-open continuation") + if (process.env.CODENOMAD_SETUP_CAPTURE) await page.screenshot({ path: path.join(process.env.CODENOMAD_SETUP_CAPTURE, `opencode-troubleshooting-${surface}.png`), fullPage: true }) + await panel.getByRole("button", { name: "Reload OpenCode configuration", exact: true }).click() + await page.waitForFunction(() => !document.querySelector(".opencode-setup-progress")) + assert.deepEqual(mutations, [{ restart: true }, { reload: true }]) + assert.deepEqual(errors, []) + } finally { release?.(); await page.close() } +}) + +test("troubleshooting remains visible with restart disabled when lifecycle authority is unavailable", async () => { + const page = await browser.newPage({ viewport: { width: 390, height: 800 } }) + let fail = false, mutations = 0 + await page.route("**/api/**", route => { + if (route.request().method() === "POST" && /\/api\/opencode\/(service|update)$/.test(route.request().url())) mutations++ + return route.fulfill(fail ? { status: 503, json: { error: "unavailable" } } + : { json: { ...healthyStatus, canRestart: false, canReload: false } }) + }) + try { + await page.goto(`${url}?settings=1&locale=fr&theme=dark`, { waitUntil: "domcontentloaded", timeout: 90_000 }) + const panel = page.locator(".opencode-setup-panel") + await panel.getByText("OpenCode est connecté.", { exact: true }).waitFor() + await panel.getByText("Dépannage", { exact: true }).click() + const restart = panel.getByRole("button", { name: "Redémarrer le service OpenCode", exact: true }) + assert.equal(await restart.isDisabled(), true) + assert.equal(await panel.getByRole("button", { name: "Recharger la configuration OpenCode", exact: true }).count(), 0) + assert.equal(await page.locator("main").evaluate(element => element.scrollWidth <= element.clientWidth), true) + if (process.env.CODENOMAD_SETUP_CAPTURE) await page.screenshot({ path: path.join(process.env.CODENOMAD_SETUP_CAPTURE, "opencode-troubleshooting-fr-narrow.png"), fullPage: true }) + fail = true + await panel.getByRole("button", { name: "Vérifier l’état et les mises à jour", exact: true }).click() + await panel.getByRole("alert").waitFor() + assert.equal(await restart.isDisabled(), true) + assert.equal(mutations, 0) + } finally { await page.close() } +}) + +test("a changed executable or lost restart capability fences a pending confirmation", async () => { + for (const change of ["binary", "capability"]) { + const page = await browser.newPage() + let permitted = true, mutations = 0 + await page.route("**/api/**", route => { + if (route.request().method() === "POST" && /\/api\/opencode\/(service|update)$/.test(route.request().url())) mutations++ + return route.fulfill({ json: { ...healthyStatus, canRestart: permitted } }) + }) + try { + await page.goto(`${url}?settings=1`, { waitUntil: "domcontentloaded", timeout: 90_000 }) + const panel = page.locator(".opencode-setup-panel") + await panel.getByText("OpenCode is connected.", { exact: true }).waitFor() + await panel.getByText("Troubleshooting", { exact: true }).click() + await panel.getByRole("button", { name: "Restart OpenCode service", exact: true }).click() + const confirm = page.getByRole("dialog", { name: "Restart OpenCode service", exact: true }) + await confirm.waitFor() + if (change === "binary") { + await page.evaluate(() => (window as any).fixture.selectBinary("/replacement/opencode")) + await page.waitForFunction(() => (window as any).fixture.selectedBinary() === "/replacement/opencode") + } else { + permitted = false + await page.evaluate(() => (window as any).fixture.refresh()) + } + await confirm.getByRole("button", { name: "Restart OpenCode service", exact: true }).click() + await confirm.waitFor({ state: "hidden" }) + assert.equal(mutations, 0) + } finally { await page.close() } + } +}) + +test("settings troubleshooting restart retains a dismissed recovery continuation for explicit Continue", async () => { + const page = await browser.newPage() + let restarts = 0 + await page.route("**/api/**", route => { + if (route.request().method() === "POST" && /\/api\/opencode\/(service|update)$/.test(route.request().url())) { + assert.deepEqual(route.request().postDataJSON(), { restart: true }) + restarts++ + } + return route.fulfill({ json: { ...healthyStatus, canRestart: true } }) + }) + try { + await page.goto(`${url}?settings=1`, { waitUntil: "domcontentloaded", timeout: 90_000 }) + const panel = page.locator(".opencode-setup-panel") + await panel.getByText("OpenCode is connected.", { exact: true }).waitFor() + await page.evaluate(() => (window as any).fixture.open()) + await page.getByRole("dialog").getByRole("button", { name: "Close", exact: true }).click() + await panel.getByText("Troubleshooting", { exact: true }).click() + await panel.getByRole("button", { name: "Restart OpenCode service", exact: true }).click() + await page.getByRole("dialog", { name: "Restart OpenCode service", exact: true }).getByRole("button", { name: "Restart OpenCode service", exact: true }).click() + await page.waitForFunction(() => !document.querySelector(".opencode-setup-progress")) + assert.equal(restarts, 1) + assert.equal(await page.evaluate(() => (window as any).fixture.resumed()), 0) + await page.evaluate(() => (window as any).fixture.reopen()) + await page.getByRole("dialog").getByRole("button", { name: "Continue", exact: true }).click() + await page.waitForFunction(() => (window as any).fixture.resumed() === 1) + assert.equal(restarts, 1) + } finally { await page.close() } +}) + test("instance info explicitly confirms a global V2 reload and never calls the retired dispose endpoint", async () => { const page = await browser.newPage() const mutations: Array<{ url: string; body: unknown }> = [] @@ -688,7 +824,7 @@ test("settings expose and install an optional update directly, with retry feedba await page.getByRole("alert").waitFor() assert.equal(await page.getByRole("dialog").count(), 0) await update.click() - await page.locator(".opencode-setup-panel button:disabled").filter({ hasText: "OpenCode" }).waitFor() + await page.locator(".opencode-setup-panel button:disabled:visible").filter({ hasText: "OpenCode" }).waitFor() for (let count = 0; count < 100 && !releaseInstall; count++) await page.waitForTimeout(10) assert.ok(releaseInstall) releaseInstall() From 84b4c743e447a7bb68b81e2a276389f94d4dcbaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 4 Oct 2026 17:19:37 +0200 Subject: [PATCH 2/2] fix(alerts): cancel pending answers when a dialog is superseded Settle a replaced singleton confirmation as false and a replaced prompt as null without invoking action callbacks. Asynchronous session-error alerts can otherwise strand the troubleshooting restart confirmation and leave the runtime controls disabled until remount. Add a real-browser regression covering restart confirmation replacement by alert, confirmation and prompt, restored maintenance controls, prompt cancellation and no service mutations. This addresses the P2 finding from gatekeeper review pass 1 on PR #844. --- packages/ui/src/stores/alerts.ts | 15 +++++-- .../tests/browser/fixtures/opencode-setup.tsx | 9 ++++ .../ui/tests/browser/opencode-setup.test.ts | 42 +++++++++++++++++++ 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/stores/alerts.ts b/packages/ui/src/stores/alerts.ts index 18071520b..4cb444bb8 100644 --- a/packages/ui/src/stores/alerts.ts +++ b/packages/ui/src/stores/alerts.ts @@ -29,8 +29,17 @@ export type AlertDialogState = { const [alertDialogState, setAlertDialogState] = createSignal(null) +function replaceAlertDialog(next: AlertDialogState) { + const previous = alertDialogState() + setAlertDialogState(next) + // Replacing the singleton is not consent. Release pending callers without + // invoking action callbacks or leaving their local controls locked forever. + if (previous?.type === "confirm") previous.resolve?.(false) + if (previous?.type === "prompt") previous.resolvePrompt?.(null) +} + export function showAlertDialog(message: string, options?: Omit) { - setAlertDialogState({ + replaceAlertDialog({ type: "alert", message, ...options, @@ -42,7 +51,7 @@ export function showConfirmDialog(message: string, options?: Omit((resolve) => { - setAlertDialogState({ + replaceAlertDialog({ type: "confirm", message, ...options, @@ -59,7 +68,7 @@ export function showPromptDialog( activeElement?.blur() return new Promise((resolvePrompt) => { - setAlertDialogState({ + replaceAlertDialog({ type: "prompt", message, ...options, diff --git a/packages/ui/tests/browser/fixtures/opencode-setup.tsx b/packages/ui/tests/browser/fixtures/opencode-setup.tsx index 162886fed..8f19d12a5 100644 --- a/packages/ui/tests/browser/fixtures/opencode-setup.tsx +++ b/packages/ui/tests/browser/fixtures/opencode-setup.tsx @@ -10,11 +10,13 @@ import { createInstanceFetch } from "../../../src/lib/sdk-manager" import InstanceInfo from "../../../src/components/instance-info" import AlertDialog from "../../../src/components/alert-dialog" import { getToastHistory } from "../../../src/lib/notifications" +import { showAlertDialog, showConfirmDialog, showPromptDialog } from "../../../src/stores/alerts" import "../../../src/index.css" serverApi.fetchConfigOwner = async () => ({ settings: { locale: "en" } }) as any serverApi.fetchStateOwner = async () => ({}) as any serverApi.patchConfigOwner = async (_owner, patch) => patch as any let resumed = 0 +let replacementResult: boolean | string | null | undefined const params = new URLSearchParams(location.search) const settings = params.has("settings") const info = params.has("info") @@ -37,6 +39,13 @@ await setThemePreference(params.get("theme") === "dark" ? "dark" : "light") refresh: refreshOpenCodeSetup, invalidate: invalidateOpenCodeSetup, notifications: getToastHistory, + replaceDialog: (type: "alert" | "confirm" | "prompt") => { + replacementResult = undefined + if (type === "alert") showAlertDialog("Fixture execution error", { title: "Fixture replacement", variant: "error" }) + if (type === "confirm") void showConfirmDialog("Fixture replacement confirmation", { title: "Fixture replacement" }).then(value => { replacementResult = value }) + if (type === "prompt") void showPromptDialog("Fixture replacement prompt", { title: "Fixture replacement" }).then(value => { replacementResult = value }) + }, + replacementResult: () => replacementResult, unsupported: () => createInstanceFetch(`${location.origin}/workspaces/w/instance/`)(`${location.origin}/workspaces/w/instance/api/session/s/prompt`, { method: "POST", body: "{}" }).then(response => response.status), } diff --git a/packages/ui/tests/browser/opencode-setup.test.ts b/packages/ui/tests/browser/opencode-setup.test.ts index 75af65d4d..bc230af85 100644 --- a/packages/ui/tests/browser/opencode-setup.test.ts +++ b/packages/ui/tests/browser/opencode-setup.test.ts @@ -503,6 +503,48 @@ test("troubleshooting remains visible with restart disabled when lifecycle autho } finally { await page.close() } }) +test("superseding a restart confirmation cancels it and unlocks maintenance without a mutation", async () => { + for (const replacement of ["alert", "confirm", "prompt"]) { + const page = await browser.newPage() + let mutations = 0 + await page.route("**/api/**", route => { + if (route.request().method() === "POST" && /\/api\/opencode\/(service|update)$/.test(route.request().url())) mutations++ + return route.fulfill({ json: { ...healthyStatus, canRestart: true, canReload: true } }) + }) + try { + await page.goto(`${url}?settings=1`, { waitUntil: "domcontentloaded", timeout: 90_000 }) + const panel = page.locator(".opencode-setup-panel") + await panel.getByText("OpenCode is connected.", { exact: true }).waitFor() + await panel.getByText("Troubleshooting", { exact: true }).click() + const restart = panel.getByRole("button", { name: "Restart OpenCode service", exact: true }) + await restart.click() + await page.getByRole("dialog", { name: "Restart OpenCode service", exact: true }).waitFor() + await page.evaluate(type => (window as any).fixture.replaceDialog(type), replacement) + const next = page.getByRole("dialog", { name: "Fixture replacement", exact: true }) + await next.waitFor() + await next.getByRole("button", { name: replacement === "alert" ? "OK" : "Cancel", exact: true }).click() + await next.waitFor({ state: "hidden" }) + await page.waitForFunction(() => [...document.querySelectorAll(".opencode-setup-panel button")] + .filter(button => /^(Restart OpenCode service|Reload OpenCode configuration|Check status and updates)$/.test(button.textContent ?? "")) + .every(button => !(button as HTMLButtonElement).disabled)) + assert.equal(await restart.isEnabled(), true) + assert.equal(await panel.getByRole("button", { name: "Reload OpenCode configuration", exact: true }).isEnabled(), true) + assert.equal(await panel.getByRole("button", { name: "Check status and updates", exact: true }).isEnabled(), true) + assert.equal(mutations, 0) + // Superseding a prompt must also settle its caller, without treating the + // replacement's dismissal as an affirmative answer to the old dialog. + await page.evaluate(() => (window as any).fixture.replaceDialog("prompt")) + await page.getByRole("dialog", { name: "Fixture replacement", exact: true }).waitFor() + await page.evaluate(() => (window as any).fixture.replaceDialog("alert")) + await page.waitForFunction(() => (window as any).fixture.replacementResult() === null) + await page.getByRole("dialog", { name: "Fixture replacement", exact: true }).getByRole("button", { name: "OK", exact: true }).click() + await restart.click() + await page.getByRole("dialog", { name: "Restart OpenCode service", exact: true }).getByRole("button", { name: "Cancel", exact: true }).click() + assert.equal(mutations, 0) + } finally { await page.close() } + } +}) + test("a changed executable or lost restart capability fences a pending confirmation", async () => { for (const change of ["binary", "capability"]) { const page = await browser.newPage()