mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 21:23:07 +00:00
fix: Handle Claude CLI unavailability gracefully in CI
- Add try-catch around pty.spawn() to prevent crashes when PTY unavailable - Add unhandledRejection/uncaughtException handlers for graceful degradation - Add checkBackendHealth/waitForBackendHealth utilities for tests - Add data/.api-key and data/credentials.json to .gitignore
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -92,3 +92,6 @@ yarn.lock
|
|||||||
# Fork-specific workflow files (should never be committed)
|
# Fork-specific workflow files (should never be committed)
|
||||||
DEVELOPMENT_WORKFLOW.md
|
DEVELOPMENT_WORKFLOW.md
|
||||||
check-sync.sh
|
check-sync.sh
|
||||||
|
# API key files
|
||||||
|
data/.api-key
|
||||||
|
data/credentials.json
|
||||||
|
|||||||
@@ -597,6 +597,26 @@ const startServer = (port: number) => {
|
|||||||
|
|
||||||
startServer(PORT);
|
startServer(PORT);
|
||||||
|
|
||||||
|
// Global error handlers to prevent crashes from uncaught errors
|
||||||
|
process.on('unhandledRejection', (reason: unknown, _promise: Promise<unknown>) => {
|
||||||
|
logger.error('Unhandled Promise Rejection:', {
|
||||||
|
reason: reason instanceof Error ? reason.message : String(reason),
|
||||||
|
stack: reason instanceof Error ? reason.stack : undefined,
|
||||||
|
});
|
||||||
|
// Don't exit - log the error and continue running
|
||||||
|
// This prevents the server from crashing due to unhandled rejections
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('uncaughtException', (error: Error) => {
|
||||||
|
logger.error('Uncaught Exception:', {
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
});
|
||||||
|
// Exit on uncaught exceptions to prevent undefined behavior
|
||||||
|
// The process is in an unknown state after an uncaught exception
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
process.on('SIGTERM', () => {
|
process.on('SIGTERM', () => {
|
||||||
logger.info('SIGTERM received, shutting down...');
|
logger.info('SIGTERM received, shutting down...');
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { spawn } from 'child_process';
|
|||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as pty from 'node-pty';
|
import * as pty from 'node-pty';
|
||||||
import { ClaudeUsage } from '../routes/claude/types.js';
|
import { ClaudeUsage } from '../routes/claude/types.js';
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Claude Usage Service
|
* Claude Usage Service
|
||||||
@@ -14,6 +15,8 @@ import { ClaudeUsage } from '../routes/claude/types.js';
|
|||||||
* - macOS: Uses 'expect' command for PTY
|
* - macOS: Uses 'expect' command for PTY
|
||||||
* - Windows/Linux: Uses node-pty for PTY
|
* - Windows/Linux: Uses node-pty for PTY
|
||||||
*/
|
*/
|
||||||
|
const logger = createLogger('ClaudeUsage');
|
||||||
|
|
||||||
export class ClaudeUsageService {
|
export class ClaudeUsageService {
|
||||||
private claudeBinary = 'claude';
|
private claudeBinary = 'claude';
|
||||||
private timeout = 30000; // 30 second timeout
|
private timeout = 30000; // 30 second timeout
|
||||||
@@ -164,7 +167,10 @@ export class ClaudeUsageService {
|
|||||||
const shell = this.isWindows ? 'cmd.exe' : '/bin/sh';
|
const shell = this.isWindows ? 'cmd.exe' : '/bin/sh';
|
||||||
const args = this.isWindows ? ['/c', 'claude', '/usage'] : ['-c', 'claude /usage'];
|
const args = this.isWindows ? ['/c', 'claude', '/usage'] : ['-c', 'claude /usage'];
|
||||||
|
|
||||||
const ptyProcess = pty.spawn(shell, args, {
|
let ptyProcess: any = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
ptyProcess = pty.spawn(shell, args, {
|
||||||
name: 'xterm-256color',
|
name: 'xterm-256color',
|
||||||
cols: 120,
|
cols: 120,
|
||||||
rows: 30,
|
rows: 30,
|
||||||
@@ -174,11 +180,27 @@ export class ClaudeUsageService {
|
|||||||
TERM: 'xterm-256color',
|
TERM: 'xterm-256color',
|
||||||
} as Record<string, string>,
|
} as Record<string, string>,
|
||||||
});
|
});
|
||||||
|
} catch (spawnError) {
|
||||||
|
// pty.spawn() can throw synchronously if the native module fails to load
|
||||||
|
// or if PTY is not available in the current environment (e.g., containers without /dev/pts)
|
||||||
|
const errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
|
||||||
|
logger.error('[executeClaudeUsageCommandPty] Failed to spawn PTY:', errorMessage);
|
||||||
|
|
||||||
|
// Return a user-friendly error instead of crashing
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
`Unable to access terminal: ${errorMessage}. Claude CLI may not be available or PTY support is limited in this environment.`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
if (!settled) {
|
if (!settled) {
|
||||||
settled = true;
|
settled = true;
|
||||||
|
if (ptyProcess && !ptyProcess.killed) {
|
||||||
ptyProcess.kill();
|
ptyProcess.kill();
|
||||||
|
}
|
||||||
// Don't fail if we have data - return it instead
|
// Don't fail if we have data - return it instead
|
||||||
if (output.includes('Current session')) {
|
if (output.includes('Current session')) {
|
||||||
resolve(output);
|
resolve(output);
|
||||||
@@ -188,7 +210,7 @@ export class ClaudeUsageService {
|
|||||||
}
|
}
|
||||||
}, this.timeout);
|
}, this.timeout);
|
||||||
|
|
||||||
ptyProcess.onData((data) => {
|
ptyProcess.onData((data: string) => {
|
||||||
output += data;
|
output += data;
|
||||||
|
|
||||||
// Check if we've seen the usage data (look for "Current session")
|
// Check if we've seen the usage data (look for "Current session")
|
||||||
@@ -196,12 +218,12 @@ export class ClaudeUsageService {
|
|||||||
hasSeenUsageData = true;
|
hasSeenUsageData = true;
|
||||||
// Wait for full output, then send escape to exit
|
// Wait for full output, then send escape to exit
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!settled) {
|
if (!settled && ptyProcess && !ptyProcess.killed) {
|
||||||
ptyProcess.write('\x1b'); // Send escape key
|
ptyProcess.write('\x1b'); // Send escape key
|
||||||
|
|
||||||
// Fallback: if ESC doesn't exit (Linux), use SIGTERM after 2s
|
// Fallback: if ESC doesn't exit (Linux), use SIGTERM after 2s
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!settled) {
|
if (!settled && ptyProcess && !ptyProcess.killed) {
|
||||||
ptyProcess.kill('SIGTERM');
|
ptyProcess.kill('SIGTERM');
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
@@ -212,14 +234,14 @@ export class ClaudeUsageService {
|
|||||||
// Fallback: if we see "Esc to cancel" but haven't seen usage data yet
|
// Fallback: if we see "Esc to cancel" but haven't seen usage data yet
|
||||||
if (!hasSeenUsageData && output.includes('Esc to cancel')) {
|
if (!hasSeenUsageData && output.includes('Esc to cancel')) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!settled) {
|
if (!settled && ptyProcess && !ptyProcess.killed) {
|
||||||
ptyProcess.write('\x1b'); // Send escape key
|
ptyProcess.write('\x1b'); // Send escape key
|
||||||
}
|
}
|
||||||
}, 3000);
|
}, 3000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ptyProcess.onExit(({ exitCode }) => {
|
ptyProcess.onExit(({ exitCode }: { exitCode: number }) => {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
|
|||||||
@@ -368,3 +368,42 @@ export async function authenticateForTests(page: Page): Promise<boolean> {
|
|||||||
const apiKey = process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests';
|
const apiKey = process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests';
|
||||||
return authenticateWithApiKey(page, apiKey);
|
return authenticateWithApiKey(page, apiKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the backend server is healthy
|
||||||
|
* Returns true if the server responds with status 200, false otherwise
|
||||||
|
*/
|
||||||
|
export async function checkBackendHealth(page: Page, timeout = 5000): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await page.request.get(`${API_BASE_URL}/api/health`, {
|
||||||
|
timeout,
|
||||||
|
});
|
||||||
|
return response.ok();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for the backend to be healthy, with retry logic
|
||||||
|
* Throws an error if the backend doesn't become healthy within the timeout
|
||||||
|
*/
|
||||||
|
export async function waitForBackendHealth(
|
||||||
|
page: Page,
|
||||||
|
maxWaitMs = 30000,
|
||||||
|
checkIntervalMs = 500
|
||||||
|
): Promise<void> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
while (Date.now() - startTime < maxWaitMs) {
|
||||||
|
if (await checkBackendHealth(page, checkIntervalMs)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(checkIntervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Backend did not become healthy within ${maxWaitMs}ms. ` +
|
||||||
|
`Last health check failed or timed out.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user