Merge branch 'v0.10.0rc' of github.com:AutoMaker-Org/automaker into v0.10.0rc

This commit is contained in:
webdevcody
2026-01-10 20:08:02 -05:00
8 changed files with 267 additions and 26 deletions

View File

@@ -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
View File

@@ -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

View File

@@ -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...');

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,
}));
/**

View File

@@ -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'

View File

@@ -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.`
);
}