mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
Merge branch 'v0.10.0rc' of github.com:AutoMaker-Org/automaker into v0.10.0rc
This commit is contained in:
88
.github/workflows/e2e-tests.yml
vendored
88
.github/workflows/e2e-tests.yml
vendored
@@ -37,7 +37,14 @@ jobs:
|
||||
git config --global user.email "ci@example.com"
|
||||
|
||||
- name: Start backend server
|
||||
run: npm run start --workspace=apps/server &
|
||||
run: |
|
||||
echo "Starting backend server..."
|
||||
# Start server in background and save PID
|
||||
npm run start --workspace=apps/server > backend.log 2>&1 &
|
||||
SERVER_PID=$!
|
||||
echo "Server started with PID: $SERVER_PID"
|
||||
echo "SERVER_PID=$SERVER_PID" >> $GITHUB_ENV
|
||||
|
||||
env:
|
||||
PORT: 3008
|
||||
NODE_ENV: test
|
||||
@@ -53,21 +60,70 @@ jobs:
|
||||
- name: Wait for backend server
|
||||
run: |
|
||||
echo "Waiting for backend server to be ready..."
|
||||
|
||||
# Check if server process is running
|
||||
if [ -z "$SERVER_PID" ]; then
|
||||
echo "ERROR: Server PID not found in environment"
|
||||
cat backend.log 2>/dev/null || echo "No backend log found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if process is actually running
|
||||
if ! kill -0 $SERVER_PID 2>/dev/null; then
|
||||
echo "ERROR: Server process $SERVER_PID is not running!"
|
||||
echo "=== Backend logs ==="
|
||||
cat backend.log
|
||||
echo ""
|
||||
echo "=== Recent system logs ==="
|
||||
dmesg 2>/dev/null | tail -20 || echo "No dmesg available"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Wait for health endpoint
|
||||
for i in {1..60}; do
|
||||
if curl -s -f http://localhost:3008/api/health > /dev/null 2>&1; then
|
||||
echo "Backend server is ready!"
|
||||
curl -s http://localhost:3008/api/health | jq . 2>/dev/null || echo "Health check response: $(curl -s http://localhost:3008/api/health 2>/dev/null || echo 'No response')"
|
||||
echo "=== Backend logs ==="
|
||||
cat backend.log
|
||||
echo ""
|
||||
echo "Health check response:"
|
||||
curl -s http://localhost:3008/api/health | jq . 2>/dev/null || echo "Health check: $(curl -s http://localhost:3008/api/health 2>/dev/null || echo 'No response')"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if server process is still running
|
||||
if ! kill -0 $SERVER_PID 2>/dev/null; then
|
||||
echo "ERROR: Server process died during wait!"
|
||||
echo "=== Backend logs ==="
|
||||
cat backend.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Waiting... ($i/60)"
|
||||
sleep 1
|
||||
done
|
||||
echo "Backend server failed to start!"
|
||||
echo "Checking server status..."
|
||||
|
||||
echo "ERROR: Backend server failed to start within 60 seconds!"
|
||||
echo "=== Backend logs ==="
|
||||
cat backend.log
|
||||
echo ""
|
||||
echo "=== Process status ==="
|
||||
ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found"
|
||||
echo ""
|
||||
echo "=== Port status ==="
|
||||
netstat -tlnp 2>/dev/null | grep :3008 || echo "Port 3008 not listening"
|
||||
echo "Testing health endpoint..."
|
||||
lsof -i :3008 2>/dev/null || echo "lsof not available or port not in use"
|
||||
echo ""
|
||||
echo "=== Health endpoint test ==="
|
||||
curl -v http://localhost:3008/api/health 2>&1 || echo "Health endpoint failed"
|
||||
|
||||
# Kill the server process if it's still hanging
|
||||
if kill -0 $SERVER_PID 2>/dev/null; then
|
||||
echo ""
|
||||
echo "Killing stuck server process..."
|
||||
kill -9 $SERVER_PID 2>/dev/null || true
|
||||
fi
|
||||
|
||||
exit 1
|
||||
|
||||
- name: Run E2E tests
|
||||
@@ -81,6 +137,18 @@ jobs:
|
||||
# Keep UI-side login/defaults consistent
|
||||
AUTOMAKER_API_KEY: test-api-key-for-e2e-tests
|
||||
|
||||
- name: Print backend logs on failure
|
||||
if: failure()
|
||||
run: |
|
||||
echo "=== E2E Tests Failed - Backend Logs ==="
|
||||
cat backend.log 2>/dev/null || echo "No backend log found"
|
||||
echo ""
|
||||
echo "=== Process status at failure ==="
|
||||
ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found"
|
||||
echo ""
|
||||
echo "=== Port status ==="
|
||||
netstat -tlnp 2>/dev/null | grep :3008 || echo "Port 3008 not listening"
|
||||
|
||||
- name: Upload Playwright report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
@@ -98,3 +166,13 @@ jobs:
|
||||
apps/ui/test-results/
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Cleanup - Kill backend server
|
||||
if: always()
|
||||
run: |
|
||||
if [ -n "$SERVER_PID" ]; then
|
||||
echo "Cleaning up backend server (PID: $SERVER_PID)..."
|
||||
kill $SERVER_PID 2>/dev/null || true
|
||||
kill -9 $SERVER_PID 2>/dev/null || true
|
||||
echo "Backend server cleanup complete"
|
||||
fi
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -87,4 +87,11 @@ docker-compose.override.yml
|
||||
.claude/hans/
|
||||
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
yarn.lock
|
||||
|
||||
# Fork-specific workflow files (should never be committed)
|
||||
DEVELOPMENT_WORKFLOW.md
|
||||
check-sync.sh
|
||||
# API key files
|
||||
data/.api-key
|
||||
data/credentials.json
|
||||
|
||||
@@ -597,6 +597,26 @@ const startServer = (port: number) => {
|
||||
|
||||
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
|
||||
process.on('SIGTERM', () => {
|
||||
logger.info('SIGTERM received, shutting down...');
|
||||
|
||||
@@ -2,6 +2,7 @@ import { spawn } from 'child_process';
|
||||
import * as os from 'os';
|
||||
import * as pty from 'node-pty';
|
||||
import { ClaudeUsage } from '../routes/claude/types.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
/**
|
||||
* Claude Usage Service
|
||||
@@ -14,6 +15,8 @@ import { ClaudeUsage } from '../routes/claude/types.js';
|
||||
* - macOS: Uses 'expect' command for PTY
|
||||
* - Windows/Linux: Uses node-pty for PTY
|
||||
*/
|
||||
const logger = createLogger('ClaudeUsage');
|
||||
|
||||
export class ClaudeUsageService {
|
||||
private claudeBinary = 'claude';
|
||||
private timeout = 30000; // 30 second timeout
|
||||
@@ -164,21 +167,40 @@ export class ClaudeUsageService {
|
||||
const shell = this.isWindows ? 'cmd.exe' : '/bin/sh';
|
||||
const args = this.isWindows ? ['/c', 'claude', '/usage'] : ['-c', 'claude /usage'];
|
||||
|
||||
const ptyProcess = pty.spawn(shell, args, {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
cwd: workingDirectory,
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: 'xterm-256color',
|
||||
} as Record<string, string>,
|
||||
});
|
||||
let ptyProcess: any = null;
|
||||
|
||||
try {
|
||||
ptyProcess = pty.spawn(shell, args, {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 30,
|
||||
cwd: workingDirectory,
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: 'xterm-256color',
|
||||
} 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(() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
ptyProcess.kill();
|
||||
if (ptyProcess && !ptyProcess.killed) {
|
||||
ptyProcess.kill();
|
||||
}
|
||||
// Don't fail if we have data - return it instead
|
||||
if (output.includes('Current session')) {
|
||||
resolve(output);
|
||||
@@ -188,7 +210,7 @@ export class ClaudeUsageService {
|
||||
}
|
||||
}, this.timeout);
|
||||
|
||||
ptyProcess.onData((data) => {
|
||||
ptyProcess.onData((data: string) => {
|
||||
output += data;
|
||||
|
||||
// Check if we've seen the usage data (look for "Current session")
|
||||
@@ -196,12 +218,12 @@ export class ClaudeUsageService {
|
||||
hasSeenUsageData = true;
|
||||
// Wait for full output, then send escape to exit
|
||||
setTimeout(() => {
|
||||
if (!settled) {
|
||||
if (!settled && ptyProcess && !ptyProcess.killed) {
|
||||
ptyProcess.write('\x1b'); // Send escape key
|
||||
|
||||
// Fallback: if ESC doesn't exit (Linux), use SIGTERM after 2s
|
||||
setTimeout(() => {
|
||||
if (!settled) {
|
||||
if (!settled && ptyProcess && !ptyProcess.killed) {
|
||||
ptyProcess.kill('SIGTERM');
|
||||
}
|
||||
}, 2000);
|
||||
@@ -212,14 +234,14 @@ export class ClaudeUsageService {
|
||||
// Fallback: if we see "Esc to cancel" but haven't seen usage data yet
|
||||
if (!hasSeenUsageData && output.includes('Esc to cancel')) {
|
||||
setTimeout(() => {
|
||||
if (!settled) {
|
||||
if (!settled && ptyProcess && !ptyProcess.killed) {
|
||||
ptyProcess.write('\x1b'); // Send escape key
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
});
|
||||
|
||||
ptyProcess.onExit(({ exitCode }) => {
|
||||
ptyProcess.onExit(({ exitCode }: { exitCode: number }) => {
|
||||
clearTimeout(timeoutId);
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
|
||||
@@ -13,6 +13,8 @@ const PROVIDER_ICON_KEYS = {
|
||||
deepseek: 'deepseek',
|
||||
qwen: 'qwen',
|
||||
nova: 'nova',
|
||||
meta: 'meta',
|
||||
mistral: 'mistral',
|
||||
} as const;
|
||||
|
||||
type ProviderIconKey = keyof typeof PROVIDER_ICON_KEYS;
|
||||
@@ -73,6 +75,17 @@ const PROVIDER_ICON_DEFINITIONS: Record<ProviderIconKey, ProviderIconDefinition>
|
||||
viewBox: '0 0 33 32',
|
||||
// Official Amazon Nova logo from lobehub/lobe-icons
|
||||
path: 'm17.865 23.28 1.533 1.543c.07.07.092.175.055.267l-2.398 6.118A1.24 1.24 0 0 1 15.9 32c-.51 0-.969-.315-1.155-.793l-3.451-8.804-5.582 5.617a.246.246 0 0 1-.35 0l-1.407-1.415a.25.25 0 0 1 0-.352l6.89-6.932a1.3 1.3 0 0 1 .834-.398 1.25 1.25 0 0 1 1.232.79l2.992 7.63 1.557-3.977a.248.248 0 0 1 .408-.085zm8.224-19.3-5.583 5.617-3.45-8.805a1.24 1.24 0 0 0-1.43-.762c-.414.092-.744.407-.899.805l-2.38 6.072a.25.25 0 0 0 .055.267l1.533 1.543c.127.127.34.082.407-.085L15.9 4.655l2.991 7.629a1.24 1.24 0 0 0 2.035.425l6.922-6.965a.25.25 0 0 0 0-.352L26.44 3.977a.246.246 0 0 0-.35 0zM8.578 17.566l-3.953-1.567 7.582-3.01c.49-.195.815-.685.785-1.24a1.3 1.3 0 0 0-.395-.84l-6.886-6.93a.246.246 0 0 0-.35 0L3.954 5.395a.25.25 0 0 0 0 .353l5.583 5.617-8.75 3.472a1.25 1.25 0 0 0 0 2.325l6.079 2.412a.24.24 0 0 0 .266-.055l1.533-1.542a.25.25 0 0 0-.085-.41zm22.434-2.73-6.08-2.412a.24.24 0 0 0-.265.055l-1.533 1.542a.25.25 0 0 0 .084.41L27.172 16l-7.583 3.01a1.255 1.255 0 0 0-.785 1.24c.018.317.172.614.395.84l6.89 6.931a.246.246 0 0 0 .35 0l1.406-1.415a.25.25 0 0 0 0-.352l-5.582-5.617 8.75-3.472a1.25 1.25 0 0 0 0-2.325z',
|
||||
fill: '#FF9900',
|
||||
},
|
||||
// Meta and Mistral use custom standalone SVG components
|
||||
// These placeholder entries prevent TypeScript errors
|
||||
meta: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: '',
|
||||
},
|
||||
mistral: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: '',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -287,7 +300,32 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey {
|
||||
const modelStr = typeof model === 'string' ? model.toLowerCase() : model;
|
||||
|
||||
// Check for OpenCode models (opencode/, amazon-bedrock/, opencode-*)
|
||||
if (modelStr.includes('opencode') || modelStr.includes('amazon-bedrock')) {
|
||||
if (modelStr.includes('opencode')) {
|
||||
// For OpenCode models, check which specific provider
|
||||
if (modelStr.includes('amazon-bedrock')) {
|
||||
// Bedrock-hosted models - detect the specific provider
|
||||
if (modelStr.includes('anthropic') || modelStr.includes('claude')) {
|
||||
return 'anthropic';
|
||||
}
|
||||
if (modelStr.includes('deepseek')) {
|
||||
return 'deepseek';
|
||||
}
|
||||
if (modelStr.includes('nova')) {
|
||||
return 'nova';
|
||||
}
|
||||
if (modelStr.includes('meta') || modelStr.includes('llama')) {
|
||||
return 'meta';
|
||||
}
|
||||
if (modelStr.includes('mistral')) {
|
||||
return 'mistral';
|
||||
}
|
||||
if (modelStr.includes('qwen')) {
|
||||
return 'qwen';
|
||||
}
|
||||
// Default for Bedrock
|
||||
return 'opencode';
|
||||
}
|
||||
// Native OpenCode models (opencode/big-pickle, etc.)
|
||||
return 'opencode';
|
||||
}
|
||||
|
||||
@@ -328,6 +366,11 @@ export function getProviderIconForModel(
|
||||
gemini: GeminiIcon,
|
||||
grok: GrokIcon,
|
||||
opencode: OpenCodeIcon,
|
||||
deepseek: DeepSeekIcon,
|
||||
qwen: QwenIcon,
|
||||
nova: NovaIcon,
|
||||
meta: MetaIcon,
|
||||
mistral: MistralIcon,
|
||||
};
|
||||
|
||||
return iconMap[iconKey] || AnthropicIcon;
|
||||
|
||||
@@ -110,7 +110,7 @@ export const OPENCODE_MODELS: ModelOption[] = OPENCODE_MODEL_CONFIGS.map((config
|
||||
label: config.label,
|
||||
description: config.description,
|
||||
badge: config.tier === 'free' ? 'Free' : config.tier === 'premium' ? 'Premium' : undefined,
|
||||
provider: 'opencode' as ModelProvider,
|
||||
provider: config.provider as ModelProvider,
|
||||
}));
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,7 +27,17 @@ import {
|
||||
REASONING_EFFORT_LABELS,
|
||||
} from '@/components/views/board-view/shared/model-constants';
|
||||
import { Check, ChevronsUpDown, Star, ChevronRight } from 'lucide-react';
|
||||
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
|
||||
import {
|
||||
AnthropicIcon,
|
||||
CursorIcon,
|
||||
OpenAIIcon,
|
||||
OpenCodeIcon,
|
||||
DeepSeekIcon,
|
||||
NovaIcon,
|
||||
QwenIcon,
|
||||
MistralIcon,
|
||||
MetaIcon,
|
||||
} from '@/components/ui/provider-icon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Command,
|
||||
@@ -503,6 +513,28 @@ export function PhaseModelSelector({
|
||||
const isSelected = selectedModel === model.id;
|
||||
const isFavorite = favoriteModels.includes(model.id);
|
||||
|
||||
// Get the appropriate icon based on provider
|
||||
const ProviderIcon = (() => {
|
||||
switch (model.provider) {
|
||||
case 'opencode':
|
||||
return OpenCodeIcon;
|
||||
case 'amazon-bedrock-anthropic':
|
||||
return AnthropicIcon;
|
||||
case 'amazon-bedrock-deepseek':
|
||||
return DeepSeekIcon;
|
||||
case 'amazon-bedrock-amazon':
|
||||
return NovaIcon;
|
||||
case 'amazon-bedrock-meta':
|
||||
return MetaIcon;
|
||||
case 'amazon-bedrock-mistral':
|
||||
return MistralIcon;
|
||||
case 'amazon-bedrock-qwen':
|
||||
return QwenIcon;
|
||||
default:
|
||||
return OpenCodeIcon;
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={model.id}
|
||||
@@ -514,7 +546,7 @@ export function PhaseModelSelector({
|
||||
className="group flex items-center justify-between py-2"
|
||||
>
|
||||
<div className="flex items-center gap-3 overflow-hidden">
|
||||
<OpenCodeIcon
|
||||
<ProviderIcon
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0',
|
||||
isSelected ? 'text-primary' : 'text-muted-foreground'
|
||||
|
||||
@@ -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';
|
||||
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