diff --git a/.release-it.notes.js b/.release-it.notes.js index 6b942f3e..3d88c3aa 100755 --- a/.release-it.notes.js +++ b/.release-it.notes.js @@ -1,51 +1,57 @@ #!/usr/bin/env node -import { execSync } from 'child_process'; +import { execSync } from "child_process"; /** * Generate release notes using Claude Code by analyzing git commits * Usage: node scripts/generate-release-notes.js */ -const [,, fromTag, toVersion] = process.argv; +const [, , fromTag, toVersion] = process.argv; if (!fromTag || !toVersion) { - console.error('Usage: node scripts/generate-release-notes.js '); - process.exit(1); + console.error( + "Usage: node scripts/generate-release-notes.js " + ); + process.exit(1); } async function generateReleaseNotes() { + try { + // Get commit range for the release + const commitRange = + fromTag === "null" || !fromTag ? "--all" : `${fromTag}..HEAD`; + + // Get git log for the commits + let gitLog; try { - // Get commit range for the release - const commitRange = fromTag === 'null' || !fromTag ? '--all' : `${fromTag}..HEAD`; - - // Get git log for the commits - let gitLog; - try { - gitLog = execSync( - `git log ${commitRange} --pretty=format:"%h - %s (%an, %ar)" --no-merges`, - { encoding: 'utf8' } - ); - } catch (error) { - // Fallback to recent commits if tag doesn't exist - console.error(`Tag ${fromTag} not found, using recent commits instead`); - gitLog = execSync( - `git log -10 --pretty=format:"%h - %s (%an, %ar)" --no-merges`, - { encoding: 'utf8' } - ); - } + gitLog = execSync( + `git log ${commitRange} --pretty=format:"%h - %s (%an, %ar)" --no-merges`, + { encoding: "utf8" } + ); + } catch (error) { + // Fallback to recent commits if tag doesn't exist + console.error(`Tag ${fromTag} not found, using recent commits instead`); + gitLog = execSync( + `git log -10 --pretty=format:"%h - %s (%an, %ar)" --no-merges`, + { encoding: "utf8" } + ); + } - if (!gitLog.trim()) { - console.error('No commits found for release notes generation'); - process.exit(1); - } + if (!gitLog.trim()) { + console.error("No commits found for release notes generation"); + process.exit(1); + } - // Create a prompt for Claude to analyze commits and generate release notes - const prompt = `Please analyze these git commits and generate professional release notes for version ${toVersion} of the Happy CLI tool (a Claude Code session sharing CLI). + // Create a prompt for Claude to analyze commits and generate release notes + const prompt = `Please analyze these git commits and generate professional release notes for version ${toVersion} of the Happy CLI tool (a Claude Code session sharing CLI). Git commits: ${gitLog} +If the previous version was a beta version - like x.y.z-a +You should look back in the commit history until the previous non-beta version tag. These are really the changes that will go into this non-beta release. + Please format the output as markdown with: - A brief summary of the release - Organized sections for: @@ -60,24 +66,20 @@ Please format the output as markdown with: Do not include any preamble or explanations, just return the markdown release notes.`; - // Call Claude Code to generate release notes - console.error('Generating release notes with Claude Code...'); - const releaseNotes = execSync( - `claude --print "${prompt}"`, - { - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'inherit'], - maxBuffer: 1024 * 1024 * 10 // 10MB buffer - } - ); - - // Output release notes to stdout for release-it to use - console.log(releaseNotes.trim()); + // Call Claude Code to generate release notes + console.error("Generating release notes with Claude Code..."); + const releaseNotes = execSync(`claude --add-dir . --print "${prompt}"`, { + encoding: "utf8", + stdio: ["pipe", "pipe", "inherit"], + maxBuffer: 1024 * 1024 * 10, // 10MB buffer + }); - } catch (error) { - console.error('Error generating release notes:', error.message); - process.exit(1); - } + // Output release notes to stdout for release-it to use + console.log(releaseNotes.trim()); + } catch (error) { + console.error("Error generating release notes:", error.message); + process.exit(1); + } } -generateReleaseNotes(); \ No newline at end of file +generateReleaseNotes(); diff --git a/README.md b/README.md index 4280c213..d8ab661a 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ This will: - `-v, --version` - Show version - `-m, --model ` - Claude model to use (default: sonnet) - `-p, --permission-mode ` - Permission mode: auto, default, or plan -- `--claude-env KEY=VALUE` - Set environment variable for Claude Code +- `--claude-env KEY=VALUE` - Set environment variable for Claude Code (e.g., for [claude-code-router](https://github.com/musistudio/claude-code-router)) - `--claude-arg ARG` - Pass additional argument to Claude CLI ## Environment Variables diff --git a/package.json b/package.json index b9cc33bd..59e02675 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "happy-coder", - "version": "0.12.0-0", + "version": "0.12.0", "description": "Mobile and Web client for Claude Code and Codex", "author": "Kirill Dubovitskiy", "license": "MIT", diff --git a/src/api/api.test.ts b/src/api/api.test.ts new file mode 100644 index 00000000..c636829a --- /dev/null +++ b/src/api/api.test.ts @@ -0,0 +1,266 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ApiClient } from './api'; +import axios from 'axios'; +import { connectionState } from '@/utils/serverConnectionErrors'; + +// Mock axios - use vi.hoisted to ensure mocks are available at hoist time +const { mockPost, mockIsAxiosError } = vi.hoisted(() => ({ + mockPost: vi.fn(), + mockIsAxiosError: vi.fn(() => true) +})); +vi.mock('axios', () => ({ + default: { + post: mockPost, + isAxiosError: mockIsAxiosError + }, + isAxiosError: mockIsAxiosError +})); + +vi.mock('@/ui/logger', () => ({ + logger: { + debug: vi.fn() + } +})); + +// Mock encryption utilities +vi.mock('./encryption', () => ({ + decodeBase64: vi.fn((data: string) => data), + encodeBase64: vi.fn((data: any) => data), + decrypt: vi.fn((data: any) => data), + encrypt: vi.fn((data: any) => data) +})); + +// Mock configuration +vi.mock('./configuration', () => ({ + configuration: { + serverUrl: 'https://api.example.com' + } +})); + +// Mock libsodium encryption +vi.mock('./libsodiumEncryption', () => ({ + libsodiumEncryptForPublicKey: vi.fn((data: any) => new Uint8Array(32)) +})); + +// Global test metadata +const testMetadata = { + path: '/tmp', + host: 'localhost', + homeDir: '/home/user', + happyHomeDir: '/home/user/.happy', + happyLibDir: '/home/user/.happy/lib', + happyToolsDir: '/home/user/.happy/tools' +}; + +const testMachineMetadata = { + host: 'localhost', + platform: 'darwin', + happyCliVersion: '1.0.0', + homeDir: '/home/user', + happyHomeDir: '/home/user/.happy', + happyLibDir: '/home/user/.happy/lib' +}; + +describe('Api server error handling', () => { + let api: ApiClient; + + beforeEach(async () => { + vi.clearAllMocks(); + connectionState.reset(); // Reset offline state between tests + + // Create a mock credential + const mockCredential = { + token: 'fake-token', + encryption: { + type: 'legacy' as const, + secret: new Uint8Array(32) + } + }; + + api = await ApiClient.create(mockCredential); + }); + + describe('getOrCreateSession', () => { + it('should return null when Happy server is unreachable (ECONNREFUSED)', async () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Mock axios to throw connection refused error + mockPost.mockRejectedValue({ code: 'ECONNREFUSED' }); + + const result = await api.getOrCreateSession({ + tag: 'test-tag', + metadata: testMetadata, + state: null + }); + + expect(result).toBeNull(); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('⚠️ Happy server unreachable') + ); + + consoleSpy.mockRestore(); + }); + + it('should return null when Happy server cannot be found (ENOTFOUND)', async () => { + connectionState.reset(); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Mock axios to throw DNS resolution error + mockPost.mockRejectedValue({ code: 'ENOTFOUND' }); + + const result = await api.getOrCreateSession({ + tag: 'test-tag', + metadata: testMetadata, + state: null + }); + + expect(result).toBeNull(); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('⚠️ Happy server unreachable') + ); + + consoleSpy.mockRestore(); + }); + + it('should return null when Happy server times out (ETIMEDOUT)', async () => { + connectionState.reset(); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Mock axios to throw timeout error + mockPost.mockRejectedValue({ code: 'ETIMEDOUT' }); + + const result = await api.getOrCreateSession({ + tag: 'test-tag', + metadata: testMetadata, + state: null + }); + + expect(result).toBeNull(); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('⚠️ Happy server unreachable') + ); + + consoleSpy.mockRestore(); + }); + + it('should return null when session endpoint returns 404', async () => { + connectionState.reset(); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Mock axios to return 404 + mockPost.mockRejectedValue({ + response: { status: 404 }, + isAxiosError: true + }); + + const result = await api.getOrCreateSession({ + tag: 'test-tag', + metadata: testMetadata, + state: null + }); + + expect(result).toBeNull(); + // New unified format via connectionState.fail() + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('⚠️ Happy server unreachable') + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Session creation failed: 404') + ); + + consoleSpy.mockRestore(); + }); + + it('should re-throw non-connection errors', async () => { + // Mock axios to throw a different type of error (e.g., authentication error) + const authError = new Error('Invalid API key'); + (authError as any).code = 'UNAUTHORIZED'; + mockPost.mockRejectedValue(authError); + + await expect( + api.getOrCreateSession({ tag: 'test-tag', metadata: testMetadata, state: null }) + ).rejects.toThrow('Failed to get or create session: Invalid API key'); + + // Should not show the offline mode message + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + expect(consoleSpy).not.toHaveBeenCalledWith( + expect.stringContaining('⚠️ Happy server unreachable') + ); + consoleSpy.mockRestore(); + }); + }); + + describe('getOrCreateMachine', () => { + it('should return minimal machine object when server is unreachable (ECONNREFUSED)', async () => { + connectionState.reset(); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Mock axios to throw connection refused error + mockPost.mockRejectedValue({ code: 'ECONNREFUSED' }); + + const result = await api.getOrCreateMachine({ + machineId: 'test-machine', + metadata: testMachineMetadata, + daemonState: { + status: 'running', + pid: 1234 + } + }); + + expect(result).toEqual({ + id: 'test-machine', + encryptionKey: expect.any(Uint8Array), + encryptionVariant: 'legacy', + metadata: testMachineMetadata, + metadataVersion: 0, + daemonState: { + status: 'running', + pid: 1234 + }, + daemonStateVersion: 0, + }); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('⚠️ Happy server unreachable') + ); + + consoleSpy.mockRestore(); + }); + + it('should return minimal machine object when server endpoint returns 404', async () => { + connectionState.reset(); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Mock axios to return 404 + mockPost.mockRejectedValue({ + response: { status: 404 }, + isAxiosError: true + }); + + const result = await api.getOrCreateMachine({ + machineId: 'test-machine', + metadata: testMachineMetadata + }); + + expect(result).toEqual({ + id: 'test-machine', + encryptionKey: expect.any(Uint8Array), + encryptionVariant: 'legacy', + metadata: testMachineMetadata, + metadataVersion: 0, + daemonState: null, + daemonStateVersion: 0, + }); + + // New unified format via connectionState.fail() + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('⚠️ Happy server unreachable') + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Machine registration failed: 404') + ); + + consoleSpy.mockRestore(); + }); + }); +}); \ No newline at end of file diff --git a/src/api/api.ts b/src/api/api.ts index 8d1c0208..ffa5f0cd 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -8,6 +8,7 @@ import { PushNotificationClient } from './pushNotifications'; import { configuration } from '@/configuration'; import chalk from 'chalk'; import { Credentials } from '@/persistence'; +import { connectionState, isNetworkError } from '@/utils/serverConnectionErrors'; export class ApiClient { @@ -30,7 +31,7 @@ export class ApiClient { tag: string, metadata: Metadata, state: AgentState | null - }): Promise { + }): Promise { // Resolve encryption key let dataEncryptionKey: Uint8Array | null = null; @@ -88,6 +89,34 @@ export class ApiClient { return session; } catch (error) { logger.debug('[API] [ERROR] Failed to get or create session:', error); + + // Check if it's a connection error + if (error && typeof error === 'object' && 'code' in error) { + const errorCode = (error as any).code; + if (isNetworkError(errorCode)) { + connectionState.fail({ + operation: 'Session creation', + errorCode, + url: `${configuration.serverUrl}/v1/sessions` + }); + return null; + } + } + + // Handle 404 gracefully - server endpoint may not be available yet + const is404Error = ( + (axios.isAxiosError(error) && error.response?.status === 404) || + (error && typeof error === 'object' && 'response' in error && (error as any).response?.status === 404) + ); + if (is404Error) { + connectionState.fail({ + operation: 'Session creation', + errorCode: '404', + url: `${configuration.serverUrl}/v1/sessions` + }); + return null; + } + throw new Error(`Failed to get or create session: ${error instanceof Error ? error.message : 'Unknown error'}`); } } @@ -120,29 +149,36 @@ export class ApiClient { encryptionVariant = 'legacy'; } + // Helper to create minimal machine object for offline mode (DRY) + const createMinimalMachine = (): Machine => ({ + id: opts.machineId, + encryptionKey: encryptionKey, + encryptionVariant: encryptionVariant, + metadata: opts.metadata, + metadataVersion: 0, + daemonState: opts.daemonState || null, + daemonStateVersion: 0, + }); + // Create machine - const response = await axios.post( - `${configuration.serverUrl}/v1/machines`, - { - id: opts.machineId, - metadata: encodeBase64(encrypt(encryptionKey, encryptionVariant, opts.metadata)), - daemonState: opts.daemonState ? encodeBase64(encrypt(encryptionKey, encryptionVariant, opts.daemonState)) : undefined, - dataEncryptionKey: dataEncryptionKey ? encodeBase64(dataEncryptionKey) : undefined - }, - { - headers: { - 'Authorization': `Bearer ${this.credential.token}`, - 'Content-Type': 'application/json' + try { + const response = await axios.post( + `${configuration.serverUrl}/v1/machines`, + { + id: opts.machineId, + metadata: encodeBase64(encrypt(encryptionKey, encryptionVariant, opts.metadata)), + daemonState: opts.daemonState ? encodeBase64(encrypt(encryptionKey, encryptionVariant, opts.daemonState)) : undefined, + dataEncryptionKey: dataEncryptionKey ? encodeBase64(dataEncryptionKey) : undefined }, - timeout: 60000 // 1 minute timeout for very bad network connections - } - ); + { + headers: { + 'Authorization': `Bearer ${this.credential.token}`, + 'Content-Type': 'application/json' + }, + timeout: 60000 // 1 minute timeout for very bad network connections + } + ); - if (response.status !== 200) { - console.error(chalk.red(`[API] Failed to create machine: ${response.statusText}`)); - console.log(chalk.yellow(`[API] Failed to create machine: ${response.statusText}, most likely you have re-authenticated, but you still have a machine associated with the old account. Now we are trying to re-associate the machine with the new account. That is not allowed. Please run 'happy doctor clean' to clean up your happy state, and try your original command again. Please create an issue on github if this is causing you problems. We apologize for the inconvenience.`)); - process.exit(1); - } const raw = response.data.machine; logger.debug(`[API] Machine ${opts.machineId} registered/updated with server`); @@ -158,6 +194,67 @@ export class ApiClient { daemonStateVersion: raw.daemonStateVersion || 0, }; return machine; + } catch (error) { + // Handle connection errors gracefully + if (axios.isAxiosError(error) && error.code && isNetworkError(error.code)) { + connectionState.fail({ + operation: 'Machine registration', + errorCode: error.code, + url: `${configuration.serverUrl}/v1/machines` + }); + return createMinimalMachine(); + } + + // Handle 403/409 - server rejected request due to authorization conflict + // This is NOT "server unreachable" - server responded, so don't use connectionState + if (axios.isAxiosError(error) && error.response?.status) { + const status = error.response.status; + + if (status === 403 || status === 409) { + // Re-auth conflict: machine registered to old account, re-association not allowed + console.log(chalk.yellow( + `⚠️ Machine registration rejected by the server with status ${status}` + )); + console.log(chalk.yellow( + ` → This machine ID is already registered to another account on the server` + )); + console.log(chalk.yellow( + ` → This usually happens after re-authenticating with a different account` + )); + console.log(chalk.yellow( + ` → Run 'happy doctor clean' to reset local state and generate a new machine ID` + )); + console.log(chalk.yellow( + ` → Open a GitHub issue if this problem persists` + )); + return createMinimalMachine(); + } + + // Handle 5xx - server error, use offline mode with auto-reconnect + if (status >= 500) { + connectionState.fail({ + operation: 'Machine registration', + errorCode: String(status), + url: `${configuration.serverUrl}/v1/machines`, + details: ['Server encountered an error, will retry automatically'] + }); + return createMinimalMachine(); + } + + // Handle 404 - endpoint may not be available yet + if (status === 404) { + connectionState.fail({ + operation: 'Machine registration', + errorCode: '404', + url: `${configuration.serverUrl}/v1/machines` + }); + return createMinimalMachine(); + } + } + + // For other errors, rethrow + throw error; + } } sessionSyncClient(session: Session): ApiSessionClient { diff --git a/src/claude/runClaude.ts b/src/claude/runClaude.ts index dc5d1982..2bfd1948 100644 --- a/src/claude/runClaude.ts +++ b/src/claude/runClaude.ts @@ -21,6 +21,9 @@ import { startHappyServer } from '@/claude/utils/startHappyServer'; import { registerKillSessionHandler } from './registerKillSessionHandler'; import { projectPath } from '../projectPath'; import { resolve } from 'node:path'; +import { startOfflineReconnection, connectionState } from '@/utils/serverConnectionErrors'; +import { claudeLocal } from '@/claude/claudeLocal'; +import { createSessionScanner } from '@/claude/utils/sessionScanner'; export interface StartOptions { model?: string @@ -48,6 +51,9 @@ export async function runClaude(credentials: Credentials, options: StartOptions // throw new Error('Daemon-spawned sessions cannot use local/interactive mode'); } + // Set backend for offline warnings (before any API calls) + connectionState.setBackend('Claude'); + // Create session service const api = await ApiClient.create(credentials); @@ -88,6 +94,51 @@ export async function runClaude(credentials: Credentials, options: StartOptions flavor: 'claude' }; const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state }); + + // Handle server unreachable case - run Claude locally with hot reconnection + // Note: connectionState.notifyOffline() was already called by api.ts with error details + if (!response) { + let offlineSessionId: string | null = null; + + const reconnection = startOfflineReconnection({ + serverUrl: configuration.serverUrl, + onReconnected: async () => { + const resp = await api.getOrCreateSession({ tag: randomUUID(), metadata, state }); + if (!resp) throw new Error('Server unavailable'); + const session = api.sessionSyncClient(resp); + const scanner = await createSessionScanner({ + sessionId: null, + workingDirectory, + onMessage: (msg) => session.sendClaudeSessionMessage(msg) + }); + if (offlineSessionId) scanner.onNewSession(offlineSessionId); + return { session, scanner }; + }, + onNotify: console.log, + onCleanup: () => { + // Scanner cleanup handled automatically when process exits + } + }); + + try { + await claudeLocal({ + path: workingDirectory, + sessionId: null, + onSessionFound: (id) => { offlineSessionId = id; }, + onThinkingChange: () => {}, + abort: new AbortController().signal, + claudeEnvVars: options.claudeEnvVars, + claudeArgs: options.claudeArgs, + mcpServers: {}, + allowedTools: [] + }); + } finally { + reconnection.cancel(); + stopCaffeinate(); + } + process.exit(0); + } + logger.debug(`Session created: ${response.id}`); // Always report to daemon if it exists diff --git a/src/claude/utils/path.test.ts b/src/claude/utils/path.test.ts index 3e0e594c..6708be26 100644 --- a/src/claude/utils/path.test.ts +++ b/src/claude/utils/path.test.ts @@ -1,61 +1,65 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { getProjectPath } from './path'; import { join } from 'node:path'; -vi.mock('node:os', () => ({ - homedir: vi.fn(() => '/home/user') -})); - // Store original env -const originalEnv = process.env; +const originalEnv = { ...process.env }; describe('getProjectPath', () => { beforeEach(() => { - // Reset process.env to a clean state + // Reset process.env to a clean state - make a fresh copy each time process.env = { ...originalEnv }; delete process.env.CLAUDE_CONFIG_DIR; }); afterEach(() => { // Restore original env - process.env = originalEnv; + process.env = { ...originalEnv }; }); + it('should replace slashes with hyphens in the project path', () => { + process.env.CLAUDE_CONFIG_DIR = '/test/home/.claude'; const workingDir = '/Users/steve/projects/my-app'; const result = getProjectPath(workingDir); - expect(result).toBe(join('/home/user', '.claude', 'projects', '-Users-steve-projects-my-app')); + expect(result).toBe(join('/test/home/.claude', 'projects', '-Users-steve-projects-my-app')); }); it('should replace dots with hyphens in the project path', () => { + process.env.CLAUDE_CONFIG_DIR = '/test/home/.claude'; const workingDir = '/Users/steve/projects/app.test.js'; const result = getProjectPath(workingDir); - expect(result).toBe(join('/home/user', '.claude', 'projects', '-Users-steve-projects-app-test-js')); + expect(result).toBe(join('/test/home/.claude', 'projects', '-Users-steve-projects-app-test-js')); }); it('should handle paths with both slashes and dots', () => { + process.env.CLAUDE_CONFIG_DIR = '/test/home/.claude'; const workingDir = '/var/www/my.site.com/public'; const result = getProjectPath(workingDir); - expect(result).toBe(join('/home/user', '.claude', 'projects', '-var-www-my-site-com-public')); + expect(result).toBe(join('/test/home/.claude', 'projects', '-var-www-my-site-com-public')); }); it('should handle relative paths by resolving them first', () => { + process.env.CLAUDE_CONFIG_DIR = '/test/home/.claude'; const workingDir = './my-project'; const result = getProjectPath(workingDir); - expect(result).toContain(join('/home/user', '.claude', 'projects')); + expect(result).toContain(join('/test/home/.claude', 'projects')); expect(result).toContain('my-project'); }); it('should handle empty directory path', () => { + process.env.CLAUDE_CONFIG_DIR = '/test/home/.claude'; const workingDir = ''; const result = getProjectPath(workingDir); - expect(result).toContain(join('/home/user', '.claude', 'projects')); + expect(result).toContain(join('/test/home/.claude', 'projects')); }); describe('CLAUDE_CONFIG_DIR support', () => { it('should use default .claude directory when CLAUDE_CONFIG_DIR is not set', () => { + // When CLAUDE_CONFIG_DIR is not set, it uses homedir()/.claude const workingDir = '/Users/steve/projects/my-app'; const result = getProjectPath(workingDir); - expect(result).toBe(join('/home/user', '.claude', 'projects', '-Users-steve-projects-my-app')); + expect(result).toContain('projects'); + expect(result).toContain('-Users-steve-projects-my-app'); }); it('should use CLAUDE_CONFIG_DIR when set', () => { @@ -76,7 +80,9 @@ describe('getProjectPath', () => { process.env.CLAUDE_CONFIG_DIR = ''; const workingDir = '/Users/steve/projects/my-app'; const result = getProjectPath(workingDir); - expect(result).toBe(join('/home/user', '.claude', 'projects', '-Users-steve-projects-my-app')); + // With empty CLAUDE_CONFIG_DIR, it uses homedir()/.claude + expect(result).toContain('projects'); + expect(result).toContain('-Users-steve-projects-my-app'); }); it('should handle CLAUDE_CONFIG_DIR with trailing slash', () => { @@ -86,4 +92,4 @@ describe('getProjectPath', () => { expect(result).toBe(join('/custom/claude/config/', 'projects', '-Users-steve-projects-my-app')); }); }); -}); \ No newline at end of file +}); diff --git a/src/codex/runCodex.ts b/src/codex/runCodex.ts index 62b4b5fd..b4ef94c4 100644 --- a/src/codex/runCodex.ts +++ b/src/codex/runCodex.ts @@ -27,6 +27,8 @@ import { notifyDaemonSessionStarted } from "@/daemon/controlClient"; import { registerKillSessionHandler } from "@/claude/registerKillSessionHandler"; import { delay } from "@/utils/time"; import { stopCaffeinate } from "@/utils/caffeinate"; +import { startOfflineReconnection, connectionState } from '@/utils/serverConnectionErrors'; +import type { ApiSessionClient } from '@/api/apiSession'; type ReadyEventOptions = { pending: unknown; @@ -74,6 +76,10 @@ export async function runCodex(opts: { // const sessionTag = randomUUID(); + + // Set backend for offline warnings (before any API calls) + connectionState.setBackend('Codex'); + const api = await ApiClient.create(opts.credentials); // Log startup options @@ -121,19 +127,68 @@ export async function runCodex(opts: { flavor: 'codex' }; const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state }); - const session = api.sessionSyncClient(response); - // Always report to daemon if it exists - try { - logger.debug(`[START] Reporting session ${response.id} to daemon`); - const result = await notifyDaemonSessionStarted(response.id, metadata); - if (result.error) { - logger.debug(`[START] Failed to report to daemon (may not be running):`, result.error); - } else { - logger.debug(`[START] Reported session ${response.id} to daemon`); + // Handle server unreachable case - create offline stub with hot reconnection + let session: ApiSessionClient; + let reconnectionHandle: ReturnType> | null = null; + + // Note: connectionState.notifyOffline() was already called by api.ts with error details + if (!response) { + // Create a no-op session stub for offline mode + // All session methods become no-ops until reconnection succeeds + const offlineSessionStub = { + sessionId: `offline-${sessionTag}`, + sendCodexMessage: () => {}, + sendClaudeSessionMessage: () => {}, + keepAlive: () => {}, + sendSessionEvent: () => {}, + sendSessionDeath: () => {}, + flush: async () => {}, + close: async () => {}, + updateMetadata: () => {}, + updateAgentState: () => {}, + onUserMessage: () => {}, + rpcHandlerManager: { + registerHandler: () => {} + } + } as unknown as ApiSessionClient; + + session = offlineSessionStub; + + // Start background reconnection + reconnectionHandle = startOfflineReconnection({ + serverUrl: configuration.serverUrl, + onReconnected: async () => { + const resp = await api.getOrCreateSession({ tag: sessionTag, metadata, state }); + if (!resp) throw new Error('Server unavailable'); + const realSession = api.sessionSyncClient(resp); + // Swap the session reference so future calls use the real session + session = realSession; + return realSession; + }, + onNotify: (msg) => { + // We can't use messageBuffer here since it's defined later + // Just log to console - this matches Claude's behavior + console.log(msg); + } + }); + } else { + session = api.sessionSyncClient(response); + } + + // Always report to daemon if it exists (skip if offline) + if (response) { + try { + logger.debug(`[START] Reporting session ${response.id} to daemon`); + const result = await notifyDaemonSessionStarted(response.id, metadata); + if (result.error) { + logger.debug(`[START] Failed to report to daemon (may not be running):`, result.error); + } else { + logger.debug(`[START] Reported session ${response.id} to daemon`); + } + } catch (error) { + logger.debug('[START] Failed to report to daemon (may not be running):', error); } - } catch (error) { - logger.debug('[START] Failed to report to daemon (may not be running):', error); } const messageQueue = new MessageQueue2((mode) => hashObject({ @@ -725,6 +780,13 @@ export async function runCodex(opts: { // Clean up resources when main loop exits logger.debug('[codex]: Final cleanup start'); logActiveHandles('cleanup-start'); + + // Cancel offline reconnection if still running + if (reconnectionHandle) { + logger.debug('[codex]: Cancelling offline reconnection'); + reconnectionHandle.cancel(); + } + try { logger.debug('[codex]: sendSessionDeath'); session.sendSessionDeath(); diff --git a/src/index.ts b/src/index.ts index c0293eb2..72febfa8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -271,6 +271,19 @@ ${chalk.bold('To clean up runaway processes:')} Use ${chalk.cyan('happy doctor c unknownArgs.push('--dangerously-skip-permissions') } else if (arg === '--started-by') { options.startedBy = args[++i] as 'daemon' | 'terminal' + } else if (arg === '--claude-env') { + // Parse KEY=VALUE environment variable to pass to Claude + const envArg = args[++i] + if (envArg && envArg.includes('=')) { + const eqIndex = envArg.indexOf('=') + const key = envArg.substring(0, eqIndex) + const value = envArg.substring(eqIndex + 1) + options.claudeEnvVars = options.claudeEnvVars || {} + options.claudeEnvVars[key] = value + } else { + console.error(chalk.red(`Invalid --claude-env format: ${envArg}. Expected KEY=VALUE`)) + process.exit(1) + } } else { // Pass unknown arguments through to claude unknownArgs.push(arg) @@ -303,8 +316,10 @@ ${chalk.bold('Usage:')} ${chalk.bold('Examples:')} happy Start session - happy --yolo Start with bypassing permissions + happy --yolo Start with bypassing permissions happy sugar for --dangerously-skip-permissions + happy --claude-env ANTHROPIC_BASE_URL=http://127.0.0.1:3456 + Use a custom API endpoint (e.g., claude-code-router) happy auth login --force Authenticate happy doctor Run diagnostics diff --git a/src/utils/serverConnectionErrors.test.ts b/src/utils/serverConnectionErrors.test.ts new file mode 100644 index 00000000..9f9e2d5e --- /dev/null +++ b/src/utils/serverConnectionErrors.test.ts @@ -0,0 +1,589 @@ +/** + * Unit tests for serverConnectionErrors utility. + * + * ## Test Coverage Strategy + * These tests exercise the real code paths with minimal mocking: + * - Only axios.isAxiosError is mocked (needed for error type detection) + * - Health check is injected for deterministic behavior + * - Real exponentialBackoffDelay is used (tests account for timing) + * + * ## Requirements Verified + * - REQ-1: Continue working when server unreachable (via graceful callback pattern) + * - REQ-3: Exponential backoff (via retry tests) + * - REQ-7: User notification (via onNotify callback verification) + * - REQ-8: DRY implementation (single utility, verified by type system) + * - REQ-9: Backend transparency (via generic TSession tests) + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { startOfflineReconnection, printOfflineWarning, connectionState, isNetworkError, NETWORK_ERROR_CODES } from './serverConnectionErrors'; + +// Mock axios - only isAxiosError needed for error type detection +vi.mock('axios', () => ({ + default: { + get: vi.fn(), + isAxiosError: (e: unknown) => { + return e !== null && typeof e === 'object' && 'isAxiosError' in e && (e as any).isAxiosError === true; + } + }, + isAxiosError: (e: unknown) => { + return e !== null && typeof e === 'object' && 'isAxiosError' in e && (e as any).isAxiosError === true; + } +})); + +// Mock logger to prevent console noise in tests +vi.mock('@/ui/logger', () => ({ + logger: { + debug: vi.fn() + } +})); + +// ============================================================================ +// Test Helpers (DRY) +// ============================================================================ + +interface TestHandleConfig { + healthCheck?: () => Promise; + onReconnected?: () => Promise; + onNotify?: (msg: string) => void; + onCleanup?: () => void; + initialDelayMs?: number; +} + +/** + * Creates a test reconnection handle with sensible defaults. + * Reduces boilerplate in individual tests. + */ +function createTestHandle(config: TestHandleConfig = {}) { + const onReconnected = config.onReconnected ?? vi.fn().mockResolvedValue({ id: 'test-session' }); + const onNotify = config.onNotify ?? vi.fn(); + const onCleanup = config.onCleanup ?? vi.fn(); + + const handle = startOfflineReconnection({ + serverUrl: 'http://test-server', + onReconnected: onReconnected as () => Promise, + onNotify, + onCleanup, + healthCheck: config.healthCheck ?? (async () => { /* success */ }), + initialDelayMs: config.initialDelayMs ?? 1 + }); + + return { handle, onReconnected, onNotify, onCleanup }; +} + +/** + * Waits for reconnection to succeed, with timeout protection. + * Polls isReconnected() to avoid flaky timing issues. + */ +async function waitForReconnection( + handle: ReturnType, + timeoutMs: number = 15000 +): Promise { + const startTime = Date.now(); + while (Date.now() - startTime < timeoutMs) { + if (handle.isReconnected()) return true; + await new Promise(resolve => setTimeout(resolve, 100)); + } + return false; +} + +/** + * Creates an axios-style error for testing error handling paths. + */ +function createAxiosError(status: number): Error & { response: { status: number }, isAxiosError: true } { + const error = new Error(`HTTP ${status}`) as any; + error.response = { status }; + error.isAxiosError = true; + return error; +} + +// ============================================================================ +// Core Functionality Tests +// ============================================================================ + +describe('startOfflineReconnection', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('successful reconnection', () => { + it('should call onReconnected when health check succeeds', async () => { + const { handle, onReconnected, onNotify } = createTestHandle(); + + await waitForReconnection(handle); + + expect(onReconnected).toHaveBeenCalledOnce(); + expect(onNotify).toHaveBeenCalledWith('✅ Reconnected! Session syncing in background.'); + expect(handle.isReconnected()).toBe(true); + + handle.cancel(); + }); + + it('should return session via getSession() after reconnection', async () => { + const mockSession = { id: 'session-123', metadata: { path: '/test' } }; + const { handle } = createTestHandle({ + onReconnected: vi.fn().mockResolvedValue(mockSession) + }); + + expect(handle.getSession()).toBeNull(); // Before reconnection + + await waitForReconnection(handle); + + expect(handle.getSession()).toEqual(mockSession); + expect(handle.getSession()?.id).toBe('session-123'); + + handle.cancel(); + }); + + it('should only reconnect once (idempotent)', async () => { + const { handle, onReconnected } = createTestHandle(); + + await waitForReconnection(handle); + await new Promise(resolve => setTimeout(resolve, 200)); // Extra wait + + expect(onReconnected).toHaveBeenCalledTimes(1); + + handle.cancel(); + }); + }); + + describe('retry behavior', () => { + // NOTE: Retries are UNLIMITED. The "10" in exponentialBackoffDelay(failureCount, 5000, 60000, 10) + // is the DELAY cap (delay stops growing at ~60s), NOT a retry limit. + // Servers can be down for hours; sessions can stay open for weeks. + + it('should retry when health check fails then succeeds', async () => { + let attemptCount = 0; + const healthCheck = async () => { + attemptCount++; + if (attemptCount < 2) throw new Error('ECONNREFUSED'); + }; + + const { handle, onReconnected } = createTestHandle({ healthCheck }); + + const success = await waitForReconnection(handle); + + expect(success).toBe(true); + expect(attemptCount).toBeGreaterThanOrEqual(2); + expect(onReconnected).toHaveBeenCalledOnce(); + + handle.cancel(); + }, 20000); + + it('should retry when onReconnected throws', async () => { + let callCount = 0; + const onReconnected = vi.fn().mockImplementation(async () => { + callCount++; + if (callCount === 1) throw new Error('Session creation failed'); + return { id: 'session' }; + }); + + const { handle } = createTestHandle({ onReconnected }); + + const success = await waitForReconnection(handle); + + expect(success).toBe(true); + expect(onReconnected).toHaveBeenCalledTimes(2); + + handle.cancel(); + }, 20000); + + it('should increment failure count on each retry', async () => { + let attemptCount = 0; + const healthCheck = async () => { + attemptCount++; + if (attemptCount < 3) throw new Error('Network error'); + }; + + const { handle } = createTestHandle({ healthCheck }); + + // With real exponential backoff (5s + 10s delays with jitter), + // we need ~20s to reach attempt 3 + await waitForReconnection(handle, 25000); + + expect(attemptCount).toBe(3); + + handle.cancel(); + }, 30000); + }); + + describe('cancellation', () => { + it('should stop attempts when cancelled', async () => { + let attemptCount = 0; + const healthCheck = async () => { + attemptCount++; + throw new Error('Always fail'); + }; + + const { handle, onCleanup } = createTestHandle({ healthCheck }); + + await new Promise(resolve => setTimeout(resolve, 50)); + const countBeforeCancel = attemptCount; + + handle.cancel(); + expect(onCleanup).toHaveBeenCalledOnce(); + + await new Promise(resolve => setTimeout(resolve, 200)); + expect(attemptCount).toBe(countBeforeCancel); + }); + + it('should prevent reconnection if cancelled before first attempt', async () => { + const { handle, onReconnected, onCleanup } = createTestHandle({ + initialDelayMs: 500 // Long delay to allow cancel before attempt + }); + + handle.cancel(); + expect(onCleanup).toHaveBeenCalledOnce(); + + await new Promise(resolve => setTimeout(resolve, 600)); + + expect(onReconnected).not.toHaveBeenCalled(); + expect(handle.isReconnected()).toBe(false); + }); + + it('should be safe to call cancel() multiple times', async () => { + const onCleanup = vi.fn(); + const { handle } = createTestHandle({ onCleanup }); + + handle.cancel(); + handle.cancel(); + handle.cancel(); + + // onCleanup should still only be called once per cancel() call + // (cancel sets cancelled=true, preventing further action) + expect(onCleanup).toHaveBeenCalledTimes(3); + }); + }); + + describe('error handling', () => { + it('should stop retrying on 401 authentication error', async () => { + let attemptCount = 0; + const healthCheck = async () => { + attemptCount++; + throw createAxiosError(401); + }; + + const { handle, onNotify, onReconnected } = createTestHandle({ healthCheck }); + + await new Promise(resolve => setTimeout(resolve, 100)); + + expect(attemptCount).toBe(1); + expect(onNotify).toHaveBeenCalledWith( + '❌ Authentication failed. Please re-authenticate with `happy auth`.' + ); + expect(onReconnected).not.toHaveBeenCalled(); + + await new Promise(resolve => setTimeout(resolve, 200)); + expect(attemptCount).toBe(1); // No retry after auth failure + + handle.cancel(); + }); + + it('should retry on 500 server error', async () => { + let attemptCount = 0; + const healthCheck = async () => { + attemptCount++; + if (attemptCount < 2) throw createAxiosError(500); + }; + + const { handle } = createTestHandle({ healthCheck }); + + await waitForReconnection(handle); + + expect(attemptCount).toBeGreaterThanOrEqual(2); + expect(handle.isReconnected()).toBe(true); + + handle.cancel(); + }, 20000); + + it('should retry on 503 service unavailable', async () => { + let attemptCount = 0; + const healthCheck = async () => { + attemptCount++; + if (attemptCount < 2) throw createAxiosError(503); + }; + + const { handle } = createTestHandle({ healthCheck }); + + await waitForReconnection(handle); + + expect(attemptCount).toBeGreaterThanOrEqual(2); + + handle.cancel(); + }, 20000); + + it('should retry on non-axios network errors', async () => { + let attemptCount = 0; + const healthCheck = async () => { + attemptCount++; + if (attemptCount < 2) { + const error = new Error('ECONNREFUSED'); + (error as any).code = 'ECONNREFUSED'; + throw error; + } + }; + + const { handle } = createTestHandle({ healthCheck }); + + await waitForReconnection(handle); + + expect(attemptCount).toBeGreaterThanOrEqual(2); + + handle.cancel(); + }, 20000); + + it('should retry on ETIMEDOUT errors', async () => { + let attemptCount = 0; + const healthCheck = async () => { + attemptCount++; + if (attemptCount < 2) { + const error = new Error('ETIMEDOUT'); + (error as any).code = 'ETIMEDOUT'; + throw error; + } + }; + + const { handle } = createTestHandle({ healthCheck }); + + await waitForReconnection(handle); + + expect(attemptCount).toBeGreaterThanOrEqual(2); + + handle.cancel(); + }, 20000); + + it('should NOT stop retrying on 403 forbidden (not auth failure)', async () => { + let attemptCount = 0; + const healthCheck = async () => { + attemptCount++; + if (attemptCount < 2) throw createAxiosError(403); + }; + + const { handle, onNotify } = createTestHandle({ healthCheck }); + + await waitForReconnection(handle); + + expect(attemptCount).toBeGreaterThanOrEqual(2); + // Should NOT show auth failure message for 403 + expect(onNotify).not.toHaveBeenCalledWith( + expect.stringContaining('Authentication failed') + ); + expect(onNotify).toHaveBeenCalledWith('✅ Reconnected! Session syncing in background.'); + + handle.cancel(); + }, 20000); + }); + + describe('edge cases', () => { + it('should handle race condition: cancel during async health check', async () => { + let healthCheckResolve: () => void; + const healthCheckPromise = new Promise(resolve => { + healthCheckResolve = resolve; + }); + + const { handle, onReconnected } = createTestHandle({ + healthCheck: async () => { + await healthCheckPromise; + } + }); + + // Wait for health check to start + await new Promise(resolve => setTimeout(resolve, 50)); + + // Cancel while health check is in progress + handle.cancel(); + + // Now let health check complete + healthCheckResolve!(); + + await new Promise(resolve => setTimeout(resolve, 50)); + + // onReconnected should NOT be called because cancelled flag is set + expect(onReconnected).not.toHaveBeenCalled(); + expect(handle.isReconnected()).toBe(false); + }); + + it('should handle race condition: cancel during async onReconnected', async () => { + let onReconnectedResolve: () => void; + const onReconnectedPromise = new Promise<{ id: string }>(resolve => { + onReconnectedResolve = () => resolve({ id: 'session' }); + }); + + const onReconnected = vi.fn().mockImplementation(async () => { + return onReconnectedPromise; + }); + + const { handle, onNotify } = createTestHandle({ onReconnected }); + + // Wait for onReconnected to start + await new Promise(resolve => setTimeout(resolve, 50)); + expect(onReconnected).toHaveBeenCalled(); + + // Cancel while onReconnected is in progress + handle.cancel(); + + // Now let onReconnected complete + onReconnectedResolve!(); + + await new Promise(resolve => setTimeout(resolve, 50)); + + // Session should still be set (async operation completed) + // but no further actions should occur + expect(handle.getSession()).toEqual({ id: 'session' }); + }); + + it('should handle empty/undefined session from onReconnected', async () => { + const { handle } = createTestHandle({ + onReconnected: vi.fn().mockResolvedValue(undefined) + }); + + await waitForReconnection(handle); + + expect(handle.isReconnected()).toBe(true); + expect(handle.getSession()).toBeUndefined(); + + handle.cancel(); + }); + + it('should handle null session from onReconnected', async () => { + const { handle } = createTestHandle({ + onReconnected: vi.fn().mockResolvedValue(null) + }); + + await waitForReconnection(handle); + + expect(handle.isReconnected()).toBe(true); + expect(handle.getSession()).toBeNull(); + + handle.cancel(); + }); + + it('should support generic session types (type safety)', async () => { + interface CustomSession { + sessionId: string; + metadata: { + path: string; + host: string; + }; + capabilities: string[]; + } + + const customSession: CustomSession = { + sessionId: 'custom-123', + metadata: { path: '/workspace', host: 'localhost' }, + capabilities: ['read', 'write', 'execute'] + }; + + const { handle } = createTestHandle({ + onReconnected: vi.fn().mockResolvedValue(customSession) + }); + + await waitForReconnection(handle); + + const session = handle.getSession(); + expect(session?.sessionId).toBe('custom-123'); + expect(session?.metadata.path).toBe('/workspace'); + expect(session?.capabilities).toContain('write'); + + handle.cancel(); + }); + + it('should work without optional onCleanup callback', async () => { + const handle = startOfflineReconnection({ + serverUrl: 'http://test', + onReconnected: async () => ({ id: 'session' }), + onNotify: vi.fn(), + healthCheck: async () => {}, + initialDelayMs: 1 + // onCleanup intentionally omitted + }); + + await waitForReconnection(handle); + + // Should not throw when cancelling without onCleanup + expect(() => handle.cancel()).not.toThrow(); + }); + }); +}); + +// ============================================================================ +// printOfflineWarning Tests +// ============================================================================ + +describe('printOfflineWarning', () => { + beforeEach(() => { + connectionState.reset(); // Reset singleton state between tests + }); + + it('should print offline warning with unified format', () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + printOfflineWarning(); + + // New unified format via connectionState.fail() + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('⚠️ Happy server unreachable, offline mode with auto-reconnect enabled') + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Server connection failed') + ); + + consoleSpy.mockRestore(); + }); + + it('should deduplicate repeated calls', () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + printOfflineWarning('Claude'); + const callCountAfterFirst = consoleSpy.mock.calls.length; + + printOfflineWarning('Claude'); // Second call should be deduplicated + const callCountAfterSecond = consoleSpy.mock.calls.length; + + // Should not print again (same call count) + expect(callCountAfterSecond).toBe(callCountAfterFirst); + + consoleSpy.mockRestore(); + }); +}); + +// ============================================================================ +// isNetworkError Tests +// ============================================================================ + +describe('isNetworkError', () => { + it('should return true for all NETWORK_ERROR_CODES', () => { + // All codes in NETWORK_ERROR_CODES should return true + expect(isNetworkError('ECONNREFUSED')).toBe(true); + expect(isNetworkError('ENOTFOUND')).toBe(true); + expect(isNetworkError('ETIMEDOUT')).toBe(true); + expect(isNetworkError('ECONNRESET')).toBe(true); + expect(isNetworkError('EHOSTUNREACH')).toBe(true); + expect(isNetworkError('ENETUNREACH')).toBe(true); + }); + + it('should return false for non-network error codes', () => { + expect(isNetworkError('UNAUTHORIZED')).toBe(false); + expect(isNetworkError('EACCES')).toBe(false); + expect(isNetworkError('ENOENT')).toBe(false); + expect(isNetworkError('UNKNOWN')).toBe(false); + }); + + it('should return false for undefined', () => { + expect(isNetworkError(undefined)).toBe(false); + }); + + it('should return false for empty string', () => { + expect(isNetworkError('')).toBe(false); + }); + + it('should have exactly 6 network error codes', () => { + expect(NETWORK_ERROR_CODES).toHaveLength(6); + expect(NETWORK_ERROR_CODES).toContain('ECONNREFUSED'); + expect(NETWORK_ERROR_CODES).toContain('ENOTFOUND'); + expect(NETWORK_ERROR_CODES).toContain('ETIMEDOUT'); + expect(NETWORK_ERROR_CODES).toContain('ECONNRESET'); + expect(NETWORK_ERROR_CODES).toContain('EHOSTUNREACH'); + expect(NETWORK_ERROR_CODES).toContain('ENETUNREACH'); + }); +}); diff --git a/src/utils/serverConnectionErrors.ts b/src/utils/serverConnectionErrors.ts new file mode 100644 index 00000000..79f192d2 --- /dev/null +++ b/src/utils/serverConnectionErrors.ts @@ -0,0 +1,344 @@ +/** + * Offline reconnection utility for graceful server disconnection handling. + * + * Provides a backend-agnostic reconnection mechanism with exponential backoff + * that works for both Claude and Codex (and future backends). + * + * ## Requirements Satisfied + * - REQ-1: Claude/Codex keeps working when server unreachable + * - REQ-3: Exponential backoff reconnection attempts + * - REQ-4: Hot reconnection without PTY exit + * - REQ-7: Notify user when server becomes available + * - REQ-8: DRY - single shared implementation for all backends + * - REQ-9: Backend-transparent design via generic TSession type + * + * ## Key Features + * - Exponential backoff with jitter (prevents thundering herd) + * - Auth error detection (stops retrying on 401) + * - Cancellable for clean process cleanup (RAII pattern) + * - Generic session type for backend transparency + * - Dependency injection for health check (testability) + * + * ## State Machine + * ``` + * [IDLE] --initialDelay--> [ATTEMPTING] + * | + * +------------------+------------------+ + * | | | + * v v v + * [RECONNECTED] [RETRY_PENDING] [AUTH_FAILED] + * (final) | (final) + * | + * --backoff--> + * | + * v + * [ATTEMPTING] + * + * cancel() from any state --> [CANCELLED] (final) + * ``` + * + * ## Edge Cases Handled + * - Auth errors (401): Stop retrying, notify user to re-authenticate + * - Server 4xx: Treated as "server is up" (validateStatus < 500) + * - Server 5xx: Retry with backoff (server error, may recover) + * - Cancel during async: `cancelled` flag checked before state changes + * - onReconnected throws: Treated as connection error, retry with backoff + * - Multiple success attempts: `reconnected` flag prevents duplicates + * + * @module serverConnectionErrors + */ + +import axios from 'axios'; +import chalk from 'chalk'; +import { exponentialBackoffDelay } from '@/utils/time'; +import { logger } from '@/ui/logger'; + +/** + * Configuration for offline reconnection behavior. + * Uses dependency injection for testability. + */ +export interface OfflineReconnectionConfig { + /** Server URL to health-check against (e.g., 'https://api.happy-servers.com') */ + serverUrl: string; + + /** + * Called when server becomes available - should create and return session. + * If this throws, it's treated as a connection error and retried. + */ + onReconnected: () => Promise; + + /** Called to notify user of status changes (success or auth failure) */ + onNotify: (message: string) => void; + + /** Optional cleanup callback invoked when cancel() is called */ + onCleanup?: () => void; + + /** + * Optional: override the health check function. + * Injected for testing. Default uses axios.get to /v1/sessions. + * Should throw on failure, resolve on success. + */ + healthCheck?: () => Promise; + + /** + * Optional: initial delay in ms before first attempt. + * Default: 5000ms. Set to small value in tests. + */ + initialDelayMs?: number; +} + +/** + * Handle returned by startOfflineReconnection for controlling the reconnection process. + */ +export interface OfflineReconnectionHandle { + /** + * Cancel reconnection attempts and clean up timers. + * Safe to call multiple times. Invokes onCleanup if provided. + */ + cancel: () => void; + + /** Get the session if reconnection succeeded, null otherwise */ + getSession: () => TSession | null; + + /** Check if reconnection has succeeded (idempotent) */ + isReconnected: () => boolean; +} + +/** + * Starts background reconnection with exponential backoff. + * Backend-agnostic: works for Claude, Codex, or any future backend. + * + * ## Retry Behavior + * - **Retries are UNLIMITED** - will keep trying for hours/days/weeks + * - Only auth failures (401) stop retrying + * - Sessions can stay open indefinitely; server outages are expected + * + * ## Backoff Timing (via exponentialBackoffDelay from time.ts) + * - Attempt 1: ~5 seconds (min delay with jitter) + * - Attempt 5: ~30 seconds + * - Attempt 10+: ~60 seconds (delay caps here, retries continue forever) + * - Random jitter prevents thundering herd problem + * + * ## Usage Example + * ```typescript + * const handle = startOfflineReconnection({ + * serverUrl: 'https://api.example.com', + * onReconnected: async () => { + * const session = await createSession(); + * return session; + * }, + * onNotify: console.log + * }); + * + * // Later, on cleanup: + * handle.cancel(); + * ``` + * + * @param config - Reconnection configuration + * @returns Handle to control reconnection and access session + */ +export function startOfflineReconnection( + config: OfflineReconnectionConfig +): OfflineReconnectionHandle { + // State variables + let reconnected = false; // Prevents duplicate reconnections + let session: TSession | null = null; + let timeoutId: NodeJS.Timeout | null = null; + let failureCount = 0; + let cancelled = false; // Prevents action after cancel() + + /** + * Default health check: HTTP GET to /v1/sessions endpoint. + * Uses validateStatus to treat 4xx as "server is up" (client error, not server down). + * Only 5xx or network errors trigger retry. + */ + const defaultHealthCheck = async () => { + await axios.get(`${config.serverUrl}/v1/sessions`, { + timeout: 5000, + validateStatus: (status) => status < 500 // 4xx = server is up, 5xx = server error + }); + }; + + const healthCheck = config.healthCheck ?? defaultHealthCheck; + const initialDelayMs = config.initialDelayMs ?? 5000; + + /** + * Core reconnection attempt logic. + * Handles success, retryable errors, and permanent auth errors. + */ + const attemptReconnect = async () => { + // Check cancellation/success before any action (handles race conditions) + if (reconnected || cancelled) return; + + try { + // Step 1: Health check - verify server is reachable + await healthCheck(); + + // Re-check after async operation (handles cancel during health check) + if (cancelled) return; + + // Step 2: Server available - perform reconnection callback + // If onReconnected throws, we treat it as a connection error and retry + session = await config.onReconnected(); + + // Re-check after async operation (handles cancel during onReconnected) + // Note: session is set even if cancelled - the operation completed + if (cancelled) return; + + // Step 3: Mark success and notify user + reconnected = true; + config.onNotify('✅ Reconnected! Session syncing in background.'); + logger.debug('[OfflineReconnection] Successfully reconnected'); + } catch (e: unknown) { + // Check for permanent errors that shouldn't be retried + // 401 = auth token invalid, user needs to re-authenticate + if (axios.isAxiosError(e) && e.response?.status === 401) { + logger.debug('[OfflineReconnection] Authentication error, stopping retries'); + config.onNotify('❌ Authentication failed. Please re-authenticate with `happy auth`.'); + return; // Don't schedule retry - this is a permanent failure + } + + // Retryable error: network error, 5xx, or onReconnected failure + // Retries are UNLIMITED - only the delay caps at 60s after 10 failures + failureCount++; + const delay = exponentialBackoffDelay(failureCount, 5000, 60000, 10); // 10 = delay cap, NOT retry limit + logger.debug(`[OfflineReconnection] Attempt ${failureCount} failed, retrying in ${delay}ms`); + + // Schedule next attempt (only if not cancelled) + if (!cancelled) { + timeoutId = setTimeout(attemptReconnect, delay); + } + } + }; + + // Start first attempt after initial delay + timeoutId = setTimeout(attemptReconnect, initialDelayMs); + + // Return control handle + return { + cancel: () => { + cancelled = true; + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + config.onCleanup?.(); + }, + getSession: () => session, + isReconnected: () => reconnected + }; +} + +// ============================================================================ +// Connection State - Simple state machine for offline status with deduplication +// ============================================================================ + +/** All network error codes that trigger offline mode */ +export const NETWORK_ERROR_CODES = [ + 'ECONNREFUSED', 'ENOTFOUND', 'ETIMEDOUT', + 'ECONNRESET', 'EHOSTUNREACH', 'ENETUNREACH' +] as const; + +/** Check if error code indicates server unreachable */ +export function isNetworkError(code: string | undefined): boolean { + return code !== undefined && (NETWORK_ERROR_CODES as readonly string[]).includes(code); +} + +/** Maps error codes to human-readable descriptions - exported for discoverability */ +export const ERROR_DESCRIPTIONS: Record = { + // Network errors (Node.js) + ECONNREFUSED: 'server not accepting connections', + ENOTFOUND: 'server hostname not found', + ETIMEDOUT: 'connection timed out', + ECONNRESET: 'connection reset by server', + EHOSTUNREACH: 'server host unreachable', + ENETUNREACH: 'network unreachable', + // HTTP errors + '404': 'endpoint not found, check server deployment', + '500': 'server internal error', + '502': 'bad gateway', + '503': 'service unavailable', +}; + +/** Failure context for accumulating multiple failures into one warning */ +export type OfflineFailure = { + operation: string; + caller?: string; + errorCode?: string; + url?: string; + details?: string[]; // Additional context lines, each printed on new line with arrow +}; + +/** + * Coordinates offline warnings across multiple API callers. + * + * When server goes down, session + machine API calls both fail. This class + * consolidates those into one clear message with all failure details, then + * suppresses duplicates until recovery. Call recover() when back online to + * re-enable warnings for future disconnections. + */ +class OfflineState { + private state: 'online' | 'offline' = 'online'; + private failures = new Map(); // Dedupe by operation + private backend = 'Claude'; + + /** Report failure - accumulates context, prints once on first offline transition */ + fail(failure: OfflineFailure): void { + this.failures.set(failure.operation, failure); + if (this.state === 'online') { + this.state = 'offline'; + this.print(); + } + } + + /** Reset on reconnection */ + recover(): void { + this.state = 'online'; + this.failures.clear(); + } + + /** Set backend name before API calls */ + setBackend(name: string): void { this.backend = name; } + + /** Check current state */ + isOffline(): boolean { return this.state === 'offline'; } + + /** Reset for testing - clears all state */ + reset(): void { + this.state = 'online'; + this.failures.clear(); + this.backend = 'Claude'; + } + + private print(): void { + const summary = [...this.failures.values()] + .map(f => { + const desc = f.errorCode + ? `${f.errorCode} - ${ERROR_DESCRIPTIONS[f.errorCode] || 'unknown error'}` + : 'unknown error'; + const url = f.url ? ` at ${f.url}` : ''; + return `${f.operation} failed: ${desc}${url}`; + }) + .join('; '); + console.log(`⚠️ Happy server unreachable, offline mode with auto-reconnect enabled - error details: ${summary}`); + + // Print detail lines if present - consistent 3-space indent with arrow + const allDetails = [...this.failures.values()] + .flatMap(f => f.details || []); + allDetails.forEach(line => console.log(chalk.yellow(` → ${line}`))); + } +} + +/** + * Shared singleton - call setBackend() before API calls, fail() on errors, + * recover() on successful reconnection. + */ +export const connectionState = new OfflineState(); + +/** + * @deprecated Use connectionState.fail() for deduplication and context tracking + */ +export function printOfflineWarning(backendName: string = 'Claude'): void { + connectionState.setBackend(backendName); + connectionState.fail({ operation: 'Server connection' }); +}