diff --git a/backend/src/index.ts b/backend/src/index.ts index 81df10fa..6c06b282 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -44,6 +44,7 @@ import { NotificationService } from './services/notification' import { ScheduleRunner, ScheduleService } from './services/schedules' import { migrateGlobalSkills } from './services/skills' import { getOpenCodeImportStatus, syncOpenCodeImport } from './services/opencode-import' +import { OpenCodeSupervisor } from './services/opencode-supervisor' import { OpenCodeConfigSchema } from '@opencode-manager/shared/schemas' import { parse as parseJsonc } from 'jsonc-parser' @@ -84,6 +85,7 @@ import { DEFAULT_AGENTS_MD } from './constants' let ipcServer: IPCServer | undefined const gitAuthService = new GitAuthService() +let openCodeSupervisor: OpenCodeSupervisor | undefined async function ensureDefaultConfigExists(): Promise { const settingsService = new SettingsService(db) const workspaceConfigPath = getOpenCodeConfigFilePath() @@ -208,6 +210,10 @@ try { const settingsService = new SettingsService(db) settingsService.initializeLastKnownGoodConfig() + openCodeSupervisor = new OpenCodeSupervisor(opencodeServerManager, settingsService, { + userId: 'default' + }) + await migrateGlobalSkills() ipcServer = await createIPCServer(process.env.STORAGE_PATH || undefined) @@ -215,8 +221,12 @@ try { logger.info(`Git IPC server running at ${ipcServer.ipcHandlePath}`) opencodeServerManager.setDatabase(db) - await opencodeServerManager.start() - logger.info(`OpenCode server running on port ${opencodeServerManager.getPort()}`) + const openCodeStatus = await openCodeSupervisor.start() + if (openCodeStatus.healthy) { + logger.info(`OpenCode server running on port ${openCodeStatus.port}`) + } else { + logger.warn(`OpenCode server unavailable after startup recovery: ${openCodeStatus.lastError ?? openCodeStatus.state}`) + } await syncAdminFromEnv(auth, db) } catch (error) { @@ -251,18 +261,18 @@ void scheduleRunnerInstance.start() app.route('/api/auth', createAuthRoutes(auth)) app.route('/api/auth-info', createAuthInfoRoutes(auth, db)) -app.route('/api/health', createHealthRoutes(db)) +app.route('/api/health', createHealthRoutes(db, openCodeSupervisor)) app.route('/api/mcp-oauth-proxy', createMcpOauthProxyRoutes(requireAuth)) const protectedApi = new Hono() protectedApi.use('/*', requireAuth) -protectedApi.route('/repos', createRepoRoutes(db, gitAuthService, scheduleService)) -protectedApi.route('/settings', createSettingsRoutes(db, gitAuthService)) +protectedApi.route('/repos', createRepoRoutes(db, gitAuthService, scheduleService, openCodeSupervisor)) +protectedApi.route('/settings', createSettingsRoutes(db, gitAuthService, openCodeSupervisor)) protectedApi.route('/files', createFileRoutes()) -protectedApi.route('/providers', createProvidersRoutes()) -protectedApi.route('/oauth', createOAuthRoutes()) +protectedApi.route('/providers', createProvidersRoutes(openCodeSupervisor)) +protectedApi.route('/oauth', createOAuthRoutes(openCodeSupervisor)) protectedApi.route('/tts', createTTSRoutes(db)) protectedApi.route('/stt', createSTTRoutes(db)) protectedApi.route('/sse', createSSERoutes()) @@ -373,9 +383,14 @@ const shutdown = async (signal: string) => { await ipcServer.dispose() logger.info('Git IPC server stopped') } + if (openCodeSupervisor) { + await openCodeSupervisor.stop() + } scheduleRunnerInstance?.stop() logger.info('Schedule runner stopped') - await opencodeServerManager.stop() + if (!openCodeSupervisor) { + await opencodeServerManager.stop() + } logger.info('OpenCode server stopped') } catch (error) { logger.error('Error during shutdown:', error) diff --git a/backend/src/routes/health.ts b/backend/src/routes/health.ts index eb9127b1..78f8caa1 100644 --- a/backend/src/routes/health.ts +++ b/backend/src/routes/health.ts @@ -2,6 +2,7 @@ import { Hono } from 'hono' import type { Database } from 'bun:sqlite' import { readFile } from 'fs/promises' import { opencodeServerManager } from '../services/opencode-single-server' +import type { OpenCodeSupervisor } from '../services/opencode-supervisor' import { compareVersions } from '../utils/version-utils' const GITHUB_REPO_OWNER = 'chriswritescode-dev' @@ -66,17 +67,22 @@ const opencodeManagerVersionPromise = (async (): Promise => { } })() -export function createHealthRoutes(db: Database) { +export function createHealthRoutes(db: Database, openCodeSupervisor?: OpenCodeSupervisor) { const app = new Hono() app.get('/', async (c) => { try { const opencodeManagerVersion = await opencodeManagerVersionPromise const dbCheck = db.prepare('SELECT 1').get() - const opencodeHealthy = await opencodeServerManager.checkHealth() - const startupError = opencodeServerManager.getLastStartupError() - - const status = startupError && !opencodeHealthy + const lifecycle = openCodeSupervisor + ? await openCodeSupervisor.checkNow('api_probe') + : null + const opencodeHealthy = lifecycle?.healthy ?? await opencodeServerManager.checkHealth() + const startupError = lifecycle?.lastError ?? opencodeServerManager.getLastStartupError() + + const status = lifecycle?.state === 'recovering' + ? 'degraded' + : startupError && !opencodeHealthy ? 'unhealthy' : (dbCheck && opencodeHealthy ? 'healthy' : 'degraded') @@ -92,6 +98,10 @@ export function createHealthRoutes(db: Database) { opencodeManagerVersion, } + if (lifecycle) { + response.opencodeLifecycle = lifecycle + } + if (startupError && !opencodeHealthy) { response.error = startupError } @@ -110,12 +120,16 @@ export function createHealthRoutes(db: Database) { app.get('/processes', async (c) => { try { - const opencodeHealthy = await opencodeServerManager.checkHealth() - + const lifecycle = openCodeSupervisor + ? await openCodeSupervisor.checkNow('api_probe') + : null + const opencodeHealthy = lifecycle?.healthy ?? await opencodeServerManager.checkHealth() + return c.json({ opencode: { port: opencodeServerManager.getPort(), - healthy: opencodeHealthy + healthy: opencodeHealthy, + lifecycle, }, timestamp: new Date().toISOString() }) diff --git a/backend/src/routes/oauth.ts b/backend/src/routes/oauth.ts index 5b4d64d4..571c2dd7 100644 --- a/backend/src/routes/oauth.ts +++ b/backend/src/routes/oauth.ts @@ -8,8 +8,18 @@ import { OAuthCallbackRequestSchema } from '../../../shared/src/schemas/auth' import { opencodeServerManager } from '../services/opencode-single-server' +import type { OpenCodeSupervisor } from '../services/opencode-supervisor' -export function createOAuthRoutes() { +async function reloadOpenCodeConfig(openCodeSupervisor?: OpenCodeSupervisor): Promise { + if (openCodeSupervisor) { + await openCodeSupervisor.reloadConfig('settings_reload') + return + } + + await opencodeServerManager.reloadConfig() +} + +export function createOAuthRoutes(openCodeSupervisor?: OpenCodeSupervisor) { const app = new Hono() app.post('/:id/oauth/authorize', async (c) => { @@ -76,7 +86,7 @@ export function createOAuthRoutes() { const data = await response.json() try { - await opencodeServerManager.reloadConfig() + await reloadOpenCodeConfig(openCodeSupervisor) } catch (reloadError) { logger.warn(`Failed to reload OpenCode config after OAuth callback for ${providerId}:`, reloadError) } diff --git a/backend/src/routes/providers.ts b/backend/src/routes/providers.ts index 1ad00014..dd9e5c80 100644 --- a/backend/src/routes/providers.ts +++ b/backend/src/routes/providers.ts @@ -5,8 +5,18 @@ import { SetCredentialRequestSchema } from '../../../shared/src/schemas/auth' import { logger } from '../utils/logger' import { setOpenCodeAuth, deleteOpenCodeAuth } from '../services/proxy' import { opencodeServerManager } from '../services/opencode-single-server' +import type { OpenCodeSupervisor } from '../services/opencode-supervisor' -export function createProvidersRoutes() { +async function reloadOpenCodeConfig(openCodeSupervisor?: OpenCodeSupervisor): Promise { + if (openCodeSupervisor) { + await openCodeSupervisor.reloadConfig('settings_reload') + return + } + + await opencodeServerManager.reloadConfig() +} + +export function createProvidersRoutes(openCodeSupervisor?: OpenCodeSupervisor) { const app = new Hono() const authService = new AuthService() @@ -45,7 +55,7 @@ export function createProvidersRoutes() { await authService.set(providerId, validated.apiKey) try { - await opencodeServerManager.reloadConfig() + await reloadOpenCodeConfig(openCodeSupervisor) } catch (reloadError) { logger.warn(`Failed to reload OpenCode config after saving credentials for ${providerId}:`, reloadError) } @@ -72,7 +82,7 @@ export function createProvidersRoutes() { await authService.delete(providerId) try { - await opencodeServerManager.reloadConfig() + await reloadOpenCodeConfig(openCodeSupervisor) } catch (reloadError) { logger.warn(`Failed to reload OpenCode config after deleting credentials for ${providerId}:`, reloadError) } diff --git a/backend/src/routes/repos.ts b/backend/src/routes/repos.ts index f8ebb06d..9085ad55 100644 --- a/backend/src/routes/repos.ts +++ b/backend/src/routes/repos.ts @@ -8,6 +8,7 @@ import * as archiveService from '../services/archive' import { SettingsService } from '../services/settings' import { writeFileContent } from '../services/file-operations' import { opencodeServerManager } from '../services/opencode-single-server' +import type { OpenCodeSupervisor } from '../services/opencode-supervisor' import { proxyToOpenCodeWithDirectory } from '../services/proxy' import { logger } from '../utils/logger' import { getErrorMessage, getStatusCode } from '../utils/error-utils' @@ -19,7 +20,22 @@ import { ScheduleService } from '../services/schedules' import { ensureAssistantMode, getAssistantModeStatus } from '../services/assistant-mode' import path from 'path' -export function createRepoRoutes(database: Database, gitAuthService: GitAuthService, scheduleService: ScheduleService) { +async function restartOpenCode(openCodeSupervisor?: OpenCodeSupervisor): Promise { + if (openCodeSupervisor) { + await openCodeSupervisor.restart('settings_restart') + return + } + + opencodeServerManager.clearStartupError() + await opencodeServerManager.restart() +} + +export function createRepoRoutes( + database: Database, + gitAuthService: GitAuthService, + scheduleService: ScheduleService, + openCodeSupervisor?: OpenCodeSupervisor, +) { const app = new Hono() app.route('/', createRepoGitRoutes(database, gitAuthService)) @@ -235,8 +251,7 @@ app.get('/', async (c) => { logger.info(`Updated OpenCode config: ${openCodeConfigPath}`) logger.info('Restarting OpenCode server due to workspace config change') - await opencodeServerManager.stop() - await opencodeServerManager.start() + await restartOpenCode(openCodeSupervisor) const updatedRepo = getRepoById(database, id) return c.json(updatedRepo) @@ -410,7 +425,7 @@ app.get('/', async (c) => { const status = await ensureAssistantMode(repo, options) if (status.files.opencodeJson.created) { opencodeServerManager.clearStartupError() - await opencodeServerManager.restart() + await restartOpenCode(openCodeSupervisor) } return c.json(status) } catch (error: unknown) { diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 3f88f5bc..3895a760 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -20,6 +20,7 @@ import { } from '@opencode-manager/shared' import { logger } from '../utils/logger' import { opencodeServerManager, ConfigReloadError } from '../services/opencode-single-server' +import type { OpenCodeSupervisor } from '../services/opencode-supervisor' import type { GitAuthService } from '../services/git-auth' import { DEFAULT_AGENTS_MD } from '../constants' import { validateSSHPrivateKey } from '../utils/ssh-validation' @@ -68,6 +69,25 @@ function getOpenCodeConfigContentToWrite( return JSON.stringify(appliedConfig, null, 2) } +async function reloadOpenCodeConfig(openCodeSupervisor?: OpenCodeSupervisor): Promise { + if (openCodeSupervisor) { + await openCodeSupervisor.reloadConfig('settings_reload') + return + } + + await opencodeServerManager.reloadConfig() +} + +async function restartOpenCode(openCodeSupervisor?: OpenCodeSupervisor): Promise { + if (openCodeSupervisor) { + await openCodeSupervisor.restart('settings_restart') + return + } + + opencodeServerManager.clearStartupError() + await opencodeServerManager.restart() +} + function didConfigFieldChange( previous: Record | undefined, next: Record | undefined, @@ -199,7 +219,7 @@ async function extractOpenCodeError(response: Response, defaultError: string): P : defaultError } -export function createSettingsRoutes(db: Database, gitAuthService: GitAuthService) { +export function createSettingsRoutes(db: Database, gitAuthService: GitAuthService, openCodeSupervisor?: OpenCodeSupervisor) { const app = new Hono() const settingsService = new SettingsService(db) @@ -273,7 +293,7 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic const changeType = [credentialsChanged && 'credentials', identityChanged && 'identity'].filter(Boolean).join(' and ') logger.info(`Git ${changeType} changed, reloading OpenCode configuration`) try { - await opencodeServerManager.reloadConfig() + await reloadOpenCodeConfig(openCodeSupervisor) serverRestarted = true } catch (error) { logger.warn('Failed to reload OpenCode config after git settings change:', error) @@ -346,7 +366,7 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic await writeFileContent(configPath, provisionalConfig.rawContent) logger.info(`Wrote default config to: ${configPath}`) opencodeServerManager.clearStartupError() - await opencodeServerManager.restart() + await restartOpenCode(openCodeSupervisor) return c.json(config) } @@ -426,7 +446,7 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic logger.info(`Wrote default config to: ${configPath}`) logger.info('OpenCode configuration requires process restart') opencodeServerManager.clearStartupError() - await opencodeServerManager.restart() + await restartOpenCode(openCodeSupervisor) } else { const patchResult = await patchOpenCodeConfig(config.content) if (!patchResult.success) { @@ -501,7 +521,7 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic await writeFileContent(configPath, existingConfig.rawContent) logger.info(`Wrote default config '${configName}' to: ${configPath}`) opencodeServerManager.clearStartupError() - await opencodeServerManager.restart() + await restartOpenCode(openCodeSupervisor) return c.json(config) } @@ -570,7 +590,7 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic try { logger.info('Manual OpenCode server restart requested') opencodeServerManager.clearStartupError() - await opencodeServerManager.restart() + await restartOpenCode(openCodeSupervisor) return c.json({ success: true, message: 'OpenCode server restarted successfully' }) } catch (error) { logger.error('Failed to restart OpenCode server:', error) @@ -629,7 +649,7 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic } opencodeServerManager.clearStartupError() - await opencodeServerManager.restart() + await restartOpenCode(openCodeSupervisor) return c.json({ success: true, @@ -660,7 +680,7 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic app.post('/opencode-reload', async (c) => { try { logger.info('OpenCode configuration reload requested') - await opencodeServerManager.reloadConfig() + await reloadOpenCodeConfig(openCodeSupervisor) return c.json({ success: true, message: 'OpenCode configuration reloaded successfully' }) } catch (error) { logger.error('Failed to reload OpenCode config:', error) @@ -703,7 +723,7 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic opencodeServerManager.clearStartupError() try { - await opencodeServerManager.reloadConfig() + await reloadOpenCodeConfig(openCodeSupervisor) } catch (reloadError) { logger.error('Rollback config reload failed, attempting restart:', reloadError) @@ -713,7 +733,7 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic await new Promise(r => setTimeout(r, 1000)) opencodeServerManager.clearStartupError() - await opencodeServerManager.restart() + await restartOpenCode(openCodeSupervisor) return c.json({ success: true, @@ -764,11 +784,11 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic logger.info(`OpenCode upgraded from v${oldVersion} to v${newVersion}`) opencodeServerManager.clearStartupError() try { - await opencodeServerManager.reloadConfig() + await reloadOpenCodeConfig(openCodeSupervisor) logger.info('OpenCode server reloaded after upgrade') } catch (reloadError) { logger.warn('Config reload after upgrade failed, attempting full restart:', reloadError) - await opencodeServerManager.restart() + await restartOpenCode(openCodeSupervisor) logger.info('OpenCode server restarted after upgrade') } @@ -798,7 +818,7 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic opencodeServerManager.clearStartupError() try { - await opencodeServerManager.restart() + await restartOpenCode(openCodeSupervisor) logger.warn('OpenCode server restarted after upgrade failure') recovered = true recoveryMessage = 'Server recovered' @@ -919,7 +939,7 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic logger.info(`New OpenCode version: ${newVersion}`) opencodeServerManager.clearStartupError() - await opencodeServerManager.restart() + await restartOpenCode(openCodeSupervisor) logger.info('OpenCode server restarted after version change') return c.json({ @@ -937,7 +957,7 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic opencodeServerManager.clearStartupError() try { - await opencodeServerManager.restart() + await restartOpenCode(openCodeSupervisor) logger.warn('OpenCode server restarted after install failure') recovered = true recoveryMessage = 'Server recovered' @@ -1096,7 +1116,7 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic await writeFileContent(agentsMdPath, content) logger.info(`Updated AGENTS.md at: ${agentsMdPath}`) - await opencodeServerManager.restart() + await restartOpenCode(openCodeSupervisor) logger.info('Restarted OpenCode server after AGENTS.md update') return c.json({ success: true }) @@ -1164,7 +1184,7 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic const skill = await createSkill(db, validated) try { - await opencodeServerManager.restart() + await restartOpenCode(openCodeSupervisor) logger.info('Restarted OpenCode server after skill creation') } catch (restartError) { logger.warn('Failed to restart OpenCode server after skill creation:', restartError) @@ -1202,7 +1222,7 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic const skill = await updateSkill(db, name, scope, validated, repoId) try { - await opencodeServerManager.restart() + await restartOpenCode(openCodeSupervisor) logger.info('Restarted OpenCode server after skill update') } catch (restartError) { logger.warn('Failed to restart OpenCode server after skill update:', restartError) @@ -1241,7 +1261,7 @@ export function createSettingsRoutes(db: Database, gitAuthService: GitAuthServic await deleteSkill(db, name, scope, repoId) try { - await opencodeServerManager.restart() + await restartOpenCode(openCodeSupervisor) logger.info('Restarted OpenCode server after skill deletion') } catch (restartError) { logger.warn('Failed to restart OpenCode server after skill deletion:', restartError) diff --git a/backend/src/services/opencode-single-server.ts b/backend/src/services/opencode-single-server.ts index 6687f350..ebc12985 100644 --- a/backend/src/services/opencode-single-server.ts +++ b/backend/src/services/opencode-single-server.ts @@ -100,6 +100,7 @@ class OpenCodeServerManager { private db: Database | null = null private version: string | null = null private lastStartupError: string | null = null + private opInProgress: boolean = false private constructor() {} @@ -122,12 +123,37 @@ class OpenCodeServerManager { OpenCodeServerManager.instance = null as unknown as OpenCodeServerManager } - async start(retryAfterPluginInstall = true): Promise { - if (this.isHealthy) { - logger.info('OpenCode server already running and healthy') + private acquireOp(): boolean { + if (this.opInProgress) { + return false + } + + this.opInProgress = true + return true + } + + private releaseOp(acquired: boolean): void { + if (acquired) { + this.opInProgress = false + } + } + + isOperationInProgress(): boolean { + return this.opInProgress + } + + async start(retryAfterPluginInstall = true, allowNested = false): Promise { + const acquired = this.acquireOp() + if (!acquired && !allowNested) { return } + try { + if (this.isHealthy) { + logger.info('OpenCode server already running and healthy') + return + } + const isDevelopment = ENV.SERVER.NODE_ENV !== 'production' let gitCredentials: GitCredential[] = [] @@ -137,7 +163,7 @@ class OpenCodeServerManager { const settingsService = new SettingsService(this.db) const settings = settingsService.getSettings('default') gitCredentials = settings.preferences.gitCredentials || [] - + const identity = await resolveGitIdentity(settings.preferences.gitIdentity, gitCredentials) if (identity) { gitIdentityEnv = createGitIdentityEnv(identity) @@ -265,9 +291,9 @@ class OpenCodeServerManager { ...(OPENCODE_SERVER_PUBLIC_URL ? { OPENCODE_PUBLIC_URL: OPENCODE_SERVER_PUBLIC_URL } : {}), ...(OPENCODE_SERVER_PASSWORD ? { - OPENCODE_SERVER_PASSWORD, - OPENCODE_SERVER_USERNAME, - } + OPENCODE_SERVER_PASSWORD, + OPENCODE_SERVER_USERNAME, + } : {}), OPENCODE_CONFIG: openCodeConfigPath, } @@ -305,9 +331,9 @@ class OpenCodeServerManager { this.lastStartupError = formatStartupError(stderrOutput, fallback) if (configuredPluginCount > 0 && retryAfterPluginInstall) { logger.warn(`OpenCode server did not become healthy after installing ${configuredPluginCount} configured plugin(s); restarting once`) - await this.stop() + await this.stop(true) await new Promise(r => setTimeout(r, 1000)) - await this.start(false) + await this.start(false, true) return } throw new Error('OpenCode server failed to become healthy') @@ -324,43 +350,55 @@ class OpenCodeServerManager { logger.warn('Some features like MCP management may not work correctly') } } + } finally { + this.releaseOp(acquired) + } } - async stop(): Promise { - if (!this.serverPid) return + async stop(allowNested = false): Promise { + const acquired = this.acquireOp() + if (!acquired && !allowNested) { + return + } - logger.info('Stopping OpenCode server') try { - process.kill(this.serverPid, 'SIGTERM') - } catch (error) { - const errorCode = error && typeof error === 'object' && 'code' in error ? (error as { code: string }).code : '' - if (errorCode === 'ESRCH') { - logger.debug(`Process ${this.serverPid} already stopped`) - } else { - logger.warn(`Failed to send SIGTERM to ${this.serverPid}:`, error) + if (!this.serverPid) return + + logger.info('Stopping OpenCode server') + try { + process.kill(this.serverPid, 'SIGTERM') + } catch (error) { + const errorCode = error && typeof error === 'object' && 'code' in error ? (error as { code: string }).code : '' + if (errorCode === 'ESRCH') { + logger.debug(`Process ${this.serverPid} already stopped`) + } else { + logger.warn(`Failed to send SIGTERM to ${this.serverPid}:`, error) + } } - } - await new Promise(r => setTimeout(r, 2000)) + await new Promise(r => setTimeout(r, 2000)) - try { - process.kill(this.serverPid, 'SIGKILL') - } catch (error) { - const errorCode = error && typeof error === 'object' && 'code' in error ? (error as { code: string }).code : '' - if (errorCode === 'ESRCH') { - logger.debug(`Process ${this.serverPid} already stopped`) - } else { - logger.warn(`Failed to send SIGKILL to ${this.serverPid}:`, error) + try { + process.kill(this.serverPid, 'SIGKILL') + } catch (error) { + const errorCode = error && typeof error === 'object' && 'code' in error ? (error as { code: string }).code : '' + if (errorCode === 'ESRCH') { + logger.debug(`Process ${this.serverPid} already stopped`) + } else { + logger.warn(`Failed to send SIGKILL to ${this.serverPid}:`, error) + } } - } - this.serverPid = null - this.isHealthy = false + this.serverPid = null + this.isHealthy = false - try { - await cleanupPersistentSSHKeys() - } catch (error) { - logger.warn('Failed to cleanup persistent SSH keys:', error) + try { + await cleanupPersistentSSHKeys() + } catch (error) { + logger.warn('Failed to cleanup persistent SSH keys:', error) + } + } finally { + this.releaseOp(acquired) } } @@ -415,44 +453,62 @@ class OpenCodeServerManager { } async restart(): Promise { - logger.info('Restarting OpenCode server (full process restart)') - await this.stop() - await new Promise(r => setTimeout(r, 1000)) - await this.start() + const acquired = this.acquireOp() + if (!acquired) { + return + } + + try { + logger.info('Restarting OpenCode server (full process restart)') + await this.stop(true) + await new Promise(r => setTimeout(r, 1000)) + await this.start(false, true) + } finally { + this.releaseOp(acquired) + } } async reloadConfig(): Promise { - logger.info('Reloading OpenCode configuration (via API)') + const acquired = this.acquireOp() + if (!acquired) { + return + } + try { - const configPath = getOpenCodeConfigFilePath() - const fileContent = await fs.readFile(configPath, 'utf-8') - const fileConfig = JSON.parse(fileContent) as Record - logger.info(`Read config from file for reload: ${configPath}`) - - const patchResult = await patchOpenCodeConfig(fileConfig) - if (!patchResult.success) { - const errorMessage = patchResult.error || 'Failed to reload config' - const validationIssues = patchResult.details || [] - const removedFields = patchResult.removedFields || [] - if (validationIssues.length > 0) { - const issueSummary = validationIssues.map((d) => `${d.path}: ${d.message}`).join('; ') - logger.error(`Config reload validation errors: ${issueSummary}`) - } - if (removedFields.length > 0) { - logger.info(`Removed fields during config reload: ${removedFields.join(', ')}`) + logger.info('Reloading OpenCode configuration (via API)') + try { + const configPath = getOpenCodeConfigFilePath() + const fileContent = await fs.readFile(configPath, 'utf-8') + const fileConfig = JSON.parse(fileContent) as Record + logger.info(`Read config from file for reload: ${configPath}`) + + const patchResult = await patchOpenCodeConfig(fileConfig) + if (!patchResult.success) { + const errorMessage = patchResult.error || 'Failed to reload config' + const validationIssues = patchResult.details || [] + const removedFields = patchResult.removedFields || [] + if (validationIssues.length > 0) { + const issueSummary = validationIssues.map((d) => `${d.path}: ${d.message}`).join('; ') + logger.error(`Config reload validation errors: ${issueSummary}`) + } + if (removedFields.length > 0) { + logger.info(`Removed fields during config reload: ${removedFields.join(', ')}`) + } + throw new ConfigReloadError(errorMessage, validationIssues, removedFields) } - throw new ConfigReloadError(errorMessage, validationIssues, removedFields) - } - logger.info('OpenCode configuration reloaded successfully') - await new Promise(r => setTimeout(r, 500)) - const healthy = await this.checkHealth() - if (!healthy) { - throw new Error('Server unhealthy after config reload') + logger.info('OpenCode configuration reloaded successfully') + await new Promise(r => setTimeout(r, 500)) + const healthy = await this.checkHealth() + if (!healthy) { + throw new Error('Server unhealthy after config reload') + } + } catch (error) { + logger.error('Failed to reload OpenCode config:', error) + throw error } - } catch (error) { - logger.error('Failed to reload OpenCode config:', error) - throw error + } finally { + this.releaseOp(acquired) } } diff --git a/backend/src/services/opencode-supervisor.ts b/backend/src/services/opencode-supervisor.ts new file mode 100644 index 00000000..fb0404e6 --- /dev/null +++ b/backend/src/services/opencode-supervisor.ts @@ -0,0 +1,386 @@ +import path from 'path' +import type { SettingsService } from './settings' +import { logger } from '../utils/logger' +import { ensureDirectoryExists, writeFileContent } from './file-operations' +import { getOpenCodeConfigFilePath, getWorkspacePath, ENV } from '@opencode-manager/shared/config/env' +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] + +export type OpenCodeOperationReason = + | 'backend_startup' + | 'health_poll' + | 'api_probe' + | 'settings_restart' + | '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 + userId?: string + watchEnabled?: boolean +} + +export class OpenCodeSupervisor { + private interval: ReturnType | null = null + private state: OpenCodeLifecycleState = 'idle' + private lastError: string | null = null + private activeRecoveryAction: OpenCodeRecoveryAction | null = null + private attemptedRecoveryActions: OpenCodeRecoveryAction[] = [] + private consecutiveFailures = 0 + private operationInProgress = false + private updatedAt = new Date().toISOString() + + constructor( + private readonly openCodeServerManager: OpenCodeServerManager, + private readonly settingsService: SettingsService, + private readonly options: OpenCodeSupervisorOptions = {}, + ) {} + + get pollIntervalMs(): number { + return this.options.pollIntervalMs ?? ENV.OPENCODE.HEALTH_POLL_MS + } + + get failureThreshold(): number { + return this.options.failureThreshold ?? ENV.OPENCODE.HEALTH_FAILURE_THRESHOLD + } + + isWatchEnabled(): boolean { + if (this.options.watchEnabled !== undefined) return this.options.watchEnabled + return ENV.OPENCODE.HEALTH_WATCH_ENABLED + } + + async start(): Promise { + await this.runLifecycleOperation(async () => { + this.setState('starting') + + try { + await this.openCodeServerManager.start() + const healthy = await this.openCodeServerManager.checkHealth() + if (healthy) { + this.markHealthy() + return this.getStatus() + } + + this.recordFailure('OpenCode server failed to become healthy during startup') + } catch (error) { + this.recordFailure(error) + } + + return this.recover('backend_startup') + }) + + this.startWatching() + return this.getStatus() + } + + async restart(reason: OpenCodeOperationReason): Promise { + return this.runLifecycleOperation(async () => { + this.setState('starting') + + try { + this.openCodeServerManager.clearStartupError() + await this.openCodeServerManager.restart() + return this.refreshHealthOrRecover(reason) + } catch (error) { + this.recordFailure(error) + return this.recover(reason) + } + }) + } + + async reloadConfig(reason: OpenCodeOperationReason): Promise { + return this.runLifecycleOperation(async () => { + this.setState('starting') + + try { + this.openCodeServerManager.clearStartupError() + await this.openCodeServerManager.reloadConfig() + return this.refreshHealthOrRecover(reason) + } catch (error) { + this.recordFailure(error) + return this.recover(reason) + } + }) + } + + async checkNow(reason: OpenCodeOperationReason): Promise { + if (reason === 'health_poll' && !this.isWatchEnabled()) { + return this.getStatus() + } + + if (this.operationInProgress || this.openCodeServerManager.isOperationInProgress()) { + return this.getStatus() + } + + return this.runLifecycleOperation(async () => this.refreshHealthOrRecover(reason, true)) + } + + async stop(): Promise { + if (this.interval) { + clearInterval(this.interval) + this.interval = null + } + + await this.runLifecycleOperation(async () => { + this.setState('stopping') + await this.openCodeServerManager.stop() + this.setState('stopped') + return this.getStatus() + }) + + logger.info('Stopped OpenCode supervisor') + } + + getStatus(): OpenCodeLifecycleStatus { + const nextRecoveryAction = this.state === 'recovering' || this.state === 'unhealthy' || this.state === 'failed' + ? this.getNextRecoveryAction() + : null + + return { + state: this.state, + healthy: this.state === 'healthy', + port: this.openCodeServerManager.getPort(), + version: this.openCodeServerManager.getVersion(), + minVersion: this.openCodeServerManager.getMinVersion(), + versionSupported: this.openCodeServerManager.isVersionSupported(), + lastError: this.lastError, + activeRecoveryAction: this.activeRecoveryAction, + attemptedRecoveryActions: [...this.attemptedRecoveryActions], + nextRecoveryAction, + failureCount: this.consecutiveFailures, + watching: this.interval !== null, + updatedAt: this.updatedAt, + } + } + + private async runLifecycleOperation(operation: () => Promise): Promise { + if (this.operationInProgress) { + return this.getStatus() + } + + this.operationInProgress = true + try { + return await operation() + } finally { + this.operationInProgress = false + this.touch() + } + } + + private async refreshHealthOrRecover(reason: OpenCodeOperationReason, respectThreshold = false): Promise { + const healthy = await this.openCodeServerManager.checkHealth() + if (healthy) { + this.markHealthy() + return this.getStatus() + } + + this.consecutiveFailures += 1 + this.setState('unhealthy') + this.lastError = this.openCodeServerManager.getLastStartupError() ?? 'OpenCode health check failed' + + if (respectThreshold && this.consecutiveFailures < this.failureThreshold) { + return this.getStatus() + } + + return this.recover(reason) + } + + private async recover(reason: OpenCodeOperationReason): Promise { + this.setState('recovering') + logger.warn(`OpenCode unhealthy during ${reason}, entering recovery`) + + for (const action of OPENCODE_RECOVERY_ACTIONS) { + this.activeRecoveryAction = action + this.attemptedRecoveryActions.push(action) + this.touch() + + try { + await this.runRecoveryAction(action) + const healthy = await this.openCodeServerManager.checkHealth() + if (healthy) { + this.markHealthy() + return this.getStatus() + } + + this.lastError = this.openCodeServerManager.getLastStartupError() ?? `Recovery action '${action}' did not restore OpenCode health` + logger.warn(this.lastError) + } catch (error) { + this.recordFailure(error) + logger.warn(`Recovery action '${action}' failed: ${this.lastError}`) + } + } + + this.activeRecoveryAction = null + this.setState('failed') + return this.getStatus() + } + + private async runRecoveryAction(action: OpenCodeRecoveryAction): Promise { + if (action === 'restart') { + await this.openCodeServerManager.restart() + return + } + + if (action === 'debug_capture') { + await this.captureDebugSnapshot() + await this.openCodeServerManager.restart() + return + } + + if (action === 'rollback_last_known_good') { + await this.rollbackToLastKnownGood() + return + } + + await this.seedDefaultConfig() + } + + private async captureDebugSnapshot(): Promise { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-') + const debugPath = path.join(getWorkspacePath(), '.opencode', 'state', 'health-watch', `opencode-health-${timestamp}.json`) + const payload = JSON.stringify({ + capturedAt: timestamp, + startupError: this.openCodeServerManager.getLastStartupError(), + lifecycleState: this.state, + attemptedRecoveryActions: this.attemptedRecoveryActions, + }, null, 2) + + await ensureDirectoryExists(path.dirname(debugPath)) + await writeFileContent(debugPath, payload) + } + + private async rollbackToLastKnownGood(): Promise { + this.settingsService.archiveBrokenConfig(this.userId) + const lastGood = this.settingsService.restoreToLastKnownGoodConfig(this.userId) + if (!lastGood) { + throw new Error('No last known good config available') + } + + const config = this.settingsService.updateOpenCodeConfig(lastGood.configName, { content: lastGood.content }, this.userId) + if (!config) { + throw new Error(`Failed to restore OpenCode config '${lastGood.configName}'`) + } + + await this.writeConfig(lastGood.content) + this.openCodeServerManager.clearStartupError() + await this.openCodeServerManager.restart() + } + + private async seedDefaultConfig(): Promise { + const seedConfig = JSON.stringify({ $schema: 'https://opencode.ai/config.json' }, null, 2) + const defaultConfig = this.settingsService.getDefaultOpenCodeConfig(this.userId) + + if (defaultConfig) { + this.settingsService.updateOpenCodeConfig(defaultConfig.name, { content: seedConfig }, this.userId) + } else { + this.settingsService.createOpenCodeConfig( + { + name: 'default', + content: seedConfig, + isDefault: true, + }, + this.userId, + ) + } + + await this.writeConfig(seedConfig) + this.openCodeServerManager.clearStartupError() + await this.openCodeServerManager.restart() + } + + private async writeConfig(content: string): Promise { + const configPath = getOpenCodeConfigFilePath() + await writeFileContent(configPath, content) + } + + private startWatching(): void { + if (!this.isWatchEnabled()) { + logger.info('OpenCode supervisor health polling disabled') + return + } + + if (this.interval) { + return + } + + this.interval = setInterval(() => { + void this.checkNow('health_poll').catch((error) => { + logger.warn('OpenCode supervisor health check encountered an unexpected error:', error) + }) + }, this.pollIntervalMs) + + logger.info(`Started OpenCode supervisor health polling (${this.pollIntervalMs}ms)`) + } + + private markHealthy(): void { + this.state = 'healthy' + this.lastError = null + this.activeRecoveryAction = null + this.attemptedRecoveryActions = [] + this.consecutiveFailures = 0 + this.touch() + } + + private recordFailure(error: unknown): void { + this.consecutiveFailures += 1 + this.lastError = error instanceof Error + ? error.message + : this.openCodeServerManager.getLastStartupError() ?? 'Unknown OpenCode lifecycle error' + this.setState('unhealthy') + } + + private setState(state: OpenCodeLifecycleState): void { + this.state = state + this.touch() + } + + private touch(): void { + this.updatedAt = new Date().toISOString() + } + + private getNextRecoveryAction(): OpenCodeRecoveryAction | null { + return OPENCODE_RECOVERY_ACTIONS.find((action) => !this.attemptedRecoveryActions.includes(action)) ?? null + } + + private get userId(): string { + return this.options.userId ?? 'default' + } +} diff --git a/backend/src/services/settings.ts b/backend/src/services/settings.ts index 8a8a4678..a9336a83 100644 --- a/backend/src/services/settings.ts +++ b/backend/src/services/settings.ts @@ -523,6 +523,32 @@ export class SettingsService { } } + archiveBrokenConfig(userId: string = 'default'): string | null { + const current = this.getDefaultOpenCodeConfig(userId) + if (!current) { + return null + } + + const ts = new Date().toISOString().replace(/[:.]/g, '-') + const backupName = `${current.name}-broken-${ts}` + try { + this.createOpenCodeConfig( + { + name: backupName, + content: current.rawContent, + isDefault: false, + }, + userId, + { suppressAutoDefault: true }, + ) + logger.warn(`Archived broken OpenCode config as '${backupName}'`) + return backupName + } catch (error) { + logger.error('Failed to archive broken config:', error) + return null + } + } + restoreToLastKnownGoodConfig(userId: string = 'default'): { configName: string; content: string } | null { if (!SettingsService.lastKnownGoodConfigContent) { logger.warn('No last known good config available for rollback') diff --git a/backend/test/services/opencode-supervisor.test.ts b/backend/test/services/opencode-supervisor.test.ts new file mode 100644 index 00000000..03c96722 --- /dev/null +++ b/backend/test/services/opencode-supervisor.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it, vi } from 'vitest' +import { ensureDirectoryExists, writeFileContent } from '../../src/services/file-operations' +import { OpenCodeSupervisor } from '../../src/services/opencode-supervisor' + +vi.mock('../../src/utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})) + +vi.mock('../../src/services/file-operations', () => ({ + writeFileContent: vi.fn(), + ensureDirectoryExists: vi.fn(), +})) + +vi.mock('@opencode-manager/shared/config/env', () => ({ + getWorkspacePath: vi.fn(() => '/tmp/opencode-workspace'), + getOpenCodeConfigFilePath: vi.fn(() => '/tmp/opencode-workspace/.config/opencode.json'), + ENV: { + OPENCODE: { + HEALTH_POLL_MS: 200, + HEALTH_FAILURE_THRESHOLD: 2, + HEALTH_WATCH_ENABLED: true, + }, + }, +})) + +interface FakeManager { + start: ReturnType + stop: ReturnType + isOperationInProgress: ReturnType + checkHealth: ReturnType + restart: ReturnType + reloadConfig: ReturnType + clearStartupError: ReturnType + getLastStartupError: ReturnType + getPort: ReturnType + getVersion: ReturnType + getMinVersion: ReturnType + isVersionSupported: ReturnType +} + +interface FakeSettingsService { + archiveBrokenConfig: ReturnType + restoreToLastKnownGoodConfig: ReturnType + getDefaultOpenCodeConfig: ReturnType + updateOpenCodeConfig: ReturnType + createOpenCodeConfig: ReturnType +} + +describe('OpenCodeSupervisor', () => { + const createManager = (): FakeManager => ({ + start: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), + isOperationInProgress: vi.fn(() => false), + checkHealth: vi.fn().mockResolvedValue(true), + restart: vi.fn().mockResolvedValue(undefined), + reloadConfig: vi.fn().mockResolvedValue(undefined), + clearStartupError: vi.fn(), + getLastStartupError: vi.fn(() => null), + getPort: vi.fn(() => 5551), + getVersion: vi.fn(() => '1.0.137'), + getMinVersion: vi.fn(() => '1.0.137'), + isVersionSupported: vi.fn(() => true), + }) + + const createSettings = (): FakeSettingsService => ({ + archiveBrokenConfig: vi.fn(() => 'default-broken-2026-01-01'), + restoreToLastKnownGoodConfig: vi.fn(() => ({ + configName: 'default', + content: '{"$schema":"https://opencode.ai/config.json"}', + })), + getDefaultOpenCodeConfig: vi.fn(() => ({ + name: 'default', + content: { $schema: 'https://opencode.ai/config.json' }, + rawContent: '{"$schema":"https://opencode.ai/config.json"}', + isDefault: true, + })), + updateOpenCodeConfig: vi.fn(() => ({ + name: 'default', + content: { $schema: 'https://opencode.ai/config.json' }, + rawContent: '{"$schema":"https://opencode.ai/config.json"}', + isDefault: true, + })), + createOpenCodeConfig: vi.fn(), + }) + + it('recovers a startup failure through rollback and keeps watching', async () => { + const manager = createManager() + const settings = createSettings() + const supervisor = new OpenCodeSupervisor(manager as unknown as never, settings as unknown as never, { + failureThreshold: 1, + userId: 'default', + }) + + manager.start.mockRejectedValueOnce(new Error('startup failed')) + manager.checkHealth + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true) + + const status = await supervisor.start() + + expect(status.healthy).toBe(true) + expect(status.state).toBe('healthy') + expect(manager.restart).toHaveBeenCalledTimes(3) + expect(settings.archiveBrokenConfig).toHaveBeenCalledWith('default') + expect(settings.restoreToLastKnownGoodConfig).toHaveBeenCalledWith('default') + expect(settings.updateOpenCodeConfig).toHaveBeenCalledWith( + 'default', + { content: '{"$schema":"https://opencode.ai/config.json"}' }, + 'default', + ) + expect(writeFileContent).toHaveBeenCalledWith( + '/tmp/opencode-workspace/.config/opencode.json', + '{"$schema":"https://opencode.ai/config.json"}', + ) + expect(status.watching).toBe(true) + + await supervisor.stop() + }) + + it('does not recover polling failures until the threshold is reached', async () => { + const manager = createManager() + const settings = createSettings() + const supervisor = new OpenCodeSupervisor(manager as unknown as never, settings as unknown as never, { + failureThreshold: 2, + watchEnabled: false, + }) + + manager.checkHealth.mockResolvedValueOnce(false) + + const status = await supervisor.checkNow('api_probe') + + expect(status.state).toBe('unhealthy') + expect(status.failureCount).toBe(1) + expect(manager.restart).not.toHaveBeenCalled() + }) + + it('captures debug state before debug recovery', async () => { + const manager = createManager() + const settings = createSettings() + const supervisor = new OpenCodeSupervisor(manager as unknown as never, settings as unknown as never, { + failureThreshold: 1, + watchEnabled: false, + }) + + manager.checkHealth + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true) + + const status = await supervisor.checkNow('api_probe') + + expect(status.healthy).toBe(true) + expect(ensureDirectoryExists).toHaveBeenCalled() + expect(writeFileContent).toHaveBeenCalled() + expect(manager.restart).toHaveBeenCalledTimes(2) + }) + + it('skips checks while OpenCode manager is busy', async () => { + const manager = createManager() + const settings = createSettings() + const supervisor = new OpenCodeSupervisor(manager as unknown as never, settings as unknown as never) + + manager.isOperationInProgress.mockReturnValue(true) + + await supervisor.checkNow('api_probe') + + expect(manager.checkHealth).not.toHaveBeenCalled() + }) +}) diff --git a/backend/test/services/settings-archive.test.ts b/backend/test/services/settings-archive.test.ts new file mode 100644 index 00000000..13469542 --- /dev/null +++ b/backend/test/services/settings-archive.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Database } from 'bun:sqlite' + +vi.mock('bun:sqlite', () => ({ + Database: vi.fn().mockImplementation(() => ({ + query: vi.fn(), + })), +})) + +import { SettingsService } from '../../src/services/settings' + +describe('SettingsService - archiveBrokenConfig', () => { + let settingsService: SettingsService + let mockGetDefaultConfig: ReturnType + let mockCreateOpenCodeConfig: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + settingsService = new SettingsService({ query: vi.fn() } as unknown as Database) + mockGetDefaultConfig = vi.fn() + mockCreateOpenCodeConfig = vi.fn() + vi.spyOn(settingsService, 'getDefaultOpenCodeConfig').mockImplementation(mockGetDefaultConfig) + vi.spyOn(settingsService, 'createOpenCodeConfig').mockImplementation(mockCreateOpenCodeConfig) + }) + + it('creates a broken config backup with default-broken prefix', () => { + const defaultConfig = { + id: 1, + name: 'default', + rawContent: '{"$schema": "https://opencode.ai/config.json"}', + isValid: true, + content: { '$schema': 'https://opencode.ai/config.json' }, + isDefault: true, + createdAt: Date.now(), + updatedAt: Date.now(), + } + + mockGetDefaultConfig.mockReturnValue(defaultConfig) + mockCreateOpenCodeConfig.mockReturnValue({ + ...defaultConfig, + id: 2, + name: 'default-broken-2026-04-25T00-00-00-000Z', + isDefault: false, + }) + + const backupName = settingsService.archiveBrokenConfig() + + expect(backupName).toMatch(/^default-broken-/) + expect(mockCreateOpenCodeConfig).toHaveBeenCalledWith( + expect.objectContaining({ + name: expect.stringMatching(/^default-broken-/), + content: defaultConfig.rawContent, + isDefault: false, + }), + 'default', + { suppressAutoDefault: true }, + ) + }) + + it('returns null when no default config exists', () => { + mockGetDefaultConfig.mockReturnValue(null) + + const result = settingsService.archiveBrokenConfig() + + expect(result).toBeNull() + expect(mockCreateOpenCodeConfig).not.toHaveBeenCalled() + }) +}) diff --git a/shared/src/config/defaults.ts b/shared/src/config/defaults.ts index 9931c041..a824b969 100644 --- a/shared/src/config/defaults.ts +++ b/shared/src/config/defaults.ts @@ -14,6 +14,9 @@ export const DEFAULTS = { PORT: 5551, HOST: '127.0.0.1', PUBLIC_URL: '', // Optional: public URL for OAuth callbacks (e.g., https://mydomain.com) + HEALTH_WATCH_ENABLED: true, + HEALTH_POLL_MS: 30000, + HEALTH_FAILURE_THRESHOLD: 2, }, DATABASE: { diff --git a/shared/src/config/env.ts b/shared/src/config/env.ts index e4558948..6be6fb97 100644 --- a/shared/src/config/env.ts +++ b/shared/src/config/env.ts @@ -42,6 +42,10 @@ const generateDefaultSecret = (): string => { return randomBytes(32).toString('base64').slice(0, 32) } +const defaultHealthWatchEnabled = getEnvString('NODE_ENV', 'development') === 'test' + ? false + : DEFAULTS.OPENCODE.HEALTH_WATCH_ENABLED + export const ENV = { SERVER: { PORT: getEnvNumber('PORT', DEFAULTS.SERVER.PORT), @@ -54,6 +58,9 @@ export const ENV = { PORT: getEnvNumber('OPENCODE_SERVER_PORT', DEFAULTS.OPENCODE.PORT), HOST: getEnvString('OPENCODE_HOST', DEFAULTS.OPENCODE.HOST), PUBLIC_URL: getEnvString('OPENCODE_PUBLIC_URL', ''), // Public URL for OAuth callbacks + HEALTH_WATCH_ENABLED: getEnvBoolean('OPENCODE_HEALTH_WATCH_ENABLED', defaultHealthWatchEnabled), + HEALTH_POLL_MS: getEnvNumber('OPENCODE_HEALTH_POLL_MS', DEFAULTS.OPENCODE.HEALTH_POLL_MS), + HEALTH_FAILURE_THRESHOLD: getEnvNumber('OPENCODE_HEALTH_FAILURE_THRESHOLD', DEFAULTS.OPENCODE.HEALTH_FAILURE_THRESHOLD), SERVER_PASSWORD: getEnvString('OPENCODE_SERVER_PASSWORD', ''), SERVER_USERNAME: getEnvString('OPENCODE_SERVER_USERNAME', 'opencode'), },