From 7e9afc3da430bd8cf200f247333e53063c0968d0 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:13:11 -0400 Subject: [PATCH 1/4] feat(opencode): surface server startup and recovery failures in-app --- .../src/services/opencode-single-server.ts | 98 ++++++++------- backend/src/services/opencode-supervisor.ts | 45 ++----- .../services/opencode-single-server.test.ts | 42 +++++++ docs/features/logs.md | 6 +- docs/features/server-health.md | 8 ++ frontend/src/App.tsx | 25 +++- frontend/src/api/fetchWrapper.ts | 5 +- frontend/src/api/settings.ts | 7 -- .../components/navigation/MoreDrawer.test.tsx | 2 - .../components/settings/LogsViewer.test.tsx | 50 ++++++++ .../src/components/settings/LogsViewer.tsx | 38 +++++- .../settings/SandboxSettings.test.tsx | 4 - .../settings/ServerHealthStatus.test.tsx | 2 - .../src/hooks/useOpenCodeFailureToast.test.ts | 100 +++++++++++++++ frontend/src/hooks/useOpenCodeFailureToast.ts | 42 +++++++ frontend/src/hooks/useServerHealth.ts | 114 ++++++------------ frontend/src/lib/toast.ts | 4 + shared/src/opencode/index.ts | 4 + shared/src/opencode/lifecycle.ts | 37 ++++++ 19 files changed, 453 insertions(+), 180 deletions(-) create mode 100644 frontend/src/hooks/useOpenCodeFailureToast.test.ts create mode 100644 frontend/src/hooks/useOpenCodeFailureToast.ts create mode 100644 shared/src/opencode/lifecycle.ts diff --git a/backend/src/services/opencode-single-server.ts b/backend/src/services/opencode-single-server.ts index d457a044b..1666fd0a2 100644 --- a/backend/src/services/opencode-single-server.ts +++ b/backend/src/services/opencode-single-server.ts @@ -606,8 +606,7 @@ class OpenCodeServerManager { await prepareOpenCodeServiceLaunch(serverEnv, password) } catch (error) { const message = `Failed to prepare the OpenCode service settings: ${error instanceof Error ? error.message : String(error)}` - this.lastStartupError = message - logger.error(message) + this.recordStartupError(message) throw new Error(message) } @@ -617,7 +616,7 @@ class OpenCodeServerManager { { cwd: openCodeServerDirectory, detached: !isDevelopment, - stdio: isDevelopment ? 'inherit' : ['ignore', 'pipe', 'pipe'], + stdio: ['ignore', 'pipe', 'pipe'], env: serverEnv, } ) @@ -625,36 +624,46 @@ class OpenCodeServerManager { const openCodeStdoutLog = createProcessLogForwarder({ source: 'opencode', defaultLevel: 'info' }) const openCodeStderrLog = createProcessLogForwarder({ source: 'opencode', defaultLevel: 'error' }) - if (!isDevelopment && this.serverProcess.stderr) { - this.serverProcess.stderr.on('data', (data) => { - const text = data.toString() - stderrOutput += text - if (stderrOutput.length > MAX_STDERR_SIZE) { - stderrOutput = stderrOutput.slice(-MAX_STDERR_SIZE) - } - openCodeStderrLog.write(data) - }) - this.serverProcess.stderr.on('end', () => openCodeStderrLog.flush()) - } + this.serverProcess.stderr?.on('data', (data) => { + if (isDevelopment) process.stderr.write(data) + stderrOutput += data.toString() + if (stderrOutput.length > MAX_STDERR_SIZE) { + stderrOutput = stderrOutput.slice(-MAX_STDERR_SIZE) + } + openCodeStderrLog.write(data) + }) + this.serverProcess.stderr?.on('end', () => openCodeStderrLog.flush()) - if (!isDevelopment && this.serverProcess.stdout) { - this.serverProcess.stdout.on('data', (data) => openCodeStdoutLog.write(data)) - this.serverProcess.stdout.on('end', () => openCodeStdoutLog.flush()) + this.serverProcess.stdout?.on('data', (data) => { + if (isDevelopment) process.stdout.write(data) + openCodeStdoutLog.write(data) + }) + this.serverProcess.stdout?.on('end', () => openCodeStdoutLog.flush()) + + let spawnedProcessExitError: string | null = null + const recordSpawnedProcessExit = (message: string) => { + spawnedProcessExitError = message + this.recordStartupError(message) } + this.serverProcess.on('error', (error) => { + recordSpawnedProcessExit(`Failed to launch the OpenCode server (${openCodeExecutable}): ${error.message}`) + }) + const spawnedServerPid = this.serverProcess.pid this.serverProcess.on('exit', (code, signal) => { + const exitedBeforeHealthy = !this.isHealthy if (spawnedServerPid !== undefined && this.serverPid === spawnedServerPid) { this.serverPid = null this.isHealthy = false this.stopChildStateMarkerRefresh() } if (code !== null && code !== 0) { - this.lastStartupError = `Server exited with code ${code}${stderrOutput ? `: ${stderrOutput.slice(-500)}` : ''}` - logger.error('OpenCode server process exited:', this.lastStartupError) + recordSpawnedProcessExit(`OpenCode server exited with code ${code}${stderrOutput ? `: ${stderrOutput.slice(-500)}` : ''}`) } else if (signal) { - this.lastStartupError = `Server terminated by signal ${signal}` - logger.error('OpenCode server process terminated:', this.lastStartupError) + recordSpawnedProcessExit(`OpenCode server terminated by signal ${signal}`) + } else if (exitedBeforeHealthy) { + recordSpawnedProcessExit('OpenCode server exited with code 0 before becoming healthy') } }) @@ -667,8 +676,7 @@ class OpenCodeServerManager { const startToken = await readProcessStartTokenWithRetry(this.serverPid) if (startToken === null) { const message = 'Failed to read the process identity of the freshly spawned OpenCode server; refusing to detach an unattestable child' - this.lastStartupError = message - logger.error(message) + this.recordStartupError(message) await this.stop(true) throw new Error(message) } @@ -687,8 +695,7 @@ class OpenCodeServerManager { }) } catch (error) { const message = `Failed to persist the OpenCode child state marker: ${error instanceof Error ? error.message : String(error)}` - this.lastStartupError = message - logger.error(message) + this.recordStartupError(message) await this.stop(true) throw new Error(message) } @@ -699,10 +706,14 @@ class OpenCodeServerManager { logger.info(`OpenCode server started with PID ${this.serverPid}`) - const healthy = await this.waitForHealth(STARTUP_HEALTH_TIMEOUT_MS) + const healthy = await this.waitForHealth(STARTUP_HEALTH_TIMEOUT_MS, () => spawnedProcessExitError !== null) if (!healthy) { - this.lastStartupError = `Server failed to become healthy after ${Math.round(STARTUP_HEALTH_TIMEOUT_MS / 1000)}s${stderrOutput ? `. Last error: ${stderrOutput.slice(-500)}` : ''}` - throw new Error('OpenCode server failed to become healthy') + if (spawnedProcessExitError !== null) { + throw new Error(spawnedProcessExitError) + } + const message = `OpenCode server failed to become healthy after ${Math.round(STARTUP_HEALTH_TIMEOUT_MS / 1000)}s${stderrOutput ? `. Last error: ${stderrOutput.slice(-500)}` : ''}` + this.recordStartupError(message) + throw new Error(message) } if (sandboxEnforced || replacingExistingServer) { @@ -711,16 +722,14 @@ class OpenCodeServerManager { portOwners = await this.findProcessesByPort(openCodeServerPort) } catch (inspectionError) { const message = `Could not verify port ${openCodeServerPort} ownership after health; refusing to mark the server healthy: ${inspectionError instanceof Error ? inspectionError.message : String(inspectionError)}` - this.lastStartupError = message - logger.error(message) + this.recordStartupError(message) await this.stop(true) throw new Error(message) } if (this.serverPid === null || !portOwners.some((proc) => proc.pid === this.serverPid)) { const owners = portOwners.length > 0 ? `; port ${openCodeServerPort} is owned by PID(s) ${portOwners.map((proc) => proc.pid).join(', ')}` : `; no process owns port ${openCodeServerPort}` const message = `The newly started OpenCode server (PID ${this.serverPid ?? 'unknown'}) does not own the OpenCode port${owners}; refusing to mark the server healthy` - this.lastStartupError = message - logger.error(message) + this.recordStartupError(message) await this.stop(true) throw new Error(message) } @@ -866,10 +875,14 @@ class OpenCodeServerManager { this.lastStartupErrorNonRecoverable = false } - private failNonRecoverable(message: string): never { + private recordStartupError(message: string): void { this.lastStartupError = message - this.lastStartupErrorNonRecoverable = true logger.error(message) + } + + private failNonRecoverable(message: string): never { + this.lastStartupErrorNonRecoverable = true + this.recordStartupError(message) throw new NonRecoverableStartupError(message) } @@ -973,8 +986,7 @@ class OpenCodeServerManager { const killed = await this.waitForProcessOrGroupExit(pid, groupTarget, PROCESS_SIGKILL_CONFIRM_MS) if (!killed) { const message = `${context} (PID ${pid}${groupTarget !== null ? `, process group ${groupTarget}` : ''}) ${failurePhrase}` - this.lastStartupError = message - logger.error(message) + this.recordStartupError(message) throw new Error(message) } } @@ -996,8 +1008,7 @@ class OpenCodeServerManager { } if (survivors.length > 0) { const message = `Failed to terminate the existing OpenCode server process(es) on port ${getOpenCodeServerPort()}: PID(s) ${survivors.join(', ')} still own the port or retain live process-group members; refusing to spawn a new server` - this.lastStartupError = message - logger.error(message) + this.recordStartupError(message) throw new Error(message) } } @@ -1039,8 +1050,7 @@ class OpenCodeServerManager { const currentMembers = resolveProcessIdentityProvider().readProcessGroupMembers(marker.pgid) if (currentMembers.length > 0 && !target.groupAttested) { const message = `Previous OpenCode server process (PID ${marker.pid}) has exited but process group ${marker.pgid} still exists and cannot be proven to belong to it; refusing to signal an unverified process group before starting an enforced server` - this.lastStartupError = message - logger.error(message) + this.recordStartupError(message) throw new Error(message) } } @@ -1080,8 +1090,7 @@ class OpenCodeServerManager { if (currentMembers.length > 0) { if (!target.groupAttested || target.groupTarget === null) { const message = `Previous OpenCode server leader (PID ${marker.pid}) has exited but process group ${marker.pgid} still exists and cannot be proven to belong to it; refusing to replace the child state marker while live processes may survive` - this.lastStartupError = message - logger.error(message) + this.recordStartupError(message) throw new Error(message) } logger.warn( @@ -1135,12 +1144,15 @@ class OpenCodeServerManager { } } - private async waitForHealth(timeoutMs: number): Promise { + private async waitForHealth(timeoutMs: number, hasProcessExited: () => boolean): Promise { const start = Date.now() while (Date.now() - start < timeoutMs) { if (await this.checkHealth()) { return true } + if (hasProcessExited()) { + return false + } await new Promise(r => setTimeout(r, 500)) } return false diff --git a/backend/src/services/opencode-supervisor.ts b/backend/src/services/opencode-supervisor.ts index 54ab56055..e1fd7f2b8 100644 --- a/backend/src/services/opencode-supervisor.ts +++ b/backend/src/services/opencode-supervisor.ts @@ -4,28 +4,12 @@ import { ENV } from '@opencode-manager/shared/config/env' import { archiveBrokenOpenCodeConfigFile, writeHealthWatchArtifact } from './opencode-config-file' import { restoreLastKnownGoodOpenCodeConfig, seedOpenCodeConfigFile } from './opencode-config-apply' import type { OpenCodeServerManager } from './opencode-single-server' - -export const OPENCODE_LIFECYCLE_STATES = [ - 'idle', - 'starting', - 'healthy', - 'unhealthy', - 'recovering', - 'failed', - 'stopping', - 'stopped', -] as const - -export type OpenCodeLifecycleState = (typeof OPENCODE_LIFECYCLE_STATES)[number] - -export const OPENCODE_RECOVERY_ACTIONS = [ - 'restart', - 'debug_capture', - 'rollback_last_known_good', - 'seed_default_config', -] as const - -export type OpenCodeRecoveryAction = (typeof OPENCODE_RECOVERY_ACTIONS)[number] +import { + OPENCODE_RECOVERY_ACTIONS, + type OpenCodeLifecycleState, + type OpenCodeLifecycleStatus, + type OpenCodeRecoveryAction, +} from '@opencode-manager/shared/opencode' const MAX_QUEUED_LIFECYCLE_OPERATIONS = 2 @@ -36,22 +20,6 @@ export type OpenCodeOperationReason = | 'settings_reload' | 'manual' -export interface OpenCodeLifecycleStatus { - state: OpenCodeLifecycleState - healthy: boolean - port: number - version: string | null - minVersion: string - versionSupported: boolean - lastError: string | null - activeRecoveryAction: OpenCodeRecoveryAction | null - attemptedRecoveryActions: OpenCodeRecoveryAction[] - nextRecoveryAction: OpenCodeRecoveryAction | null - failureCount: number - watching: boolean - updatedAt: string -} - interface OpenCodeSupervisorOptions { pollIntervalMs?: number failureThreshold?: number @@ -267,6 +235,7 @@ export class OpenCodeSupervisor { } this.activeRecoveryAction = null + logger.error(`OpenCode server failed after recovery actions [${this.attemptedRecoveryActions.join(', ')}]: ${this.lastError ?? 'unknown error'}`) this.setState('failed') this.openCodeServerManager.setLifecycleInitialized(false) return this.getStatus() diff --git a/backend/test/services/opencode-single-server.test.ts b/backend/test/services/opencode-single-server.test.ts index fc6cf5401..ff82bcbe2 100644 --- a/backend/test/services/opencode-single-server.test.ts +++ b/backend/test/services/opencode-single-server.test.ts @@ -705,6 +705,48 @@ describe('OpenCodeServerManager - server auth', () => { } }) + it('fails startup immediately with the exit reason when the spawned server exits before becoming healthy', async () => { + setOpenCodeEnv({ host: '127.0.0.1', password: 'envpassword123' }) + createOpenCodeClientMock.mockImplementationOnce(() => ({ + api: { server: { info: vi.fn().mockRejectedValue(new Error('connection refused')) } }, + forwardRaw: vi.fn(), + })) + spawnMock.mockImplementationOnce(() => ({ + pid: 1234, + stderr: null, + on: vi.fn((event: string, handler: (code: number | null, signal: string | null) => void) => { + if (event === 'exit') queueMicrotask(() => handler(1, null)) + }), + })) + const { OpenCodeServerManager } = await import('../../src/services/opencode-single-server') + const manager = OpenCodeServerManager.getInstance() + + const startedAt = Date.now() + await expect(manager.start()).rejects.toThrow('OpenCode server exited with code 1') + expect(Date.now() - startedAt).toBeLessThan(5000) + expect(manager.getLastStartupError()).toBe('OpenCode server exited with code 1') + }) + + it('records a launch failure when the OpenCode executable cannot be spawned', async () => { + setOpenCodeEnv({ host: '127.0.0.1', password: 'envpassword123' }) + createOpenCodeClientMock.mockImplementationOnce(() => ({ + api: { server: { info: vi.fn().mockRejectedValue(new Error('connection refused')) } }, + forwardRaw: vi.fn(), + })) + spawnMock.mockImplementationOnce(() => ({ + pid: undefined as unknown as number, + stderr: null, + on: vi.fn((event: string, handler: (error: Error) => void) => { + if (event === 'error') queueMicrotask(() => handler(new Error('spawn opencode ENOENT'))) + }), + })) + const { OpenCodeServerManager } = await import('../../src/services/opencode-single-server') + const manager = OpenCodeServerManager.getInstance() + + await expect(manager.start()).rejects.toThrow('spawn opencode ENOENT') + expect(manager.getLastStartupError()).toBe('Failed to launch the OpenCode server (opencode): spawn opencode ENOENT') + }) + it('honors a user-supplied HOME serverEnvVars entry while enforced', async () => { sandboxRuntimeServiceMock.SandboxRuntimeService.mockImplementation(() => ({ isEnabled: () => true, diff --git a/docs/features/logs.md b/docs/features/logs.md index 1f7043d81..967b5383a 100644 --- a/docs/features/logs.md +++ b/docs/features/logs.md @@ -28,11 +28,15 @@ Each entry shows a timestamp, severity level, source, and message: If entries were evicted before you opened the tab, a notice above the view reports how many earlier entries were dropped. +## OpenCode Server Issues + +While the OpenCode server is unhealthy, recovering, or failed, a panel above the log stream shows the current failure reason (including the server's exit code and stderr tail when it crashed during startup) and the recovery actions already attempted. **Show errors only** switches the filters to errors from all sources, so the Manager's startup messages appear alongside the server's own output. + ## Limits and Behavior - The buffer is **in-memory** on the Manager backend and holds up to **2,000** entries (`DEFAULTS.LOGS.BUFFER_CAPACITY`). Older entries are evicted first. - Individual entries are truncated at **4,000** characters (`DEFAULTS.LOGS.MAX_ENTRY_LENGTH`). - The buffer is **cleared when the Manager process restarts**. When the frontend detects a backend restart it resets its view and re-polls from the start. -- **Child-process capture is production-only.** In development the OpenCode server inherits the terminal, so only Manager lines appear in the tab. +- OpenCode server stdout and stderr are captured in every mode; in development they are also mirrored to the terminal. - The frontend polls the backend every **3 seconds** (`DEFAULTS.LOGS.POLL_INTERVAL_MS`); backend pages are capped at 1,000 entries per response. - `docker-compose logs` remains the fallback for failures that happen **before** the Manager's HTTP server is up — nothing can be captured in-app at that point. diff --git a/docs/features/server-health.md b/docs/features/server-health.md index 4f09c15da..a68bfc5b6 100644 --- a/docs/features/server-health.md +++ b/docs/features/server-health.md @@ -53,6 +53,14 @@ The health-watch ladder is the only automatic repair path. When the supervised O Because the ladder only runs after repeated failed health checks, a config file that fails validation but does not make the server unhealthy is left in place. Setting `OPENCODE_HEALTH_WATCH_ENABLED=false` disables the ladder entirely, leaving no automatic repair path. +## Failure Reporting + +When startup fails without a recovery path, or every recovery action has been tried, the server enters the **failed** state: + +- A persistent error toast shows the failure reason, with **View logs** (opens Settings → Logs) and **Restart** actions. It appears once per distinct failure, including when the app is opened while the server is already failed, and is replaced by a "back online" notice once the server is healthy again. Transient unhealthy or recovering states do not toast. +- The Settings → Logs tab shows the failure reason and the recovery actions already attempted above the log stream. +- A server process that exits or cannot be launched (for example, a missing executable) fails startup immediately with its exit code and the tail of its stderr, rather than waiting for the 30-second health timeout. + The last known good config is a snapshot of every recognized source file (including which ones exist), captured before every write made through the Settings UI, the internal API, or a host config import, so any of those can be undone with `POST /api/settings/opencode-rollback` or by the ladder. Restoring a snapshot rewrites the sources it contains and removes recognized sources it does not. Archived broken configs and debug snapshots are kept under `.opencode/state/health-watch/` in the workspace, pruned to the newest 20 files. Earlier releases stored named configuration profiles in the Manager database. On first start after upgrading, each profile is archived to `.config/opencode-configs-archive/.json` in the workspace, the default profile is restored to `opencode.json` if that file does not exist yet, and the database table is dropped. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 003be3c4e..e2917abf3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -32,7 +32,9 @@ import { loginLoader, setupLoader, registerLoader, protectedLoader } from './lib import { getSwipeBackTarget } from '@/lib/navigation' import { onNotificationClick } from '@/lib/serviceWorker' import { useAuth } from '@/hooks/useAuth' -import { useServerHealth } from '@/hooks/useServerHealth' +import { useOpenCodeFailureToast } from '@/hooks/useOpenCodeFailureToast' +import { useOpenCodeServerActions } from '@/hooks/useOpenCodeServerActions' +import { RestartServerDialog } from '@/components/settings/RestartServerDialog' const queryClient = new QueryClient({ defaultOptions: { @@ -57,8 +59,25 @@ function SSHHostKeyDialogWrapper() { function HealthMonitor() { const { isAuthenticated } = useAuth() - useServerHealth(isAuthenticated) - return null + const { + restartServerMutation, + confirmOpen, + setConfirmOpen, + activeSessionCount, + requestRestart, + confirmRestart, + } = useOpenCodeServerActions() + useOpenCodeFailureToast(isAuthenticated, requestRestart) + return ( + setConfirmOpen(false)} + onConfirm={confirmRestart} + /> + ) } function PermissionDialogWrapper() { diff --git a/frontend/src/api/fetchWrapper.ts b/frontend/src/api/fetchWrapper.ts index 403abd76e..9e81515d5 100644 --- a/frontend/src/api/fetchWrapper.ts +++ b/frontend/src/api/fetchWrapper.ts @@ -6,6 +6,7 @@ export { FetchError } interface FetchWrapperOptions extends RequestInit { timeout?: number params?: Record + acceptedStatuses?: number[] } function formatDetails(details: unknown): string | undefined { @@ -71,7 +72,7 @@ async function fetchWithTimeout( url: string, options: FetchWrapperOptions = {} ): Promise { - const { timeout = 30000, params, ...fetchOptions } = options + const { timeout = 30000, params, acceptedStatuses, ...fetchOptions } = options const urlObj = buildUrl(url, params) const controller = new AbortController() @@ -86,7 +87,7 @@ async function fetchWithTimeout( if (timeoutId) clearTimeout(timeoutId) - if (!response.ok) { + if (!response.ok && !acceptedStatuses?.includes(response.status)) { await handleResponse(response) } diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts index ac4a24cc4..6357bf568 100644 --- a/frontend/src/api/settings.ts +++ b/frontend/src/api/settings.ts @@ -97,13 +97,6 @@ export const settingsApi = { return fetchWrapper(`${API_BASE_URL}/api/settings/opencode-active-sessions`) }, - rollbackOpenCodeConfig: async (): Promise<{ success: boolean; message: string; fallback?: boolean }> => { - return fetchWrapper(`${API_BASE_URL}/api/settings/opencode-rollback`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - }) - }, - getOpenCodeImportStatus: async (): Promise => { return fetchWrapper(`${API_BASE_URL}/api/settings/opencode-import/status`) }, diff --git a/frontend/src/components/navigation/MoreDrawer.test.tsx b/frontend/src/components/navigation/MoreDrawer.test.tsx index e89ad1d2d..d2ea0a59d 100644 --- a/frontend/src/components/navigation/MoreDrawer.test.tsx +++ b/frontend/src/components/navigation/MoreDrawer.test.tsx @@ -69,8 +69,6 @@ const mockServerHealth = (health?: Partial['d isLoading: false, error: null, refetch: vi.fn(), - restartMutation: { mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false }, - rollbackMutation: { mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false }, }) } diff --git a/frontend/src/components/settings/LogsViewer.test.tsx b/frontend/src/components/settings/LogsViewer.test.tsx index 3bcc54a3d..6769ba07d 100644 --- a/frontend/src/components/settings/LogsViewer.test.tsx +++ b/frontend/src/components/settings/LogsViewer.test.tsx @@ -3,9 +3,18 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { LogsViewer } from './LogsViewer' import { useManagerLogs } from '@/hooks/useManagerLogs' +import { useServerHealth, type HealthResponse } from '@/hooks/useServerHealth' import type { ManagerLogEntry } from '@opencode-manager/shared/schemas' vi.mock('@/hooks/useManagerLogs') +vi.mock('@/hooks/useServerHealth', async (importOriginal) => ({ + ...(await importOriginal()), + useServerHealth: vi.fn(), +})) + +function mockHealth(health: Partial | undefined) { + vi.mocked(useServerHealth).mockReturnValue({ data: health } as ReturnType) +} const entries: ManagerLogEntry[] = [ { @@ -39,6 +48,47 @@ describe('LogsViewer', () => { beforeEach(() => { vi.clearAllMocks() mockLogs() + mockHealth({ opencode: 'healthy', status: 'healthy' }) + }) + + it('does not render a server issue panel while OpenCode is healthy', () => { + render() + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('renders the startup failure, attempted recovery and an errors-only shortcut', async () => { + const user = userEvent.setup() + mockHealth({ + opencode: 'unhealthy', + status: 'unhealthy', + opencodeLifecycle: { + state: 'failed', + healthy: false, + port: 5551, + version: null, + minVersion: '2.0.15', + versionSupported: false, + lastError: 'OpenCode server exited with code 1: invalid config', + activeRecoveryAction: null, + attemptedRecoveryActions: ['restart', 'rollback_last_known_good'], + nextRecoveryAction: null, + failureCount: 3, + watching: true, + updatedAt: '2026-01-01T00:00:00.000Z', + }, + }) + render() + + const alert = screen.getByRole('alert') + expect(alert).toHaveTextContent('OpenCode server failed') + expect(alert).toHaveTextContent('OpenCode server exited with code 1: invalid config') + expect(alert).toHaveTextContent('Recovery attempted: restart, rollback last known good') + + await user.click(screen.getByRole('button', { name: 'Show errors only' })) + + expect(vi.mocked(useManagerLogs).mock.lastCall?.[0]).toMatchObject({ level: 'error', source: undefined }) + expect(screen.queryByRole('button', { name: 'Show errors only' })).not.toBeInTheDocument() }) it('renders entries with their message, level and source', () => { diff --git a/frontend/src/components/settings/LogsViewer.tsx b/frontend/src/components/settings/LogsViewer.tsx index acea05044..f19544adc 100644 --- a/frontend/src/components/settings/LogsViewer.tsx +++ b/frontend/src/components/settings/LogsViewer.tsx @@ -1,15 +1,23 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import { Pause, Play, Trash2 } from 'lucide-react' +import { AlertTriangle, Pause, Play, Trash2 } from 'lucide-react' import type { ManagerLogLevel, ManagerLogSource } from '@opencode-manager/shared/schemas' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Card, CardContent } from '@/components/ui/card' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Input } from '@/components/ui/input' import { Button } from '@/components/ui/button' import { CopyButton } from '@/components/ui/copy-button' import { useManagerLogs } from '@/hooks/useManagerLogs' +import { getOpenCodeServerIssue, useServerHealth, type OpenCodeServerIssue } from '@/hooks/useServerHealth' import { DEFAULTS } from '@/config' import { cn } from '@/lib/utils' +const ISSUE_TITLES: Record = { + failed: 'OpenCode server failed', + recovering: 'OpenCode server is recovering', + unhealthy: 'OpenCode server is unhealthy', +} + type LevelFilter = ManagerLogLevel | 'all' type SourceFilter = ManagerLogSource | 'all' @@ -45,6 +53,8 @@ export function LogsViewer() { const [paused, setPaused] = useState(false) const [isFollowing, setIsFollowing] = useState(true) const scrollRef = useRef(null) + const { data: health } = useServerHealth() + const serverIssue = getOpenCodeServerIssue(health) const { entries, dropped, clear } = useManagerLogs({ level: level === 'all' ? undefined : level, @@ -122,6 +132,32 @@ export function LogsViewer() { + {serverIssue && ( + + + {ISSUE_TITLES[serverIssue.state]} + +

{serverIssue.message}

+ {serverIssue.attemptedRecoveryActions.length > 0 && ( +

+ Recovery attempted: {serverIssue.attemptedRecoveryActions.map((action) => action.replaceAll('_', ' ')).join(', ')} +

+ )} + {(level !== 'error' || source !== 'all') && ( + + )} +
+
+ )}
) } @@ -123,8 +121,6 @@ describe('SandboxSettings', () => { isLoading: true, error: null, refetch: vi.fn(), - restartMutation: { mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false }, - rollbackMutation: { mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false }, } as ReturnType) await renderSandbox() diff --git a/frontend/src/components/settings/ServerHealthStatus.test.tsx b/frontend/src/components/settings/ServerHealthStatus.test.tsx index 25ea69a51..c83bfa6bf 100644 --- a/frontend/src/components/settings/ServerHealthStatus.test.tsx +++ b/frontend/src/components/settings/ServerHealthStatus.test.tsx @@ -17,8 +17,6 @@ function mockHealth() { isLoading: false, error: null, refetch: vi.fn(), - restartMutation: { mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false }, - rollbackMutation: { mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false }, } as ReturnType) } diff --git a/frontend/src/hooks/useOpenCodeFailureToast.test.ts b/frontend/src/hooks/useOpenCodeFailureToast.test.ts new file mode 100644 index 000000000..e49478c1c --- /dev/null +++ b/frontend/src/hooks/useOpenCodeFailureToast.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { OpenCodeLifecycleStatus } from '@opencode-manager/shared/opencode' +import { renderHookWithRouter } from '@/test/test-utils' +import { OPENCODE_FAILURE_TOAST_ID, useOpenCodeFailureToast } from './useOpenCodeFailureToast' +import { useServerHealth, type HealthResponse } from './useServerHealth' + +const mocks = vi.hoisted(() => ({ + showToast: { + error: vi.fn(), + success: vi.fn(), + dismiss: vi.fn(), + }, +})) + +vi.mock('@/lib/toast', () => ({ + showToast: mocks.showToast, +})) + +vi.mock('./useServerHealth', async (importOriginal) => ({ + ...(await importOriginal()), + useServerHealth: vi.fn(), +})) + +function lifecycle(overrides: Partial): OpenCodeLifecycleStatus { + return { + state: 'healthy', + healthy: true, + port: 5551, + version: '2.0.15', + minVersion: '2.0.15', + versionSupported: true, + lastError: null, + activeRecoveryAction: null, + attemptedRecoveryActions: [], + nextRecoveryAction: null, + failureCount: 0, + watching: true, + updatedAt: '2026-01-01T00:00:00.000Z', + ...overrides, + } +} + +function mockHealth(opencodeLifecycle: OpenCodeLifecycleStatus) { + const health: Partial = { + opencode: opencodeLifecycle.healthy ? 'healthy' : 'unhealthy', + status: opencodeLifecycle.healthy ? 'healthy' : 'unhealthy', + opencodeLifecycle, + } + vi.mocked(useServerHealth).mockReturnValue({ data: health } as ReturnType) +} + +describe('useOpenCodeFailureToast', () => { + const onRestart = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('raises one persistent toast per distinct failure, including when the app loads already failed', () => { + mockHealth(lifecycle({ state: 'failed', healthy: false, lastError: 'OpenCode server exited with code 1' })) + const { rerender } = renderHookWithRouter(() => useOpenCodeFailureToast(true, onRestart)) + + expect(mocks.showToast.error).toHaveBeenCalledTimes(1) + expect(mocks.showToast.error).toHaveBeenCalledWith('OpenCode server failed', expect.objectContaining({ + id: OPENCODE_FAILURE_TOAST_ID, + duration: Infinity, + description: 'OpenCode server exited with code 1', + action: expect.objectContaining({ label: 'View logs' }), + cancel: { label: 'Restart', onClick: onRestart }, + })) + + rerender() + expect(mocks.showToast.error).toHaveBeenCalledTimes(1) + + mockHealth(lifecycle({ state: 'failed', healthy: false, lastError: 'Unsupported OpenCode version' })) + rerender() + expect(mocks.showToast.error).toHaveBeenCalledTimes(2) + }) + + it('does not toast while recovery is still in progress', () => { + mockHealth(lifecycle({ state: 'recovering', healthy: false, lastError: 'health check failed' })) + renderHookWithRouter(() => useOpenCodeFailureToast(true, onRestart)) + + expect(mocks.showToast.error).not.toHaveBeenCalled() + }) + + it('dismisses the failure and announces recovery only once the server is healthy again', () => { + mockHealth(lifecycle({ state: 'failed', healthy: false, lastError: 'boom' })) + const { rerender } = renderHookWithRouter(() => useOpenCodeFailureToast(true, onRestart)) + + mockHealth(lifecycle({ state: 'starting', healthy: false })) + rerender() + expect(mocks.showToast.success).not.toHaveBeenCalled() + + mockHealth(lifecycle({ state: 'healthy', healthy: true })) + rerender() + expect(mocks.showToast.dismiss).toHaveBeenCalledWith(OPENCODE_FAILURE_TOAST_ID) + expect(mocks.showToast.success).toHaveBeenCalledWith('OpenCode server is back online') + }) +}) diff --git a/frontend/src/hooks/useOpenCodeFailureToast.ts b/frontend/src/hooks/useOpenCodeFailureToast.ts new file mode 100644 index 000000000..3c23bf728 --- /dev/null +++ b/frontend/src/hooks/useOpenCodeFailureToast.ts @@ -0,0 +1,42 @@ +import { useEffect, useRef } from 'react' +import { showToast } from '@/lib/toast' +import { getOpenCodeServerIssue, useServerHealth } from '@/hooks/useServerHealth' +import { useSettingsDialog } from '@/hooks/useSettingsDialog' + +export const OPENCODE_FAILURE_TOAST_ID = 'opencode-server-failure' + +/** + * Raises one persistent error toast per distinct terminal OpenCode server + * failure (startup failure or exhausted recovery), offering to open the Logs + * settings tab or restart the server, and announces when the server recovers. + */ +export function useOpenCodeFailureToast(enabled: boolean, onRestart: () => void) { + const { data: health } = useServerHealth(enabled) + const { setActiveTab } = useSettingsDialog() + const notifiedFailureRef = useRef(null) + + const issue = getOpenCodeServerIssue(health) + const failureMessage = issue?.state === 'failed' ? issue.message : null + const isHealthy = health?.opencodeLifecycle ? health.opencodeLifecycle.healthy : health?.opencode === 'healthy' + + useEffect(() => { + if (failureMessage !== null) { + if (notifiedFailureRef.current === failureMessage) return + notifiedFailureRef.current = failureMessage + showToast.error('OpenCode server failed', { + id: OPENCODE_FAILURE_TOAST_ID, + duration: Infinity, + description: failureMessage, + action: { label: 'View logs', onClick: () => setActiveTab('logs') }, + cancel: { label: 'Restart', onClick: onRestart }, + }) + return + } + + if (isHealthy && notifiedFailureRef.current !== null) { + notifiedFailureRef.current = null + showToast.dismiss(OPENCODE_FAILURE_TOAST_ID) + showToast.success('OpenCode server is back online') + } + }, [failureMessage, isHealthy, setActiveTab, onRestart]) +} diff --git a/frontend/src/hooks/useServerHealth.ts b/frontend/src/hooks/useServerHealth.ts index 9b94f2a53..d69166b4e 100644 --- a/frontend/src/hooks/useServerHealth.ts +++ b/frontend/src/hooks/useServerHealth.ts @@ -1,11 +1,8 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { useEffect, useRef } from 'react' -import { toast } from 'sonner' -import { settingsApi } from '@/api/settings' -import { invalidateConfigCaches, invalidateSettingsCaches } from '@/lib/queryInvalidation' +import { useQuery } from '@tanstack/react-query' +import type { OpenCodeLifecycleStatus, OpenCodeRecoveryAction } from '@opencode-manager/shared/opencode' import { fetchWrapper } from '@/api/fetchWrapper' -interface HealthResponse { +export interface HealthResponse { status: 'healthy' | 'degraded' | 'unhealthy' timestamp: string database: 'connected' | 'disconnected' @@ -16,51 +13,49 @@ interface HealthResponse { opencodeVersionSupported: boolean opencodeManagerVersion: string | null opencodeRestartPending?: boolean + opencodeLifecycle?: OpenCodeLifecycleStatus sandbox?: { available: boolean; enabled: boolean; enforced: boolean; reason?: string; msbVersion?: string } error?: string } -async function fetchHealth(): Promise { - return fetchWrapper('/api/health') +export interface OpenCodeServerIssue { + state: 'failed' | 'recovering' | 'unhealthy' + message: string + attemptedRecoveryActions: OpenCodeRecoveryAction[] } -export function useServerHealth(enabled = true) { - const queryClient = useQueryClient() - const lastHealthStatusRef = useRef<'healthy' | 'unhealthy'>('healthy') - const prevHealthRef = useRef(null) +const DEFAULT_ISSUE_MESSAGE = 'OpenCode server is not responding' - const restartMutation = useMutation({ - mutationFn: async () => { - return await settingsApi.restartOpenCodeServer() - }, - onSuccess: () => { - invalidateConfigCaches(queryClient) - toast.success('OpenCode server restarted', { id: 'reload-config' }) - }, - onError: (error: unknown) => { - const errorMessage = error && typeof error === 'object' && 'response' in error - ? ((error as { response?: { data?: { details?: string; error?: string } } }).response?.data?.details - || (error as { response?: { data?: { details?: string; error?: string } } }).response?.data?.error - || 'Failed to restart OpenCode server') - : 'Failed to restart OpenCode server' - toast.error(errorMessage, { id: 'reload-config' }) - }, - }) +async function fetchHealth(): Promise { + return fetchWrapper('/api/health', { acceptedStatuses: [503] }) +} - const rollbackMutation = useMutation({ - mutationFn: async () => { - return await settingsApi.rollbackOpenCodeConfig() - }, - onSuccess: (data) => { - invalidateSettingsCaches(queryClient) - toast.success(data.message, { id: 'rollback-config' }) - }, - onError: () => { - toast.error('Failed to rollback to previous config', { id: 'rollback-config' }) - }, - }) +/** + * Derives the current OpenCode server problem from a health payload, preferring + * the supervisor lifecycle (which distinguishes in-progress recovery from a + * terminal failure) and falling back to the raw health probe. + */ +export function getOpenCodeServerIssue(health: HealthResponse | undefined): OpenCodeServerIssue | null { + if (!health) return null + const lifecycle = health.opencodeLifecycle + if (lifecycle) { + if (lifecycle.state !== 'failed' && lifecycle.state !== 'recovering' && lifecycle.state !== 'unhealthy') return null + return { + state: lifecycle.state, + message: lifecycle.lastError ?? health.error ?? DEFAULT_ISSUE_MESSAGE, + attemptedRecoveryActions: lifecycle.attemptedRecoveryActions, + } + } + if (health.opencode === 'healthy') return null + return { + state: health.status === 'unhealthy' ? 'failed' : 'unhealthy', + message: health.error ?? DEFAULT_ISSUE_MESSAGE, + attemptedRecoveryActions: [], + } +} - const query = useQuery({ +export function useServerHealth(enabled = true) { + return useQuery({ queryKey: ['health'], queryFn: fetchHealth, refetchInterval: 30000, @@ -68,39 +63,4 @@ export function useServerHealth(enabled = true) { enabled, staleTime: 10000, }) - - const { data: health } = query - - useEffect(() => { - if (!health) return - - const isUnhealthy = health.opencode !== 'healthy' - const currentStatus = isUnhealthy ? 'unhealthy' : 'healthy' - const previousStatus = lastHealthStatusRef.current - const prevHealth = prevHealthRef.current - - if (prevHealth && currentStatus !== prevHealth) { - if (isUnhealthy && previousStatus === 'healthy') { - toast.error(health.error || 'OpenCode server is currently unhealthy', { - id: 'server-health-unhealthy', - duration: Infinity, - action: { - label: 'Restart', - onClick: () => restartMutation.mutate(), - }, - }) - } else if (!isUnhealthy && previousStatus === 'unhealthy') { - toast.success('Server is back online', { id: 'server-health-online' }) - } - } - - lastHealthStatusRef.current = currentStatus - prevHealthRef.current = currentStatus - }, [health, restartMutation]) - - return { - ...query, - restartMutation, - rollbackMutation, - } } diff --git a/frontend/src/lib/toast.ts b/frontend/src/lib/toast.ts index e1a597a34..3392d5d69 100644 --- a/frontend/src/lib/toast.ts +++ b/frontend/src/lib/toast.ts @@ -7,6 +7,10 @@ interface ToastOptions { label: string onClick: () => void } + cancel?: { + label: string + onClick: () => void + } id?: string | number } diff --git a/shared/src/opencode/index.ts b/shared/src/opencode/index.ts index c881e4d89..674f19378 100644 --- a/shared/src/opencode/index.ts +++ b/shared/src/opencode/index.ts @@ -79,6 +79,10 @@ export { parseOpenCodeVersionOutput, } from './release' +export { OPENCODE_LIFECYCLE_STATES, OPENCODE_RECOVERY_ACTIONS } from './lifecycle' + +export type { OpenCodeLifecycleState, OpenCodeLifecycleStatus, OpenCodeRecoveryAction } from './lifecycle' + export const OPENCODE_SERVER_USERNAME = 'opencode' export type OpenCodeApi = ReturnType diff --git a/shared/src/opencode/lifecycle.ts b/shared/src/opencode/lifecycle.ts new file mode 100644 index 000000000..ca8ccb22c --- /dev/null +++ b/shared/src/opencode/lifecycle.ts @@ -0,0 +1,37 @@ +export const OPENCODE_LIFECYCLE_STATES = [ + 'idle', + 'starting', + 'healthy', + 'unhealthy', + 'recovering', + 'failed', + 'stopping', + 'stopped', +] as const + +export type OpenCodeLifecycleState = (typeof OPENCODE_LIFECYCLE_STATES)[number] + +export const OPENCODE_RECOVERY_ACTIONS = [ + 'restart', + 'debug_capture', + 'rollback_last_known_good', + 'seed_default_config', +] as const + +export type OpenCodeRecoveryAction = (typeof OPENCODE_RECOVERY_ACTIONS)[number] + +export interface OpenCodeLifecycleStatus { + state: OpenCodeLifecycleState + healthy: boolean + port: number + version: string | null + minVersion: string + versionSupported: boolean + lastError: string | null + activeRecoveryAction: OpenCodeRecoveryAction | null + attemptedRecoveryActions: OpenCodeRecoveryAction[] + nextRecoveryAction: OpenCodeRecoveryAction | null + failureCount: number + watching: boolean + updatedAt: string +} From e77badb444fb4132ba69b5bc8e1cefbd8c2ec8a0 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:26:45 -0400 Subject: [PATCH 2/4] fix(opencode): harden supervised server startup failure diagnostics --- .../src/services/opencode-single-server.ts | 214 +++++++++-- .../services/opencode-single-server.test.ts | 344 ++++++++++++++++++ 2 files changed, 530 insertions(+), 28 deletions(-) diff --git a/backend/src/services/opencode-single-server.ts b/backend/src/services/opencode-single-server.ts index 1666fd0a2..e64dd4a91 100644 --- a/backend/src/services/opencode-single-server.ts +++ b/backend/src/services/opencode-single-server.ts @@ -1,6 +1,7 @@ import { spawn, execSync, spawnSync } from 'child_process' import path from 'path' import os from 'os' +import { StringDecoder } from 'node:string_decoder' import { promises as fs, accessSync, constants } from 'fs' import { logger } from '../utils/logger' import { createGitIdentityEnv, resolveGitIdentity } from '../utils/git-auth' @@ -49,6 +50,8 @@ import { OPENCODE_SERVICE_SERVE_ARGS, prepareOpenCodeServiceLaunch } from './ope const MAX_STDERR_SIZE = 10240 const STARTUP_HEALTH_TIMEOUT_MS = 30000 +const HEALTH_POLL_INTERVAL_MS = 500 +const CHILD_EXIT_DIAGNOSTIC_DRAIN_MS = 1000 const PROCESS_SIGTERM_GRACE_MS = 10000 const PROCESS_SIGKILL_CONFIRM_MS = 2000 const PROCESS_EXIT_POLL_MS = 50 @@ -152,6 +155,48 @@ async function readProcessGroupIdWithRetry(pid: number): Promise return null } +type AbortRaceOutcome = { completed: true; value: T } | { completed: false } + +function raceWithAbort(promise: Promise, signal: AbortSignal): Promise> { + if (signal.aborted) { + return Promise.resolve({ completed: false }) + } + return new Promise>((resolve, reject) => { + const onAbort = () => { + signal.removeEventListener('abort', onAbort) + resolve({ completed: false }) + } + signal.addEventListener('abort', onAbort, { once: true }) + promise.then( + (value) => { + signal.removeEventListener('abort', onAbort) + resolve({ completed: true, value }) + }, + (error) => { + signal.removeEventListener('abort', onAbort) + reject(error) + }, + ) + }) +} + +function waitForPollInterval(signal: AbortSignal, ms: number): Promise { + if (signal.aborted) { + return Promise.resolve(false) + } + return new Promise((resolve) => { + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort) + resolve(true) + }, ms) + function onAbort() { + clearTimeout(timer) + resolve(false) + } + signal.addEventListener('abort', onAbort, { once: true }) + }) +} + function readDurableRestartGeneration(db: Database | null): number { if (!db) return 0 try { @@ -259,6 +304,8 @@ class OpenCodeServerManager { private sandboxEnforced: boolean = false private lifecycleInitialized: boolean = false private markerRefreshTimer: ReturnType | null = null + private processGeneration = 0 + private intentionalStop: { pid: number; generation: number } | null = null private constructor() {} @@ -565,6 +612,13 @@ class OpenCodeServerManager { const openCodeExecutable = resolveOpenCodeExecutable() ?? 'opencode' let stderrOutput = '' + const stderrDecoder = new StringDecoder('utf8') + const appendStderrOutput = (chunk: string) => { + stderrOutput += chunk + if (stderrOutput.length > MAX_STDERR_SIZE) { + stderrOutput = stderrOutput.slice(-MAX_STDERR_SIZE) + } + } const microsandboxEnv = resolveManagerMicrosandboxEnv() @@ -624,15 +678,89 @@ class OpenCodeServerManager { const openCodeStdoutLog = createProcessLogForwarder({ source: 'opencode', defaultLevel: 'info' }) const openCodeStderrLog = createProcessLogForwarder({ source: 'opencode', defaultLevel: 'error' }) + const spawnedServerPid = this.serverProcess.pid + const spawnedServerStderr = this.serverProcess.stderr + const spawnedGeneration = ++this.processGeneration + const childExitController = new AbortController() + + let spawnedProcessExitError: string | null = null + let stderrEnded = false + let stderrDecoderFlushed = false + let exitDiagnosticFinalized = false + let exitDiagnosticCode: number | null = null + let exitDiagnosticSignal: NodeJS.Signals | null = null + let exitDiagnosticExitedBeforeHealthy = false + let exitDiagnosticIntentionalStop = false + let exitDiagnosticDrainTimer: ReturnType | null = null + let exitDiagnosticDrainResolve: (() => void) | null = null + + const flushStderrDecoder = () => { + if (stderrDecoderFlushed) return + stderrDecoderFlushed = true + appendStderrOutput(stderrDecoder.end()) + } + + const clearExitDiagnosticDrainTimer = () => { + if (exitDiagnosticDrainTimer !== null) { + clearTimeout(exitDiagnosticDrainTimer) + exitDiagnosticDrainTimer = null + } + } + + const finalizeExitDiagnostic = (message: string | null) => { + if (exitDiagnosticFinalized) return + exitDiagnosticFinalized = true + clearExitDiagnosticDrainTimer() + if (message !== null) { + spawnedProcessExitError = message + if (this.processGeneration === spawnedGeneration) { + this.recordStartupError(message) + } + } + const resolve = exitDiagnosticDrainResolve + exitDiagnosticDrainResolve = null + if (resolve) resolve() + } + + const buildExitDiagnostic = (): string | null => { + if (exitDiagnosticIntentionalStop) return null + if (exitDiagnosticCode !== null && exitDiagnosticCode !== 0) { + return `OpenCode server exited with code ${exitDiagnosticCode}${stderrOutput ? `: ${stderrOutput.slice(-500)}` : ''}` + } + if (exitDiagnosticSignal !== null) { + return `OpenCode server terminated by signal ${exitDiagnosticSignal}` + } + if (exitDiagnosticExitedBeforeHealthy) { + return 'OpenCode server exited with code 0 before becoming healthy' + } + return null + } + + const finalizeExitDiagnosticFromState = () => { + flushStderrDecoder() + finalizeExitDiagnostic(buildExitDiagnostic()) + } + + const awaitExitDiagnostic = (): Promise => { + if (exitDiagnosticFinalized) return Promise.resolve() + return new Promise((resolve) => { + exitDiagnosticDrainResolve = resolve + }) + } + this.serverProcess.stderr?.on('data', (data) => { if (isDevelopment) process.stderr.write(data) - stderrOutput += data.toString() - if (stderrOutput.length > MAX_STDERR_SIZE) { - stderrOutput = stderrOutput.slice(-MAX_STDERR_SIZE) - } + appendStderrOutput(stderrDecoder.write(data)) openCodeStderrLog.write(data) }) - this.serverProcess.stderr?.on('end', () => openCodeStderrLog.flush()) + this.serverProcess.stderr?.on('end', () => { + stderrEnded = true + flushStderrDecoder() + openCodeStderrLog.flush() + if (childExitController.signal.aborted) { + finalizeExitDiagnosticFromState() + } + }) this.serverProcess.stdout?.on('data', (data) => { if (isDevelopment) process.stdout.write(data) @@ -640,30 +768,46 @@ class OpenCodeServerManager { }) this.serverProcess.stdout?.on('end', () => openCodeStdoutLog.flush()) - let spawnedProcessExitError: string | null = null - const recordSpawnedProcessExit = (message: string) => { - spawnedProcessExitError = message - this.recordStartupError(message) - } - this.serverProcess.on('error', (error) => { - recordSpawnedProcessExit(`Failed to launch the OpenCode server (${openCodeExecutable}): ${error.message}`) + childExitController.abort() + finalizeExitDiagnostic(`Failed to launch the OpenCode server (${openCodeExecutable}): ${error.message}`) }) - const spawnedServerPid = this.serverProcess.pid this.serverProcess.on('exit', (code, signal) => { + childExitController.abort() const exitedBeforeHealthy = !this.isHealthy if (spawnedServerPid !== undefined && this.serverPid === spawnedServerPid) { this.serverPid = null this.isHealthy = false this.stopChildStateMarkerRefresh() } - if (code !== null && code !== 0) { - recordSpawnedProcessExit(`OpenCode server exited with code ${code}${stderrOutput ? `: ${stderrOutput.slice(-500)}` : ''}`) - } else if (signal) { - recordSpawnedProcessExit(`OpenCode server terminated by signal ${signal}`) - } else if (exitedBeforeHealthy) { - recordSpawnedProcessExit('OpenCode server exited with code 0 before becoming healthy') + exitDiagnosticCode = code + exitDiagnosticSignal = signal + exitDiagnosticExitedBeforeHealthy = exitedBeforeHealthy + const intentionalStop = this.intentionalStop + exitDiagnosticIntentionalStop = intentionalStop !== null + && intentionalStop.pid === spawnedServerPid + && intentionalStop.generation === spawnedGeneration + && (signal === 'SIGTERM' || signal === 'SIGKILL' || code === 0) + if (exitDiagnosticFinalized) return + const hasDiagnostic = (code !== null && code !== 0) || signal !== null || exitedBeforeHealthy + if (!hasDiagnostic) { + finalizeExitDiagnostic(null) + return + } + if (spawnedServerStderr == null || stderrEnded) { + finalizeExitDiagnosticFromState() + return + } + exitDiagnosticDrainTimer = setTimeout(() => { + exitDiagnosticDrainTimer = null + finalizeExitDiagnosticFromState() + }, CHILD_EXIT_DIAGNOSTIC_DRAIN_MS) + }) + + this.serverProcess.on('close', () => { + if (childExitController.signal.aborted) { + finalizeExitDiagnosticFromState() } }) @@ -706,8 +850,11 @@ class OpenCodeServerManager { logger.info(`OpenCode server started with PID ${this.serverPid}`) - const healthy = await this.waitForHealth(STARTUP_HEALTH_TIMEOUT_MS, () => spawnedProcessExitError !== null) + const healthy = await this.waitForHealth(STARTUP_HEALTH_TIMEOUT_MS, childExitController.signal) if (!healthy) { + if (childExitController.signal.aborted) { + await awaitExitDiagnostic() + } if (spawnedProcessExitError !== null) { throw new Error(spawnedProcessExitError) } @@ -791,6 +938,7 @@ class OpenCodeServerManager { if (groupTarget !== null) { logger.info(`Terminating OpenCode process group ${groupTarget} so host-executed descendants do not survive the stop`) } + this.intentionalStop = { pid, generation: this.processGeneration } try { await this.terminateAndConfirm( pid, @@ -799,6 +947,7 @@ class OpenCodeServerManager { 'retained live processes after SIGTERM and SIGKILL; refusing to complete the stop while host-executed processes may survive', ) } catch (error) { + this.intentionalStop = null this.isHealthy = false throw error } @@ -908,13 +1057,15 @@ class OpenCodeServerManager { advanceDurableRestartGeneration(this.db) } - async checkHealth(): Promise { + async checkHealth(signal?: AbortSignal): Promise { if (!this.openCodeClient) { return false } + const timeoutSignal = AbortSignal.timeout(ENV.TIMEOUTS.HEALTH_CHECK_TIMEOUT_MS) + const requestSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal try { await this.openCodeClient.api.server.info({ - signal: AbortSignal.timeout(ENV.TIMEOUTS.HEALTH_CHECK_TIMEOUT_MS), + signal: requestSignal, }) return true } catch { @@ -1144,16 +1295,23 @@ class OpenCodeServerManager { } } - private async waitForHealth(timeoutMs: number, hasProcessExited: () => boolean): Promise { - const start = Date.now() - while (Date.now() - start < timeoutMs) { - if (await this.checkHealth()) { + private async waitForHealth(timeoutMs: number, exitSignal: AbortSignal): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (exitSignal.aborted) { + return false + } + const probe = await raceWithAbort(this.checkHealth(exitSignal), exitSignal) + if (exitSignal.aborted) { + return false + } + if (probe.completed && probe.value) { return true } - if (hasProcessExited()) { + const polled = await waitForPollInterval(exitSignal, HEALTH_POLL_INTERVAL_MS) + if (!polled) { return false } - await new Promise(r => setTimeout(r, 500)) } return false } diff --git a/backend/test/services/opencode-single-server.test.ts b/backend/test/services/opencode-single-server.test.ts index ff82bcbe2..737022a54 100644 --- a/backend/test/services/opencode-single-server.test.ts +++ b/backend/test/services/opencode-single-server.test.ts @@ -727,6 +727,95 @@ describe('OpenCodeServerManager - server auth', () => { expect(manager.getLastStartupError()).toBe('OpenCode server exited with code 1') }) + it('aborts an in-flight health probe and fails promptly when the child exits', async () => { + setOpenCodeEnv({ host: '127.0.0.1', password: 'envpassword123' }) + let capturedSignal: AbortSignal | undefined + let emitExit: (code: number | null, signal: NodeJS.Signals | null) => void = () => {} + const info = vi.fn((options: { signal?: AbortSignal }) => { + capturedSignal = options.signal + queueMicrotask(() => emitExit(1, null)) + return new Promise(() => {}) + }) + createOpenCodeClientMock.mockImplementationOnce(() => ({ + api: { server: { info } }, + forwardRaw: vi.fn(), + })) + spawnMock.mockImplementationOnce(() => ({ + pid: 1234, + stdout: null, + stderr: null, + on: vi.fn((event: string, handler: (code: number | null, signal: NodeJS.Signals | null) => void) => { + if (event === 'exit') emitExit = handler + }), + })) + const { OpenCodeServerManager } = await import('../../src/services/opencode-single-server') + const manager = OpenCodeServerManager.getInstance() + + const startedAt = Date.now() + await expect(manager.start()).rejects.toThrow('OpenCode server exited with code 1') + expect(Date.now() - startedAt).toBeLessThan(5000) + expect(capturedSignal?.aborted).toBe(true) + expect(manager.getLastStartupError()).toBe('OpenCode server exited with code 1') + }, 15000) + + it('marks the server healthy when a health probe succeeds while the child is still running', async () => { + setOpenCodeEnv({ host: '127.0.0.1', password: 'envpassword123' }) + let capturedSignal: AbortSignal | undefined + const info = vi.fn(async (options: { signal?: AbortSignal }) => { + capturedSignal = options.signal + return { version: '2.0.15', pid: 1234, urls: ['http://127.0.0.1:5551'], paths: { tmp: '/tmp' } } + }) + createOpenCodeClientMock.mockImplementationOnce(() => ({ + api: { server: { info } }, + forwardRaw: vi.fn(), + })) + spawnMock.mockImplementationOnce(() => ({ + pid: 1234, + stdout: null, + stderr: null, + on: vi.fn(), + })) + const { OpenCodeServerManager } = await import('../../src/services/opencode-single-server') + const manager = OpenCodeServerManager.getInstance() + + await manager.start() + + expect(info).toHaveBeenCalledTimes(1) + expect(capturedSignal?.aborted).toBe(false) + expect(manager.getLastStartupError()).toBeNull() + }, 15000) + + it('fails startup when a health probe succeeds only after the child has exited', async () => { + setOpenCodeEnv({ host: '127.0.0.1', password: 'envpassword123' }) + let capturedSignal: AbortSignal | undefined + let emitExit: (code: number | null, signal: NodeJS.Signals | null) => void = () => {} + const info = vi.fn((options: { signal?: AbortSignal }) => { + capturedSignal = options.signal + queueMicrotask(() => emitExit(1, null)) + return new Promise((resolve) => { + setTimeout(() => resolve({ version: '2.0.15', pid: 1234, urls: [], paths: { tmp: '/tmp' } }), 0) + }) + }) + createOpenCodeClientMock.mockImplementationOnce(() => ({ + api: { server: { info } }, + forwardRaw: vi.fn(), + })) + spawnMock.mockImplementationOnce(() => ({ + pid: 1234, + stdout: null, + stderr: null, + on: vi.fn((event: string, handler: (code: number | null, signal: NodeJS.Signals | null) => void) => { + if (event === 'exit') emitExit = handler + }), + })) + const { OpenCodeServerManager } = await import('../../src/services/opencode-single-server') + const manager = OpenCodeServerManager.getInstance() + + await expect(manager.start()).rejects.toThrow('OpenCode server exited with code 1') + expect(capturedSignal?.aborted).toBe(true) + expect(manager.getLastStartupError()).toBe('OpenCode server exited with code 1') + }, 15000) + it('records a launch failure when the OpenCode executable cannot be spawned', async () => { setOpenCodeEnv({ host: '127.0.0.1', password: 'envpassword123' }) createOpenCodeClientMock.mockImplementationOnce(() => ({ @@ -1546,6 +1635,7 @@ describe('OpenCodeServerManager - server auth', () => { expect(messages.filter((message) => message === 'partial tail FINAL')).toHaveLength(1) expect((manager as any).serverPid).toBeNull() expect(manager.getLastStartupError()).toContain('exited with code 1') + expect(manager.getLastStartupError()).toContain('partial tail FINAL') } finally { readFileSyncMock.mockReset() readdirSyncMock.mockReset() @@ -1553,6 +1643,133 @@ describe('OpenCodeServerManager - server auth', () => { } }, 15000) + it('preserves a split multibyte UTF-8 character in the startup stderr diagnostic tail', async () => { + const { manager, stderr, emitExit, cleanup } = await captureSpawnedServerStderrStream() + try { + const encoded = Buffer.from('Fatal: café', 'utf8') + const accentByteIndex = encoded.length - 1 + stderr.emit('data', encoded.subarray(0, accentByteIndex)) + stderr.emit('data', encoded.subarray(accentByteIndex)) + stderr.emit('end') + emitExit(1) + + const startupError = manager.getLastStartupError() + expect(startupError).toContain('Fatal: café') + expect(startupError).not.toContain('\uFFFD') + } finally { + cleanup() + } + }, 15000) + + it('flushes an incomplete multibyte sequence when the stderr stream ends', async () => { + const { manager, stderr, emitExit, cleanup } = await captureSpawnedServerStderrStream() + try { + stderr.emit('data', Buffer.from([0xC3])) + stderr.emit('end') + emitExit(1) + + expect(manager.getLastStartupError()).toContain('\uFFFD') + } finally { + cleanup() + } + }, 15000) + + it('includes stderr that arrives after a startup crash exit in the startup diagnostic', async () => { + setOpenCodeEnv({ host: '127.0.0.1', password: 'envpassword123' }) + createOpenCodeClientMock.mockImplementationOnce(() => ({ + api: { server: { info: vi.fn().mockRejectedValue(new Error('connection refused')) } }, + forwardRaw: vi.fn(), + })) + const { EventEmitter } = await import('events') + const stderr = new EventEmitter() + spawnMock.mockImplementationOnce(() => ({ + pid: 1234, + stdout: null, + stderr, + on: vi.fn((event: string, handler: (code: number | null, signal: NodeJS.Signals | null) => void) => { + if (event === 'exit') { + queueMicrotask(() => { + handler(1, null) + stderr.emit('data', Buffer.from('late startup crash detail')) + stderr.emit('end') + }) + } + }), + })) + const { OpenCodeServerManager } = await import('../../src/services/opencode-single-server') + const manager = OpenCodeServerManager.getInstance() + + await expect(manager.start()).rejects.toThrow('OpenCode server exited with code 1') + expect(manager.getLastStartupError()).toContain('late startup crash detail') + }, 15000) + + it('finalizes the exit diagnostic after the drain bound when stderr never ends', async () => { + setOpenCodeEnv({ host: '127.0.0.1', password: 'envpassword123' }) + createOpenCodeClientMock.mockImplementationOnce(() => ({ + api: { server: { info: vi.fn().mockRejectedValue(new Error('connection refused')) } }, + forwardRaw: vi.fn(), + })) + const { EventEmitter } = await import('events') + const stderr = new EventEmitter() + spawnMock.mockImplementationOnce(() => ({ + pid: 1234, + stdout: null, + stderr, + on: vi.fn((event: string, handler: (code: number | null, signal: NodeJS.Signals | null) => void) => { + if (event === 'exit') { + queueMicrotask(() => { + handler(1, null) + stderr.emit('data', Buffer.from('drain bound detail')) + }) + } + }), + })) + const { OpenCodeServerManager } = await import('../../src/services/opencode-single-server') + const manager = OpenCodeServerManager.getInstance() + + vi.useFakeTimers({ toFake: ['setTimeout', 'Date'] }) + try { + const startPromise = manager.start() + startPromise.catch(() => {}) + await vi.advanceTimersByTimeAsync(1000) + await expect(startPromise).rejects.toThrow('OpenCode server exited with code 1') + expect(manager.getLastStartupError()).toContain('drain bound detail') + } finally { + vi.useRealTimers() + } + }, 15000) + + it('finalizes the exit diagnostic on child close when stderr never ends', async () => { + setOpenCodeEnv({ host: '127.0.0.1', password: 'envpassword123' }) + createOpenCodeClientMock.mockImplementationOnce(() => ({ + api: { server: { info: vi.fn().mockRejectedValue(new Error('connection refused')) } }, + forwardRaw: vi.fn(), + })) + const { EventEmitter } = await import('events') + const stderr = new EventEmitter() + let closeHandler: (() => void) | undefined + spawnMock.mockImplementationOnce(() => ({ + pid: 1234, + stdout: null, + stderr, + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (event === 'close') closeHandler = handler as () => void + if (event === 'exit') { + queueMicrotask(() => { + handler(1, null) + stderr.emit('data', Buffer.from('close fallback detail')) + closeHandler?.() + }) + } + }), + })) + const { OpenCodeServerManager } = await import('../../src/services/opencode-single-server') + const manager = OpenCodeServerManager.getInstance() + + await expect(manager.start()).rejects.toThrow('OpenCode server exited with code 1') + expect(manager.getLastStartupError()).toContain('close fallback detail') + }, 15000) + it('reconciles an attested surviving descendant group before an unenforced replacement start', async () => { const originalNodeEnv = ENV.SERVER.NODE_ENV Object.defineProperty(ENV.SERVER, 'NODE_ENV', { value: 'production', configurable: true, writable: true }) @@ -2202,6 +2419,87 @@ describe('OpenCodeServerManager - server auth', () => { } }, 15000) + async function startProductionServerWithControllableExit() { + const originalNodeEnv = ENV.SERVER.NODE_ENV + Object.defineProperty(ENV.SERVER, 'NODE_ENV', { value: 'production', configurable: true, writable: true }) + const killSpy = vi.spyOn(process, 'kill') + readFileMock.mockReset() + readdirSyncMock.mockReset() + sandboxRuntimeServiceMock.SandboxRuntimeService.mockImplementation(() => ({ + isEnabled: () => false, + })) + execSyncMock.mockImplementation(() => { + throw new Error('not found') + }) + readFileSyncMock.mockReturnValue(procStatStringWithGroup(1234, '42')) + killSpy.mockImplementation(((pid: number, signal?: number | string) => { + if (pid === -1234 && signal !== 0) return true + const error = new Error('No such process') as NodeJS.ErrnoException + error.code = 'ESRCH' + throw error + }) as typeof process.kill) + + spawnMock.mockImplementationOnce(() => ({ pid: 1234, stdout: null, stderr: null, on: vi.fn() })) + + const { OpenCodeServerManager } = await import('../../src/services/opencode-single-server') + const manager = OpenCodeServerManager.getInstance() + manager.setDatabase(createPasswordDb(null)) + + await manager.start() + + const spawnedChild = spawnMock.mock.results[0]!.value as { on: ReturnType } + const exitCall = spawnedChild.on.mock.calls.find((call: unknown[]) => call[0] === 'exit') + expect(exitCall).toBeDefined() + + return { + manager, + emitExit: (code: number | null, signal: NodeJS.Signals | null) => { + ;(exitCall![1] as (code: number | null, signal: NodeJS.Signals | null) => void)(code, signal) + }, + cleanup: () => { + killSpy.mockRestore() + Object.defineProperty(ENV.SERVER, 'NODE_ENV', { value: originalNodeEnv, configurable: true, writable: true }) + }, + } + } + + it('does not record a deliberate SIGTERM stop as a startup failure', async () => { + const { manager, emitExit, cleanup } = await startProductionServerWithControllableExit() + try { + await manager.stop() + + emitExit(null, 'SIGTERM') + + expect(manager.getLastStartupError()).toBeNull() + } finally { + cleanup() + } + }, 15000) + + it('records an unexpected SIGTERM exit as a startup failure', async () => { + const { manager, emitExit, cleanup } = await startProductionServerWithControllableExit() + try { + emitExit(null, 'SIGTERM') + + expect(manager.getLastStartupError()).toContain('terminated by signal SIGTERM') + } finally { + cleanup() + } + }, 15000) + + it('records a nonzero exit even while the manager is deliberately stopping the child', async () => { + const { manager, emitExit, cleanup } = await startProductionServerWithControllableExit() + try { + await manager.stop() + + emitExit(1, null) + + expect(manager.getLastStartupError()).toContain('exited with code 1') + } finally { + cleanup() + } + }, 15000) + it('adopts an existing healthy process in production when enforcement is off and the child state is attested as unenforced', async () => { const originalNodeEnv = ENV.SERVER.NODE_ENV Object.defineProperty(ENV.SERVER, 'NODE_ENV', { value: 'production', configurable: true, writable: true }) @@ -2948,6 +3246,52 @@ describe('OpenCodeServerManager - server auth', () => { return `${pid} (opencode) ${fields.join(' ')}` } + async function captureSpawnedServerStderrStream() { + const originalNodeEnv = ENV.SERVER.NODE_ENV + Object.defineProperty(ENV.SERVER, 'NODE_ENV', { value: 'production', configurable: true, writable: true }) + sandboxRuntimeServiceMock.SandboxRuntimeService.mockImplementation(() => ({ + isEnabled: () => false, + })) + execSyncMock.mockImplementation(() => { + throw new Error('not found') + }) + readdirSyncMock.mockReturnValue([]) + readFileSyncMock.mockImplementation((filePath: string) => { + if (String(filePath).includes('/proc/1234/stat')) return procStatStringWithGroup(1234, '42') + const error = new Error('No such process') as NodeJS.ErrnoException + error.code = 'ENOENT' + throw error + }) + + const { EventEmitter } = await import('events') + const stdout = new EventEmitter() + const stderr = new EventEmitter() + spawnMock.mockImplementationOnce(() => ({ pid: 1234, stdout, stderr, on: vi.fn() })) + + const { OpenCodeServerManager } = await import('../../src/services/opencode-single-server') + const manager = OpenCodeServerManager.getInstance() + manager.setDatabase(createPasswordDb(null)) + + await manager.start() + + const spawnedChild = spawnMock.mock.results[0]!.value as { pid: number; on: ReturnType } + const exitCall = spawnedChild.on.mock.calls.find((call: unknown[]) => call[0] === 'exit') + expect(exitCall).toBeDefined() + + return { + manager, + stderr, + emitExit: (code: number) => { + ;(exitCall![1] as (code: number | null, signal: NodeJS.Signals | null) => void)(code, null) + }, + cleanup: () => { + readFileSyncMock.mockReset() + readdirSyncMock.mockReset() + Object.defineProperty(ENV.SERVER, 'NODE_ENV', { value: originalNodeEnv, configurable: true, writable: true }) + }, + } + } + const FAKE_SECRETS = Symbol('fakeSecrets') function createAppSecretsFake(initial: Record = {}) { From edc3fb4219a1f912e520d6958f78652dfe68694e Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:49:57 -0400 Subject: [PATCH 3/4] feat(frontend): default sidebar sections open and theme toasts - Rework useSidebarSections to track sections independently, open by default - Theme sonner toasts with app color tokens and the selected theme mode --- frontend/src/App.tsx | 24 ++++-- .../navigation/DesktopSidebar.test.tsx | 42 ++++------ .../components/navigation/DesktopSidebar.tsx | 6 +- .../src/hooks/useSidebarCollapsed.test.tsx | 84 ++++++++++++++----- frontend/src/hooks/useSidebarCollapsed.ts | 40 +++++---- frontend/src/index.css | 20 +++++ 6 files changed, 140 insertions(+), 76 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e2917abf3..fcbe2caa7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,7 +22,7 @@ import { DesktopSidebar } from '@/components/navigation/DesktopSidebar' import { useRightEdgeSwipe, useSwipeBack } from './hooks/useMobile' import { useMobileTabBar } from '@/hooks/useMobileTabBar' import { TTSProvider } from './contexts/TTSContext' -import { ThemeProvider } from './contexts/ThemeContext' +import { ThemeProvider, useThemeMode } from './contexts/ThemeContext' import { AuthProvider } from './contexts/AuthContext' import { EventProvider, usePermissions, useEventContext } from '@/contexts/EventContext' import { SwipeNavigationProvider, useSwipeNavigation } from '@/contexts/SwipeNavigationContext' @@ -57,6 +57,20 @@ function SSHHostKeyDialogWrapper() { ) } +function ThemedToaster() { + const themeMode = useThemeMode() + return ( + + ) +} + function HealthMonitor() { const { isAuthenticated } = useAuth() const { @@ -188,13 +202,7 @@ function AppShell() { - + diff --git a/frontend/src/components/navigation/DesktopSidebar.test.tsx b/frontend/src/components/navigation/DesktopSidebar.test.tsx index 70c0ba713..a7d1ace15 100644 --- a/frontend/src/components/navigation/DesktopSidebar.test.tsx +++ b/frontend/src/components/navigation/DesktopSidebar.test.tsx @@ -47,7 +47,7 @@ function createWrapper(initialEntries?: string[]) { describe('DesktopSidebar', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(useSidebarCollapsedModule.useSidebarSections).mockReturnValue({ openSection: 'sessions', toggleSection: vi.fn() }) + vi.mocked(useSidebarCollapsedModule.useSidebarSections).mockReturnValue({ isSectionOpen: () => true, toggleSection: vi.fn() }) }) it('returns null when user is not authenticated', () => { @@ -189,7 +189,6 @@ describe('DesktopSidebar', () => { it('opens dialog items by updating the dialog query param (push) and closes on back', () => { vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(true) vi.spyOn(useSidebarCollapsedModule, 'useSidebarCollapsed').mockReturnValue([false, vi.fn()]) - vi.mocked(useSidebarCollapsedModule.useSidebarSections).mockReturnValue({ openSection: 'menu', toggleSection: vi.fn() }) vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({ isAuthenticated: true, isLoading: false, @@ -238,7 +237,6 @@ describe('DesktopSidebar', () => { it('preserves session route as return target when opening schedules', () => { vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(true) vi.spyOn(useSidebarCollapsedModule, 'useSidebarCollapsed').mockReturnValue([false, vi.fn()]) - vi.mocked(useSidebarCollapsedModule.useSidebarSections).mockReturnValue({ openSection: 'menu', toggleSection: vi.fn() }) vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({ isAuthenticated: true, isLoading: false, @@ -258,7 +256,7 @@ describe('DesktopSidebar', () => { expect(screen.getByTestId('location').textContent).toBe('/repos/5/schedules?returnTo=%2Frepos%2F5%2Fsessions%2Fabc%3Fassistant%3D1') }) - it('renders the session tree and account footer when the sessions section is open', () => { + it('renders both collapsible sections open by default with the account footer', () => { vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(true) vi.spyOn(useSidebarCollapsedModule, 'useSidebarCollapsed').mockReturnValue([false, vi.fn()]) vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({ @@ -270,24 +268,6 @@ describe('DesktopSidebar', () => { render(, { wrapper: createWrapper(['/repos/5']) }) expect(screen.getByTestId('session-tree')).toBeInTheDocument() - expect(screen.queryByText('Files')).toBeNull() - expect(screen.getByText('Settings')).toBeInTheDocument() - expect(screen.getByText('Logout')).toBeInTheDocument() - }) - - it('renders the menu items and account footer when the menu section is open', () => { - vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(true) - vi.spyOn(useSidebarCollapsedModule, 'useSidebarCollapsed').mockReturnValue([false, vi.fn()]) - vi.mocked(useSidebarCollapsedModule.useSidebarSections).mockReturnValue({ openSection: 'menu', toggleSection: vi.fn() }) - vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({ - isAuthenticated: true, - isLoading: false, - logout: vi.fn(), - } as any) - - render(, { wrapper: createWrapper(['/repos/5']) }) - - expect(screen.queryByTestId('session-tree')).toBeNull() expect(screen.getByText('Files')).toBeInTheDocument() expect(screen.getByText('Settings')).toBeInTheDocument() expect(screen.getByText('Logout')).toBeInTheDocument() @@ -309,10 +289,13 @@ describe('DesktopSidebar', () => { expect(screen.getByText('Settings')).toBeInTheDocument() }) - it('hides the session tree when the menu section is open', () => { + it('hides the session tree when the sessions section is collapsed', () => { vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(true) vi.spyOn(useSidebarCollapsedModule, 'useSidebarCollapsed').mockReturnValue([false, vi.fn()]) - vi.mocked(useSidebarCollapsedModule.useSidebarSections).mockReturnValue({ openSection: 'menu', toggleSection: vi.fn() }) + vi.mocked(useSidebarCollapsedModule.useSidebarSections).mockReturnValue({ + isSectionOpen: (section) => section !== 'sessions', + toggleSection: vi.fn(), + }) vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({ isAuthenticated: true, isLoading: false, @@ -325,10 +308,13 @@ describe('DesktopSidebar', () => { expect(screen.getByText('Files')).toBeInTheDocument() }) - it('hides the menu items when the sessions section is open', () => { + it('hides the menu items when the menu section is collapsed', () => { vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(true) vi.spyOn(useSidebarCollapsedModule, 'useSidebarCollapsed').mockReturnValue([false, vi.fn()]) - vi.mocked(useSidebarCollapsedModule.useSidebarSections).mockReturnValue({ openSection: 'sessions', toggleSection: vi.fn() }) + vi.mocked(useSidebarCollapsedModule.useSidebarSections).mockReturnValue({ + isSectionOpen: (section) => section !== 'menu', + toggleSection: vi.fn(), + }) vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({ isAuthenticated: true, isLoading: false, @@ -346,7 +332,7 @@ describe('DesktopSidebar', () => { const toggle = vi.fn() vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(true) vi.spyOn(useSidebarCollapsedModule, 'useSidebarCollapsed').mockReturnValue([false, vi.fn()]) - vi.mocked(useSidebarCollapsedModule.useSidebarSections).mockReturnValue({ openSection: 'sessions', toggleSection: toggle }) + vi.mocked(useSidebarCollapsedModule.useSidebarSections).mockReturnValue({ isSectionOpen: () => true, toggleSection: toggle }) vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({ isAuthenticated: true, isLoading: false, @@ -363,7 +349,7 @@ describe('DesktopSidebar', () => { const toggle = vi.fn() vi.spyOn(useDesktopModule, 'useDesktop').mockReturnValue(true) vi.spyOn(useSidebarCollapsedModule, 'useSidebarCollapsed').mockReturnValue([false, vi.fn()]) - vi.mocked(useSidebarCollapsedModule.useSidebarSections).mockReturnValue({ openSection: 'sessions', toggleSection: toggle }) + vi.mocked(useSidebarCollapsedModule.useSidebarSections).mockReturnValue({ isSectionOpen: () => true, toggleSection: toggle }) vi.spyOn(useAuthModule, 'useAuth').mockReturnValue({ isAuthenticated: true, isLoading: false, diff --git a/frontend/src/components/navigation/DesktopSidebar.tsx b/frontend/src/components/navigation/DesktopSidebar.tsx index 1a3bf3e82..1a3bd34b9 100644 --- a/frontend/src/components/navigation/DesktopSidebar.tsx +++ b/frontend/src/components/navigation/DesktopSidebar.tsx @@ -24,7 +24,7 @@ export function DesktopSidebar() { const navigate = useNavigate() const { updateParams } = useUrlParams() const [collapsed, toggle] = useSidebarCollapsed() - const { openSection, toggleSection } = useSidebarSections(['sessions', 'menu'] as const, 'sessions') + const { isSectionOpen, toggleSection } = useSidebarSections(['sessions', 'menu'] as const) const [repoSwitcherOpen, setRepoSwitcherOpen] = useState(false) const { isAuthenticated, isLoading, logout } = useAuth() @@ -118,7 +118,7 @@ export function DesktopSidebar() { <> toggleSection('sessions')} className="border-t border-border" > @@ -127,7 +127,7 @@ export function DesktopSidebar() { toggleSection('menu')} className="flex-1 min-h-fit border-t border-border" contentClassName="overflow-y-auto" diff --git a/frontend/src/hooks/useSidebarCollapsed.test.tsx b/frontend/src/hooks/useSidebarCollapsed.test.tsx index 74c84e3dd..5e1a2febc 100644 --- a/frontend/src/hooks/useSidebarCollapsed.test.tsx +++ b/frontend/src/hooks/useSidebarCollapsed.test.tsx @@ -78,55 +78,97 @@ describe('sidebar collapse hooks', () => { describe('useSidebarSections', () => { const sections = ['sessions', 'menu'] as const - it('opens the default section when no stored value', () => { + it('opens every section by default when no stored value', () => { localStorageMock.getItem.mockReturnValue(null) - const { result } = renderHook(() => useSidebarSections(sections, 'sessions')) + const { result } = renderHook(() => useSidebarSections(sections)) - expect(result.current.openSection).toBe('sessions') + expect(result.current.isSectionOpen('sessions')).toBe(true) + expect(result.current.isSectionOpen('menu')).toBe(true) }) - it('restores the stored open section', () => { - localStorageMock.getItem.mockReturnValue(JSON.stringify('menu')) + it('restores closed sections from storage', () => { + localStorageMock.getItem.mockReturnValue(JSON.stringify(['sessions'])) + + const { result } = renderHook(() => useSidebarSections(sections)) + + expect(localStorageMock.getItem).toHaveBeenCalledWith('oc:sidebar:collapsed:closed-sections') + expect(result.current.isSectionOpen('sessions')).toBe(false) + expect(result.current.isSectionOpen('menu')).toBe(true) + }) + + it('ignores unknown sections from storage', () => { + localStorageMock.getItem.mockReturnValue(JSON.stringify(['sessions', 'other'])) - const { result } = renderHook(() => useSidebarSections(sections, 'sessions')) + const { result } = renderHook(() => useSidebarSections(sections)) - expect(localStorageMock.getItem).toHaveBeenCalledWith('oc:sidebar:collapsed:section') - expect(result.current.openSection).toBe('menu') + expect(result.current.isSectionOpen('sessions')).toBe(false) + expect(result.current.isSectionOpen('menu')).toBe(true) }) - it('falls back to the default when the stored section is unknown', () => { - localStorageMock.getItem.mockReturnValue(JSON.stringify('other')) + it('opens every section when the stored value is malformed JSON', () => { + localStorageMock.getItem.mockReturnValue('not-json{{') + + const { result } = renderHook(() => useSidebarSections(sections)) + + expect(result.current.isSectionOpen('sessions')).toBe(true) + expect(result.current.isSectionOpen('menu')).toBe(true) + }) + + it('opens every section when the stored value is not an array', () => { + localStorageMock.getItem.mockReturnValue(JSON.stringify('menu')) - const { result } = renderHook(() => useSidebarSections(sections, 'sessions')) + const { result } = renderHook(() => useSidebarSections(sections)) - expect(result.current.openSection).toBe('sessions') + expect(result.current.isSectionOpen('sessions')).toBe(true) + expect(result.current.isSectionOpen('menu')).toBe(true) }) - it('opens a section and persists it, closing the other', () => { + it('closes a section and persists it without affecting the others', () => { localStorageMock.getItem.mockReturnValue(null) - const { result } = renderHook(() => useSidebarSections(sections, 'sessions')) + const { result } = renderHook(() => useSidebarSections(sections)) act(() => { - result.current.toggleSection('menu') + result.current.toggleSection('sessions') }) - expect(result.current.openSection).toBe('menu') - expect(localStorageMock.setItem).toHaveBeenCalledWith('oc:sidebar:collapsed:section', JSON.stringify('menu')) + expect(result.current.isSectionOpen('sessions')).toBe(false) + expect(result.current.isSectionOpen('menu')).toBe(true) + expect(localStorageMock.setItem).toHaveBeenCalledWith( + 'oc:sidebar:collapsed:closed-sections', + JSON.stringify(['sessions']), + ) }) - it('closes the open section when toggled again', () => { + it('reopens a closed section when toggled again', () => { + localStorageMock.getItem.mockReturnValue(JSON.stringify(['sessions'])) + + const { result } = renderHook(() => useSidebarSections(sections)) + + act(() => { + result.current.toggleSection('sessions') + }) + + expect(result.current.isSectionOpen('sessions')).toBe(true) + expect(localStorageMock.setItem).toHaveBeenCalledWith( + 'oc:sidebar:collapsed:closed-sections', + JSON.stringify([]), + ) + }) + + it('tracks each section independently', () => { localStorageMock.getItem.mockReturnValue(null) - const { result } = renderHook(() => useSidebarSections(sections, 'sessions')) + const { result } = renderHook(() => useSidebarSections(sections)) act(() => { result.current.toggleSection('sessions') + result.current.toggleSection('menu') }) - expect(result.current.openSection).toBeNull() - expect(localStorageMock.setItem).toHaveBeenCalledWith('oc:sidebar:collapsed:section', JSON.stringify(null)) + expect(result.current.isSectionOpen('sessions')).toBe(false) + expect(result.current.isSectionOpen('menu')).toBe(false) }) }) }) diff --git a/frontend/src/hooks/useSidebarCollapsed.ts b/frontend/src/hooks/useSidebarCollapsed.ts index 0adc6df58..0ba3acf6f 100644 --- a/frontend/src/hooks/useSidebarCollapsed.ts +++ b/frontend/src/hooks/useSidebarCollapsed.ts @@ -1,7 +1,7 @@ import { useState, useCallback } from 'react' const STORAGE_KEY = 'oc:sidebar:collapsed' -const SECTION_STORAGE_KEY = `${STORAGE_KEY}:section` +const CLOSED_SECTIONS_STORAGE_KEY = `${STORAGE_KEY}:closed-sections` function readStoredBoolean(key: string, fallback: boolean): boolean { if (typeof window === 'undefined') { @@ -35,21 +35,23 @@ function usePersistentBoolean(key: string, fallback: boolean): [boolean, () => v return [value, toggle] } -function readStoredSection(sections: readonly T[], fallback: T | null): T | null { +function readStoredClosedSections(sections: readonly T[]): T[] { if (typeof window === 'undefined') { - return fallback + return [] } - const stored = localStorage.getItem(SECTION_STORAGE_KEY) + const stored = localStorage.getItem(CLOSED_SECTIONS_STORAGE_KEY) if (stored === null) { - return fallback + return [] } try { const parsed = JSON.parse(stored) - return typeof parsed === 'string' && (sections as readonly string[]).includes(parsed) - ? (parsed as T) - : fallback + if (!Array.isArray(parsed)) { + return [] + } + const known = new Set(sections) + return parsed.filter((value): value is T => typeof value === 'string' && known.has(value)) } catch { - return fallback + return [] } } @@ -59,19 +61,25 @@ export function useSidebarCollapsed(): [boolean, () => void] { export function useSidebarSections( sections: readonly T[], - defaultSection: T | null = null, -): { openSection: T | null; toggleSection: (section: T) => void } { - const [openSection, setOpenSection] = useState(() => readStoredSection(sections, defaultSection)) +): { isSectionOpen: (section: T) => boolean; toggleSection: (section: T) => void } { + const [closedSections, setClosedSections] = useState(() => readStoredClosedSections(sections)) const toggleSection = useCallback((section: T) => { - setOpenSection((prev) => { - const next = prev === section ? null : section + setClosedSections((prev) => { + const next = prev.includes(section) + ? prev.filter((item) => item !== section) + : [...prev, section] if (typeof window !== 'undefined') { - localStorage.setItem(SECTION_STORAGE_KEY, JSON.stringify(next)) + localStorage.setItem(CLOSED_SECTIONS_STORAGE_KEY, JSON.stringify(next)) } return next }) }, []) - return { openSection, toggleSection } + const isSectionOpen = useCallback( + (section: T) => !closedSections.includes(section), + [closedSections], + ) + + return { isSectionOpen, toggleSection } } diff --git a/frontend/src/index.css b/frontend/src/index.css index 33294a776..659d47e0e 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -517,3 +517,23 @@ input:-webkit-autofill:active { .animate-progress { animation: progress 2s ease-in-out infinite; } + +:root [data-sonner-toaster][data-sonner-theme] { + --normal-bg: var(--color-popover); + --normal-bg-hover: var(--color-accent); + --normal-border: var(--color-border); + --normal-border-hover: var(--color-border); + --normal-text: var(--color-foreground); + --success-bg: var(--color-popover); + --success-border: var(--color-success); + --success-text: var(--color-success); + --info-bg: var(--color-popover); + --info-border: var(--color-info); + --info-text: var(--color-info); + --warning-bg: var(--color-popover); + --warning-border: var(--color-warning); + --warning-text: var(--color-warning); + --error-bg: var(--color-popover); + --error-border: var(--color-destructive); + --error-text: var(--color-destructive); +} From 9a23aa0c2a49b38de5162b5666edc047f238992c Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:25:25 -0400 Subject: [PATCH 4/4] fix(frontend): keep sidebar state usable when localStorage throws --- .../src/hooks/useSidebarCollapsed.test.tsx | 19 +++++++++++ frontend/src/hooks/useSidebarCollapsed.ts | 34 +++++++++++-------- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/frontend/src/hooks/useSidebarCollapsed.test.tsx b/frontend/src/hooks/useSidebarCollapsed.test.tsx index 5e1a2febc..08595cfbd 100644 --- a/frontend/src/hooks/useSidebarCollapsed.test.tsx +++ b/frontend/src/hooks/useSidebarCollapsed.test.tsx @@ -157,6 +157,25 @@ describe('sidebar collapse hooks', () => { ) }) + it('defaults open and keeps toggling in memory when storage throws', () => { + localStorageMock.getItem.mockImplementationOnce(() => { + throw new Error('SecurityError') + }) + localStorageMock.setItem.mockImplementationOnce(() => { + throw new Error('QuotaExceededError') + }) + + const { result } = renderHook(() => useSidebarSections(sections)) + + expect(result.current.isSectionOpen('sessions')).toBe(true) + + act(() => { + result.current.toggleSection('sessions') + }) + + expect(result.current.isSectionOpen('sessions')).toBe(false) + }) + it('tracks each section independently', () => { localStorageMock.getItem.mockReturnValue(null) diff --git a/frontend/src/hooks/useSidebarCollapsed.ts b/frontend/src/hooks/useSidebarCollapsed.ts index 0ba3acf6f..785259fc9 100644 --- a/frontend/src/hooks/useSidebarCollapsed.ts +++ b/frontend/src/hooks/useSidebarCollapsed.ts @@ -3,11 +3,24 @@ import { useState, useCallback } from 'react' const STORAGE_KEY = 'oc:sidebar:collapsed' const CLOSED_SECTIONS_STORAGE_KEY = `${STORAGE_KEY}:closed-sections` -function readStoredBoolean(key: string, fallback: boolean): boolean { - if (typeof window === 'undefined') { - return fallback +function readStorage(key: string): string | null { + try { + return localStorage.getItem(key) + } catch { + return null + } +} + +function writeStorage(key: string, value: unknown): void { + try { + localStorage.setItem(key, JSON.stringify(value)) + } catch { + return } - const stored = localStorage.getItem(key) +} + +function readStoredBoolean(key: string, fallback: boolean): boolean { + const stored = readStorage(key) if (stored === null) { return fallback } @@ -25,9 +38,7 @@ function usePersistentBoolean(key: string, fallback: boolean): [boolean, () => v const toggle = useCallback(() => { setValue((prev: boolean) => { const newValue = !prev - if (typeof window !== 'undefined') { - localStorage.setItem(key, JSON.stringify(newValue)) - } + writeStorage(key, newValue) return newValue }) }, [key]) @@ -36,10 +47,7 @@ function usePersistentBoolean(key: string, fallback: boolean): [boolean, () => v } function readStoredClosedSections(sections: readonly T[]): T[] { - if (typeof window === 'undefined') { - return [] - } - const stored = localStorage.getItem(CLOSED_SECTIONS_STORAGE_KEY) + const stored = readStorage(CLOSED_SECTIONS_STORAGE_KEY) if (stored === null) { return [] } @@ -69,9 +77,7 @@ export function useSidebarSections( const next = prev.includes(section) ? prev.filter((item) => item !== section) : [...prev, section] - if (typeof window !== 'undefined') { - localStorage.setItem(CLOSED_SECTIONS_STORAGE_KEY, JSON.stringify(next)) - } + writeStorage(CLOSED_SECTIONS_STORAGE_KEY, next) return next }) }, [])