mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 14:22:02 +00:00
Compare commits
1 Commits
feature/ui
...
feat/debug
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78d08c2b5b |
3
.claude/.gitignore
vendored
3
.claude/.gitignore
vendored
@@ -1,2 +1 @@
|
||||
hans/
|
||||
skills/
|
||||
hans/
|
||||
@@ -1,19 +1 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
**/node_modules/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
**/dist/
|
||||
dist-electron/
|
||||
**/dist-electron/
|
||||
build/
|
||||
**/build/
|
||||
.next/
|
||||
**/.next/
|
||||
.nuxt/
|
||||
**/.nuxt/
|
||||
out/
|
||||
**/out/
|
||||
.cache/
|
||||
**/.cache/
|
||||
node_modules/
|
||||
35
.github/workflows/e2e-tests.yml
vendored
35
.github/workflows/e2e-tests.yml
vendored
@@ -31,43 +31,24 @@ jobs:
|
||||
- name: Build server
|
||||
run: npm run build --workspace=apps/server
|
||||
|
||||
- name: Set up Git user
|
||||
run: |
|
||||
git config --global user.name "GitHub CI"
|
||||
git config --global user.email "ci@example.com"
|
||||
|
||||
- name: Start backend server
|
||||
run: npm run start --workspace=apps/server &
|
||||
env:
|
||||
PORT: 3008
|
||||
NODE_ENV: test
|
||||
# Use a deterministic API key so Playwright can log in reliably
|
||||
AUTOMAKER_API_KEY: test-api-key-for-e2e-tests
|
||||
# Reduce log noise in CI
|
||||
AUTOMAKER_HIDE_API_KEY: 'true'
|
||||
# Avoid real API calls during CI
|
||||
AUTOMAKER_MOCK_AGENT: 'true'
|
||||
# Simulate containerized environment to skip sandbox confirmation dialogs
|
||||
IS_CONTAINERIZED: 'true'
|
||||
|
||||
- name: Wait for backend server
|
||||
run: |
|
||||
echo "Waiting for backend server to be ready..."
|
||||
for i in {1..60}; do
|
||||
if curl -s -f http://localhost:3008/api/health > /dev/null 2>&1; then
|
||||
for i in {1..30}; do
|
||||
if curl -s 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')"
|
||||
exit 0
|
||||
fi
|
||||
echo "Waiting... ($i/60)"
|
||||
echo "Waiting... ($i/30)"
|
||||
sleep 1
|
||||
done
|
||||
echo "Backend server failed to start!"
|
||||
echo "Checking server status..."
|
||||
ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found"
|
||||
netstat -tlnp 2>/dev/null | grep :3008 || echo "Port 3008 not listening"
|
||||
echo "Testing health endpoint..."
|
||||
curl -v http://localhost:3008/api/health 2>&1 || echo "Health endpoint failed"
|
||||
exit 1
|
||||
|
||||
- name: Run E2E tests
|
||||
@@ -78,8 +59,6 @@ jobs:
|
||||
CI: true
|
||||
VITE_SERVER_URL: http://localhost:3008
|
||||
VITE_SKIP_SETUP: 'true'
|
||||
# Keep UI-side login/defaults consistent
|
||||
AUTOMAKER_API_KEY: test-api-key-for-e2e-tests
|
||||
|
||||
- name: Upload Playwright report
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -89,12 +68,10 @@ jobs:
|
||||
path: apps/ui/playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload test results (screenshots, traces, videos)
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
if: failure()
|
||||
with:
|
||||
name: test-results
|
||||
path: |
|
||||
apps/ui/test-results/
|
||||
path: apps/ui/test-results/
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
||||
2
.github/workflows/security-audit.yml
vendored
2
.github/workflows/security-audit.yml
vendored
@@ -26,5 +26,5 @@ jobs:
|
||||
check-lockfile: 'true'
|
||||
|
||||
- name: Run npm audit
|
||||
run: npm audit --audit-level=critical
|
||||
run: npm audit --audit-level=moderate
|
||||
continue-on-error: false
|
||||
|
||||
41
CLAUDE.md
41
CLAUDE.md
@@ -170,3 +170,44 @@ Use `resolveModelString()` from `@automaker/model-resolver` to convert model ali
|
||||
- `DATA_DIR` - Data storage directory (default: ./data)
|
||||
- `ALLOWED_ROOT_DIRECTORY` - Restrict file operations to specific directory
|
||||
- `AUTOMAKER_MOCK_AGENT=true` - Enable mock agent mode for CI testing
|
||||
- `ENABLE_DEBUG_PANEL=true` - Enable debug panel in non-development builds
|
||||
|
||||
## Debug System (Development Only)
|
||||
|
||||
The debug system provides real-time monitoring of server performance. Toggle with `Cmd/Ctrl+Shift+D`.
|
||||
|
||||
### Debug Services (`apps/server/src/services/`)
|
||||
|
||||
- `PerformanceMonitorService` - Collects memory/CPU metrics, detects leaks via linear regression
|
||||
- `ProcessRegistryService` - Tracks spawned agents, terminals, CLIs with lifecycle events
|
||||
|
||||
### Debug API (`apps/server/src/routes/debug/`)
|
||||
|
||||
- `GET /api/debug/metrics` - Current metrics snapshot
|
||||
- `POST /api/debug/metrics/start` - Start collection with optional config
|
||||
- `POST /api/debug/metrics/stop` - Stop collection
|
||||
- `GET /api/debug/processes` - List tracked processes with filters
|
||||
|
||||
### Debug Types (`libs/types/src/debug.ts`)
|
||||
|
||||
All debug types are exported from `@automaker/types`:
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
DebugMetricsSnapshot,
|
||||
TrackedProcess,
|
||||
MemoryTrend,
|
||||
ProcessSummary,
|
||||
} from '@automaker/types';
|
||||
import { formatBytes, formatDuration } from '@automaker/types';
|
||||
```
|
||||
|
||||
### UI Components (`apps/ui/src/components/debug/`)
|
||||
|
||||
- `DebugPanel` - Main draggable container with tabs
|
||||
- `MemoryMonitor` - Heap usage charts and leak indicators
|
||||
- `CPUMonitor` - CPU gauge and event loop lag display
|
||||
- `ProcessKanban` - Visual board of tracked processes
|
||||
- `RenderTracker` - React component render statistics
|
||||
|
||||
See `docs/server/debug-api.md` for full API documentation.
|
||||
|
||||
89
Dockerfile
89
Dockerfile
@@ -8,12 +8,10 @@
|
||||
# =============================================================================
|
||||
# BASE STAGE - Common setup for all builds (DRY: defined once, used by all)
|
||||
# =============================================================================
|
||||
FROM node:22-slim AS base
|
||||
FROM node:22-alpine AS base
|
||||
|
||||
# Install build dependencies for native modules (node-pty)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -53,64 +51,31 @@ RUN npm run build:packages && npm run build --workspace=apps/server
|
||||
# =============================================================================
|
||||
# SERVER PRODUCTION STAGE
|
||||
# =============================================================================
|
||||
FROM node:22-slim AS server
|
||||
FROM node:22-alpine AS server
|
||||
|
||||
# Build argument for tracking which commit this image was built from
|
||||
ARG GIT_COMMIT_SHA=unknown
|
||||
LABEL automaker.git.commit.sha="${GIT_COMMIT_SHA}"
|
||||
|
||||
# Install git, curl, bash (for terminal), gosu (for user switching), and GitHub CLI (pinned version, multi-arch)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git curl bash gosu ca-certificates openssh-client \
|
||||
&& GH_VERSION="2.63.2" \
|
||||
&& ARCH=$(uname -m) \
|
||||
&& case "$ARCH" in \
|
||||
# Install git, curl, bash (for terminal), and GitHub CLI (pinned version, multi-arch)
|
||||
RUN apk add --no-cache git curl bash && \
|
||||
GH_VERSION="2.63.2" && \
|
||||
ARCH=$(uname -m) && \
|
||||
case "$ARCH" in \
|
||||
x86_64) GH_ARCH="amd64" ;; \
|
||||
aarch64|arm64) GH_ARCH="arm64" ;; \
|
||||
*) echo "Unsupported architecture: $ARCH" && exit 1 ;; \
|
||||
esac \
|
||||
&& curl -L "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_${GH_ARCH}.tar.gz" -o gh.tar.gz \
|
||||
&& tar -xzf gh.tar.gz \
|
||||
&& mv gh_${GH_VERSION}_linux_${GH_ARCH}/bin/gh /usr/local/bin/gh \
|
||||
&& rm -rf gh.tar.gz gh_${GH_VERSION}_linux_${GH_ARCH} \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
esac && \
|
||||
curl -L "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_${GH_ARCH}.tar.gz" -o gh.tar.gz && \
|
||||
tar -xzf gh.tar.gz && \
|
||||
mv gh_${GH_VERSION}_linux_${GH_ARCH}/bin/gh /usr/local/bin/gh && \
|
||||
rm -rf gh.tar.gz gh_${GH_VERSION}_linux_${GH_ARCH}
|
||||
|
||||
# Install Claude CLI globally (available to all users via npm global bin)
|
||||
# Install Claude CLI globally
|
||||
RUN npm install -g @anthropic-ai/claude-code
|
||||
|
||||
# Create non-root user with home directory BEFORE installing Cursor CLI
|
||||
RUN groupadd -g 1001 automaker && \
|
||||
useradd -u 1001 -g automaker -m -d /home/automaker -s /bin/bash automaker && \
|
||||
mkdir -p /home/automaker/.local/bin && \
|
||||
mkdir -p /home/automaker/.cursor && \
|
||||
chown -R automaker:automaker /home/automaker && \
|
||||
chmod 700 /home/automaker/.cursor
|
||||
|
||||
# Install Cursor CLI as the automaker user
|
||||
# Set HOME explicitly and install to /home/automaker/.local/bin/
|
||||
USER automaker
|
||||
ENV HOME=/home/automaker
|
||||
RUN curl https://cursor.com/install -fsS | bash && \
|
||||
echo "=== Checking Cursor CLI installation ===" && \
|
||||
ls -la /home/automaker/.local/bin/ && \
|
||||
echo "=== PATH is: $PATH ===" && \
|
||||
(which cursor-agent && cursor-agent --version) || echo "cursor-agent installed (may need auth setup)"
|
||||
USER root
|
||||
|
||||
# Add PATH to profile so it's available in all interactive shells (for login shells)
|
||||
RUN mkdir -p /etc/profile.d && \
|
||||
echo 'export PATH="/home/automaker/.local/bin:$PATH"' > /etc/profile.d/cursor-cli.sh && \
|
||||
chmod +x /etc/profile.d/cursor-cli.sh
|
||||
|
||||
# Add to automaker's .bashrc for bash interactive shells
|
||||
RUN echo 'export PATH="/home/automaker/.local/bin:$PATH"' >> /home/automaker/.bashrc && \
|
||||
chown automaker:automaker /home/automaker/.bashrc
|
||||
|
||||
# Also add to root's .bashrc since docker exec defaults to root
|
||||
RUN echo 'export PATH="/home/automaker/.local/bin:$PATH"' >> /root/.bashrc
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 -S automaker && \
|
||||
adduser -S automaker -u 1001
|
||||
|
||||
# Copy root package.json (needed for workspace resolution)
|
||||
COPY --from=server-builder /app/package*.json ./
|
||||
|
||||
@@ -133,19 +98,12 @@ RUN git config --system --add safe.directory '*' && \
|
||||
# Use gh as credential helper (works with GH_TOKEN env var)
|
||||
git config --system credential.helper '!gh auth git-credential'
|
||||
|
||||
# Copy entrypoint script for fixing permissions on mounted volumes
|
||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# Note: We stay as root here so entrypoint can fix permissions
|
||||
# The entrypoint script will switch to automaker user before running the command
|
||||
# Switch to non-root user
|
||||
USER automaker
|
||||
|
||||
# Environment variables
|
||||
ENV PORT=3008
|
||||
ENV DATA_DIR=/data
|
||||
ENV HOME=/home/automaker
|
||||
# Add user's local bin to PATH for cursor-agent
|
||||
ENV PATH="/home/automaker/.local/bin:${PATH}"
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3008
|
||||
@@ -154,9 +112,6 @@ EXPOSE 3008
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:3008/api/health || exit 1
|
||||
|
||||
# Use entrypoint to fix permissions before starting
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
|
||||
# Start server
|
||||
CMD ["node", "apps/server/dist/index.js"]
|
||||
|
||||
@@ -188,10 +143,6 @@ RUN npm run build:packages && npm run build --workspace=apps/ui
|
||||
# =============================================================================
|
||||
FROM nginx:alpine AS ui
|
||||
|
||||
# Build argument for tracking which commit this image was built from
|
||||
ARG GIT_COMMIT_SHA=unknown
|
||||
LABEL automaker.git.commit.sha="${GIT_COMMIT_SHA}"
|
||||
|
||||
# Copy built files
|
||||
COPY --from=ui-builder /app/apps/ui/dist /usr/share/nginx/html
|
||||
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
# Automaker Development Dockerfile
|
||||
# For development with live reload via volume mounting
|
||||
# Source code is NOT copied - it's mounted as a volume
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.dev.yml up
|
||||
|
||||
FROM node:22-slim
|
||||
|
||||
# Install build dependencies for native modules (node-pty) and runtime tools
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 make g++ \
|
||||
git curl bash gosu ca-certificates openssh-client \
|
||||
&& GH_VERSION="2.63.2" \
|
||||
&& ARCH=$(uname -m) \
|
||||
&& case "$ARCH" in \
|
||||
x86_64) GH_ARCH="amd64" ;; \
|
||||
aarch64|arm64) GH_ARCH="arm64" ;; \
|
||||
*) echo "Unsupported architecture: $ARCH" && exit 1 ;; \
|
||||
esac \
|
||||
&& curl -L "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_${GH_ARCH}.tar.gz" -o gh.tar.gz \
|
||||
&& tar -xzf gh.tar.gz \
|
||||
&& mv gh_${GH_VERSION}_linux_${GH_ARCH}/bin/gh /usr/local/bin/gh \
|
||||
&& rm -rf gh.tar.gz gh_${GH_VERSION}_linux_${GH_ARCH} \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Claude CLI globally
|
||||
RUN npm install -g @anthropic-ai/claude-code
|
||||
|
||||
# Create non-root user
|
||||
RUN groupadd -g 1001 automaker && \
|
||||
useradd -u 1001 -g automaker -m -d /home/automaker -s /bin/bash automaker && \
|
||||
mkdir -p /home/automaker/.local/bin && \
|
||||
mkdir -p /home/automaker/.cursor && \
|
||||
chown -R automaker:automaker /home/automaker && \
|
||||
chmod 700 /home/automaker/.cursor
|
||||
|
||||
# Install Cursor CLI as automaker user
|
||||
USER automaker
|
||||
ENV HOME=/home/automaker
|
||||
RUN curl https://cursor.com/install -fsS | bash || true
|
||||
USER root
|
||||
|
||||
# Add PATH to profile for Cursor CLI
|
||||
RUN mkdir -p /etc/profile.d && \
|
||||
echo 'export PATH="/home/automaker/.local/bin:$PATH"' > /etc/profile.d/cursor-cli.sh && \
|
||||
chmod +x /etc/profile.d/cursor-cli.sh
|
||||
|
||||
# Add to user bashrc files
|
||||
RUN echo 'export PATH="/home/automaker/.local/bin:$PATH"' >> /home/automaker/.bashrc && \
|
||||
chown automaker:automaker /home/automaker/.bashrc
|
||||
RUN echo 'export PATH="/home/automaker/.local/bin:$PATH"' >> /root/.bashrc
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Create directories with proper permissions
|
||||
RUN mkdir -p /data /projects && chown automaker:automaker /data /projects
|
||||
|
||||
# Configure git for mounted volumes
|
||||
RUN git config --system --add safe.directory '*' && \
|
||||
git config --system credential.helper '!gh auth git-credential'
|
||||
|
||||
# Copy entrypoint script
|
||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# Environment variables
|
||||
ENV PORT=3008
|
||||
ENV DATA_DIR=/data
|
||||
ENV HOME=/home/automaker
|
||||
ENV PATH="/home/automaker/.local/bin:${PATH}"
|
||||
|
||||
# Expose both dev ports
|
||||
EXPOSE 3007 3008
|
||||
|
||||
# Use entrypoint for permission handling
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
|
||||
# Default command - will be overridden by docker-compose
|
||||
CMD ["npm", "run", "dev:web"]
|
||||
21
README.md
21
README.md
@@ -117,16 +117,24 @@ cd automaker
|
||||
# 2. Install dependencies
|
||||
npm install
|
||||
|
||||
# 3. Build shared packages (can be skipped - npm run dev does it automatically)
|
||||
# 3. Build shared packages (Now can be skipped npm install / run dev does it automaticly)
|
||||
npm run build:packages
|
||||
|
||||
# 4. Start Automaker
|
||||
npm run dev
|
||||
# 4. Start Automaker (production mode)
|
||||
npm run start
|
||||
# Choose between:
|
||||
# 1. Web Application (browser at localhost:3007)
|
||||
# 2. Desktop Application (Electron - recommended)
|
||||
```
|
||||
|
||||
**Note:** The `npm run start` command will:
|
||||
|
||||
- Check for dependencies and install if needed
|
||||
- Build the application if needed
|
||||
- Kill any processes on ports 3007/3008
|
||||
- Present an interactive menu to choose your run mode
|
||||
- Run in production mode (no hot reload)
|
||||
|
||||
**Authentication Setup:** On first run, Automaker will automatically show a setup wizard where you can configure authentication. You can choose to:
|
||||
|
||||
- Use **Claude Code CLI** (recommended) - Automaker will detect your CLI credentials automatically
|
||||
@@ -142,7 +150,7 @@ export ANTHROPIC_API_KEY="sk-ant-..."
|
||||
echo "ANTHROPIC_API_KEY=sk-ant-..." > .env
|
||||
```
|
||||
|
||||
**For Development:** `npm run dev` starts the development server with Vite live reload and hot module replacement for fast refresh and instant updates as you make changes.
|
||||
**For Development:** If you want to develop on Automaker with Vite live reload and hot module replacement, use `npm run dev` instead. This will start the development server with fast refresh and instant updates as you make changes.
|
||||
|
||||
## How to Run
|
||||
|
||||
@@ -186,6 +194,9 @@ npm run dev:web
|
||||
```bash
|
||||
# Build for web deployment (uses Vite)
|
||||
npm run build
|
||||
|
||||
# Run production build
|
||||
npm run start
|
||||
```
|
||||
|
||||
#### Desktop Application
|
||||
@@ -363,6 +374,7 @@ npm run lint
|
||||
|
||||
- `VITE_SKIP_ELECTRON` - Skip Electron in dev mode
|
||||
- `OPEN_DEVTOOLS` - Auto-open DevTools in Electron
|
||||
- `ENABLE_DEBUG_PANEL` - Enable the debug panel in non-development builds (for staging environments)
|
||||
|
||||
### Authentication Setup
|
||||
|
||||
@@ -444,6 +456,7 @@ The application can store your API key securely in the settings UI. The key is p
|
||||
- 🎨 **Theme System** - 25+ themes including Dark, Light, Dracula, Nord, Catppuccin, and more
|
||||
- 🖥️ **Cross-Platform** - Desktop app for macOS (x64, arm64), Windows (x64), and Linux (x64)
|
||||
- 🌐 **Web Mode** - Run in browser or as Electron desktop app
|
||||
- 🐛 **Debug Panel** - Floating debug overlay for monitoring memory, CPU, and process performance (dev mode only, toggle with `Cmd/Ctrl+Shift+D`)
|
||||
|
||||
### Advanced Features
|
||||
|
||||
|
||||
@@ -8,20 +8,6 @@
|
||||
# Your Anthropic API key for Claude models
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
|
||||
# ============================================
|
||||
# OPTIONAL - Additional API Keys
|
||||
# ============================================
|
||||
|
||||
# OpenAI API key for Codex/GPT models
|
||||
OPENAI_API_KEY=sk-...
|
||||
|
||||
# Cursor API key for Cursor models
|
||||
CURSOR_API_KEY=...
|
||||
|
||||
# OAuth credentials for CLI authentication (extracted automatically)
|
||||
CLAUDE_OAUTH_CREDENTIALS=
|
||||
CURSOR_AUTH_TOKEN=
|
||||
|
||||
# ============================================
|
||||
# OPTIONAL - Security
|
||||
# ============================================
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@automaker/server",
|
||||
"version": "0.9.0",
|
||||
"version": "0.7.3",
|
||||
"description": "Backend server for Automaker - provides API for both web and Electron modes",
|
||||
"author": "AutoMaker Team",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
@@ -33,7 +33,6 @@
|
||||
"@automaker/types": "1.0.0",
|
||||
"@automaker/utils": "1.0.0",
|
||||
"@modelcontextprotocol/sdk": "1.25.1",
|
||||
"@openai/codex-sdk": "^0.77.0",
|
||||
"cookie-parser": "1.4.7",
|
||||
"cors": "2.8.5",
|
||||
"dotenv": "17.2.3",
|
||||
|
||||
@@ -53,8 +53,6 @@ import { SettingsService } from './services/settings-service.js';
|
||||
import { createSpecRegenerationRoutes } from './routes/app-spec/index.js';
|
||||
import { createClaudeRoutes } from './routes/claude/index.js';
|
||||
import { ClaudeUsageService } from './services/claude-usage-service.js';
|
||||
import { createCodexRoutes } from './routes/codex/index.js';
|
||||
import { CodexUsageService } from './services/codex-usage-service.js';
|
||||
import { createGitHubRoutes } from './routes/github/index.js';
|
||||
import { createContextRoutes } from './routes/context/index.js';
|
||||
import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js';
|
||||
@@ -65,6 +63,12 @@ import { createPipelineRoutes } from './routes/pipeline/index.js';
|
||||
import { pipelineService } from './services/pipeline-service.js';
|
||||
import { createIdeationRoutes } from './routes/ideation/index.js';
|
||||
import { IdeationService } from './services/ideation-service.js';
|
||||
import {
|
||||
createDebugRoutes,
|
||||
createDebugServices,
|
||||
stopDebugServices,
|
||||
type DebugServices,
|
||||
} from './routes/debug/index.js';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
@@ -72,6 +76,8 @@ dotenv.config();
|
||||
const PORT = parseInt(process.env.PORT || '3008', 10);
|
||||
const DATA_DIR = process.env.DATA_DIR || './data';
|
||||
const ENABLE_REQUEST_LOGGING = process.env.ENABLE_REQUEST_LOGGING !== 'false'; // Default to true
|
||||
const ENABLE_DEBUG_PANEL =
|
||||
process.env.NODE_ENV !== 'production' || process.env.ENABLE_DEBUG_PANEL === 'true';
|
||||
|
||||
// Check for required environment variables
|
||||
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
|
||||
@@ -168,10 +174,16 @@ const agentService = new AgentService(DATA_DIR, events, settingsService);
|
||||
const featureLoader = new FeatureLoader();
|
||||
const autoModeService = new AutoModeService(events, settingsService);
|
||||
const claudeUsageService = new ClaudeUsageService();
|
||||
const codexUsageService = new CodexUsageService();
|
||||
const mcpTestService = new MCPTestService(settingsService);
|
||||
const ideationService = new IdeationService(events, settingsService, featureLoader);
|
||||
|
||||
// Create debug services (dev mode only)
|
||||
let debugServices: DebugServices | null = null;
|
||||
if (ENABLE_DEBUG_PANEL) {
|
||||
debugServices = createDebugServices(events);
|
||||
logger.info('Debug services enabled');
|
||||
}
|
||||
|
||||
// Initialize services
|
||||
(async () => {
|
||||
await agentService.initialize();
|
||||
@@ -191,10 +203,9 @@ setInterval(() => {
|
||||
// This helps prevent CSRF and content-type confusion attacks
|
||||
app.use('/api', requireJsonContentType);
|
||||
|
||||
// Mount API routes - health, auth, and setup are unauthenticated
|
||||
// Mount API routes - health and auth are unauthenticated
|
||||
app.use('/api/health', createHealthRoutes());
|
||||
app.use('/api/auth', createAuthRoutes());
|
||||
app.use('/api/setup', createSetupRoutes());
|
||||
|
||||
// Apply authentication to all other routes
|
||||
app.use('/api', authMiddleware);
|
||||
@@ -210,6 +221,7 @@ app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
|
||||
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
|
||||
app.use('/api/worktree', createWorktreeRoutes());
|
||||
app.use('/api/git', createGitRoutes());
|
||||
app.use('/api/setup', createSetupRoutes());
|
||||
app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService));
|
||||
app.use('/api/models', createModelsRoutes());
|
||||
app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events, settingsService));
|
||||
@@ -219,7 +231,6 @@ app.use('/api/templates', createTemplatesRoutes());
|
||||
app.use('/api/terminal', createTerminalRoutes());
|
||||
app.use('/api/settings', createSettingsRoutes(settingsService));
|
||||
app.use('/api/claude', createClaudeRoutes(claudeUsageService));
|
||||
app.use('/api/codex', createCodexRoutes(codexUsageService));
|
||||
app.use('/api/github', createGitHubRoutes(events, settingsService));
|
||||
app.use('/api/context', createContextRoutes(settingsService));
|
||||
app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
|
||||
@@ -227,6 +238,12 @@ app.use('/api/mcp', createMCPRoutes(mcpTestService));
|
||||
app.use('/api/pipeline', createPipelineRoutes(pipelineService));
|
||||
app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader));
|
||||
|
||||
// Debug routes (dev mode only)
|
||||
if (debugServices) {
|
||||
app.use('/api/debug', createDebugRoutes(debugServices));
|
||||
logger.info('Debug API routes mounted at /api/debug');
|
||||
}
|
||||
|
||||
// Create HTTP server
|
||||
const server = createServer(app);
|
||||
|
||||
@@ -592,6 +609,9 @@ startServer(PORT);
|
||||
process.on('SIGTERM', () => {
|
||||
logger.info('SIGTERM received, shutting down...');
|
||||
terminalService.cleanup();
|
||||
if (debugServices) {
|
||||
stopDebugServices(debugServices);
|
||||
}
|
||||
server.close(() => {
|
||||
logger.info('Server closed');
|
||||
process.exit(0);
|
||||
@@ -601,6 +621,9 @@ process.on('SIGTERM', () => {
|
||||
process.on('SIGINT', () => {
|
||||
logger.info('SIGINT received, shutting down...');
|
||||
terminalService.cleanup();
|
||||
if (debugServices) {
|
||||
stopDebugServices(debugServices);
|
||||
}
|
||||
server.close(() => {
|
||||
logger.info('Server closed');
|
||||
process.exit(0);
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
/**
|
||||
* Agent Discovery - Scans filesystem for AGENT.md files
|
||||
*
|
||||
* Discovers agents from:
|
||||
* - ~/.claude/agents/ (user-level, global)
|
||||
* - .claude/agents/ (project-level)
|
||||
*
|
||||
* Similar to Skills, but for custom subagents defined in AGENT.md files.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { secureFs, systemPaths } from '@automaker/platform';
|
||||
import type { AgentDefinition } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('AgentDiscovery');
|
||||
|
||||
export interface FilesystemAgent {
|
||||
name: string; // Directory name (e.g., 'code-reviewer')
|
||||
definition: AgentDefinition;
|
||||
source: 'user' | 'project';
|
||||
filePath: string; // Full path to AGENT.md
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse agent content string into AgentDefinition
|
||||
* Format:
|
||||
* ---
|
||||
* name: agent-name # Optional
|
||||
* description: When to use this agent
|
||||
* tools: tool1, tool2, tool3 # Optional (comma or space separated list)
|
||||
* model: sonnet # Optional: sonnet, opus, haiku
|
||||
* ---
|
||||
* System prompt content here...
|
||||
*/
|
||||
function parseAgentContent(content: string, filePath: string): AgentDefinition | null {
|
||||
// Extract frontmatter
|
||||
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
||||
if (!frontmatterMatch) {
|
||||
logger.warn(`Invalid agent file format (missing frontmatter): ${filePath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, frontmatter, prompt] = frontmatterMatch;
|
||||
|
||||
// Parse description (required)
|
||||
const description = frontmatter.match(/description:\s*(.+)/)?.[1]?.trim();
|
||||
if (!description) {
|
||||
logger.warn(`Missing description in agent file: ${filePath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse tools (optional) - supports both comma-separated and space-separated
|
||||
const toolsMatch = frontmatter.match(/tools:\s*(.+)/);
|
||||
const tools = toolsMatch
|
||||
? toolsMatch[1]
|
||||
.split(/[,\s]+/) // Split by comma or whitespace
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t && t !== '')
|
||||
: undefined;
|
||||
|
||||
// Parse model (optional) - validate against allowed values
|
||||
const modelMatch = frontmatter.match(/model:\s*(\w+)/);
|
||||
const modelValue = modelMatch?.[1]?.trim();
|
||||
const validModels = ['sonnet', 'opus', 'haiku', 'inherit'] as const;
|
||||
const model =
|
||||
modelValue && validModels.includes(modelValue as (typeof validModels)[number])
|
||||
? (modelValue as 'sonnet' | 'opus' | 'haiku' | 'inherit')
|
||||
: undefined;
|
||||
|
||||
if (modelValue && !model) {
|
||||
logger.warn(
|
||||
`Invalid model "${modelValue}" in agent file: ${filePath}. Expected one of: ${validModels.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
description,
|
||||
prompt: prompt.trim(),
|
||||
tools,
|
||||
model,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Directory entry with type information
|
||||
*/
|
||||
interface DirEntry {
|
||||
name: string;
|
||||
isFile: boolean;
|
||||
isDirectory: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filesystem adapter interface for abstracting systemPaths vs secureFs
|
||||
*/
|
||||
interface FsAdapter {
|
||||
exists: (filePath: string) => Promise<boolean>;
|
||||
readdir: (dirPath: string) => Promise<DirEntry[]>;
|
||||
readFile: (filePath: string) => Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a filesystem adapter for system paths (user directory)
|
||||
*/
|
||||
function createSystemPathAdapter(): FsAdapter {
|
||||
return {
|
||||
exists: (filePath) => Promise.resolve(systemPaths.systemPathExists(filePath)),
|
||||
readdir: async (dirPath) => {
|
||||
const entryNames = await systemPaths.systemPathReaddir(dirPath);
|
||||
const entries: DirEntry[] = [];
|
||||
for (const name of entryNames) {
|
||||
const stat = await systemPaths.systemPathStat(path.join(dirPath, name));
|
||||
entries.push({
|
||||
name,
|
||||
isFile: stat.isFile(),
|
||||
isDirectory: stat.isDirectory(),
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
},
|
||||
readFile: (filePath) => systemPaths.systemPathReadFile(filePath, 'utf-8') as Promise<string>,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a filesystem adapter for project paths (secureFs)
|
||||
*/
|
||||
function createSecureFsAdapter(): FsAdapter {
|
||||
return {
|
||||
exists: (filePath) =>
|
||||
secureFs
|
||||
.access(filePath)
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
readdir: async (dirPath) => {
|
||||
const entries = await secureFs.readdir(dirPath, { withFileTypes: true });
|
||||
return entries.map((entry) => ({
|
||||
name: entry.name,
|
||||
isFile: entry.isFile(),
|
||||
isDirectory: entry.isDirectory(),
|
||||
}));
|
||||
},
|
||||
readFile: (filePath) => secureFs.readFile(filePath, 'utf-8') as Promise<string>,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse agent file using the provided filesystem adapter
|
||||
*/
|
||||
async function parseAgentFileWithAdapter(
|
||||
filePath: string,
|
||||
fsAdapter: FsAdapter
|
||||
): Promise<AgentDefinition | null> {
|
||||
try {
|
||||
const content = await fsAdapter.readFile(filePath);
|
||||
return parseAgentContent(content, filePath);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to parse agent file: ${filePath}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a directory for agent .md files
|
||||
* Agents can be in two formats:
|
||||
* 1. Flat: agent-name.md (file directly in agents/)
|
||||
* 2. Subdirectory: agent-name/AGENT.md (folder + file, similar to Skills)
|
||||
*/
|
||||
async function scanAgentsDirectory(
|
||||
baseDir: string,
|
||||
source: 'user' | 'project'
|
||||
): Promise<FilesystemAgent[]> {
|
||||
const agents: FilesystemAgent[] = [];
|
||||
const fsAdapter = source === 'user' ? createSystemPathAdapter() : createSecureFsAdapter();
|
||||
|
||||
try {
|
||||
// Check if directory exists
|
||||
const exists = await fsAdapter.exists(baseDir);
|
||||
if (!exists) {
|
||||
logger.debug(`Directory does not exist: ${baseDir}`);
|
||||
return agents;
|
||||
}
|
||||
|
||||
// Read all entries in the directory
|
||||
const entries = await fsAdapter.readdir(baseDir);
|
||||
|
||||
for (const entry of entries) {
|
||||
// Check for flat .md file format (agent-name.md)
|
||||
if (entry.isFile && entry.name.endsWith('.md')) {
|
||||
const agentName = entry.name.slice(0, -3); // Remove .md extension
|
||||
const agentFilePath = path.join(baseDir, entry.name);
|
||||
const definition = await parseAgentFileWithAdapter(agentFilePath, fsAdapter);
|
||||
if (definition) {
|
||||
agents.push({
|
||||
name: agentName,
|
||||
definition,
|
||||
source,
|
||||
filePath: agentFilePath,
|
||||
});
|
||||
logger.debug(`Discovered ${source} agent (flat): ${agentName}`);
|
||||
}
|
||||
}
|
||||
// Check for subdirectory format (agent-name/AGENT.md)
|
||||
else if (entry.isDirectory) {
|
||||
const agentFilePath = path.join(baseDir, entry.name, 'AGENT.md');
|
||||
const agentFileExists = await fsAdapter.exists(agentFilePath);
|
||||
|
||||
if (agentFileExists) {
|
||||
const definition = await parseAgentFileWithAdapter(agentFilePath, fsAdapter);
|
||||
if (definition) {
|
||||
agents.push({
|
||||
name: entry.name,
|
||||
definition,
|
||||
source,
|
||||
filePath: agentFilePath,
|
||||
});
|
||||
logger.debug(`Discovered ${source} agent (subdirectory): ${entry.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to scan agents directory: ${baseDir}`, error);
|
||||
}
|
||||
|
||||
return agents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover all filesystem-based agents from user and project sources
|
||||
*/
|
||||
export async function discoverFilesystemAgents(
|
||||
projectPath?: string,
|
||||
sources: Array<'user' | 'project'> = ['user', 'project']
|
||||
): Promise<FilesystemAgent[]> {
|
||||
const agents: FilesystemAgent[] = [];
|
||||
|
||||
// Discover user-level agents from ~/.claude/agents/
|
||||
if (sources.includes('user')) {
|
||||
const userAgentsDir = path.join(os.homedir(), '.claude', 'agents');
|
||||
const userAgents = await scanAgentsDirectory(userAgentsDir, 'user');
|
||||
agents.push(...userAgents);
|
||||
logger.info(`Discovered ${userAgents.length} user-level agents from ${userAgentsDir}`);
|
||||
}
|
||||
|
||||
// Discover project-level agents from .claude/agents/
|
||||
if (sources.includes('project') && projectPath) {
|
||||
const projectAgentsDir = path.join(projectPath, '.claude', 'agents');
|
||||
const projectAgents = await scanAgentsDirectory(projectAgentsDir, 'project');
|
||||
agents.push(...projectAgents);
|
||||
logger.info(`Discovered ${projectAgents.length} project-level agents from ${projectAgentsDir}`);
|
||||
}
|
||||
|
||||
return agents;
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
/**
|
||||
* Secure authentication utilities that avoid environment variable race conditions
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('AuthUtils');
|
||||
|
||||
export interface SecureAuthEnv {
|
||||
[key: string]: string | undefined;
|
||||
}
|
||||
|
||||
export interface AuthValidationResult {
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
normalizedKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates API key format without modifying process.env
|
||||
*/
|
||||
export function validateApiKey(
|
||||
key: string,
|
||||
provider: 'anthropic' | 'openai' | 'cursor'
|
||||
): AuthValidationResult {
|
||||
if (!key || typeof key !== 'string' || key.trim().length === 0) {
|
||||
return { isValid: false, error: 'API key is required' };
|
||||
}
|
||||
|
||||
const trimmedKey = key.trim();
|
||||
|
||||
switch (provider) {
|
||||
case 'anthropic':
|
||||
if (!trimmedKey.startsWith('sk-ant-')) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: 'Invalid Anthropic API key format. Should start with "sk-ant-"',
|
||||
};
|
||||
}
|
||||
if (trimmedKey.length < 20) {
|
||||
return { isValid: false, error: 'Anthropic API key too short' };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'openai':
|
||||
if (!trimmedKey.startsWith('sk-')) {
|
||||
return { isValid: false, error: 'Invalid OpenAI API key format. Should start with "sk-"' };
|
||||
}
|
||||
if (trimmedKey.length < 20) {
|
||||
return { isValid: false, error: 'OpenAI API key too short' };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'cursor':
|
||||
// Cursor API keys might have different format
|
||||
if (trimmedKey.length < 10) {
|
||||
return { isValid: false, error: 'Cursor API key too short' };
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return { isValid: true, normalizedKey: trimmedKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a secure environment object for authentication testing
|
||||
* without modifying the global process.env
|
||||
*/
|
||||
export function createSecureAuthEnv(
|
||||
authMethod: 'cli' | 'api_key',
|
||||
apiKey?: string,
|
||||
provider: 'anthropic' | 'openai' | 'cursor' = 'anthropic'
|
||||
): SecureAuthEnv {
|
||||
const env: SecureAuthEnv = { ...process.env };
|
||||
|
||||
if (authMethod === 'cli') {
|
||||
// For CLI auth, remove the API key to force CLI authentication
|
||||
const envKey = provider === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY';
|
||||
delete env[envKey];
|
||||
} else if (authMethod === 'api_key' && apiKey) {
|
||||
// For API key auth, validate and set the provided key
|
||||
const validation = validateApiKey(apiKey, provider);
|
||||
if (!validation.isValid) {
|
||||
throw new Error(validation.error);
|
||||
}
|
||||
const envKey = provider === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY';
|
||||
env[envKey] = validation.normalizedKey;
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a temporary environment override for the current process
|
||||
* WARNING: This should only be used in isolated contexts and immediately cleaned up
|
||||
*/
|
||||
export function createTempEnvOverride(authEnv: SecureAuthEnv): () => void {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
// Apply the auth environment
|
||||
Object.assign(process.env, authEnv);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
// Restore original environment
|
||||
Object.keys(process.env).forEach((key) => {
|
||||
if (!(key in originalEnv)) {
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
Object.assign(process.env, originalEnv);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawns a process with secure environment isolation
|
||||
*/
|
||||
export function spawnSecureAuth(
|
||||
command: string,
|
||||
args: string[],
|
||||
authEnv: SecureAuthEnv,
|
||||
options: {
|
||||
cwd?: string;
|
||||
timeout?: number;
|
||||
} = {}
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { cwd = process.cwd(), timeout = 30000 } = options;
|
||||
|
||||
logger.debug(`Spawning secure auth process: ${command} ${args.join(' ')}`);
|
||||
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
env: authEnv,
|
||||
stdio: 'pipe',
|
||||
shell: false,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let isResolved = false;
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!isResolved) {
|
||||
child.kill('SIGTERM');
|
||||
isResolved = true;
|
||||
reject(new Error('Authentication process timed out'));
|
||||
}
|
||||
}, timeout);
|
||||
|
||||
child.stdout?.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timeoutId);
|
||||
if (!isResolved) {
|
||||
isResolved = true;
|
||||
resolve({ stdout, stderr, exitCode: code });
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timeoutId);
|
||||
if (!isResolved) {
|
||||
isResolved = true;
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely extracts environment variable without race conditions
|
||||
*/
|
||||
export function safeGetEnv(key: string): string | undefined {
|
||||
return process.env[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an environment variable would be modified without actually modifying it
|
||||
*/
|
||||
export function wouldModifyEnv(key: string, newValue: string): boolean {
|
||||
const currentValue = safeGetEnv(key);
|
||||
return currentValue !== newValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Secure auth session management
|
||||
*/
|
||||
export class AuthSessionManager {
|
||||
private static activeSessions = new Map<string, SecureAuthEnv>();
|
||||
|
||||
static createSession(
|
||||
sessionId: string,
|
||||
authMethod: 'cli' | 'api_key',
|
||||
apiKey?: string,
|
||||
provider: 'anthropic' | 'openai' | 'cursor' = 'anthropic'
|
||||
): SecureAuthEnv {
|
||||
const env = createSecureAuthEnv(authMethod, apiKey, provider);
|
||||
this.activeSessions.set(sessionId, env);
|
||||
return env;
|
||||
}
|
||||
|
||||
static getSession(sessionId: string): SecureAuthEnv | undefined {
|
||||
return this.activeSessions.get(sessionId);
|
||||
}
|
||||
|
||||
static destroySession(sessionId: string): void {
|
||||
this.activeSessions.delete(sessionId);
|
||||
}
|
||||
|
||||
static cleanup(): void {
|
||||
this.activeSessions.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limiting for auth attempts to prevent abuse
|
||||
*/
|
||||
export class AuthRateLimiter {
|
||||
private attempts = new Map<string, { count: number; lastAttempt: number }>();
|
||||
|
||||
constructor(
|
||||
private maxAttempts = 5,
|
||||
private windowMs = 60000
|
||||
) {}
|
||||
|
||||
canAttempt(identifier: string): boolean {
|
||||
const now = Date.now();
|
||||
const record = this.attempts.get(identifier);
|
||||
|
||||
if (!record || now - record.lastAttempt > this.windowMs) {
|
||||
this.attempts.set(identifier, { count: 1, lastAttempt: now });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (record.count >= this.maxAttempts) {
|
||||
return false;
|
||||
}
|
||||
|
||||
record.count++;
|
||||
record.lastAttempt = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
getRemainingAttempts(identifier: string): number {
|
||||
const record = this.attempts.get(identifier);
|
||||
if (!record) return this.maxAttempts;
|
||||
return Math.max(0, this.maxAttempts - record.count);
|
||||
}
|
||||
|
||||
getResetTime(identifier: string): Date | null {
|
||||
const record = this.attempts.get(identifier);
|
||||
if (!record) return null;
|
||||
return new Date(record.lastAttempt + this.windowMs);
|
||||
}
|
||||
}
|
||||
@@ -262,7 +262,7 @@ export function getSessionCookieOptions(): {
|
||||
return {
|
||||
httpOnly: true, // JavaScript cannot access this cookie
|
||||
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
|
||||
sameSite: 'lax', // Sent for same-site requests and top-level navigations, but not cross-origin fetch/XHR
|
||||
sameSite: 'strict', // Only sent for same-site requests (CSRF protection)
|
||||
maxAge: SESSION_MAX_AGE_MS,
|
||||
path: '/',
|
||||
};
|
||||
|
||||
@@ -1,447 +0,0 @@
|
||||
/**
|
||||
* Unified CLI Detection Framework
|
||||
*
|
||||
* Provides consistent CLI detection and management across all providers
|
||||
*/
|
||||
|
||||
import { spawn, execSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('CliDetection');
|
||||
|
||||
export interface CliInfo {
|
||||
name: string;
|
||||
command: string;
|
||||
version?: string;
|
||||
path?: string;
|
||||
installed: boolean;
|
||||
authenticated: boolean;
|
||||
authMethod: 'cli' | 'api_key' | 'none';
|
||||
platform?: string;
|
||||
architectures?: string[];
|
||||
}
|
||||
|
||||
export interface CliDetectionOptions {
|
||||
timeout?: number;
|
||||
includeWsl?: boolean;
|
||||
wslDistribution?: string;
|
||||
}
|
||||
|
||||
export interface CliDetectionResult {
|
||||
cli: CliInfo;
|
||||
detected: boolean;
|
||||
issues: string[];
|
||||
}
|
||||
|
||||
export interface UnifiedCliDetection {
|
||||
claude?: CliDetectionResult;
|
||||
codex?: CliDetectionResult;
|
||||
cursor?: CliDetectionResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI Configuration for different providers
|
||||
*/
|
||||
const CLI_CONFIGS = {
|
||||
claude: {
|
||||
name: 'Claude CLI',
|
||||
commands: ['claude'],
|
||||
versionArgs: ['--version'],
|
||||
installCommands: {
|
||||
darwin: 'brew install anthropics/claude/claude',
|
||||
linux: 'curl -fsSL https://claude.ai/install.sh | sh',
|
||||
win32: 'iwr https://claude.ai/install.ps1 -UseBasicParsing | iex',
|
||||
},
|
||||
},
|
||||
codex: {
|
||||
name: 'Codex CLI',
|
||||
commands: ['codex', 'openai'],
|
||||
versionArgs: ['--version'],
|
||||
installCommands: {
|
||||
darwin: 'npm install -g @openai/codex-cli',
|
||||
linux: 'npm install -g @openai/codex-cli',
|
||||
win32: 'npm install -g @openai/codex-cli',
|
||||
},
|
||||
},
|
||||
cursor: {
|
||||
name: 'Cursor CLI',
|
||||
commands: ['cursor-agent', 'cursor'],
|
||||
versionArgs: ['--version'],
|
||||
installCommands: {
|
||||
darwin: 'brew install cursor/cursor/cursor-agent',
|
||||
linux: 'curl -fsSL https://cursor.sh/install.sh | sh',
|
||||
win32: 'iwr https://cursor.sh/install.ps1 -UseBasicParsing | iex',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Detect if a CLI is installed and available
|
||||
*/
|
||||
export async function detectCli(
|
||||
provider: keyof typeof CLI_CONFIGS,
|
||||
options: CliDetectionOptions = {}
|
||||
): Promise<CliDetectionResult> {
|
||||
const config = CLI_CONFIGS[provider];
|
||||
const { timeout = 5000, includeWsl = false, wslDistribution } = options;
|
||||
const issues: string[] = [];
|
||||
|
||||
const cliInfo: CliInfo = {
|
||||
name: config.name,
|
||||
command: '',
|
||||
installed: false,
|
||||
authenticated: false,
|
||||
authMethod: 'none',
|
||||
};
|
||||
|
||||
try {
|
||||
// Find the command in PATH
|
||||
const command = await findCommand([...config.commands]);
|
||||
if (command) {
|
||||
cliInfo.command = command;
|
||||
}
|
||||
|
||||
if (!cliInfo.command) {
|
||||
issues.push(`${config.name} not found in PATH`);
|
||||
return { cli: cliInfo, detected: false, issues };
|
||||
}
|
||||
|
||||
cliInfo.path = cliInfo.command;
|
||||
cliInfo.installed = true;
|
||||
|
||||
// Get version
|
||||
try {
|
||||
cliInfo.version = await getCliVersion(cliInfo.command, [...config.versionArgs], timeout);
|
||||
} catch (error) {
|
||||
issues.push(`Failed to get ${config.name} version: ${error}`);
|
||||
}
|
||||
|
||||
// Check authentication
|
||||
cliInfo.authMethod = await checkCliAuth(provider, cliInfo.command);
|
||||
cliInfo.authenticated = cliInfo.authMethod !== 'none';
|
||||
|
||||
return { cli: cliInfo, detected: true, issues };
|
||||
} catch (error) {
|
||||
issues.push(`Error detecting ${config.name}: ${error}`);
|
||||
return { cli: cliInfo, detected: false, issues };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect all CLIs in the system
|
||||
*/
|
||||
export async function detectAllCLis(
|
||||
options: CliDetectionOptions = {}
|
||||
): Promise<UnifiedCliDetection> {
|
||||
const results: UnifiedCliDetection = {};
|
||||
|
||||
// Detect all providers in parallel
|
||||
const providers = Object.keys(CLI_CONFIGS) as Array<keyof typeof CLI_CONFIGS>;
|
||||
const detectionPromises = providers.map(async (provider) => {
|
||||
const result = await detectCli(provider, options);
|
||||
return { provider, result };
|
||||
});
|
||||
|
||||
const detections = await Promise.all(detectionPromises);
|
||||
|
||||
for (const { provider, result } of detections) {
|
||||
results[provider] = result;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first available command from a list of alternatives
|
||||
*/
|
||||
export async function findCommand(commands: string[]): Promise<string | null> {
|
||||
for (const command of commands) {
|
||||
try {
|
||||
const whichCommand = process.platform === 'win32' ? 'where' : 'which';
|
||||
const result = execSync(`${whichCommand} ${command}`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 2000,
|
||||
}).trim();
|
||||
|
||||
if (result) {
|
||||
return result.split('\n')[0]; // Take first result on Windows
|
||||
}
|
||||
} catch {
|
||||
// Command not found, try next
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CLI version
|
||||
*/
|
||||
export async function getCliVersion(
|
||||
command: string,
|
||||
args: string[],
|
||||
timeout: number = 5000
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
stdio: 'pipe',
|
||||
timeout,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout?.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code === 0 && stdout) {
|
||||
resolve(stdout.trim());
|
||||
} else if (stderr) {
|
||||
reject(stderr.trim());
|
||||
} else {
|
||||
reject(`Command exited with code ${code}`);
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check authentication status for a CLI
|
||||
*/
|
||||
export async function checkCliAuth(
|
||||
provider: keyof typeof CLI_CONFIGS,
|
||||
command: string
|
||||
): Promise<'cli' | 'api_key' | 'none'> {
|
||||
try {
|
||||
switch (provider) {
|
||||
case 'claude':
|
||||
return await checkClaudeAuth(command);
|
||||
case 'codex':
|
||||
return await checkCodexAuth(command);
|
||||
case 'cursor':
|
||||
return await checkCursorAuth(command);
|
||||
default:
|
||||
return 'none';
|
||||
}
|
||||
} catch {
|
||||
return 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check Claude CLI authentication
|
||||
*/
|
||||
async function checkClaudeAuth(command: string): Promise<'cli' | 'api_key' | 'none'> {
|
||||
try {
|
||||
// Check for environment variable
|
||||
if (process.env.ANTHROPIC_API_KEY) {
|
||||
return 'api_key';
|
||||
}
|
||||
|
||||
// Try running a simple command to check CLI auth
|
||||
const result = await getCliVersion(command, ['--version'], 3000);
|
||||
if (result) {
|
||||
return 'cli'; // If version works, assume CLI is authenticated
|
||||
}
|
||||
} catch {
|
||||
// Version command might work even without auth, so we need a better check
|
||||
}
|
||||
|
||||
// Try a more specific auth check
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(command, ['whoami'], {
|
||||
stdio: 'pipe',
|
||||
timeout: 3000,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout?.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code === 0 && stdout && !stderr.includes('not authenticated')) {
|
||||
resolve('cli');
|
||||
} else {
|
||||
resolve('none');
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', () => {
|
||||
resolve('none');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check Codex CLI authentication
|
||||
*/
|
||||
async function checkCodexAuth(command: string): Promise<'cli' | 'api_key' | 'none'> {
|
||||
// Check for environment variable
|
||||
if (process.env.OPENAI_API_KEY) {
|
||||
return 'api_key';
|
||||
}
|
||||
|
||||
try {
|
||||
// Try a simple auth check
|
||||
const result = await getCliVersion(command, ['--version'], 3000);
|
||||
if (result) {
|
||||
return 'cli';
|
||||
}
|
||||
} catch {
|
||||
// Version check failed
|
||||
}
|
||||
|
||||
return 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check Cursor CLI authentication
|
||||
*/
|
||||
async function checkCursorAuth(command: string): Promise<'cli' | 'api_key' | 'none'> {
|
||||
// Check for environment variable
|
||||
if (process.env.CURSOR_API_KEY) {
|
||||
return 'api_key';
|
||||
}
|
||||
|
||||
// Check for credentials files
|
||||
const credentialPaths = [
|
||||
path.join(os.homedir(), '.cursor', 'credentials.json'),
|
||||
path.join(os.homedir(), '.config', 'cursor', 'credentials.json'),
|
||||
path.join(os.homedir(), '.cursor', 'auth.json'),
|
||||
path.join(os.homedir(), '.config', 'cursor', 'auth.json'),
|
||||
];
|
||||
|
||||
for (const credPath of credentialPaths) {
|
||||
try {
|
||||
if (fs.existsSync(credPath)) {
|
||||
const content = fs.readFileSync(credPath, 'utf8');
|
||||
const creds = JSON.parse(content);
|
||||
if (creds.accessToken || creds.token || creds.apiKey) {
|
||||
return 'cli';
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Invalid credentials file
|
||||
}
|
||||
}
|
||||
|
||||
// Try a simple command
|
||||
try {
|
||||
const result = await getCliVersion(command, ['--version'], 3000);
|
||||
if (result) {
|
||||
return 'cli';
|
||||
}
|
||||
} catch {
|
||||
// Version check failed
|
||||
}
|
||||
|
||||
return 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get installation instructions for a provider
|
||||
*/
|
||||
export function getInstallInstructions(
|
||||
provider: keyof typeof CLI_CONFIGS,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): string {
|
||||
const config = CLI_CONFIGS[provider];
|
||||
const command = config.installCommands[platform as keyof typeof config.installCommands];
|
||||
|
||||
if (!command) {
|
||||
return `No installation instructions available for ${provider} on ${platform}`;
|
||||
}
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get platform-specific CLI paths and versions
|
||||
*/
|
||||
export function getPlatformCliPaths(provider: keyof typeof CLI_CONFIGS): string[] {
|
||||
const config = CLI_CONFIGS[provider];
|
||||
const platform = process.platform;
|
||||
|
||||
switch (platform) {
|
||||
case 'darwin':
|
||||
return [
|
||||
`/usr/local/bin/${config.commands[0]}`,
|
||||
`/opt/homebrew/bin/${config.commands[0]}`,
|
||||
path.join(os.homedir(), '.local', 'bin', config.commands[0]),
|
||||
];
|
||||
|
||||
case 'linux':
|
||||
return [
|
||||
`/usr/bin/${config.commands[0]}`,
|
||||
`/usr/local/bin/${config.commands[0]}`,
|
||||
path.join(os.homedir(), '.local', 'bin', config.commands[0]),
|
||||
path.join(os.homedir(), '.npm', 'global', 'bin', config.commands[0]),
|
||||
];
|
||||
|
||||
case 'win32':
|
||||
return [
|
||||
path.join(
|
||||
os.homedir(),
|
||||
'AppData',
|
||||
'Local',
|
||||
'Programs',
|
||||
config.commands[0],
|
||||
`${config.commands[0]}.exe`
|
||||
),
|
||||
path.join(process.env.ProgramFiles || '', config.commands[0], `${config.commands[0]}.exe`),
|
||||
path.join(
|
||||
process.env.ProgramFiles || '',
|
||||
config.commands[0],
|
||||
'bin',
|
||||
`${config.commands[0]}.exe`
|
||||
),
|
||||
];
|
||||
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate CLI installation
|
||||
*/
|
||||
export function validateCliInstallation(cliInfo: CliInfo): {
|
||||
valid: boolean;
|
||||
issues: string[];
|
||||
} {
|
||||
const issues: string[] = [];
|
||||
|
||||
if (!cliInfo.installed) {
|
||||
issues.push('CLI is not installed');
|
||||
}
|
||||
|
||||
if (cliInfo.installed && !cliInfo.version) {
|
||||
issues.push('Could not determine CLI version');
|
||||
}
|
||||
|
||||
if (cliInfo.installed && cliInfo.authMethod === 'none') {
|
||||
issues.push('CLI is not authenticated');
|
||||
}
|
||||
|
||||
return {
|
||||
valid: issues.length === 0,
|
||||
issues,
|
||||
};
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
/**
|
||||
* Shared utility for checking Codex CLI authentication status
|
||||
*
|
||||
* Uses 'codex login status' command to verify authentication.
|
||||
* Never assumes authenticated - only returns true if CLI confirms.
|
||||
*/
|
||||
|
||||
import { spawnProcess, getCodexAuthPath } from '@automaker/platform';
|
||||
import { findCodexCliPath } from '@automaker/platform';
|
||||
import * as fs from 'fs';
|
||||
|
||||
const CODEX_COMMAND = 'codex';
|
||||
const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY';
|
||||
|
||||
export interface CodexAuthCheckResult {
|
||||
authenticated: boolean;
|
||||
method: 'api_key_env' | 'cli_authenticated' | 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check Codex authentication status using 'codex login status' command
|
||||
*
|
||||
* @param cliPath Optional CLI path. If not provided, will attempt to find it.
|
||||
* @returns Authentication status and method
|
||||
*/
|
||||
export async function checkCodexAuthentication(
|
||||
cliPath?: string | null
|
||||
): Promise<CodexAuthCheckResult> {
|
||||
console.log('[CodexAuth] checkCodexAuthentication called with cliPath:', cliPath);
|
||||
|
||||
const resolvedCliPath = cliPath || (await findCodexCliPath());
|
||||
const hasApiKey = !!process.env[OPENAI_API_KEY_ENV];
|
||||
|
||||
console.log('[CodexAuth] resolvedCliPath:', resolvedCliPath);
|
||||
console.log('[CodexAuth] hasApiKey:', hasApiKey);
|
||||
|
||||
// Debug: Check auth file
|
||||
const authFilePath = getCodexAuthPath();
|
||||
console.log('[CodexAuth] Auth file path:', authFilePath);
|
||||
try {
|
||||
const authFileExists = fs.existsSync(authFilePath);
|
||||
console.log('[CodexAuth] Auth file exists:', authFileExists);
|
||||
if (authFileExists) {
|
||||
const authContent = fs.readFileSync(authFilePath, 'utf-8');
|
||||
console.log('[CodexAuth] Auth file content:', authContent.substring(0, 500)); // First 500 chars
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('[CodexAuth] Error reading auth file:', error);
|
||||
}
|
||||
|
||||
// If CLI is not installed, cannot be authenticated
|
||||
if (!resolvedCliPath) {
|
||||
console.log('[CodexAuth] No CLI path found, returning not authenticated');
|
||||
return { authenticated: false, method: 'none' };
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[CodexAuth] Running: ' + resolvedCliPath + ' login status');
|
||||
const result = await spawnProcess({
|
||||
command: resolvedCliPath || CODEX_COMMAND,
|
||||
args: ['login', 'status'],
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: 'dumb', // Avoid interactive output
|
||||
},
|
||||
});
|
||||
|
||||
console.log('[CodexAuth] Command result:');
|
||||
console.log('[CodexAuth] exitCode:', result.exitCode);
|
||||
console.log('[CodexAuth] stdout:', JSON.stringify(result.stdout));
|
||||
console.log('[CodexAuth] stderr:', JSON.stringify(result.stderr));
|
||||
|
||||
// Check both stdout and stderr for "logged in" - Codex CLI outputs to stderr
|
||||
const combinedOutput = (result.stdout + result.stderr).toLowerCase();
|
||||
const isLoggedIn = combinedOutput.includes('logged in');
|
||||
console.log('[CodexAuth] isLoggedIn (contains "logged in" in stdout or stderr):', isLoggedIn);
|
||||
|
||||
if (result.exitCode === 0 && isLoggedIn) {
|
||||
// Determine auth method based on what we know
|
||||
const method = hasApiKey ? 'api_key_env' : 'cli_authenticated';
|
||||
console.log('[CodexAuth] Authenticated! method:', method);
|
||||
return { authenticated: true, method };
|
||||
}
|
||||
|
||||
console.log(
|
||||
'[CodexAuth] Not authenticated. exitCode:',
|
||||
result.exitCode,
|
||||
'isLoggedIn:',
|
||||
isLoggedIn
|
||||
);
|
||||
} catch (error) {
|
||||
console.log('[CodexAuth] Error running command:', error);
|
||||
}
|
||||
|
||||
console.log('[CodexAuth] Returning not authenticated');
|
||||
return { authenticated: false, method: 'none' };
|
||||
}
|
||||
@@ -1,414 +0,0 @@
|
||||
/**
|
||||
* Unified Error Handling System for CLI Providers
|
||||
*
|
||||
* Provides consistent error classification, user-friendly messages, and debugging support
|
||||
* across all AI providers (Claude, Codex, Cursor)
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('ErrorHandler');
|
||||
|
||||
export enum ErrorType {
|
||||
AUTHENTICATION = 'authentication',
|
||||
BILLING = 'billing',
|
||||
RATE_LIMIT = 'rate_limit',
|
||||
NETWORK = 'network',
|
||||
TIMEOUT = 'timeout',
|
||||
VALIDATION = 'validation',
|
||||
PERMISSION = 'permission',
|
||||
CLI_NOT_FOUND = 'cli_not_found',
|
||||
CLI_NOT_INSTALLED = 'cli_not_installed',
|
||||
MODEL_NOT_SUPPORTED = 'model_not_supported',
|
||||
INVALID_REQUEST = 'invalid_request',
|
||||
SERVER_ERROR = 'server_error',
|
||||
UNKNOWN = 'unknown',
|
||||
}
|
||||
|
||||
export enum ErrorSeverity {
|
||||
LOW = 'low',
|
||||
MEDIUM = 'medium',
|
||||
HIGH = 'high',
|
||||
CRITICAL = 'critical',
|
||||
}
|
||||
|
||||
export interface ErrorClassification {
|
||||
type: ErrorType;
|
||||
severity: ErrorSeverity;
|
||||
userMessage: string;
|
||||
technicalMessage: string;
|
||||
suggestedAction?: string;
|
||||
retryable: boolean;
|
||||
provider?: string;
|
||||
context?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ErrorPattern {
|
||||
type: ErrorType;
|
||||
severity: ErrorSeverity;
|
||||
patterns: RegExp[];
|
||||
userMessage: string;
|
||||
suggestedAction?: string;
|
||||
retryable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error patterns for different types of errors
|
||||
*/
|
||||
const ERROR_PATTERNS: ErrorPattern[] = [
|
||||
// Authentication errors
|
||||
{
|
||||
type: ErrorType.AUTHENTICATION,
|
||||
severity: ErrorSeverity.HIGH,
|
||||
patterns: [
|
||||
/unauthorized/i,
|
||||
/authentication.*fail/i,
|
||||
/invalid_api_key/i,
|
||||
/invalid api key/i,
|
||||
/not authenticated/i,
|
||||
/please.*log/i,
|
||||
/token.*revoked/i,
|
||||
/oauth.*error/i,
|
||||
/credentials.*invalid/i,
|
||||
],
|
||||
userMessage: 'Authentication failed. Please check your API key or login credentials.',
|
||||
suggestedAction:
|
||||
"Verify your API key is correct and hasn't expired, or run the CLI login command.",
|
||||
retryable: false,
|
||||
},
|
||||
|
||||
// Billing errors
|
||||
{
|
||||
type: ErrorType.BILLING,
|
||||
severity: ErrorSeverity.HIGH,
|
||||
patterns: [
|
||||
/credit.*balance.*low/i,
|
||||
/insufficient.*credit/i,
|
||||
/billing.*issue/i,
|
||||
/payment.*required/i,
|
||||
/usage.*exceeded/i,
|
||||
/quota.*exceeded/i,
|
||||
/add.*credit/i,
|
||||
],
|
||||
userMessage: 'Account has insufficient credits or billing issues.',
|
||||
suggestedAction: 'Please add credits to your account or check your billing settings.',
|
||||
retryable: false,
|
||||
},
|
||||
|
||||
// Rate limit errors
|
||||
{
|
||||
type: ErrorType.RATE_LIMIT,
|
||||
severity: ErrorSeverity.MEDIUM,
|
||||
patterns: [
|
||||
/rate.*limit/i,
|
||||
/too.*many.*request/i,
|
||||
/limit.*reached/i,
|
||||
/try.*later/i,
|
||||
/429/i,
|
||||
/reset.*time/i,
|
||||
/upgrade.*plan/i,
|
||||
],
|
||||
userMessage: 'Rate limit reached. Please wait before trying again.',
|
||||
suggestedAction: 'Wait a few minutes before retrying, or consider upgrading your plan.',
|
||||
retryable: true,
|
||||
},
|
||||
|
||||
// Network errors
|
||||
{
|
||||
type: ErrorType.NETWORK,
|
||||
severity: ErrorSeverity.MEDIUM,
|
||||
patterns: [/network/i, /connection/i, /dns/i, /timeout/i, /econnrefused/i, /enotfound/i],
|
||||
userMessage: 'Network connection issue.',
|
||||
suggestedAction: 'Check your internet connection and try again.',
|
||||
retryable: true,
|
||||
},
|
||||
|
||||
// Timeout errors
|
||||
{
|
||||
type: ErrorType.TIMEOUT,
|
||||
severity: ErrorSeverity.MEDIUM,
|
||||
patterns: [/timeout/i, /aborted/i, /time.*out/i],
|
||||
userMessage: 'Operation timed out.',
|
||||
suggestedAction: 'Try again with a simpler request or check your connection.',
|
||||
retryable: true,
|
||||
},
|
||||
|
||||
// Permission errors
|
||||
{
|
||||
type: ErrorType.PERMISSION,
|
||||
severity: ErrorSeverity.HIGH,
|
||||
patterns: [/permission.*denied/i, /access.*denied/i, /forbidden/i, /403/i, /not.*authorized/i],
|
||||
userMessage: 'Permission denied.',
|
||||
suggestedAction: 'Check if you have the required permissions for this operation.',
|
||||
retryable: false,
|
||||
},
|
||||
|
||||
// CLI not found
|
||||
{
|
||||
type: ErrorType.CLI_NOT_FOUND,
|
||||
severity: ErrorSeverity.HIGH,
|
||||
patterns: [/command not found/i, /not recognized/i, /not.*installed/i, /ENOENT/i],
|
||||
userMessage: 'CLI tool not found.',
|
||||
suggestedAction: "Please install the required CLI tool and ensure it's in your PATH.",
|
||||
retryable: false,
|
||||
},
|
||||
|
||||
// Model not supported
|
||||
{
|
||||
type: ErrorType.MODEL_NOT_SUPPORTED,
|
||||
severity: ErrorSeverity.HIGH,
|
||||
patterns: [/model.*not.*support/i, /unknown.*model/i, /invalid.*model/i],
|
||||
userMessage: 'Model not supported.',
|
||||
suggestedAction: 'Check available models and use a supported one.',
|
||||
retryable: false,
|
||||
},
|
||||
|
||||
// Server errors
|
||||
{
|
||||
type: ErrorType.SERVER_ERROR,
|
||||
severity: ErrorSeverity.HIGH,
|
||||
patterns: [/internal.*server/i, /server.*error/i, /500/i, /502/i, /503/i, /504/i],
|
||||
userMessage: 'Server error occurred.',
|
||||
suggestedAction: 'Try again in a few minutes or contact support if the issue persists.',
|
||||
retryable: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Classify an error into a specific type with user-friendly message
|
||||
*/
|
||||
export function classifyError(
|
||||
error: unknown,
|
||||
provider?: string,
|
||||
context?: Record<string, any>
|
||||
): ErrorClassification {
|
||||
const errorText = getErrorText(error);
|
||||
|
||||
// Try to match against known patterns
|
||||
for (const pattern of ERROR_PATTERNS) {
|
||||
for (const regex of pattern.patterns) {
|
||||
if (regex.test(errorText)) {
|
||||
return {
|
||||
type: pattern.type,
|
||||
severity: pattern.severity,
|
||||
userMessage: pattern.userMessage,
|
||||
technicalMessage: errorText,
|
||||
suggestedAction: pattern.suggestedAction,
|
||||
retryable: pattern.retryable,
|
||||
provider,
|
||||
context,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown error
|
||||
return {
|
||||
type: ErrorType.UNKNOWN,
|
||||
severity: ErrorSeverity.MEDIUM,
|
||||
userMessage: 'An unexpected error occurred.',
|
||||
technicalMessage: errorText,
|
||||
suggestedAction: 'Please try again or contact support if the issue persists.',
|
||||
retryable: true,
|
||||
provider,
|
||||
context,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user-friendly error message
|
||||
*/
|
||||
export function getUserFriendlyErrorMessage(error: unknown, provider?: string): string {
|
||||
const classification = classifyError(error, provider);
|
||||
|
||||
let message = classification.userMessage;
|
||||
|
||||
if (classification.suggestedAction) {
|
||||
message += ` ${classification.suggestedAction}`;
|
||||
}
|
||||
|
||||
// Add provider-specific context if available
|
||||
if (provider) {
|
||||
message = `[${provider.toUpperCase()}] ${message}`;
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is retryable
|
||||
*/
|
||||
export function isRetryableError(error: unknown): boolean {
|
||||
const classification = classifyError(error);
|
||||
return classification.retryable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is authentication-related
|
||||
*/
|
||||
export function isAuthenticationError(error: unknown): boolean {
|
||||
const classification = classifyError(error);
|
||||
return classification.type === ErrorType.AUTHENTICATION;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is billing-related
|
||||
*/
|
||||
export function isBillingError(error: unknown): boolean {
|
||||
const classification = classifyError(error);
|
||||
return classification.type === ErrorType.BILLING;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is rate limit related
|
||||
*/
|
||||
export function isRateLimitError(error: unknown): boolean {
|
||||
const classification = classifyError(error);
|
||||
return classification.type === ErrorType.RATE_LIMIT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error text from various error types
|
||||
*/
|
||||
function getErrorText(error: unknown): string {
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
if (typeof error === 'object' && error !== null) {
|
||||
// Handle structured error objects
|
||||
const errorObj = error as any;
|
||||
|
||||
if (errorObj.message) {
|
||||
return errorObj.message;
|
||||
}
|
||||
|
||||
if (errorObj.error?.message) {
|
||||
return errorObj.error.message;
|
||||
}
|
||||
|
||||
if (errorObj.error) {
|
||||
return typeof errorObj.error === 'string' ? errorObj.error : JSON.stringify(errorObj.error);
|
||||
}
|
||||
|
||||
return JSON.stringify(error);
|
||||
}
|
||||
|
||||
return String(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a standardized error response
|
||||
*/
|
||||
export function createErrorResponse(
|
||||
error: unknown,
|
||||
provider?: string,
|
||||
context?: Record<string, any>
|
||||
): {
|
||||
success: false;
|
||||
error: string;
|
||||
errorType: ErrorType;
|
||||
severity: ErrorSeverity;
|
||||
retryable: boolean;
|
||||
suggestedAction?: string;
|
||||
} {
|
||||
const classification = classifyError(error, provider, context);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: classification.userMessage,
|
||||
errorType: classification.type,
|
||||
severity: classification.severity,
|
||||
retryable: classification.retryable,
|
||||
suggestedAction: classification.suggestedAction,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Log error with full context
|
||||
*/
|
||||
export function logError(
|
||||
error: unknown,
|
||||
provider?: string,
|
||||
operation?: string,
|
||||
additionalContext?: Record<string, any>
|
||||
): void {
|
||||
const classification = classifyError(error, provider, {
|
||||
operation,
|
||||
...additionalContext,
|
||||
});
|
||||
|
||||
logger.error(`Error in ${provider || 'unknown'}${operation ? ` during ${operation}` : ''}`, {
|
||||
type: classification.type,
|
||||
severity: classification.severity,
|
||||
message: classification.userMessage,
|
||||
technicalMessage: classification.technicalMessage,
|
||||
retryable: classification.retryable,
|
||||
suggestedAction: classification.suggestedAction,
|
||||
context: classification.context,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider-specific error handlers
|
||||
*/
|
||||
export const ProviderErrorHandler = {
|
||||
claude: {
|
||||
classify: (error: unknown) => classifyError(error, 'claude'),
|
||||
getUserMessage: (error: unknown) => getUserFriendlyErrorMessage(error, 'claude'),
|
||||
isAuth: (error: unknown) => isAuthenticationError(error),
|
||||
isBilling: (error: unknown) => isBillingError(error),
|
||||
isRateLimit: (error: unknown) => isRateLimitError(error),
|
||||
},
|
||||
|
||||
codex: {
|
||||
classify: (error: unknown) => classifyError(error, 'codex'),
|
||||
getUserMessage: (error: unknown) => getUserFriendlyErrorMessage(error, 'codex'),
|
||||
isAuth: (error: unknown) => isAuthenticationError(error),
|
||||
isBilling: (error: unknown) => isBillingError(error),
|
||||
isRateLimit: (error: unknown) => isRateLimitError(error),
|
||||
},
|
||||
|
||||
cursor: {
|
||||
classify: (error: unknown) => classifyError(error, 'cursor'),
|
||||
getUserMessage: (error: unknown) => getUserFriendlyErrorMessage(error, 'cursor'),
|
||||
isAuth: (error: unknown) => isAuthenticationError(error),
|
||||
isBilling: (error: unknown) => isBillingError(error),
|
||||
isRateLimit: (error: unknown) => isRateLimitError(error),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a retry handler for retryable errors
|
||||
*/
|
||||
export function createRetryHandler(maxRetries: number = 3, baseDelay: number = 1000) {
|
||||
return async function <T>(
|
||||
operation: () => Promise<T>,
|
||||
shouldRetry: (error: unknown) => boolean = isRetryableError
|
||||
): Promise<T> {
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
if (attempt === maxRetries || !shouldRetry(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Exponential backoff with jitter
|
||||
const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 1000;
|
||||
logger.debug(`Retrying operation in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
};
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
/**
|
||||
* Permission enforcement utilities for Cursor provider
|
||||
*/
|
||||
|
||||
import type { CursorCliConfigFile } from '@automaker/types';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('PermissionEnforcer');
|
||||
|
||||
export interface PermissionCheckResult {
|
||||
allowed: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool call is allowed based on permissions
|
||||
*/
|
||||
export function checkToolCallPermission(
|
||||
toolCall: any,
|
||||
permissions: CursorCliConfigFile | null
|
||||
): PermissionCheckResult {
|
||||
if (!permissions || !permissions.permissions) {
|
||||
// If no permissions are configured, allow everything (backward compatibility)
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
const { allow = [], deny = [] } = permissions.permissions;
|
||||
|
||||
// Check shell tool calls
|
||||
if (toolCall.shellToolCall?.args?.command) {
|
||||
const command = toolCall.shellToolCall.args.command;
|
||||
const toolName = `Shell(${extractCommandName(command)})`;
|
||||
|
||||
// Check deny list first (deny takes precedence)
|
||||
for (const denyRule of deny) {
|
||||
if (matchesRule(toolName, denyRule)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Operation blocked by permission rule: ${denyRule}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Then check allow list
|
||||
for (const allowRule of allow) {
|
||||
if (matchesRule(toolName, allowRule)) {
|
||||
return { allowed: true };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Operation not in allow list: ${toolName}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check read tool calls
|
||||
if (toolCall.readToolCall?.args?.path) {
|
||||
const path = toolCall.readToolCall.args.path;
|
||||
const toolName = `Read(${path})`;
|
||||
|
||||
// Check deny list first
|
||||
for (const denyRule of deny) {
|
||||
if (matchesRule(toolName, denyRule)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Read operation blocked by permission rule: ${denyRule}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Then check allow list
|
||||
for (const allowRule of allow) {
|
||||
if (matchesRule(toolName, allowRule)) {
|
||||
return { allowed: true };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Read operation not in allow list: ${toolName}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check write tool calls
|
||||
if (toolCall.writeToolCall?.args?.path) {
|
||||
const path = toolCall.writeToolCall.args.path;
|
||||
const toolName = `Write(${path})`;
|
||||
|
||||
// Check deny list first
|
||||
for (const denyRule of deny) {
|
||||
if (matchesRule(toolName, denyRule)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Write operation blocked by permission rule: ${denyRule}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Then check allow list
|
||||
for (const allowRule of allow) {
|
||||
if (matchesRule(toolName, allowRule)) {
|
||||
return { allowed: true };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Write operation not in allow list: ${toolName}`,
|
||||
};
|
||||
}
|
||||
|
||||
// For other tool types, allow by default for now
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the base command name from a shell command
|
||||
*/
|
||||
function extractCommandName(command: string): string {
|
||||
// Remove leading spaces and get the first word
|
||||
const trimmed = command.trim();
|
||||
const firstWord = trimmed.split(/\s+/)[0];
|
||||
return firstWord || 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool name matches a permission rule
|
||||
*/
|
||||
function matchesRule(toolName: string, rule: string): boolean {
|
||||
// Exact match
|
||||
if (toolName === rule) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Wildcard patterns
|
||||
if (rule.includes('*')) {
|
||||
const regex = new RegExp(rule.replace(/\*/g, '.*'));
|
||||
return regex.test(toolName);
|
||||
}
|
||||
|
||||
// Prefix match for shell commands (e.g., "Shell(git)" matches "Shell(git status)")
|
||||
if (rule.startsWith('Shell(') && toolName.startsWith('Shell(')) {
|
||||
const ruleCommand = rule.slice(6, -1); // Remove "Shell(" and ")"
|
||||
const toolCommand = extractCommandName(toolName.slice(6, -1)); // Remove "Shell(" and ")"
|
||||
return toolCommand.startsWith(ruleCommand);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log permission violations
|
||||
*/
|
||||
export function logPermissionViolation(toolCall: any, reason: string, sessionId?: string): void {
|
||||
const sessionIdStr = sessionId ? ` [${sessionId}]` : '';
|
||||
|
||||
if (toolCall.shellToolCall?.args?.command) {
|
||||
logger.warn(
|
||||
`Permission violation${sessionIdStr}: Shell command blocked - ${toolCall.shellToolCall.args.command} (${reason})`
|
||||
);
|
||||
} else if (toolCall.readToolCall?.args?.path) {
|
||||
logger.warn(
|
||||
`Permission violation${sessionIdStr}: Read operation blocked - ${toolCall.readToolCall.args.path} (${reason})`
|
||||
);
|
||||
} else if (toolCall.writeToolCall?.args?.path) {
|
||||
logger.warn(
|
||||
`Permission violation${sessionIdStr}: Write operation blocked - ${toolCall.writeToolCall.args.path} (${reason})`
|
||||
);
|
||||
} else {
|
||||
logger.warn(`Permission violation${sessionIdStr}: Tool call blocked (${reason})`, { toolCall });
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
*/
|
||||
|
||||
import type { Options } from '@anthropic-ai/claude-agent-sdk';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { resolveModelString } from '@automaker/model-resolver';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
@@ -30,68 +31,6 @@ import {
|
||||
} from '@automaker/types';
|
||||
import { isPathAllowed, PathNotAllowedError, getAllowedRootDirectory } from '@automaker/platform';
|
||||
|
||||
/**
|
||||
* Result of sandbox compatibility check
|
||||
*/
|
||||
export interface SandboxCompatibilityResult {
|
||||
/** Whether sandbox mode can be enabled for this path */
|
||||
enabled: boolean;
|
||||
/** Optional message explaining why sandbox is disabled */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a working directory is compatible with sandbox mode.
|
||||
* Some paths (like cloud storage mounts) may not work with sandboxed execution.
|
||||
*
|
||||
* @param cwd - The working directory to check
|
||||
* @param sandboxRequested - Whether sandbox mode was requested by settings
|
||||
* @returns Object indicating if sandbox can be enabled and why not if disabled
|
||||
*/
|
||||
export function checkSandboxCompatibility(
|
||||
cwd: string,
|
||||
sandboxRequested: boolean
|
||||
): SandboxCompatibilityResult {
|
||||
if (!sandboxRequested) {
|
||||
return { enabled: false };
|
||||
}
|
||||
|
||||
const resolvedCwd = path.resolve(cwd);
|
||||
|
||||
// Check for cloud storage paths that may not be compatible with sandbox
|
||||
const cloudStoragePatterns = [
|
||||
// macOS mounted volumes
|
||||
/^\/Volumes\/GoogleDrive/i,
|
||||
/^\/Volumes\/Dropbox/i,
|
||||
/^\/Volumes\/OneDrive/i,
|
||||
/^\/Volumes\/iCloud/i,
|
||||
// macOS home directory
|
||||
/^\/Users\/[^/]+\/Google Drive/i,
|
||||
/^\/Users\/[^/]+\/Dropbox/i,
|
||||
/^\/Users\/[^/]+\/OneDrive/i,
|
||||
/^\/Users\/[^/]+\/Library\/Mobile Documents/i, // iCloud
|
||||
// Linux home directory
|
||||
/^\/home\/[^/]+\/Google Drive/i,
|
||||
/^\/home\/[^/]+\/Dropbox/i,
|
||||
/^\/home\/[^/]+\/OneDrive/i,
|
||||
// Windows
|
||||
/^C:\\Users\\[^\\]+\\Google Drive/i,
|
||||
/^C:\\Users\\[^\\]+\\Dropbox/i,
|
||||
/^C:\\Users\\[^\\]+\\OneDrive/i,
|
||||
];
|
||||
|
||||
for (const pattern of cloudStoragePatterns) {
|
||||
if (pattern.test(resolvedCwd)) {
|
||||
return {
|
||||
enabled: false,
|
||||
message: `Sandbox disabled: Cloud storage path detected (${resolvedCwd}). Sandbox mode may not work correctly with cloud-synced directories.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { enabled: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a working directory is allowed by ALLOWED_ROOT_DIRECTORY.
|
||||
* This is the centralized security check for ALL AI model invocations.
|
||||
@@ -118,6 +57,139 @@ export function validateWorkingDirectory(cwd: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Known cloud storage path patterns where sandbox mode is incompatible.
|
||||
*
|
||||
* The Claude CLI sandbox feature uses filesystem isolation that conflicts with
|
||||
* cloud storage providers' virtual filesystem implementations. This causes the
|
||||
* Claude process to exit with code 1 when sandbox is enabled for these paths.
|
||||
*
|
||||
* Affected providers (macOS paths):
|
||||
* - Dropbox: ~/Library/CloudStorage/Dropbox-*
|
||||
* - Google Drive: ~/Library/CloudStorage/GoogleDrive-*
|
||||
* - OneDrive: ~/Library/CloudStorage/OneDrive-*
|
||||
* - iCloud Drive: ~/Library/Mobile Documents/
|
||||
* - Box: ~/Library/CloudStorage/Box-*
|
||||
*
|
||||
* Note: This is a known limitation when using cloud storage paths.
|
||||
*/
|
||||
|
||||
/**
|
||||
* macOS-specific cloud storage patterns that appear under ~/Library/
|
||||
* These are specific enough to use with includes() safely.
|
||||
*/
|
||||
const MACOS_CLOUD_STORAGE_PATTERNS = [
|
||||
'/Library/CloudStorage/', // Dropbox, Google Drive, OneDrive, Box on macOS
|
||||
'/Library/Mobile Documents/', // iCloud Drive on macOS
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Generic cloud storage folder names that need to be anchored to the home directory
|
||||
* to avoid false positives (e.g., /home/user/my-project-about-dropbox/).
|
||||
*/
|
||||
const HOME_ANCHORED_CLOUD_FOLDERS = [
|
||||
'Google Drive', // Google Drive on some systems
|
||||
'Dropbox', // Dropbox on Linux/alternative installs
|
||||
'OneDrive', // OneDrive on Linux/alternative installs
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Check if a path is within a cloud storage location.
|
||||
*
|
||||
* Cloud storage providers use virtual filesystem implementations that are
|
||||
* incompatible with the Claude CLI sandbox feature, causing process crashes.
|
||||
*
|
||||
* Uses two detection strategies:
|
||||
* 1. macOS-specific patterns (under ~/Library/) - checked via includes()
|
||||
* 2. Generic folder names - anchored to home directory to avoid false positives
|
||||
*
|
||||
* @param cwd - The working directory path to check
|
||||
* @returns true if the path is in a cloud storage location
|
||||
*/
|
||||
export function isCloudStoragePath(cwd: string): boolean {
|
||||
const resolvedPath = path.resolve(cwd);
|
||||
// Normalize to forward slashes for consistent pattern matching across platforms
|
||||
let normalizedPath = resolvedPath.split(path.sep).join('/');
|
||||
// Remove Windows drive letter if present (e.g., "C:/Users" -> "/Users")
|
||||
// This ensures Unix paths in tests work the same on Windows
|
||||
normalizedPath = normalizedPath.replace(/^[A-Za-z]:/, '');
|
||||
|
||||
// Check macOS-specific patterns (these are specific enough to use includes)
|
||||
if (MACOS_CLOUD_STORAGE_PATTERNS.some((pattern) => normalizedPath.includes(pattern))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check home-anchored patterns to avoid false positives
|
||||
// e.g., /home/user/my-project-about-dropbox/ should NOT match
|
||||
const home = os.homedir();
|
||||
for (const folder of HOME_ANCHORED_CLOUD_FOLDERS) {
|
||||
const cloudPath = path.join(home, folder);
|
||||
let normalizedCloudPath = cloudPath.split(path.sep).join('/');
|
||||
// Remove Windows drive letter if present
|
||||
normalizedCloudPath = normalizedCloudPath.replace(/^[A-Za-z]:/, '');
|
||||
// Check if resolved path starts with the cloud storage path followed by a separator
|
||||
// This ensures we match ~/Dropbox/project but not ~/Dropbox-archive or ~/my-dropbox-tool
|
||||
if (
|
||||
normalizedPath === normalizedCloudPath ||
|
||||
normalizedPath.startsWith(normalizedCloudPath + '/')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of sandbox compatibility check
|
||||
*/
|
||||
export interface SandboxCheckResult {
|
||||
/** Whether sandbox should be enabled */
|
||||
enabled: boolean;
|
||||
/** If disabled, the reason why */
|
||||
disabledReason?: 'cloud_storage' | 'user_setting';
|
||||
/** Human-readable message for logging/UI */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if sandbox mode should be enabled for a given configuration.
|
||||
*
|
||||
* Sandbox mode is automatically disabled for cloud storage paths because the
|
||||
* Claude CLI sandbox feature is incompatible with virtual filesystem
|
||||
* implementations used by cloud storage providers (Dropbox, Google Drive, etc.).
|
||||
*
|
||||
* @param cwd - The working directory
|
||||
* @param enableSandboxMode - User's sandbox mode setting
|
||||
* @returns SandboxCheckResult with enabled status and reason if disabled
|
||||
*/
|
||||
export function checkSandboxCompatibility(
|
||||
cwd: string,
|
||||
enableSandboxMode?: boolean
|
||||
): SandboxCheckResult {
|
||||
// User has explicitly disabled sandbox mode
|
||||
if (enableSandboxMode === false) {
|
||||
return {
|
||||
enabled: false,
|
||||
disabledReason: 'user_setting',
|
||||
};
|
||||
}
|
||||
|
||||
// Check for cloud storage incompatibility (applies when enabled or undefined)
|
||||
if (isCloudStoragePath(cwd)) {
|
||||
return {
|
||||
enabled: false,
|
||||
disabledReason: 'cloud_storage',
|
||||
message: `Sandbox mode auto-disabled: Project is in a cloud storage location (${cwd}). The Claude CLI sandbox feature is incompatible with cloud storage filesystems. To use sandbox mode, move your project to a local directory.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Sandbox is compatible and enabled (true or undefined defaults to enabled)
|
||||
return {
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool presets for different use cases
|
||||
*/
|
||||
@@ -200,31 +272,55 @@ export function getModelForUseCase(
|
||||
|
||||
/**
|
||||
* Base options that apply to all SDK calls
|
||||
* AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation
|
||||
*/
|
||||
function getBaseOptions(): Partial<Options> {
|
||||
return {
|
||||
permissionMode: 'bypassPermissions',
|
||||
allowDangerouslySkipPermissions: true,
|
||||
permissionMode: 'acceptEdits',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP options result
|
||||
* MCP permission options result
|
||||
*/
|
||||
interface McpOptions {
|
||||
interface McpPermissionOptions {
|
||||
/** Whether tools should be restricted to a preset */
|
||||
shouldRestrictTools: boolean;
|
||||
/** Options to spread when MCP bypass is enabled */
|
||||
bypassOptions: Partial<Options>;
|
||||
/** Options to spread for MCP servers */
|
||||
mcpServerOptions: Partial<Options>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build MCP-related options based on configuration.
|
||||
* Centralizes the logic for determining permission modes and tool restrictions
|
||||
* when MCP servers are configured.
|
||||
*
|
||||
* @param config - The SDK options config
|
||||
* @returns Object with MCP server settings to spread into final options
|
||||
* @returns Object with MCP permission settings to spread into final options
|
||||
*/
|
||||
function buildMcpOptions(config: CreateSdkOptionsConfig): McpOptions {
|
||||
function buildMcpOptions(config: CreateSdkOptionsConfig): McpPermissionOptions {
|
||||
const hasMcpServers = config.mcpServers && Object.keys(config.mcpServers).length > 0;
|
||||
// Default to true for autonomous workflow. Security is enforced when adding servers
|
||||
// via the security warning dialog that explains the risks.
|
||||
const mcpAutoApprove = config.mcpAutoApproveTools ?? true;
|
||||
const mcpUnrestricted = config.mcpUnrestrictedTools ?? true;
|
||||
|
||||
// Determine if we should bypass permissions based on settings
|
||||
const shouldBypassPermissions = hasMcpServers && mcpAutoApprove;
|
||||
// Determine if we should restrict tools (only when no MCP or unrestricted is disabled)
|
||||
const shouldRestrictTools = !hasMcpServers || !mcpUnrestricted;
|
||||
|
||||
return {
|
||||
shouldRestrictTools,
|
||||
// Only include bypass options when MCP is configured and auto-approve is enabled
|
||||
bypassOptions: shouldBypassPermissions
|
||||
? {
|
||||
permissionMode: 'bypassPermissions' as const,
|
||||
// Required flag when using bypassPermissions mode
|
||||
allowDangerouslySkipPermissions: true,
|
||||
}
|
||||
: {},
|
||||
// Include MCP servers if configured
|
||||
mcpServerOptions: config.mcpServers ? { mcpServers: config.mcpServers } : {},
|
||||
};
|
||||
@@ -326,9 +422,18 @@ export interface CreateSdkOptionsConfig {
|
||||
/** Enable auto-loading of CLAUDE.md files via SDK's settingSources */
|
||||
autoLoadClaudeMd?: boolean;
|
||||
|
||||
/** Enable sandbox mode for bash command isolation */
|
||||
enableSandboxMode?: boolean;
|
||||
|
||||
/** MCP servers to make available to the agent */
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
|
||||
/** Auto-approve MCP tool calls without permission prompts */
|
||||
mcpAutoApproveTools?: boolean;
|
||||
|
||||
/** Allow unrestricted tools when MCP servers are enabled */
|
||||
mcpUnrestrictedTools?: boolean;
|
||||
|
||||
/** Extended thinking level for Claude models */
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
}
|
||||
@@ -449,6 +554,7 @@ export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Option
|
||||
* - Full tool access for code modification
|
||||
* - Standard turns for interactive sessions
|
||||
* - Model priority: explicit model > session model > chat default
|
||||
* - Sandbox mode controlled by enableSandboxMode setting (auto-disabled for cloud storage)
|
||||
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
|
||||
*/
|
||||
export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
||||
@@ -467,12 +573,24 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
||||
// Build thinking options
|
||||
const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
|
||||
|
||||
// Check sandbox compatibility (auto-disables for cloud storage paths)
|
||||
const sandboxCheck = checkSandboxCompatibility(config.cwd, config.enableSandboxMode);
|
||||
|
||||
return {
|
||||
...getBaseOptions(),
|
||||
model: getModelForUseCase('chat', effectiveModel),
|
||||
maxTurns: MAX_TURNS.standard,
|
||||
cwd: config.cwd,
|
||||
allowedTools: [...TOOL_PRESETS.chat],
|
||||
// Only restrict tools if no MCP servers configured or unrestricted is disabled
|
||||
...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.chat] }),
|
||||
// Apply MCP bypass options if configured
|
||||
...mcpOptions.bypassOptions,
|
||||
...(sandboxCheck.enabled && {
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
autoAllowBashIfSandboxed: true,
|
||||
},
|
||||
}),
|
||||
...claudeMdOptions,
|
||||
...thinkingOptions,
|
||||
...(config.abortController && { abortController: config.abortController }),
|
||||
@@ -487,6 +605,7 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
||||
* - Full tool access for code modification and implementation
|
||||
* - Extended turns for thorough feature implementation
|
||||
* - Uses default model (can be overridden)
|
||||
* - Sandbox mode controlled by enableSandboxMode setting (auto-disabled for cloud storage)
|
||||
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
|
||||
*/
|
||||
export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
|
||||
@@ -502,12 +621,24 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
|
||||
// Build thinking options
|
||||
const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
|
||||
|
||||
// Check sandbox compatibility (auto-disables for cloud storage paths)
|
||||
const sandboxCheck = checkSandboxCompatibility(config.cwd, config.enableSandboxMode);
|
||||
|
||||
return {
|
||||
...getBaseOptions(),
|
||||
model: getModelForUseCase('auto', config.model),
|
||||
maxTurns: MAX_TURNS.maximum,
|
||||
cwd: config.cwd,
|
||||
allowedTools: [...TOOL_PRESETS.fullAccess],
|
||||
// Only restrict tools if no MCP servers configured or unrestricted is disabled
|
||||
...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.fullAccess] }),
|
||||
// Apply MCP bypass options if configured
|
||||
...mcpOptions.bypassOptions,
|
||||
...(sandboxCheck.enabled && {
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
autoAllowBashIfSandboxed: true,
|
||||
},
|
||||
}),
|
||||
...claudeMdOptions,
|
||||
...thinkingOptions,
|
||||
...(config.abortController && { abortController: config.abortController }),
|
||||
@@ -525,6 +656,7 @@ export function createCustomOptions(
|
||||
config: CreateSdkOptionsConfig & {
|
||||
maxTurns?: number;
|
||||
allowedTools?: readonly string[];
|
||||
sandbox?: { enabled: boolean; autoAllowBashIfSandboxed?: boolean };
|
||||
}
|
||||
): Options {
|
||||
// Validate working directory before creating options
|
||||
@@ -539,17 +671,22 @@ export function createCustomOptions(
|
||||
// Build thinking options
|
||||
const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
|
||||
|
||||
// For custom options: use explicit allowedTools if provided, otherwise default to readOnly
|
||||
// For custom options: use explicit allowedTools if provided, otherwise use preset based on MCP settings
|
||||
const effectiveAllowedTools = config.allowedTools
|
||||
? [...config.allowedTools]
|
||||
: [...TOOL_PRESETS.readOnly];
|
||||
: mcpOptions.shouldRestrictTools
|
||||
? [...TOOL_PRESETS.readOnly]
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...getBaseOptions(),
|
||||
model: getModelForUseCase('default', config.model),
|
||||
maxTurns: config.maxTurns ?? MAX_TURNS.maximum,
|
||||
cwd: config.cwd,
|
||||
allowedTools: effectiveAllowedTools,
|
||||
...(effectiveAllowedTools && { allowedTools: effectiveAllowedTools }),
|
||||
...(config.sandbox && { sandbox: config.sandbox }),
|
||||
// Apply MCP bypass options if configured
|
||||
...mcpOptions.bypassOptions,
|
||||
...claudeMdOptions,
|
||||
...thinkingOptions,
|
||||
...(config.abortController && { abortController: config.abortController }),
|
||||
|
||||
@@ -55,6 +55,34 @@ export async function getAutoLoadClaudeMdSetting(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the enableSandboxMode setting from global settings.
|
||||
* Returns false if settings service is not available.
|
||||
*
|
||||
* @param settingsService - Optional settings service instance
|
||||
* @param logPrefix - Prefix for log messages (e.g., '[AgentService]')
|
||||
* @returns Promise resolving to the enableSandboxMode setting value
|
||||
*/
|
||||
export async function getEnableSandboxModeSetting(
|
||||
settingsService?: SettingsService | null,
|
||||
logPrefix = '[SettingsHelper]'
|
||||
): Promise<boolean> {
|
||||
if (!settingsService) {
|
||||
logger.info(`${logPrefix} SettingsService not available, sandbox mode disabled`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const globalSettings = await settingsService.getGlobalSettings();
|
||||
const result = globalSettings.enableSandboxMode ?? false;
|
||||
logger.info(`${logPrefix} enableSandboxMode from global settings: ${result}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Failed to load enableSandboxMode setting:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out CLAUDE.md from context files when autoLoadClaudeMd is enabled
|
||||
* and rebuilds the formatted prompt without it.
|
||||
@@ -241,83 +269,3 @@ export async function getPromptCustomization(
|
||||
enhancement: mergeEnhancementPrompts(customization.enhancement),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Skills configuration from settings.
|
||||
* Returns configuration for enabling skills and which sources to load from.
|
||||
*
|
||||
* @param settingsService - Settings service instance
|
||||
* @returns Skills configuration with enabled state, sources, and tool inclusion flag
|
||||
*/
|
||||
export async function getSkillsConfiguration(settingsService: SettingsService): Promise<{
|
||||
enabled: boolean;
|
||||
sources: Array<'user' | 'project'>;
|
||||
shouldIncludeInTools: boolean;
|
||||
}> {
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
const enabled = settings.enableSkills ?? true; // Default enabled
|
||||
const sources = settings.skillsSources ?? ['user', 'project']; // Default both sources
|
||||
|
||||
return {
|
||||
enabled,
|
||||
sources,
|
||||
shouldIncludeInTools: enabled && sources.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Subagents configuration from settings.
|
||||
* Returns configuration for enabling subagents and which sources to load from.
|
||||
*
|
||||
* @param settingsService - Settings service instance
|
||||
* @returns Subagents configuration with enabled state, sources, and tool inclusion flag
|
||||
*/
|
||||
export async function getSubagentsConfiguration(settingsService: SettingsService): Promise<{
|
||||
enabled: boolean;
|
||||
sources: Array<'user' | 'project'>;
|
||||
shouldIncludeInTools: boolean;
|
||||
}> {
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
const enabled = settings.enableSubagents ?? true; // Default enabled
|
||||
const sources = settings.subagentsSources ?? ['user', 'project']; // Default both sources
|
||||
|
||||
return {
|
||||
enabled,
|
||||
sources,
|
||||
shouldIncludeInTools: enabled && sources.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom subagents from settings, merging global and project-level definitions.
|
||||
* Project-level subagents take precedence over global ones with the same name.
|
||||
*
|
||||
* @param settingsService - Settings service instance
|
||||
* @param projectPath - Path to the project for loading project-specific subagents
|
||||
* @returns Record of agent names to definitions, or undefined if none configured
|
||||
*/
|
||||
export async function getCustomSubagents(
|
||||
settingsService: SettingsService,
|
||||
projectPath?: string
|
||||
): Promise<Record<string, import('@automaker/types').AgentDefinition> | undefined> {
|
||||
// Get global subagents
|
||||
const globalSettings = await settingsService.getGlobalSettings();
|
||||
const globalSubagents = globalSettings.customSubagents || {};
|
||||
|
||||
// If no project path, return only global subagents
|
||||
if (!projectPath) {
|
||||
return Object.keys(globalSubagents).length > 0 ? globalSubagents : undefined;
|
||||
}
|
||||
|
||||
// Get project-specific subagents
|
||||
const projectSettings = await settingsService.getProjectSettings(projectPath);
|
||||
const projectSubagents = projectSettings.customSubagents || {};
|
||||
|
||||
// Merge: project-level takes precedence
|
||||
const merged = {
|
||||
...globalSubagents,
|
||||
...projectSubagents,
|
||||
};
|
||||
|
||||
return Object.keys(merged).length > 0 ? merged : undefined;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { BaseProvider } from './base-provider.js';
|
||||
import { classifyError, getUserFriendlyErrorMessage, createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('ClaudeProvider');
|
||||
import { getThinkingTokenBudget, validateBareModelId } from '@automaker/types';
|
||||
import { getThinkingTokenBudget } from '@automaker/types';
|
||||
import type {
|
||||
ExecuteOptions,
|
||||
ProviderMessage,
|
||||
@@ -53,10 +53,6 @@ export class ClaudeProvider extends BaseProvider {
|
||||
* Execute a query using Claude Agent SDK
|
||||
*/
|
||||
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
||||
// Validate that model doesn't have a provider prefix
|
||||
// AgentService should strip prefixes before passing to providers
|
||||
validateBareModelId(options.model, 'ClaudeProvider');
|
||||
|
||||
const {
|
||||
prompt,
|
||||
model,
|
||||
@@ -74,6 +70,14 @@ export class ClaudeProvider extends BaseProvider {
|
||||
const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel);
|
||||
|
||||
// Build Claude SDK options
|
||||
// AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation
|
||||
const hasMcpServers = options.mcpServers && Object.keys(options.mcpServers).length > 0;
|
||||
const defaultTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'];
|
||||
|
||||
// AUTONOMOUS MODE: Always bypass permissions and allow unrestricted tools
|
||||
// Only restrict tools when no MCP servers are configured
|
||||
const shouldRestrictTools = !hasMcpServers;
|
||||
|
||||
const sdkOptions: Options = {
|
||||
model,
|
||||
systemPrompt,
|
||||
@@ -81,9 +85,10 @@ export class ClaudeProvider extends BaseProvider {
|
||||
cwd,
|
||||
// Pass only explicitly allowed environment variables to SDK
|
||||
env: buildEnv(),
|
||||
// Pass through allowedTools if provided by caller (decided by sdk-options.ts)
|
||||
...(allowedTools && { allowedTools }),
|
||||
// AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation
|
||||
// Only restrict tools if explicitly set OR (no MCP / unrestricted disabled)
|
||||
...(allowedTools && shouldRestrictTools && { allowedTools }),
|
||||
...(!allowedTools && shouldRestrictTools && { allowedTools: defaultTools }),
|
||||
// AUTONOMOUS MODE: Always bypass permissions and allow dangerous operations
|
||||
permissionMode: 'bypassPermissions',
|
||||
allowDangerouslySkipPermissions: true,
|
||||
abortController,
|
||||
@@ -93,12 +98,12 @@ export class ClaudeProvider extends BaseProvider {
|
||||
: {}),
|
||||
// Forward settingSources for CLAUDE.md file loading
|
||||
...(options.settingSources && { settingSources: options.settingSources }),
|
||||
// Forward sandbox configuration
|
||||
...(options.sandbox && { sandbox: options.sandbox }),
|
||||
// Forward MCP servers configuration
|
||||
...(options.mcpServers && { mcpServers: options.mcpServers }),
|
||||
// Extended thinking configuration
|
||||
...(maxThinkingTokens && { maxThinkingTokens }),
|
||||
// Subagents configuration for specialized task delegation
|
||||
...(options.agents && { agents: options.agents }),
|
||||
};
|
||||
|
||||
// Build prompt payload
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* Codex Config Manager - Writes MCP server configuration for Codex CLI
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import type { McpServerConfig } from '@automaker/types';
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
|
||||
const CODEX_CONFIG_DIR = '.codex';
|
||||
const CODEX_CONFIG_FILENAME = 'config.toml';
|
||||
const CODEX_MCP_SECTION = 'mcp_servers';
|
||||
|
||||
function formatTomlString(value: string): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function formatTomlArray(values: string[]): string {
|
||||
const formatted = values.map((value) => formatTomlString(value)).join(', ');
|
||||
return `[${formatted}]`;
|
||||
}
|
||||
|
||||
function formatTomlInlineTable(values: Record<string, string>): string {
|
||||
const entries = Object.entries(values).map(
|
||||
([key, value]) => `${key} = ${formatTomlString(value)}`
|
||||
);
|
||||
return `{ ${entries.join(', ')} }`;
|
||||
}
|
||||
|
||||
function formatTomlKey(key: string): string {
|
||||
return `"${key.replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
function buildServerBlock(name: string, server: McpServerConfig): string[] {
|
||||
const lines: string[] = [];
|
||||
const section = `${CODEX_MCP_SECTION}.${formatTomlKey(name)}`;
|
||||
lines.push(`[${section}]`);
|
||||
|
||||
if (server.type) {
|
||||
lines.push(`type = ${formatTomlString(server.type)}`);
|
||||
}
|
||||
|
||||
if ('command' in server && server.command) {
|
||||
lines.push(`command = ${formatTomlString(server.command)}`);
|
||||
}
|
||||
|
||||
if ('args' in server && server.args && server.args.length > 0) {
|
||||
lines.push(`args = ${formatTomlArray(server.args)}`);
|
||||
}
|
||||
|
||||
if ('env' in server && server.env && Object.keys(server.env).length > 0) {
|
||||
lines.push(`env = ${formatTomlInlineTable(server.env)}`);
|
||||
}
|
||||
|
||||
if ('url' in server && server.url) {
|
||||
lines.push(`url = ${formatTomlString(server.url)}`);
|
||||
}
|
||||
|
||||
if ('headers' in server && server.headers && Object.keys(server.headers).length > 0) {
|
||||
lines.push(`headers = ${formatTomlInlineTable(server.headers)}`);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
export class CodexConfigManager {
|
||||
async configureMcpServers(
|
||||
cwd: string,
|
||||
mcpServers: Record<string, McpServerConfig>
|
||||
): Promise<void> {
|
||||
const configDir = path.join(cwd, CODEX_CONFIG_DIR);
|
||||
const configPath = path.join(configDir, CODEX_CONFIG_FILENAME);
|
||||
|
||||
await secureFs.mkdir(configDir, { recursive: true });
|
||||
|
||||
const blocks: string[] = [];
|
||||
for (const [name, server] of Object.entries(mcpServers)) {
|
||||
blocks.push(...buildServerBlock(name, server), '');
|
||||
}
|
||||
|
||||
const content = blocks.join('\n').trim();
|
||||
if (content) {
|
||||
await secureFs.writeFile(configPath, content + '\n', 'utf-8');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
/**
|
||||
* Codex Model Definitions
|
||||
*
|
||||
* Official Codex CLI models as documented at https://developers.openai.com/codex/models/
|
||||
*/
|
||||
|
||||
import { CODEX_MODEL_MAP } from '@automaker/types';
|
||||
import type { ModelDefinition } from './types.js';
|
||||
|
||||
const CONTEXT_WINDOW_256K = 256000;
|
||||
const CONTEXT_WINDOW_128K = 128000;
|
||||
const MAX_OUTPUT_32K = 32000;
|
||||
const MAX_OUTPUT_16K = 16000;
|
||||
|
||||
/**
|
||||
* All available Codex models with their specifications
|
||||
* Based on https://developers.openai.com/codex/models/
|
||||
*/
|
||||
export const CODEX_MODELS: ModelDefinition[] = [
|
||||
// ========== Recommended Codex Models ==========
|
||||
{
|
||||
id: CODEX_MODEL_MAP.gpt52Codex,
|
||||
name: 'GPT-5.2-Codex',
|
||||
modelString: CODEX_MODEL_MAP.gpt52Codex,
|
||||
provider: 'openai',
|
||||
description:
|
||||
'Most advanced agentic coding model for complex software engineering (default for ChatGPT users).',
|
||||
contextWindow: CONTEXT_WINDOW_256K,
|
||||
maxOutputTokens: MAX_OUTPUT_32K,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
tier: 'premium' as const,
|
||||
default: true,
|
||||
hasReasoning: true,
|
||||
},
|
||||
{
|
||||
id: CODEX_MODEL_MAP.gpt51CodexMax,
|
||||
name: 'GPT-5.1-Codex-Max',
|
||||
modelString: CODEX_MODEL_MAP.gpt51CodexMax,
|
||||
provider: 'openai',
|
||||
description: 'Optimized for long-horizon, agentic coding tasks in Codex.',
|
||||
contextWindow: CONTEXT_WINDOW_256K,
|
||||
maxOutputTokens: MAX_OUTPUT_32K,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
tier: 'premium' as const,
|
||||
hasReasoning: true,
|
||||
},
|
||||
{
|
||||
id: CODEX_MODEL_MAP.gpt51CodexMini,
|
||||
name: 'GPT-5.1-Codex-Mini',
|
||||
modelString: CODEX_MODEL_MAP.gpt51CodexMini,
|
||||
provider: 'openai',
|
||||
description: 'Smaller, more cost-effective version for faster workflows.',
|
||||
contextWindow: CONTEXT_WINDOW_128K,
|
||||
maxOutputTokens: MAX_OUTPUT_16K,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
tier: 'basic' as const,
|
||||
hasReasoning: false,
|
||||
},
|
||||
|
||||
// ========== General-Purpose GPT Models ==========
|
||||
{
|
||||
id: CODEX_MODEL_MAP.gpt52,
|
||||
name: 'GPT-5.2',
|
||||
modelString: CODEX_MODEL_MAP.gpt52,
|
||||
provider: 'openai',
|
||||
description: 'Best general agentic model for tasks across industries and domains.',
|
||||
contextWindow: CONTEXT_WINDOW_256K,
|
||||
maxOutputTokens: MAX_OUTPUT_32K,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
tier: 'standard' as const,
|
||||
hasReasoning: true,
|
||||
},
|
||||
{
|
||||
id: CODEX_MODEL_MAP.gpt51,
|
||||
name: 'GPT-5.1',
|
||||
modelString: CODEX_MODEL_MAP.gpt51,
|
||||
provider: 'openai',
|
||||
description: 'Great for coding and agentic tasks across domains.',
|
||||
contextWindow: CONTEXT_WINDOW_256K,
|
||||
maxOutputTokens: MAX_OUTPUT_32K,
|
||||
supportsVision: true,
|
||||
supportsTools: true,
|
||||
tier: 'standard' as const,
|
||||
hasReasoning: true,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Get model definition by ID
|
||||
*/
|
||||
export function getCodexModelById(modelId: string): ModelDefinition | undefined {
|
||||
return CODEX_MODELS.find((m) => m.id === modelId || m.modelString === modelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all models that support reasoning
|
||||
*/
|
||||
export function getReasoningModels(): ModelDefinition[] {
|
||||
return CODEX_MODELS.filter((m) => m.hasReasoning);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get models by tier
|
||||
*/
|
||||
export function getModelsByTier(tier: 'premium' | 'standard' | 'basic'): ModelDefinition[] {
|
||||
return CODEX_MODELS.filter((m) => m.tier === tier);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,173 +0,0 @@
|
||||
/**
|
||||
* Codex SDK client - Executes Codex queries via official @openai/codex-sdk
|
||||
*
|
||||
* Used for programmatic control of Codex from within the application.
|
||||
* Provides cleaner integration than spawning CLI processes.
|
||||
*/
|
||||
|
||||
import { Codex } from '@openai/codex-sdk';
|
||||
import { formatHistoryAsText, classifyError, getUserFriendlyErrorMessage } from '@automaker/utils';
|
||||
import { supportsReasoningEffort } from '@automaker/types';
|
||||
import type { ExecuteOptions, ProviderMessage } from './types.js';
|
||||
|
||||
const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY';
|
||||
const SDK_HISTORY_HEADER = 'Current request:\n';
|
||||
const DEFAULT_RESPONSE_TEXT = '';
|
||||
const SDK_ERROR_DETAILS_LABEL = 'Details:';
|
||||
|
||||
type PromptBlock = {
|
||||
type: string;
|
||||
text?: string;
|
||||
source?: {
|
||||
type?: string;
|
||||
media_type?: string;
|
||||
data?: string;
|
||||
};
|
||||
};
|
||||
|
||||
function resolveApiKey(): string {
|
||||
const apiKey = process.env[OPENAI_API_KEY_ENV];
|
||||
if (!apiKey) {
|
||||
throw new Error('OPENAI_API_KEY is not set.');
|
||||
}
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
function normalizePromptBlocks(prompt: ExecuteOptions['prompt']): PromptBlock[] {
|
||||
if (Array.isArray(prompt)) {
|
||||
return prompt as PromptBlock[];
|
||||
}
|
||||
return [{ type: 'text', text: prompt }];
|
||||
}
|
||||
|
||||
function buildPromptText(options: ExecuteOptions, systemPrompt: string | null): string {
|
||||
const historyText =
|
||||
options.conversationHistory && options.conversationHistory.length > 0
|
||||
? formatHistoryAsText(options.conversationHistory)
|
||||
: '';
|
||||
|
||||
const promptBlocks = normalizePromptBlocks(options.prompt);
|
||||
const promptTexts: string[] = [];
|
||||
|
||||
for (const block of promptBlocks) {
|
||||
if (block.type === 'text' && typeof block.text === 'string' && block.text.trim()) {
|
||||
promptTexts.push(block.text);
|
||||
}
|
||||
}
|
||||
|
||||
const promptContent = promptTexts.join('\n\n');
|
||||
if (!promptContent.trim()) {
|
||||
throw new Error('Codex SDK prompt is empty.');
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
if (systemPrompt) {
|
||||
parts.push(`System: ${systemPrompt}`);
|
||||
}
|
||||
if (historyText) {
|
||||
parts.push(historyText);
|
||||
}
|
||||
parts.push(`${SDK_HISTORY_HEADER}${promptContent}`);
|
||||
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
function buildSdkErrorMessage(rawMessage: string, userMessage: string): string {
|
||||
if (!rawMessage) {
|
||||
return userMessage;
|
||||
}
|
||||
if (!userMessage || rawMessage === userMessage) {
|
||||
return rawMessage;
|
||||
}
|
||||
return `${userMessage}\n\n${SDK_ERROR_DETAILS_LABEL} ${rawMessage}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a query using the official Codex SDK
|
||||
*
|
||||
* The SDK provides a cleaner interface than spawning CLI processes:
|
||||
* - Handles authentication automatically
|
||||
* - Provides TypeScript types
|
||||
* - Supports thread management and resumption
|
||||
* - Better error handling
|
||||
*/
|
||||
export async function* executeCodexSdkQuery(
|
||||
options: ExecuteOptions,
|
||||
systemPrompt: string | null
|
||||
): AsyncGenerator<ProviderMessage> {
|
||||
try {
|
||||
const apiKey = resolveApiKey();
|
||||
const codex = new Codex({ apiKey });
|
||||
|
||||
// Resume existing thread or start new one
|
||||
let thread;
|
||||
if (options.sdkSessionId) {
|
||||
try {
|
||||
thread = codex.resumeThread(options.sdkSessionId);
|
||||
} catch {
|
||||
// If resume fails, start a new thread
|
||||
thread = codex.startThread();
|
||||
}
|
||||
} else {
|
||||
thread = codex.startThread();
|
||||
}
|
||||
|
||||
const promptText = buildPromptText(options, systemPrompt);
|
||||
|
||||
// Build run options with reasoning effort if supported
|
||||
const runOptions: {
|
||||
signal?: AbortSignal;
|
||||
reasoning?: { effort: string };
|
||||
} = {
|
||||
signal: options.abortController?.signal,
|
||||
};
|
||||
|
||||
// Add reasoning effort if model supports it and reasoningEffort is specified
|
||||
if (
|
||||
options.reasoningEffort &&
|
||||
supportsReasoningEffort(options.model) &&
|
||||
options.reasoningEffort !== 'none'
|
||||
) {
|
||||
runOptions.reasoning = { effort: options.reasoningEffort };
|
||||
}
|
||||
|
||||
// Run the query
|
||||
const result = await thread.run(promptText, runOptions);
|
||||
|
||||
// Extract response text (from finalResponse property)
|
||||
const outputText = result.finalResponse ?? DEFAULT_RESPONSE_TEXT;
|
||||
|
||||
// Get thread ID (may be null if not populated yet)
|
||||
const threadId = thread.id ?? undefined;
|
||||
|
||||
// Yield assistant message
|
||||
yield {
|
||||
type: 'assistant',
|
||||
session_id: threadId,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: outputText }],
|
||||
},
|
||||
};
|
||||
|
||||
// Yield result
|
||||
yield {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
session_id: threadId,
|
||||
result: outputText,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorInfo = classifyError(error);
|
||||
const userMessage = getUserFriendlyErrorMessage(error);
|
||||
const combinedMessage = buildSdkErrorMessage(errorInfo.message, userMessage);
|
||||
console.error('[CodexSDK] executeQuery() error during execution:', {
|
||||
type: errorInfo.type,
|
||||
message: errorInfo.message,
|
||||
isRateLimit: errorInfo.isRateLimit,
|
||||
retryAfter: errorInfo.retryAfter,
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
yield { type: 'error', error: combinedMessage };
|
||||
}
|
||||
}
|
||||
@@ -1,436 +0,0 @@
|
||||
export type CodexToolResolution = {
|
||||
name: string;
|
||||
input: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CodexTodoItem = {
|
||||
content: string;
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
activeForm?: string;
|
||||
};
|
||||
|
||||
const TOOL_NAME_BASH = 'Bash';
|
||||
const TOOL_NAME_READ = 'Read';
|
||||
const TOOL_NAME_EDIT = 'Edit';
|
||||
const TOOL_NAME_WRITE = 'Write';
|
||||
const TOOL_NAME_GREP = 'Grep';
|
||||
const TOOL_NAME_GLOB = 'Glob';
|
||||
const TOOL_NAME_TODO = 'TodoWrite';
|
||||
const TOOL_NAME_DELETE = 'Delete';
|
||||
const TOOL_NAME_LS = 'Ls';
|
||||
|
||||
const INPUT_KEY_COMMAND = 'command';
|
||||
const INPUT_KEY_FILE_PATH = 'file_path';
|
||||
const INPUT_KEY_PATTERN = 'pattern';
|
||||
|
||||
const SHELL_WRAPPER_PATTERNS = [
|
||||
/^\/bin\/bash\s+-lc\s+["']([\s\S]+)["']$/,
|
||||
/^bash\s+-lc\s+["']([\s\S]+)["']$/,
|
||||
/^\/bin\/sh\s+-lc\s+["']([\s\S]+)["']$/,
|
||||
/^sh\s+-lc\s+["']([\s\S]+)["']$/,
|
||||
/^cmd\.exe\s+\/c\s+["']?([\s\S]+)["']?$/i,
|
||||
/^powershell(?:\.exe)?\s+-Command\s+["']?([\s\S]+)["']?$/i,
|
||||
/^pwsh(?:\.exe)?\s+-Command\s+["']?([\s\S]+)["']?$/i,
|
||||
] as const;
|
||||
|
||||
const COMMAND_SEPARATOR_PATTERN = /\s*(?:&&|\|\||;)\s*/;
|
||||
const SEGMENT_SKIP_PREFIXES = ['cd ', 'export ', 'set ', 'pushd '] as const;
|
||||
const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'command']);
|
||||
const READ_COMMANDS = new Set(['cat', 'sed', 'head', 'tail', 'less', 'more', 'bat', 'stat', 'wc']);
|
||||
const SEARCH_COMMANDS = new Set(['rg', 'grep', 'ag', 'ack']);
|
||||
const GLOB_COMMANDS = new Set(['ls', 'find', 'fd', 'tree']);
|
||||
const DELETE_COMMANDS = new Set(['rm', 'del', 'erase', 'remove', 'unlink']);
|
||||
const LIST_COMMANDS = new Set(['ls', 'dir', 'll', 'la']);
|
||||
const WRITE_COMMANDS = new Set(['tee', 'touch', 'mkdir']);
|
||||
const APPLY_PATCH_COMMAND = 'apply_patch';
|
||||
const APPLY_PATCH_PATTERN = /\bapply_patch\b/;
|
||||
const REDIRECTION_TARGET_PATTERN = /(?:>>|>)\s*([^\s]+)/;
|
||||
const SED_IN_PLACE_FLAGS = new Set(['-i', '--in-place']);
|
||||
const PERL_IN_PLACE_FLAG = /-.*i/;
|
||||
const SEARCH_PATTERN_FLAGS = new Set(['-e', '--regexp']);
|
||||
const SEARCH_VALUE_FLAGS = new Set([
|
||||
'-g',
|
||||
'--glob',
|
||||
'--iglob',
|
||||
'--type',
|
||||
'--type-add',
|
||||
'--type-clear',
|
||||
'--encoding',
|
||||
]);
|
||||
const SEARCH_FILE_LIST_FLAGS = new Set(['--files']);
|
||||
const TODO_LINE_PATTERN = /^[-*]\s*(?:\[(?<status>[ x~])\]\s*)?(?<content>.+)$/;
|
||||
const TODO_STATUS_COMPLETED = 'completed';
|
||||
const TODO_STATUS_IN_PROGRESS = 'in_progress';
|
||||
const TODO_STATUS_PENDING = 'pending';
|
||||
const PATCH_FILE_MARKERS = [
|
||||
'*** Update File: ',
|
||||
'*** Add File: ',
|
||||
'*** Delete File: ',
|
||||
'*** Move to: ',
|
||||
] as const;
|
||||
|
||||
function stripShellWrapper(command: string): string {
|
||||
const trimmed = command.trim();
|
||||
for (const pattern of SHELL_WRAPPER_PATTERNS) {
|
||||
const match = trimmed.match(pattern);
|
||||
if (match && match[1]) {
|
||||
return unescapeCommand(match[1].trim());
|
||||
}
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function unescapeCommand(command: string): string {
|
||||
return command.replace(/\\(["'])/g, '$1');
|
||||
}
|
||||
|
||||
function extractPrimarySegment(command: string): string {
|
||||
const segments = command
|
||||
.split(COMMAND_SEPARATOR_PATTERN)
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const segment of segments) {
|
||||
const shouldSkip = SEGMENT_SKIP_PREFIXES.some((prefix) => segment.startsWith(prefix));
|
||||
if (!shouldSkip) {
|
||||
return segment;
|
||||
}
|
||||
}
|
||||
|
||||
return command.trim();
|
||||
}
|
||||
|
||||
function tokenizeCommand(command: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let current = '';
|
||||
let inSingleQuote = false;
|
||||
let inDoubleQuote = false;
|
||||
let isEscaped = false;
|
||||
|
||||
for (const char of command) {
|
||||
if (isEscaped) {
|
||||
current += char;
|
||||
isEscaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '\\') {
|
||||
isEscaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "'" && !inDoubleQuote) {
|
||||
inSingleQuote = !inSingleQuote;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"' && !inSingleQuote) {
|
||||
inDoubleQuote = !inDoubleQuote;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inSingleQuote && !inDoubleQuote && /\s/.test(char)) {
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
current = '';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
current += char;
|
||||
}
|
||||
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function stripWrapperTokens(tokens: string[]): string[] {
|
||||
let index = 0;
|
||||
while (index < tokens.length && WRAPPER_COMMANDS.has(tokens[index].toLowerCase())) {
|
||||
index += 1;
|
||||
}
|
||||
return tokens.slice(index);
|
||||
}
|
||||
|
||||
function extractFilePathFromTokens(tokens: string[]): string | null {
|
||||
const candidates = tokens.slice(1).filter((token) => token && !token.startsWith('-'));
|
||||
if (candidates.length === 0) return null;
|
||||
return candidates[candidates.length - 1];
|
||||
}
|
||||
|
||||
function extractSearchPattern(tokens: string[]): string | null {
|
||||
const remaining = tokens.slice(1);
|
||||
|
||||
for (let index = 0; index < remaining.length; index += 1) {
|
||||
const token = remaining[index];
|
||||
if (token === '--') {
|
||||
return remaining[index + 1] ?? null;
|
||||
}
|
||||
if (SEARCH_PATTERN_FLAGS.has(token)) {
|
||||
return remaining[index + 1] ?? null;
|
||||
}
|
||||
if (SEARCH_VALUE_FLAGS.has(token)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (token.startsWith('-')) {
|
||||
continue;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractTeeTarget(tokens: string[]): string | null {
|
||||
const teeIndex = tokens.findIndex((token) => token === 'tee');
|
||||
if (teeIndex < 0) return null;
|
||||
const candidate = tokens[teeIndex + 1];
|
||||
return candidate && !candidate.startsWith('-') ? candidate : null;
|
||||
}
|
||||
|
||||
function extractRedirectionTarget(command: string): string | null {
|
||||
const match = command.match(REDIRECTION_TARGET_PATTERN);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
function extractFilePathFromDeleteTokens(tokens: string[]): string | null {
|
||||
// rm file.txt or rm /path/to/file.txt
|
||||
// Skip flags and get the first non-flag argument
|
||||
for (let i = 1; i < tokens.length; i++) {
|
||||
const token = tokens[i];
|
||||
if (token && !token.startsWith('-')) {
|
||||
return token;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasSedInPlaceFlag(tokens: string[]): boolean {
|
||||
return tokens.some((token) => SED_IN_PLACE_FLAGS.has(token) || token.startsWith('-i'));
|
||||
}
|
||||
|
||||
function hasPerlInPlaceFlag(tokens: string[]): boolean {
|
||||
return tokens.some((token) => PERL_IN_PLACE_FLAG.test(token));
|
||||
}
|
||||
|
||||
function extractPatchFilePath(command: string): string | null {
|
||||
for (const marker of PATCH_FILE_MARKERS) {
|
||||
const index = command.indexOf(marker);
|
||||
if (index < 0) continue;
|
||||
const start = index + marker.length;
|
||||
const end = command.indexOf('\n', start);
|
||||
const rawPath = (end === -1 ? command.slice(start) : command.slice(start, end)).trim();
|
||||
if (rawPath) return rawPath;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildInputWithFilePath(filePath: string | null): Record<string, unknown> {
|
||||
return filePath ? { [INPUT_KEY_FILE_PATH]: filePath } : {};
|
||||
}
|
||||
|
||||
function buildInputWithPattern(pattern: string | null): Record<string, unknown> {
|
||||
return pattern ? { [INPUT_KEY_PATTERN]: pattern } : {};
|
||||
}
|
||||
|
||||
export function resolveCodexToolCall(command: string): CodexToolResolution {
|
||||
const normalized = stripShellWrapper(command);
|
||||
const primarySegment = extractPrimarySegment(normalized);
|
||||
const tokens = stripWrapperTokens(tokenizeCommand(primarySegment));
|
||||
const commandToken = tokens[0]?.toLowerCase() ?? '';
|
||||
|
||||
const redirectionTarget = extractRedirectionTarget(primarySegment);
|
||||
if (redirectionTarget) {
|
||||
return {
|
||||
name: TOOL_NAME_WRITE,
|
||||
input: buildInputWithFilePath(redirectionTarget),
|
||||
};
|
||||
}
|
||||
|
||||
if (commandToken === APPLY_PATCH_COMMAND || APPLY_PATCH_PATTERN.test(primarySegment)) {
|
||||
return {
|
||||
name: TOOL_NAME_EDIT,
|
||||
input: buildInputWithFilePath(extractPatchFilePath(primarySegment)),
|
||||
};
|
||||
}
|
||||
|
||||
if (commandToken === 'sed' && hasSedInPlaceFlag(tokens)) {
|
||||
return {
|
||||
name: TOOL_NAME_EDIT,
|
||||
input: buildInputWithFilePath(extractFilePathFromTokens(tokens)),
|
||||
};
|
||||
}
|
||||
|
||||
if (commandToken === 'perl' && hasPerlInPlaceFlag(tokens)) {
|
||||
return {
|
||||
name: TOOL_NAME_EDIT,
|
||||
input: buildInputWithFilePath(extractFilePathFromTokens(tokens)),
|
||||
};
|
||||
}
|
||||
|
||||
if (WRITE_COMMANDS.has(commandToken)) {
|
||||
const filePath =
|
||||
commandToken === 'tee' ? extractTeeTarget(tokens) : extractFilePathFromTokens(tokens);
|
||||
return {
|
||||
name: TOOL_NAME_WRITE,
|
||||
input: buildInputWithFilePath(filePath),
|
||||
};
|
||||
}
|
||||
|
||||
if (SEARCH_COMMANDS.has(commandToken)) {
|
||||
if (tokens.some((token) => SEARCH_FILE_LIST_FLAGS.has(token))) {
|
||||
return {
|
||||
name: TOOL_NAME_GLOB,
|
||||
input: buildInputWithPattern(extractFilePathFromTokens(tokens)),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: TOOL_NAME_GREP,
|
||||
input: buildInputWithPattern(extractSearchPattern(tokens)),
|
||||
};
|
||||
}
|
||||
|
||||
// Handle Delete commands (rm, del, erase, remove, unlink)
|
||||
if (DELETE_COMMANDS.has(commandToken)) {
|
||||
// Skip if -r or -rf flags (recursive delete should go to Bash)
|
||||
if (
|
||||
tokens.some((token) => token === '-r' || token === '-rf' || token === '-f' || token === '-rf')
|
||||
) {
|
||||
return {
|
||||
name: TOOL_NAME_BASH,
|
||||
input: { [INPUT_KEY_COMMAND]: normalized },
|
||||
};
|
||||
}
|
||||
// Simple file deletion - extract the file path
|
||||
const filePath = extractFilePathFromDeleteTokens(tokens);
|
||||
if (filePath) {
|
||||
return {
|
||||
name: TOOL_NAME_DELETE,
|
||||
input: { path: filePath },
|
||||
};
|
||||
}
|
||||
// Fall back to bash if we can't determine the file path
|
||||
return {
|
||||
name: TOOL_NAME_BASH,
|
||||
input: { [INPUT_KEY_COMMAND]: normalized },
|
||||
};
|
||||
}
|
||||
|
||||
// Handle simple Ls commands (just listing, not find/glob)
|
||||
if (LIST_COMMANDS.has(commandToken)) {
|
||||
const filePath = extractFilePathFromTokens(tokens);
|
||||
return {
|
||||
name: TOOL_NAME_LS,
|
||||
input: { path: filePath || '.' },
|
||||
};
|
||||
}
|
||||
|
||||
if (GLOB_COMMANDS.has(commandToken)) {
|
||||
return {
|
||||
name: TOOL_NAME_GLOB,
|
||||
input: buildInputWithPattern(extractFilePathFromTokens(tokens)),
|
||||
};
|
||||
}
|
||||
|
||||
if (READ_COMMANDS.has(commandToken)) {
|
||||
return {
|
||||
name: TOOL_NAME_READ,
|
||||
input: buildInputWithFilePath(extractFilePathFromTokens(tokens)),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: TOOL_NAME_BASH,
|
||||
input: { [INPUT_KEY_COMMAND]: normalized },
|
||||
};
|
||||
}
|
||||
|
||||
function parseTodoLines(lines: string[]): CodexTodoItem[] {
|
||||
const todos: CodexTodoItem[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(TODO_LINE_PATTERN);
|
||||
if (!match?.groups?.content) continue;
|
||||
|
||||
const statusToken = match.groups.status;
|
||||
const status =
|
||||
statusToken === 'x'
|
||||
? TODO_STATUS_COMPLETED
|
||||
: statusToken === '~'
|
||||
? TODO_STATUS_IN_PROGRESS
|
||||
: TODO_STATUS_PENDING;
|
||||
|
||||
todos.push({ content: match.groups.content.trim(), status });
|
||||
}
|
||||
|
||||
return todos;
|
||||
}
|
||||
|
||||
function extractTodoFromArray(value: unknown[]): CodexTodoItem[] {
|
||||
return value
|
||||
.map((entry) => {
|
||||
if (typeof entry === 'string') {
|
||||
return { content: entry, status: TODO_STATUS_PENDING };
|
||||
}
|
||||
if (entry && typeof entry === 'object') {
|
||||
const record = entry as Record<string, unknown>;
|
||||
const content =
|
||||
typeof record.content === 'string'
|
||||
? record.content
|
||||
: typeof record.text === 'string'
|
||||
? record.text
|
||||
: typeof record.title === 'string'
|
||||
? record.title
|
||||
: null;
|
||||
if (!content) return null;
|
||||
const status =
|
||||
record.status === TODO_STATUS_COMPLETED ||
|
||||
record.status === TODO_STATUS_IN_PROGRESS ||
|
||||
record.status === TODO_STATUS_PENDING
|
||||
? (record.status as CodexTodoItem['status'])
|
||||
: TODO_STATUS_PENDING;
|
||||
const activeForm = typeof record.activeForm === 'string' ? record.activeForm : undefined;
|
||||
return { content, status, activeForm };
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((item): item is CodexTodoItem => Boolean(item));
|
||||
}
|
||||
|
||||
export function extractCodexTodoItems(item: Record<string, unknown>): CodexTodoItem[] | null {
|
||||
const todosValue = item.todos;
|
||||
if (Array.isArray(todosValue)) {
|
||||
const todos = extractTodoFromArray(todosValue);
|
||||
return todos.length > 0 ? todos : null;
|
||||
}
|
||||
|
||||
const itemsValue = item.items;
|
||||
if (Array.isArray(itemsValue)) {
|
||||
const todos = extractTodoFromArray(itemsValue);
|
||||
return todos.length > 0 ? todos : null;
|
||||
}
|
||||
|
||||
const textValue =
|
||||
typeof item.text === 'string'
|
||||
? item.text
|
||||
: typeof item.content === 'string'
|
||||
? item.content
|
||||
: null;
|
||||
if (!textValue) return null;
|
||||
|
||||
const lines = textValue
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const todos = parseTodoLines(lines);
|
||||
return todos.length > 0 ? todos : null;
|
||||
}
|
||||
|
||||
export function getCodexTodoToolName(): string {
|
||||
return TOOL_NAME_TODO;
|
||||
}
|
||||
@@ -28,9 +28,7 @@ import type {
|
||||
ModelDefinition,
|
||||
ContentBlock,
|
||||
} from './types.js';
|
||||
import { validateBareModelId } from '@automaker/types';
|
||||
import { validateApiKey } from '../lib/auth-utils.js';
|
||||
import { getEffectivePermissions } from '../services/cursor-config-service.js';
|
||||
import { stripProviderPrefix } from '@automaker/types';
|
||||
import {
|
||||
type CursorStreamEvent,
|
||||
type CursorSystemEvent,
|
||||
@@ -317,25 +315,18 @@ export class CursorProvider extends CliProvider {
|
||||
}
|
||||
|
||||
buildCliArgs(options: ExecuteOptions): string[] {
|
||||
// Model is already bare (no prefix) - validated by executeQuery
|
||||
const model = options.model || 'auto';
|
||||
// Extract model (strip 'cursor-' prefix if present)
|
||||
const model = stripProviderPrefix(options.model || 'auto');
|
||||
|
||||
// Build CLI arguments for cursor-agent
|
||||
// NOTE: Prompt is NOT included here - it's passed via stdin to avoid
|
||||
// shell escaping issues when content contains $(), backticks, etc.
|
||||
const cliArgs: string[] = [];
|
||||
|
||||
// If using Cursor IDE (cliPath is 'cursor' not 'cursor-agent'), add 'agent' subcommand
|
||||
if (this.cliPath && !this.cliPath.includes('cursor-agent')) {
|
||||
cliArgs.push('agent');
|
||||
}
|
||||
|
||||
cliArgs.push(
|
||||
const cliArgs: string[] = [
|
||||
'-p', // Print mode (non-interactive)
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--stream-partial-output' // Real-time streaming
|
||||
);
|
||||
'--stream-partial-output', // Real-time streaming
|
||||
];
|
||||
|
||||
// Only add --force if NOT in read-only mode
|
||||
// Without --force, Cursor CLI suggests changes but doesn't apply them
|
||||
@@ -481,9 +472,7 @@ export class CursorProvider extends CliProvider {
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Override CLI detection to add Cursor-specific checks:
|
||||
* 1. Versions directory for cursor-agent installations
|
||||
* 2. Cursor IDE with 'cursor agent' subcommand support
|
||||
* Override CLI detection to add Cursor-specific versions directory check
|
||||
*/
|
||||
protected detectCli(): CliDetectionResult {
|
||||
// First try standard detection (PATH, common paths, WSL)
|
||||
@@ -518,39 +507,6 @@ export class CursorProvider extends CliProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// If cursor-agent not found, try to find 'cursor' IDE and use 'cursor agent' subcommand
|
||||
// The Cursor IDE includes the agent as a subcommand: cursor agent
|
||||
if (process.platform !== 'win32') {
|
||||
const cursorPaths = [
|
||||
'/usr/bin/cursor',
|
||||
'/usr/local/bin/cursor',
|
||||
path.join(os.homedir(), '.local/bin/cursor'),
|
||||
'/opt/cursor/cursor',
|
||||
];
|
||||
|
||||
for (const cursorPath of cursorPaths) {
|
||||
if (fs.existsSync(cursorPath)) {
|
||||
// Verify cursor agent subcommand works
|
||||
try {
|
||||
execSync(`"${cursorPath}" agent --version`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
logger.debug(`Using cursor agent via Cursor IDE: ${cursorPath}`);
|
||||
// Return cursor path but we'll use 'cursor agent' subcommand
|
||||
return {
|
||||
cliPath: cursorPath,
|
||||
useWsl: false,
|
||||
strategy: 'native',
|
||||
};
|
||||
} catch {
|
||||
// cursor agent subcommand doesn't work, try next path
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -649,10 +605,6 @@ export class CursorProvider extends CliProvider {
|
||||
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
||||
this.ensureCliDetected();
|
||||
|
||||
// Validate that model doesn't have a provider prefix
|
||||
// AgentService should strip prefixes before passing to providers
|
||||
validateBareModelId(options.model, 'CursorProvider');
|
||||
|
||||
if (!this.cliPath) {
|
||||
throw this.createError(
|
||||
CursorErrorCode.NOT_INSTALLED,
|
||||
@@ -690,9 +642,6 @@ export class CursorProvider extends CliProvider {
|
||||
|
||||
logger.debug(`CursorProvider.executeQuery called with model: "${options.model}"`);
|
||||
|
||||
// Get effective permissions for this project
|
||||
const effectivePermissions = await getEffectivePermissions(options.cwd || process.cwd());
|
||||
|
||||
// Debug: log raw events when AUTOMAKER_DEBUG_RAW_OUTPUT is enabled
|
||||
const debugRawEvents =
|
||||
process.env.AUTOMAKER_DEBUG_RAW_OUTPUT === 'true' ||
|
||||
@@ -889,16 +838,9 @@ export class CursorProvider extends CliProvider {
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// If using Cursor IDE, use 'cursor agent --version'
|
||||
const versionCmd = this.cliPath.includes('cursor-agent')
|
||||
? `"${this.cliPath}" --version`
|
||||
: `"${this.cliPath}" agent --version`;
|
||||
|
||||
const result = execSync(versionCmd, {
|
||||
const result = execSync(`"${this.cliPath}" --version`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
stdio: 'pipe',
|
||||
}).trim();
|
||||
return result;
|
||||
} catch {
|
||||
@@ -915,13 +857,8 @@ export class CursorProvider extends CliProvider {
|
||||
return { authenticated: false, method: 'none' };
|
||||
}
|
||||
|
||||
// Check for API key in environment with validation
|
||||
// Check for API key in environment
|
||||
if (process.env.CURSOR_API_KEY) {
|
||||
const validation = validateApiKey(process.env.CURSOR_API_KEY, 'cursor');
|
||||
if (!validation.isValid) {
|
||||
logger.warn('Cursor API key validation failed:', validation.error);
|
||||
return { authenticated: false, method: 'api_key', error: validation.error };
|
||||
}
|
||||
return { authenticated: true, method: 'api_key' };
|
||||
}
|
||||
|
||||
|
||||
@@ -25,8 +25,5 @@ export { ClaudeProvider } from './claude-provider.js';
|
||||
export { CursorProvider, CursorErrorCode, CursorError } from './cursor-provider.js';
|
||||
export { CursorConfigManager } from './cursor-config-manager.js';
|
||||
|
||||
// OpenCode provider
|
||||
export { OpencodeProvider } from './opencode-provider.js';
|
||||
|
||||
// Provider factory
|
||||
export { ProviderFactory } from './provider-factory.js';
|
||||
|
||||
@@ -1,666 +0,0 @@
|
||||
/**
|
||||
* OpenCode Provider - Executes queries using opencode CLI
|
||||
*
|
||||
* Extends CliProvider with OpenCode-specific configuration:
|
||||
* - Event normalization for OpenCode's stream-json format
|
||||
* - Model definitions for anthropic, openai, and google models
|
||||
* - NPX-based Windows execution strategy
|
||||
* - Platform-specific npm global installation paths
|
||||
*
|
||||
* Spawns the opencode CLI with --output-format stream-json for streaming responses.
|
||||
*/
|
||||
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { CliProvider, type CliSpawnConfig } from './cli-provider.js';
|
||||
import type {
|
||||
ProviderConfig,
|
||||
ExecuteOptions,
|
||||
ProviderMessage,
|
||||
ModelDefinition,
|
||||
InstallationStatus,
|
||||
ContentBlock,
|
||||
} from '@automaker/types';
|
||||
import { stripProviderPrefix } from '@automaker/types';
|
||||
import { type SubprocessOptions, getOpenCodeAuthIndicators } from '@automaker/platform';
|
||||
|
||||
// =============================================================================
|
||||
// OpenCode Auth Types
|
||||
// =============================================================================
|
||||
|
||||
export interface OpenCodeAuthStatus {
|
||||
authenticated: boolean;
|
||||
method: 'api_key' | 'oauth' | 'none';
|
||||
hasOAuthToken?: boolean;
|
||||
hasApiKey?: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// OpenCode Stream Event Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Base interface for all OpenCode stream events
|
||||
*/
|
||||
interface OpenCodeBaseEvent {
|
||||
/** Event type identifier */
|
||||
type: string;
|
||||
/** Optional session identifier */
|
||||
session_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Text delta event - Incremental text output from the model
|
||||
*/
|
||||
export interface OpenCodeTextDeltaEvent extends OpenCodeBaseEvent {
|
||||
type: 'text-delta';
|
||||
/** The incremental text content */
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Text end event - Signals completion of text generation
|
||||
*/
|
||||
export interface OpenCodeTextEndEvent extends OpenCodeBaseEvent {
|
||||
type: 'text-end';
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool call event - Request to execute a tool
|
||||
*/
|
||||
export interface OpenCodeToolCallEvent extends OpenCodeBaseEvent {
|
||||
type: 'tool-call';
|
||||
/** Unique identifier for this tool call */
|
||||
call_id?: string;
|
||||
/** Tool name to invoke */
|
||||
name: string;
|
||||
/** Arguments to pass to the tool */
|
||||
args: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool result event - Output from a tool execution
|
||||
*/
|
||||
export interface OpenCodeToolResultEvent extends OpenCodeBaseEvent {
|
||||
type: 'tool-result';
|
||||
/** The tool call ID this result corresponds to */
|
||||
call_id?: string;
|
||||
/** Output from the tool execution */
|
||||
output: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool error event - Tool execution failed
|
||||
*/
|
||||
export interface OpenCodeToolErrorEvent extends OpenCodeBaseEvent {
|
||||
type: 'tool-error';
|
||||
/** The tool call ID that failed */
|
||||
call_id?: string;
|
||||
/** Error message describing the failure */
|
||||
error: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start step event - Begins an agentic loop iteration
|
||||
*/
|
||||
export interface OpenCodeStartStepEvent extends OpenCodeBaseEvent {
|
||||
type: 'start-step';
|
||||
/** Step number in the agentic loop */
|
||||
step?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finish step event - Completes an agentic loop iteration
|
||||
*/
|
||||
export interface OpenCodeFinishStepEvent extends OpenCodeBaseEvent {
|
||||
type: 'finish-step';
|
||||
/** Step number that completed */
|
||||
step?: number;
|
||||
/** Whether the step completed successfully */
|
||||
success?: boolean;
|
||||
/** Optional result data */
|
||||
result?: string;
|
||||
/** Optional error if step failed */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type of all OpenCode stream events
|
||||
*/
|
||||
export type OpenCodeStreamEvent =
|
||||
| OpenCodeTextDeltaEvent
|
||||
| OpenCodeTextEndEvent
|
||||
| OpenCodeToolCallEvent
|
||||
| OpenCodeToolResultEvent
|
||||
| OpenCodeToolErrorEvent
|
||||
| OpenCodeStartStepEvent
|
||||
| OpenCodeFinishStepEvent;
|
||||
|
||||
// =============================================================================
|
||||
// Tool Use ID Generation
|
||||
// =============================================================================
|
||||
|
||||
/** Counter for generating unique tool use IDs when call_id is not provided */
|
||||
let toolUseIdCounter = 0;
|
||||
|
||||
/**
|
||||
* Generate a unique tool use ID for tool calls without explicit IDs
|
||||
*/
|
||||
function generateToolUseId(): string {
|
||||
toolUseIdCounter += 1;
|
||||
return `opencode-tool-${toolUseIdCounter}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the tool use ID counter (useful for testing)
|
||||
*/
|
||||
export function resetToolUseIdCounter(): void {
|
||||
toolUseIdCounter = 0;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Provider Implementation
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* OpencodeProvider - Integrates opencode CLI as an AI provider
|
||||
*
|
||||
* OpenCode is an npm-distributed CLI tool that provides access to
|
||||
* multiple AI model providers through a unified interface.
|
||||
*/
|
||||
export class OpencodeProvider extends CliProvider {
|
||||
constructor(config: ProviderConfig = {}) {
|
||||
super(config);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// CliProvider Abstract Method Implementations
|
||||
// ==========================================================================
|
||||
|
||||
getName(): string {
|
||||
return 'opencode';
|
||||
}
|
||||
|
||||
getCliName(): string {
|
||||
return 'opencode';
|
||||
}
|
||||
|
||||
getSpawnConfig(): CliSpawnConfig {
|
||||
return {
|
||||
windowsStrategy: 'npx',
|
||||
npxPackage: 'opencode-ai@latest',
|
||||
commonPaths: {
|
||||
linux: [
|
||||
path.join(os.homedir(), '.opencode/bin/opencode'),
|
||||
path.join(os.homedir(), '.npm-global/bin/opencode'),
|
||||
'/usr/local/bin/opencode',
|
||||
'/usr/bin/opencode',
|
||||
path.join(os.homedir(), '.local/bin/opencode'),
|
||||
],
|
||||
darwin: [
|
||||
path.join(os.homedir(), '.opencode/bin/opencode'),
|
||||
path.join(os.homedir(), '.npm-global/bin/opencode'),
|
||||
'/usr/local/bin/opencode',
|
||||
'/opt/homebrew/bin/opencode',
|
||||
path.join(os.homedir(), '.local/bin/opencode'),
|
||||
],
|
||||
win32: [
|
||||
path.join(os.homedir(), '.opencode', 'bin', 'opencode.exe'),
|
||||
path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'opencode.cmd'),
|
||||
path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'opencode'),
|
||||
path.join(process.env.APPDATA || '', 'npm', 'opencode.cmd'),
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build CLI arguments for the `opencode run` command
|
||||
*
|
||||
* Arguments built:
|
||||
* - 'run' subcommand for executing queries
|
||||
* - '--format', 'stream-json' for JSONL streaming output
|
||||
* - '-q' / '--quiet' to suppress spinner and interactive elements
|
||||
* - '-c', '<cwd>' for working directory
|
||||
* - '--model', '<model>' for model selection (if specified)
|
||||
* - '-' as final arg to read prompt from stdin
|
||||
*
|
||||
* The prompt is NOT included in CLI args - it's passed via stdin to avoid
|
||||
* shell escaping issues with special characters in content.
|
||||
*
|
||||
* @param options - Execution options containing model, cwd, etc.
|
||||
* @returns Array of CLI arguments for opencode run
|
||||
*/
|
||||
buildCliArgs(options: ExecuteOptions): string[] {
|
||||
const args: string[] = ['run'];
|
||||
|
||||
// Add streaming JSON output format for JSONL parsing
|
||||
args.push('--format', 'stream-json');
|
||||
|
||||
// Suppress spinner and interactive elements for non-TTY usage
|
||||
args.push('-q');
|
||||
|
||||
// Set working directory
|
||||
if (options.cwd) {
|
||||
args.push('-c', options.cwd);
|
||||
}
|
||||
|
||||
// Handle model selection
|
||||
// Strip 'opencode-' prefix if present, OpenCode uses format like 'anthropic/claude-sonnet-4-5'
|
||||
if (options.model) {
|
||||
const model = stripProviderPrefix(options.model);
|
||||
args.push('--model', model);
|
||||
}
|
||||
|
||||
// Use '-' to indicate reading prompt from stdin
|
||||
// This avoids shell escaping issues with special characters
|
||||
args.push('-');
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Prompt Handling
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Extract prompt text from ExecuteOptions for passing via stdin
|
||||
*
|
||||
* Handles both string prompts and array-based prompts with content blocks.
|
||||
* For array prompts with images, extracts only text content (images would
|
||||
* need separate handling via file paths if OpenCode supports them).
|
||||
*
|
||||
* @param options - Execution options containing the prompt
|
||||
* @returns Plain text prompt string
|
||||
*/
|
||||
private extractPromptText(options: ExecuteOptions): string {
|
||||
if (typeof options.prompt === 'string') {
|
||||
return options.prompt;
|
||||
}
|
||||
|
||||
// Array-based prompt - extract text content
|
||||
if (Array.isArray(options.prompt)) {
|
||||
return options.prompt
|
||||
.filter((block) => block.type === 'text' && block.text)
|
||||
.map((block) => block.text)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
throw new Error('Invalid prompt format: expected string or content block array');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build subprocess options with stdin data for prompt
|
||||
*
|
||||
* Extends the base class method to add stdinData containing the prompt.
|
||||
* This allows passing prompts via stdin instead of CLI arguments,
|
||||
* avoiding shell escaping issues with special characters.
|
||||
*
|
||||
* @param options - Execution options
|
||||
* @param cliArgs - CLI arguments from buildCliArgs
|
||||
* @returns SubprocessOptions with stdinData set
|
||||
*/
|
||||
protected buildSubprocessOptions(options: ExecuteOptions, cliArgs: string[]): SubprocessOptions {
|
||||
const subprocessOptions = super.buildSubprocessOptions(options, cliArgs);
|
||||
|
||||
// Pass prompt via stdin to avoid shell interpretation of special characters
|
||||
// like $(), backticks, quotes, etc. that may appear in prompts or file content
|
||||
subprocessOptions.stdinData = this.extractPromptText(options);
|
||||
|
||||
return subprocessOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a raw CLI event to ProviderMessage format
|
||||
*
|
||||
* Maps OpenCode event types to the standard ProviderMessage structure:
|
||||
* - text-delta -> type: 'assistant', content with type: 'text'
|
||||
* - text-end -> null (informational, no message needed)
|
||||
* - tool-call -> type: 'assistant', content with type: 'tool_use'
|
||||
* - tool-result -> type: 'assistant', content with type: 'tool_result'
|
||||
* - tool-error -> type: 'error'
|
||||
* - start-step -> null (informational, no message needed)
|
||||
* - finish-step with success -> type: 'result', subtype: 'success'
|
||||
* - finish-step with error -> type: 'error'
|
||||
*
|
||||
* @param event - Raw event from OpenCode CLI JSONL output
|
||||
* @returns Normalized ProviderMessage or null to skip the event
|
||||
*/
|
||||
normalizeEvent(event: unknown): ProviderMessage | null {
|
||||
if (!event || typeof event !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const openCodeEvent = event as OpenCodeStreamEvent;
|
||||
|
||||
switch (openCodeEvent.type) {
|
||||
case 'text-delta': {
|
||||
const textEvent = openCodeEvent as OpenCodeTextDeltaEvent;
|
||||
|
||||
// Skip empty text deltas
|
||||
if (!textEvent.text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content: ContentBlock[] = [
|
||||
{
|
||||
type: 'text',
|
||||
text: textEvent.text,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
type: 'assistant',
|
||||
session_id: textEvent.session_id,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'text-end': {
|
||||
// Text end is informational - no message needed
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'tool-call': {
|
||||
const toolEvent = openCodeEvent as OpenCodeToolCallEvent;
|
||||
|
||||
// Generate a tool use ID if not provided
|
||||
const toolUseId = toolEvent.call_id || generateToolUseId();
|
||||
|
||||
const content: ContentBlock[] = [
|
||||
{
|
||||
type: 'tool_use',
|
||||
name: toolEvent.name,
|
||||
tool_use_id: toolUseId,
|
||||
input: toolEvent.args,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
type: 'assistant',
|
||||
session_id: toolEvent.session_id,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'tool-result': {
|
||||
const resultEvent = openCodeEvent as OpenCodeToolResultEvent;
|
||||
|
||||
const content: ContentBlock[] = [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: resultEvent.call_id,
|
||||
content: resultEvent.output,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
type: 'assistant',
|
||||
session_id: resultEvent.session_id,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'tool-error': {
|
||||
const errorEvent = openCodeEvent as OpenCodeToolErrorEvent;
|
||||
|
||||
return {
|
||||
type: 'error',
|
||||
session_id: errorEvent.session_id,
|
||||
error: errorEvent.error || 'Tool execution failed',
|
||||
};
|
||||
}
|
||||
|
||||
case 'start-step': {
|
||||
// Start step is informational - no message needed
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'finish-step': {
|
||||
const finishEvent = openCodeEvent as OpenCodeFinishStepEvent;
|
||||
|
||||
// Check if the step failed
|
||||
if (finishEvent.success === false || finishEvent.error) {
|
||||
return {
|
||||
type: 'error',
|
||||
session_id: finishEvent.session_id,
|
||||
error: finishEvent.error || 'Step execution failed',
|
||||
};
|
||||
}
|
||||
|
||||
// Successful completion
|
||||
return {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
session_id: finishEvent.session_id,
|
||||
result: finishEvent.result,
|
||||
};
|
||||
}
|
||||
|
||||
default: {
|
||||
// Unknown event type - skip it
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Model Configuration
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Get available models for OpenCode
|
||||
*
|
||||
* Returns model definitions for supported AI providers:
|
||||
* - Anthropic Claude models (Sonnet, Opus, Haiku)
|
||||
* - OpenAI GPT-4o
|
||||
* - Google Gemini 2.5 Pro
|
||||
*/
|
||||
getAvailableModels(): ModelDefinition[] {
|
||||
return [
|
||||
// OpenCode Free Tier Models
|
||||
{
|
||||
id: 'opencode/big-pickle',
|
||||
name: 'Big Pickle (Free)',
|
||||
modelString: 'opencode/big-pickle',
|
||||
provider: 'opencode',
|
||||
description: 'OpenCode free tier model - great for general coding',
|
||||
supportsTools: true,
|
||||
supportsVision: false,
|
||||
tier: 'basic',
|
||||
},
|
||||
{
|
||||
id: 'opencode/gpt-5-nano',
|
||||
name: 'GPT-5 Nano (Free)',
|
||||
modelString: 'opencode/gpt-5-nano',
|
||||
provider: 'opencode',
|
||||
description: 'Fast and lightweight free tier model',
|
||||
supportsTools: true,
|
||||
supportsVision: false,
|
||||
tier: 'basic',
|
||||
},
|
||||
{
|
||||
id: 'opencode/grok-code',
|
||||
name: 'Grok Code (Free)',
|
||||
modelString: 'opencode/grok-code',
|
||||
provider: 'opencode',
|
||||
description: 'OpenCode free tier Grok model for coding',
|
||||
supportsTools: true,
|
||||
supportsVision: false,
|
||||
tier: 'basic',
|
||||
},
|
||||
// Amazon Bedrock - Claude Models
|
||||
{
|
||||
id: 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0',
|
||||
name: 'Claude Sonnet 4.5 (Bedrock)',
|
||||
modelString: 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0',
|
||||
provider: 'opencode',
|
||||
description: 'Latest Claude Sonnet via AWS Bedrock - fast and intelligent',
|
||||
supportsTools: true,
|
||||
supportsVision: true,
|
||||
tier: 'premium',
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
id: 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0',
|
||||
name: 'Claude Opus 4.5 (Bedrock)',
|
||||
modelString: 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0',
|
||||
provider: 'opencode',
|
||||
description: 'Most capable Claude model via AWS Bedrock',
|
||||
supportsTools: true,
|
||||
supportsVision: true,
|
||||
tier: 'premium',
|
||||
},
|
||||
{
|
||||
id: 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0',
|
||||
name: 'Claude Haiku 4.5 (Bedrock)',
|
||||
modelString: 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0',
|
||||
provider: 'opencode',
|
||||
description: 'Fastest Claude model via AWS Bedrock',
|
||||
supportsTools: true,
|
||||
supportsVision: true,
|
||||
tier: 'standard',
|
||||
},
|
||||
// Amazon Bedrock - DeepSeek Models
|
||||
{
|
||||
id: 'amazon-bedrock/deepseek.r1-v1:0',
|
||||
name: 'DeepSeek R1 (Bedrock)',
|
||||
modelString: 'amazon-bedrock/deepseek.r1-v1:0',
|
||||
provider: 'opencode',
|
||||
description: 'DeepSeek R1 reasoning model - excellent for coding',
|
||||
supportsTools: true,
|
||||
supportsVision: false,
|
||||
tier: 'premium',
|
||||
},
|
||||
// Amazon Bedrock - Amazon Nova Models
|
||||
{
|
||||
id: 'amazon-bedrock/amazon.nova-pro-v1:0',
|
||||
name: 'Amazon Nova Pro (Bedrock)',
|
||||
modelString: 'amazon-bedrock/amazon.nova-pro-v1:0',
|
||||
provider: 'opencode',
|
||||
description: 'Amazon Nova Pro - balanced performance',
|
||||
supportsTools: true,
|
||||
supportsVision: true,
|
||||
tier: 'standard',
|
||||
},
|
||||
// Amazon Bedrock - Meta Llama Models
|
||||
{
|
||||
id: 'amazon-bedrock/meta.llama4-maverick-17b-instruct-v1:0',
|
||||
name: 'Llama 4 Maverick 17B (Bedrock)',
|
||||
modelString: 'amazon-bedrock/meta.llama4-maverick-17b-instruct-v1:0',
|
||||
provider: 'opencode',
|
||||
description: 'Meta Llama 4 Maverick via AWS Bedrock',
|
||||
supportsTools: true,
|
||||
supportsVision: false,
|
||||
tier: 'standard',
|
||||
},
|
||||
// Amazon Bedrock - Qwen Models
|
||||
{
|
||||
id: 'amazon-bedrock/qwen.qwen3-coder-480b-a35b-v1:0',
|
||||
name: 'Qwen3 Coder 480B (Bedrock)',
|
||||
modelString: 'amazon-bedrock/qwen.qwen3-coder-480b-a35b-v1:0',
|
||||
provider: 'opencode',
|
||||
description: 'Qwen3 Coder 480B - excellent for coding',
|
||||
supportsTools: true,
|
||||
supportsVision: false,
|
||||
tier: 'premium',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Feature Support
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Check if a feature is supported by OpenCode
|
||||
*
|
||||
* Supported features:
|
||||
* - tools: Function calling / tool use
|
||||
* - text: Text generation
|
||||
* - vision: Image understanding
|
||||
*/
|
||||
supportsFeature(feature: string): boolean {
|
||||
const supportedFeatures = ['tools', 'text', 'vision'];
|
||||
return supportedFeatures.includes(feature);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Authentication
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Check authentication status for OpenCode CLI
|
||||
*
|
||||
* Checks for authentication via:
|
||||
* - OAuth token in auth file
|
||||
* - API key in auth file
|
||||
*/
|
||||
async checkAuth(): Promise<OpenCodeAuthStatus> {
|
||||
const authIndicators = await getOpenCodeAuthIndicators();
|
||||
|
||||
// Check for OAuth token
|
||||
if (authIndicators.hasOAuthToken) {
|
||||
return {
|
||||
authenticated: true,
|
||||
method: 'oauth',
|
||||
hasOAuthToken: true,
|
||||
hasApiKey: authIndicators.hasApiKey,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for API key
|
||||
if (authIndicators.hasApiKey) {
|
||||
return {
|
||||
authenticated: true,
|
||||
method: 'api_key',
|
||||
hasOAuthToken: false,
|
||||
hasApiKey: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: false,
|
||||
method: 'none',
|
||||
hasOAuthToken: false,
|
||||
hasApiKey: false,
|
||||
};
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Installation Detection
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Detect OpenCode installation status
|
||||
*
|
||||
* Checks if the opencode CLI is available either through:
|
||||
* - Direct installation (npm global)
|
||||
* - NPX (fallback on Windows)
|
||||
* Also checks authentication status.
|
||||
*/
|
||||
async detectInstallation(): Promise<InstallationStatus> {
|
||||
this.ensureCliDetected();
|
||||
|
||||
const installed = await this.isInstalled();
|
||||
const auth = await this.checkAuth();
|
||||
|
||||
return {
|
||||
installed,
|
||||
path: this.cliPath || undefined,
|
||||
method: this.detectedStrategy === 'npx' ? 'npm' : 'cli',
|
||||
authenticated: auth.authenticated,
|
||||
hasApiKey: auth.hasApiKey,
|
||||
hasOAuthToken: auth.hasOAuthToken,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -7,27 +7,7 @@
|
||||
|
||||
import { BaseProvider } from './base-provider.js';
|
||||
import type { InstallationStatus, ModelDefinition } from './types.js';
|
||||
import { isCursorModel, isCodexModel, isOpencodeModel, type ModelProvider } from '@automaker/types';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const DISCONNECTED_MARKERS: Record<string, string> = {
|
||||
claude: '.claude-disconnected',
|
||||
codex: '.codex-disconnected',
|
||||
cursor: '.cursor-disconnected',
|
||||
opencode: '.opencode-disconnected',
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a provider CLI is disconnected from the app
|
||||
*/
|
||||
export function isProviderDisconnected(providerName: string): boolean {
|
||||
const markerFile = DISCONNECTED_MARKERS[providerName.toLowerCase()];
|
||||
if (!markerFile) return false;
|
||||
|
||||
const markerPath = path.join(process.cwd(), '.automaker', markerFile);
|
||||
return fs.existsSync(markerPath);
|
||||
}
|
||||
import { isCursorModel, type ModelProvider } from '@automaker/types';
|
||||
|
||||
/**
|
||||
* Provider registration entry
|
||||
@@ -95,26 +75,10 @@ export class ProviderFactory {
|
||||
* Get the appropriate provider for a given model ID
|
||||
*
|
||||
* @param modelId Model identifier (e.g., "claude-opus-4-5-20251101", "cursor-gpt-4o", "cursor-auto")
|
||||
* @param options Optional settings
|
||||
* @param options.throwOnDisconnected Throw error if provider is disconnected (default: true)
|
||||
* @returns Provider instance for the model
|
||||
* @throws Error if provider is disconnected and throwOnDisconnected is true
|
||||
*/
|
||||
static getProviderForModel(
|
||||
modelId: string,
|
||||
options: { throwOnDisconnected?: boolean } = {}
|
||||
): BaseProvider {
|
||||
const { throwOnDisconnected = true } = options;
|
||||
const providerName = this.getProviderForModelName(modelId);
|
||||
|
||||
// Check if provider is disconnected
|
||||
if (throwOnDisconnected && isProviderDisconnected(providerName)) {
|
||||
throw new Error(
|
||||
`${providerName.charAt(0).toUpperCase() + providerName.slice(1)} CLI is disconnected from the app. ` +
|
||||
`Please go to Settings > Providers and click "Sign In" to reconnect.`
|
||||
);
|
||||
}
|
||||
|
||||
static getProviderForModel(modelId: string): BaseProvider {
|
||||
const providerName = this.getProviderNameForModel(modelId);
|
||||
const provider = this.getProviderByName(providerName);
|
||||
|
||||
if (!provider) {
|
||||
@@ -129,35 +93,6 @@ export class ProviderFactory {
|
||||
return provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the provider name for a given model ID (without creating provider instance)
|
||||
*/
|
||||
static getProviderForModelName(modelId: string): string {
|
||||
const lowerModel = modelId.toLowerCase();
|
||||
|
||||
// Get all registered providers sorted by priority (descending)
|
||||
const registrations = Array.from(providerRegistry.entries()).sort(
|
||||
([, a], [, b]) => (b.priority ?? 0) - (a.priority ?? 0)
|
||||
);
|
||||
|
||||
// Check each provider's canHandleModel function
|
||||
for (const [name, reg] of registrations) {
|
||||
if (reg.canHandleModel?.(lowerModel)) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Check for explicit prefixes
|
||||
for (const [name] of registrations) {
|
||||
if (lowerModel.startsWith(`${name}-`)) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
// Default to claude (first registered provider or claude)
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available providers
|
||||
*/
|
||||
@@ -221,41 +156,6 @@ export class ProviderFactory {
|
||||
static getRegisteredProviderNames(): string[] {
|
||||
return Array.from(providerRegistry.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific model supports vision/image input
|
||||
*
|
||||
* @param modelId Model identifier
|
||||
* @returns Whether the model supports vision (defaults to true if model not found)
|
||||
*/
|
||||
static modelSupportsVision(modelId: string): boolean {
|
||||
const provider = this.getProviderForModel(modelId);
|
||||
const models = provider.getAvailableModels();
|
||||
|
||||
// Find the model in the available models list
|
||||
for (const model of models) {
|
||||
if (
|
||||
model.id === modelId ||
|
||||
model.modelString === modelId ||
|
||||
model.id.endsWith(`-${modelId}`) ||
|
||||
model.modelString.endsWith(`-${modelId}`) ||
|
||||
model.modelString === modelId.replace(/^(claude|cursor|codex)-/, '') ||
|
||||
model.modelString === modelId.replace(/-(claude|cursor|codex)$/, '')
|
||||
) {
|
||||
return model.supportsVision ?? true;
|
||||
}
|
||||
}
|
||||
|
||||
// Also try exact match with model string from provider's model map
|
||||
for (const model of models) {
|
||||
if (model.modelString === modelId || model.id === modelId) {
|
||||
return model.supportsVision ?? true;
|
||||
}
|
||||
}
|
||||
|
||||
// Default to true (Claude SDK supports vision by default)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -265,8 +165,6 @@ export class ProviderFactory {
|
||||
// Import providers for registration side-effects
|
||||
import { ClaudeProvider } from './claude-provider.js';
|
||||
import { CursorProvider } from './cursor-provider.js';
|
||||
import { CodexProvider } from './codex-provider.js';
|
||||
import { OpencodeProvider } from './opencode-provider.js';
|
||||
|
||||
// Register Claude provider
|
||||
registerProvider('claude', {
|
||||
@@ -286,18 +184,3 @@ registerProvider('cursor', {
|
||||
canHandleModel: (model: string) => isCursorModel(model),
|
||||
priority: 10, // Higher priority - check Cursor models first
|
||||
});
|
||||
|
||||
// Register Codex provider
|
||||
registerProvider('codex', {
|
||||
factory: () => new CodexProvider(),
|
||||
aliases: ['openai'],
|
||||
canHandleModel: (model: string) => isCodexModel(model),
|
||||
priority: 5, // Medium priority - check after Cursor but before Claude
|
||||
});
|
||||
|
||||
// Register OpenCode provider
|
||||
registerProvider('opencode', {
|
||||
factory: () => new OpencodeProvider(),
|
||||
canHandleModel: (model: string) => isOpencodeModel(model),
|
||||
priority: 3, // Between codex (5) and claude (0)
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import * as secureFs from '../../lib/secure-fs.js';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { createFeatureGenerationOptions } from '../../lib/sdk-options.js';
|
||||
import { ProviderFactory } from '../../providers/provider-factory.js';
|
||||
@@ -124,8 +124,6 @@ IMPORTANT: Do not ask for clarification. The specification is provided above. Ge
|
||||
logger.info('[FeatureGeneration] Using Cursor provider');
|
||||
|
||||
const provider = ProviderFactory.getProviderForModel(model);
|
||||
// Strip provider prefix - providers expect bare model IDs
|
||||
const bareModel = stripProviderPrefix(model);
|
||||
|
||||
// Add explicit instructions for Cursor to return JSON in response
|
||||
const cursorPrompt = `${prompt}
|
||||
@@ -137,7 +135,7 @@ CRITICAL INSTRUCTIONS:
|
||||
|
||||
for await (const msg of provider.executeQuery({
|
||||
prompt: cursorPrompt,
|
||||
model: bareModel,
|
||||
model,
|
||||
cwd: projectPath,
|
||||
maxTurns: 250,
|
||||
allowedTools: ['Read', 'Glob', 'Grep'],
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
type SpecOutput,
|
||||
} from '../../lib/app-spec-format.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { createSpecGenerationOptions } from '../../lib/sdk-options.js';
|
||||
import { extractJson } from '../../lib/json-extractor.js';
|
||||
@@ -118,8 +118,6 @@ ${getStructuredSpecPromptInstruction()}`;
|
||||
logger.info('[SpecGeneration] Using Cursor provider');
|
||||
|
||||
const provider = ProviderFactory.getProviderForModel(model);
|
||||
// Strip provider prefix - providers expect bare model IDs
|
||||
const bareModel = stripProviderPrefix(model);
|
||||
|
||||
// For Cursor, include the JSON schema in the prompt with clear instructions
|
||||
// to return JSON in the response (not write to a file)
|
||||
@@ -136,7 +134,7 @@ Your entire response should be valid JSON starting with { and ending with }. No
|
||||
|
||||
for await (const msg of provider.executeQuery({
|
||||
prompt: cursorPrompt,
|
||||
model: bareModel,
|
||||
model,
|
||||
cwd: projectPath,
|
||||
maxTurns: 250,
|
||||
allowedTools: ['Read', 'Glob', 'Grep'],
|
||||
|
||||
@@ -229,13 +229,12 @@ export function createAuthRoutes(): Router {
|
||||
await invalidateSession(sessionToken);
|
||||
}
|
||||
|
||||
// Clear the cookie by setting it to empty with immediate expiration
|
||||
// Using res.cookie() with maxAge: 0 is more reliable than clearCookie()
|
||||
// in cross-origin development environments
|
||||
res.cookie(cookieName, '', {
|
||||
...getSessionCookieOptions(),
|
||||
maxAge: 0,
|
||||
expires: new Date(0),
|
||||
// Clear the cookie
|
||||
res.clearCookie(cookieName, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
path: '/',
|
||||
});
|
||||
|
||||
res.json({
|
||||
|
||||
@@ -31,9 +31,7 @@ export function createFollowUpFeatureHandler(autoModeService: AutoModeService) {
|
||||
// Start follow-up in background
|
||||
// followUpFeature derives workDir from feature.branchName
|
||||
autoModeService
|
||||
// Default to false to match run-feature/resume-feature behavior.
|
||||
// Worktrees should only be used when explicitly enabled by the user.
|
||||
.followUpFeature(projectPath, featureId, prompt, imagePaths, useWorktrees ?? false)
|
||||
.followUpFeature(projectPath, featureId, prompt, imagePaths, useWorktrees ?? true)
|
||||
.catch((error) => {
|
||||
logger.error(`[AutoMode] Follow up feature ${featureId} error:`, error);
|
||||
})
|
||||
|
||||
@@ -7,12 +7,7 @@
|
||||
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import type { Feature, BacklogPlanResult, BacklogChange, DependencyUpdate } from '@automaker/types';
|
||||
import {
|
||||
DEFAULT_PHASE_MODELS,
|
||||
isCursorModel,
|
||||
stripProviderPrefix,
|
||||
type ThinkingLevel,
|
||||
} from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS, isCursorModel, type ThinkingLevel } from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { FeatureLoader } from '../../services/feature-loader.js';
|
||||
import { ProviderFactory } from '../../providers/provider-factory.js';
|
||||
@@ -125,8 +120,6 @@ export async function generateBacklogPlan(
|
||||
logger.info('[BacklogPlan] Using model:', effectiveModel);
|
||||
|
||||
const provider = ProviderFactory.getProviderForModel(effectiveModel);
|
||||
// Strip provider prefix - providers expect bare model IDs
|
||||
const bareModel = stripProviderPrefix(effectiveModel);
|
||||
|
||||
// Get autoLoadClaudeMd setting
|
||||
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
|
||||
@@ -158,7 +151,7 @@ ${userPrompt}`;
|
||||
// Execute the query
|
||||
const stream = provider.executeQuery({
|
||||
prompt: finalPrompt,
|
||||
model: bareModel,
|
||||
model: effectiveModel,
|
||||
cwd: projectPath,
|
||||
systemPrompt: finalSystemPrompt,
|
||||
maxTurns: 1,
|
||||
|
||||
@@ -13,10 +13,7 @@ export function createClaudeRoutes(service: ClaudeUsageService): Router {
|
||||
// Check if Claude CLI is available first
|
||||
const isAvailable = await service.isAvailable();
|
||||
if (!isAvailable) {
|
||||
// IMPORTANT: This endpoint is behind Automaker session auth already.
|
||||
// Use a 200 + error payload for Claude CLI issues so the UI doesn't
|
||||
// interpret it as an invalid Automaker session (401/403 triggers logout).
|
||||
res.status(200).json({
|
||||
res.status(503).json({
|
||||
error: 'Claude CLI not found',
|
||||
message: "Please install Claude Code CLI and run 'claude login' to authenticate",
|
||||
});
|
||||
@@ -29,13 +26,12 @@ export function createClaudeRoutes(service: ClaudeUsageService): Router {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
if (message.includes('Authentication required') || message.includes('token_expired')) {
|
||||
// Do NOT use 401/403 here: that status code is reserved for Automaker session auth.
|
||||
res.status(200).json({
|
||||
res.status(401).json({
|
||||
error: 'Authentication required',
|
||||
message: "Please run 'claude login' to authenticate",
|
||||
});
|
||||
} else if (message.includes('timed out')) {
|
||||
res.status(200).json({
|
||||
res.status(504).json({
|
||||
error: 'Command timed out',
|
||||
message: 'The Claude CLI took too long to respond',
|
||||
});
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { CodexUsageService } from '../../services/codex-usage-service.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('Codex');
|
||||
|
||||
export function createCodexRoutes(service: CodexUsageService): Router {
|
||||
const router = Router();
|
||||
|
||||
// Get current usage (attempts to fetch from Codex CLI)
|
||||
router.get('/usage', async (req: Request, res: Response) => {
|
||||
try {
|
||||
// Check if Codex CLI is available first
|
||||
const isAvailable = await service.isAvailable();
|
||||
if (!isAvailable) {
|
||||
// IMPORTANT: This endpoint is behind Automaker session auth already.
|
||||
// Use a 200 + error payload for Codex CLI issues so the UI doesn't
|
||||
// interpret it as an invalid Automaker session (401/403 triggers logout).
|
||||
res.status(200).json({
|
||||
error: 'Codex CLI not found',
|
||||
message: "Please install Codex CLI and run 'codex login' to authenticate",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const usage = await service.fetchUsageData();
|
||||
res.json(usage);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
if (message.includes('not authenticated') || message.includes('login')) {
|
||||
// Do NOT use 401/403 here: that status code is reserved for Automaker session auth.
|
||||
res.status(200).json({
|
||||
error: 'Authentication required',
|
||||
message: "Please run 'codex login' to authenticate",
|
||||
});
|
||||
} else if (message.includes('not available') || message.includes('does not provide')) {
|
||||
// This is the expected case - Codex doesn't provide usage stats
|
||||
res.status(200).json({
|
||||
error: 'Usage statistics not available',
|
||||
message: message,
|
||||
});
|
||||
} else if (message.includes('timed out')) {
|
||||
res.status(200).json({
|
||||
error: 'Command timed out',
|
||||
message: 'The Codex CLI took too long to respond',
|
||||
});
|
||||
} else {
|
||||
logger.error('Error fetching usage:', error);
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { createCustomOptions } from '../../../lib/sdk-options.js';
|
||||
@@ -198,8 +198,6 @@ File: ${fileName}${truncated ? ' (truncated)' : ''}`;
|
||||
logger.info(`Using Cursor provider for model: ${model}`);
|
||||
|
||||
const provider = ProviderFactory.getProviderForModel(model);
|
||||
// Strip provider prefix - providers expect bare model IDs
|
||||
const bareModel = stripProviderPrefix(model);
|
||||
|
||||
// Build a simple text prompt for Cursor (no multi-part content blocks)
|
||||
const cursorPrompt = `${instructionText}\n\n--- FILE CONTENT ---\n${contentToAnalyze}`;
|
||||
@@ -207,7 +205,7 @@ File: ${fileName}${truncated ? ' (truncated)' : ''}`;
|
||||
let responseText = '';
|
||||
for await (const msg of provider.executeQuery({
|
||||
prompt: cursorPrompt,
|
||||
model: bareModel,
|
||||
model,
|
||||
cwd,
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
@@ -234,6 +232,7 @@ File: ${fileName}${truncated ? ' (truncated)' : ''}`;
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
autoLoadClaudeMd,
|
||||
sandbox: { enabled: true, autoAllowBashIfSandboxed: true },
|
||||
thinkingLevel, // Pass thinking level for extended thinking
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { createLogger, readImageAsBase64 } from '@automaker/utils';
|
||||
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { createCustomOptions } from '../../../lib/sdk-options.js';
|
||||
import { ProviderFactory } from '../../../providers/provider-factory.js';
|
||||
@@ -357,8 +357,6 @@ export function createDescribeImageHandler(
|
||||
logger.info(`[${requestId}] Using Cursor provider for model: ${model}`);
|
||||
|
||||
const provider = ProviderFactory.getProviderForModel(model);
|
||||
// Strip provider prefix - providers expect bare model IDs
|
||||
const bareModel = stripProviderPrefix(model);
|
||||
|
||||
// Build prompt with image reference for Cursor
|
||||
// Note: Cursor CLI may not support base64 image blocks directly,
|
||||
@@ -369,7 +367,7 @@ export function createDescribeImageHandler(
|
||||
const queryStart = Date.now();
|
||||
for await (const msg of provider.executeQuery({
|
||||
prompt: cursorPrompt,
|
||||
model: bareModel,
|
||||
model,
|
||||
cwd,
|
||||
maxTurns: 1,
|
||||
allowedTools: ['Read'], // Allow Read tool so Cursor can read the image if needed
|
||||
@@ -396,13 +394,14 @@ export function createDescribeImageHandler(
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
autoLoadClaudeMd,
|
||||
sandbox: { enabled: true, autoAllowBashIfSandboxed: true },
|
||||
thinkingLevel, // Pass thinking level for extended thinking
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] SDK options model=${sdkOptions.model} maxTurns=${sdkOptions.maxTurns} allowedTools=${JSON.stringify(
|
||||
sdkOptions.allowedTools
|
||||
)}`
|
||||
)} sandbox=${JSON.stringify(sdkOptions.sandbox)}`
|
||||
);
|
||||
|
||||
const promptGenerator = (async function* () {
|
||||
|
||||
332
apps/server/src/routes/debug/index.ts
Normal file
332
apps/server/src/routes/debug/index.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* Debug routes - HTTP API for debug panel and performance monitoring
|
||||
*
|
||||
* These routes are only enabled in development mode.
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { PerformanceMonitorService } from '../../services/performance-monitor-service.js';
|
||||
import { ProcessRegistryService } from '../../services/process-registry-service.js';
|
||||
import {
|
||||
createGetMetricsHandler,
|
||||
createStartMetricsHandler,
|
||||
createStopMetricsHandler,
|
||||
createForceGCHandler,
|
||||
createClearHistoryHandler,
|
||||
} from './routes/metrics.js';
|
||||
import {
|
||||
createGetProcessesHandler,
|
||||
createGetProcessHandler,
|
||||
createGetSummaryHandler,
|
||||
createGetAgentsHandler,
|
||||
createGetAgentMetricsHandler,
|
||||
createGetAgentSummaryHandler,
|
||||
} from './routes/processes.js';
|
||||
|
||||
export interface DebugServices {
|
||||
performanceMonitor: PerformanceMonitorService;
|
||||
processRegistry: ProcessRegistryService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and initialize debug services
|
||||
*/
|
||||
export function createDebugServices(events: EventEmitter): DebugServices {
|
||||
// Create services
|
||||
const processRegistry = new ProcessRegistryService(events);
|
||||
const performanceMonitor = new PerformanceMonitorService(events);
|
||||
|
||||
// Wire them together - performance monitor gets processes from registry
|
||||
performanceMonitor.setProcessProvider(processRegistry.getProcessProvider());
|
||||
|
||||
// Subscribe to AutoMode events to track feature execution as processes
|
||||
// Events are wrapped in 'auto-mode:event' with the actual type in data.type
|
||||
events.subscribe((eventType, data) => {
|
||||
// Handle auto-mode:event
|
||||
if (eventType === 'auto-mode:event') {
|
||||
handleAutoModeEvent(processRegistry, data);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle agent:stream events for chat sessions
|
||||
if (eventType === 'agent:stream') {
|
||||
handleAgentStreamEvent(processRegistry, data);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Handle AutoMode events for feature execution tracking
|
||||
*/
|
||||
function handleAutoModeEvent(registry: ProcessRegistryService, data: unknown): void {
|
||||
const eventData = data as { type?: string; [key: string]: unknown };
|
||||
const innerType = eventData.type;
|
||||
|
||||
if (innerType === 'auto_mode_feature_start') {
|
||||
const { featureId, projectPath, feature, model } = eventData as {
|
||||
featureId: string;
|
||||
projectPath: string;
|
||||
feature?: { id: string; title: string; description?: string };
|
||||
model?: string;
|
||||
};
|
||||
|
||||
// Register the feature as a tracked process
|
||||
// Use -1 for pid since this isn't a real OS process
|
||||
registry.registerProcess({
|
||||
id: `agent-${featureId}`,
|
||||
pid: -1,
|
||||
type: 'agent',
|
||||
name: feature?.title || `Feature ${featureId}`,
|
||||
featureId,
|
||||
cwd: projectPath,
|
||||
command: model ? `claude ${model}` : 'claude agent',
|
||||
});
|
||||
|
||||
// Initialize resource metrics
|
||||
registry.initializeAgentMetrics(`agent-${featureId}`, { featureId });
|
||||
|
||||
// Mark it as running
|
||||
registry.markRunning(`agent-${featureId}`);
|
||||
} else if (innerType === 'auto_mode_feature_complete') {
|
||||
const { featureId, passes, message } = eventData as {
|
||||
featureId: string;
|
||||
passes: boolean;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
const processId = `agent-${featureId}`;
|
||||
if (registry.hasProcess(processId)) {
|
||||
// Finalize the metrics before marking as stopped
|
||||
registry.finalizeAgentMetrics(processId);
|
||||
|
||||
if (passes) {
|
||||
registry.markStopped(processId, 0);
|
||||
} else {
|
||||
registry.markError(processId, message || 'Feature failed');
|
||||
}
|
||||
}
|
||||
} else if (innerType === 'auto_mode_error') {
|
||||
const { featureId, error } = eventData as {
|
||||
featureId?: string;
|
||||
error: string;
|
||||
};
|
||||
|
||||
if (featureId) {
|
||||
const processId = `agent-${featureId}`;
|
||||
if (registry.hasProcess(processId)) {
|
||||
registry.finalizeAgentMetrics(processId);
|
||||
registry.markError(processId, error);
|
||||
}
|
||||
}
|
||||
} else if (innerType === 'auto_mode_tool_use') {
|
||||
// Track tool usage for the feature
|
||||
const { featureId, tool } = eventData as {
|
||||
featureId: string;
|
||||
tool: { name: string; input?: unknown };
|
||||
};
|
||||
|
||||
const processId = `agent-${featureId}`;
|
||||
if (registry.hasProcess(processId)) {
|
||||
registry.recordToolUse(processId, { toolName: tool.name });
|
||||
|
||||
// Record file operations based on tool type
|
||||
if (tool.name === 'Read' && tool.input) {
|
||||
const input = tool.input as { file_path?: string };
|
||||
if (input.file_path) {
|
||||
registry.recordFileOperation(processId, {
|
||||
operation: 'read',
|
||||
filePath: input.file_path,
|
||||
});
|
||||
}
|
||||
} else if (tool.name === 'Write' && tool.input) {
|
||||
const input = tool.input as { file_path?: string; content?: string };
|
||||
if (input.file_path) {
|
||||
registry.recordFileOperation(processId, {
|
||||
operation: 'write',
|
||||
filePath: input.file_path,
|
||||
bytes: input.content?.length,
|
||||
});
|
||||
}
|
||||
} else if (tool.name === 'Edit' && tool.input) {
|
||||
const input = tool.input as { file_path?: string; new_string?: string };
|
||||
if (input.file_path) {
|
||||
registry.recordFileOperation(processId, {
|
||||
operation: 'edit',
|
||||
filePath: input.file_path,
|
||||
bytes: input.new_string?.length,
|
||||
});
|
||||
}
|
||||
} else if (tool.name === 'Glob') {
|
||||
const input = tool.input as { path?: string };
|
||||
registry.recordFileOperation(processId, {
|
||||
operation: 'glob',
|
||||
filePath: input?.path || '.',
|
||||
});
|
||||
} else if (tool.name === 'Grep') {
|
||||
const input = tool.input as { path?: string };
|
||||
registry.recordFileOperation(processId, {
|
||||
operation: 'grep',
|
||||
filePath: input?.path || '.',
|
||||
});
|
||||
} else if (tool.name === 'Bash' && tool.input) {
|
||||
const input = tool.input as { command?: string };
|
||||
if (input.command) {
|
||||
registry.recordBashCommand(processId, {
|
||||
command: input.command,
|
||||
executionTime: 0, // Will be updated on completion
|
||||
exitCode: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle agent:stream events for chat session tracking
|
||||
*/
|
||||
function handleAgentStreamEvent(registry: ProcessRegistryService, data: unknown): void {
|
||||
const eventData = data as {
|
||||
sessionId?: string;
|
||||
type?: string;
|
||||
tool?: { name: string; input?: unknown };
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
const { sessionId, type } = eventData;
|
||||
if (!sessionId) return;
|
||||
|
||||
const processId = `chat-${sessionId}`;
|
||||
|
||||
// Register chat session as a process if not already tracked
|
||||
if (!registry.hasProcess(processId) && type !== 'complete' && type !== 'error') {
|
||||
registry.registerProcess({
|
||||
id: processId,
|
||||
pid: -1,
|
||||
type: 'agent',
|
||||
name: `Chat Session`,
|
||||
sessionId,
|
||||
command: 'claude chat',
|
||||
});
|
||||
registry.initializeAgentMetrics(processId, { sessionId });
|
||||
registry.markRunning(processId);
|
||||
}
|
||||
|
||||
// Handle different event types
|
||||
if (type === 'tool_use' && eventData.tool) {
|
||||
const tool = eventData.tool;
|
||||
registry.recordToolUse(processId, { toolName: tool.name });
|
||||
|
||||
// Record file operations based on tool type
|
||||
if (tool.name === 'Read' && tool.input) {
|
||||
const input = tool.input as { file_path?: string };
|
||||
if (input.file_path) {
|
||||
registry.recordFileOperation(processId, {
|
||||
operation: 'read',
|
||||
filePath: input.file_path,
|
||||
});
|
||||
}
|
||||
} else if (tool.name === 'Write' && tool.input) {
|
||||
const input = tool.input as { file_path?: string; content?: string };
|
||||
if (input.file_path) {
|
||||
registry.recordFileOperation(processId, {
|
||||
operation: 'write',
|
||||
filePath: input.file_path,
|
||||
bytes: input.content?.length,
|
||||
});
|
||||
}
|
||||
} else if (tool.name === 'Edit' && tool.input) {
|
||||
const input = tool.input as { file_path?: string; new_string?: string };
|
||||
if (input.file_path) {
|
||||
registry.recordFileOperation(processId, {
|
||||
operation: 'edit',
|
||||
filePath: input.file_path,
|
||||
bytes: input.new_string?.length,
|
||||
});
|
||||
}
|
||||
} else if (tool.name === 'Glob') {
|
||||
const input = tool.input as { path?: string };
|
||||
registry.recordFileOperation(processId, {
|
||||
operation: 'glob',
|
||||
filePath: input?.path || '.',
|
||||
});
|
||||
} else if (tool.name === 'Grep') {
|
||||
const input = tool.input as { path?: string };
|
||||
registry.recordFileOperation(processId, {
|
||||
operation: 'grep',
|
||||
filePath: input?.path || '.',
|
||||
});
|
||||
} else if (tool.name === 'Bash' && tool.input) {
|
||||
const input = tool.input as { command?: string };
|
||||
if (input.command) {
|
||||
registry.recordBashCommand(processId, {
|
||||
command: input.command,
|
||||
executionTime: 0,
|
||||
exitCode: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (type === 'complete') {
|
||||
if (registry.hasProcess(processId)) {
|
||||
registry.finalizeAgentMetrics(processId);
|
||||
// Keep the session as "idle" rather than "stopped" since it can receive more messages
|
||||
registry.markIdle(processId);
|
||||
}
|
||||
} else if (type === 'error') {
|
||||
if (registry.hasProcess(processId)) {
|
||||
registry.finalizeAgentMetrics(processId);
|
||||
const errorMsg = (eventData.error as string) || 'Unknown error';
|
||||
registry.markError(processId, errorMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start services
|
||||
processRegistry.start();
|
||||
performanceMonitor.start();
|
||||
|
||||
return {
|
||||
performanceMonitor,
|
||||
processRegistry,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop debug services
|
||||
*/
|
||||
export function stopDebugServices(services: DebugServices): void {
|
||||
services.performanceMonitor.stop();
|
||||
services.processRegistry.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create debug routes
|
||||
*/
|
||||
export function createDebugRoutes(services: DebugServices): Router {
|
||||
const router = Router();
|
||||
const { performanceMonitor, processRegistry } = services;
|
||||
|
||||
// Metrics routes
|
||||
router.get('/metrics', createGetMetricsHandler(performanceMonitor));
|
||||
router.post('/metrics/start', createStartMetricsHandler(performanceMonitor));
|
||||
router.post('/metrics/stop', createStopMetricsHandler(performanceMonitor));
|
||||
router.post('/metrics/gc', createForceGCHandler(performanceMonitor));
|
||||
router.post('/metrics/clear', createClearHistoryHandler(performanceMonitor));
|
||||
|
||||
// Process routes
|
||||
router.get('/processes', createGetProcessesHandler(processRegistry));
|
||||
router.get('/processes/summary', createGetSummaryHandler(processRegistry));
|
||||
router.get('/processes/:id', createGetProcessHandler(processRegistry));
|
||||
|
||||
// Agent resource metrics routes
|
||||
router.get('/agents', createGetAgentsHandler(processRegistry));
|
||||
router.get('/agents/summary', createGetAgentSummaryHandler(processRegistry));
|
||||
router.get('/agents/:id/metrics', createGetAgentMetricsHandler(processRegistry));
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
// Re-export services for use elsewhere
|
||||
export { PerformanceMonitorService } from '../../services/performance-monitor-service.js';
|
||||
export { ProcessRegistryService } from '../../services/process-registry-service.js';
|
||||
152
apps/server/src/routes/debug/routes/metrics.ts
Normal file
152
apps/server/src/routes/debug/routes/metrics.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Debug metrics route handler
|
||||
*
|
||||
* GET /api/debug/metrics - Get current metrics snapshot
|
||||
* POST /api/debug/metrics/start - Start metrics collection
|
||||
* POST /api/debug/metrics/stop - Stop metrics collection
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { PerformanceMonitorService } from '../../../services/performance-monitor-service.js';
|
||||
import type { StartDebugMetricsRequest, DebugMetricsResponse } from '@automaker/types';
|
||||
|
||||
/**
|
||||
* Create handler for GET /api/debug/metrics
|
||||
* Returns current metrics snapshot
|
||||
*/
|
||||
export function createGetMetricsHandler(performanceMonitor: PerformanceMonitorService) {
|
||||
return (_req: Request, res: Response) => {
|
||||
const snapshot = performanceMonitor.getLatestSnapshot();
|
||||
const config = performanceMonitor.getConfig();
|
||||
const active = performanceMonitor.isActive();
|
||||
|
||||
const response: DebugMetricsResponse = {
|
||||
active,
|
||||
config,
|
||||
snapshot: snapshot ?? undefined,
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and sanitize debug metrics config values
|
||||
* Prevents DoS via extreme configuration values
|
||||
*/
|
||||
function sanitizeConfig(
|
||||
config: Partial<import('@automaker/types').DebugMetricsConfig>
|
||||
): Partial<import('@automaker/types').DebugMetricsConfig> {
|
||||
const sanitized: Partial<import('@automaker/types').DebugMetricsConfig> = {};
|
||||
|
||||
// Collection interval: min 100ms, max 60s (prevents CPU exhaustion)
|
||||
if (typeof config.collectionInterval === 'number') {
|
||||
sanitized.collectionInterval = Math.min(
|
||||
60000,
|
||||
Math.max(100, Math.floor(config.collectionInterval))
|
||||
);
|
||||
}
|
||||
|
||||
// Max data points: min 10, max 10000 (prevents memory exhaustion)
|
||||
if (typeof config.maxDataPoints === 'number') {
|
||||
sanitized.maxDataPoints = Math.min(10000, Math.max(10, Math.floor(config.maxDataPoints)));
|
||||
}
|
||||
|
||||
// Leak threshold: min 1KB, max 100MB (reasonable bounds)
|
||||
if (typeof config.leakThreshold === 'number') {
|
||||
sanitized.leakThreshold = Math.min(
|
||||
100 * 1024 * 1024,
|
||||
Math.max(1024, Math.floor(config.leakThreshold))
|
||||
);
|
||||
}
|
||||
|
||||
// Boolean flags - only accept actual booleans
|
||||
if (typeof config.memoryEnabled === 'boolean') {
|
||||
sanitized.memoryEnabled = config.memoryEnabled;
|
||||
}
|
||||
if (typeof config.cpuEnabled === 'boolean') {
|
||||
sanitized.cpuEnabled = config.cpuEnabled;
|
||||
}
|
||||
if (typeof config.processTrackingEnabled === 'boolean') {
|
||||
sanitized.processTrackingEnabled = config.processTrackingEnabled;
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create handler for POST /api/debug/metrics/start
|
||||
* Starts metrics collection with optional config overrides
|
||||
*/
|
||||
export function createStartMetricsHandler(performanceMonitor: PerformanceMonitorService) {
|
||||
return (req: Request, res: Response) => {
|
||||
const body = req.body as StartDebugMetricsRequest | undefined;
|
||||
|
||||
// Update config if provided (with validation)
|
||||
if (body?.config && typeof body.config === 'object') {
|
||||
const sanitizedConfig = sanitizeConfig(body.config);
|
||||
if (Object.keys(sanitizedConfig).length > 0) {
|
||||
performanceMonitor.updateConfig(sanitizedConfig);
|
||||
}
|
||||
}
|
||||
|
||||
// Start collection
|
||||
performanceMonitor.start();
|
||||
|
||||
const response: DebugMetricsResponse = {
|
||||
active: true,
|
||||
config: performanceMonitor.getConfig(),
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create handler for POST /api/debug/metrics/stop
|
||||
* Stops metrics collection
|
||||
*/
|
||||
export function createStopMetricsHandler(performanceMonitor: PerformanceMonitorService) {
|
||||
return (_req: Request, res: Response) => {
|
||||
performanceMonitor.stop();
|
||||
|
||||
const response: DebugMetricsResponse = {
|
||||
active: false,
|
||||
config: performanceMonitor.getConfig(),
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create handler for POST /api/debug/metrics/gc
|
||||
* Forces garbage collection if available
|
||||
*/
|
||||
export function createForceGCHandler(performanceMonitor: PerformanceMonitorService) {
|
||||
return (_req: Request, res: Response) => {
|
||||
const success = performanceMonitor.forceGC();
|
||||
|
||||
res.json({
|
||||
success,
|
||||
message: success
|
||||
? 'Garbage collection triggered'
|
||||
: 'Garbage collection not available (start Node.js with --expose-gc flag)',
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create handler for POST /api/debug/metrics/clear
|
||||
* Clears metrics history
|
||||
*/
|
||||
export function createClearHistoryHandler(performanceMonitor: PerformanceMonitorService) {
|
||||
return (_req: Request, res: Response) => {
|
||||
performanceMonitor.clearHistory();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Metrics history cleared',
|
||||
});
|
||||
};
|
||||
}
|
||||
170
apps/server/src/routes/debug/routes/processes.ts
Normal file
170
apps/server/src/routes/debug/routes/processes.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Debug processes route handler
|
||||
*
|
||||
* GET /api/debug/processes - Get list of tracked processes
|
||||
* GET /api/debug/processes/:id - Get specific process by ID
|
||||
* POST /api/debug/processes/:id/terminate - Terminate a process
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { ProcessRegistryService } from '../../../services/process-registry-service.js';
|
||||
import type {
|
||||
GetProcessesRequest,
|
||||
GetProcessesResponse,
|
||||
ProcessType,
|
||||
ProcessStatus,
|
||||
} from '@automaker/types';
|
||||
|
||||
/**
|
||||
* Create handler for GET /api/debug/processes
|
||||
* Returns list of tracked processes with optional filtering
|
||||
*/
|
||||
export function createGetProcessesHandler(processRegistry: ProcessRegistryService) {
|
||||
return (req: Request, res: Response) => {
|
||||
const query = req.query as {
|
||||
type?: string;
|
||||
status?: string;
|
||||
includeStopped?: string;
|
||||
sessionId?: string;
|
||||
featureId?: string;
|
||||
};
|
||||
|
||||
// Build query options
|
||||
const options: GetProcessesRequest = {};
|
||||
|
||||
if (query.type) {
|
||||
options.type = query.type as ProcessType;
|
||||
}
|
||||
|
||||
if (query.status) {
|
||||
options.status = query.status as ProcessStatus;
|
||||
}
|
||||
|
||||
if (query.includeStopped === 'true') {
|
||||
options.includeStoppedProcesses = true;
|
||||
}
|
||||
|
||||
const processes = processRegistry.getProcesses({
|
||||
type: options.type,
|
||||
status: options.status,
|
||||
includeStopped: options.includeStoppedProcesses,
|
||||
sessionId: query.sessionId,
|
||||
featureId: query.featureId,
|
||||
});
|
||||
|
||||
const summary = processRegistry.getProcessSummary();
|
||||
|
||||
const response: GetProcessesResponse = {
|
||||
processes,
|
||||
summary,
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate process ID format
|
||||
* Process IDs should be non-empty strings with reasonable length
|
||||
*/
|
||||
function isValidProcessId(id: unknown): id is string {
|
||||
return typeof id === 'string' && id.length > 0 && id.length <= 256;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create handler for GET /api/debug/processes/:id
|
||||
* Returns a specific process by ID
|
||||
*/
|
||||
export function createGetProcessHandler(processRegistry: ProcessRegistryService) {
|
||||
return (req: Request, res: Response) => {
|
||||
const { id } = req.params;
|
||||
|
||||
// Validate process ID format
|
||||
if (!isValidProcessId(id)) {
|
||||
res.status(400).json({
|
||||
error: 'Invalid process ID format',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const process = processRegistry.getProcess(id);
|
||||
|
||||
if (!process) {
|
||||
res.status(404).json({
|
||||
error: 'Process not found',
|
||||
id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(process);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create handler for GET /api/debug/processes/summary
|
||||
* Returns summary statistics
|
||||
*/
|
||||
export function createGetSummaryHandler(processRegistry: ProcessRegistryService) {
|
||||
return (_req: Request, res: Response) => {
|
||||
const summary = processRegistry.getProcessSummary();
|
||||
res.json(summary);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create handler for GET /api/debug/agents
|
||||
* Returns all agent processes with their resource metrics
|
||||
*/
|
||||
export function createGetAgentsHandler(processRegistry: ProcessRegistryService) {
|
||||
return (_req: Request, res: Response) => {
|
||||
const agents = processRegistry.getAgentProcessesWithMetrics();
|
||||
const summary = processRegistry.getAgentResourceSummary();
|
||||
|
||||
res.json({
|
||||
agents,
|
||||
summary,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create handler for GET /api/debug/agents/:id/metrics
|
||||
* Returns detailed resource metrics for a specific agent
|
||||
*/
|
||||
export function createGetAgentMetricsHandler(processRegistry: ProcessRegistryService) {
|
||||
return (req: Request, res: Response) => {
|
||||
const { id } = req.params;
|
||||
|
||||
// Validate process ID format
|
||||
if (!isValidProcessId(id)) {
|
||||
res.status(400).json({
|
||||
error: 'Invalid agent ID format',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const metrics = processRegistry.getAgentMetrics(id);
|
||||
|
||||
if (!metrics) {
|
||||
res.status(404).json({
|
||||
error: 'Agent metrics not found',
|
||||
id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(metrics);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create handler for GET /api/debug/agents/summary
|
||||
* Returns summary of resource usage across all agents
|
||||
*/
|
||||
export function createGetAgentSummaryHandler(processRegistry: ProcessRegistryService) {
|
||||
return (_req: Request, res: Response) => {
|
||||
const summary = processRegistry.getAgentResourceSummary();
|
||||
res.json(summary);
|
||||
};
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import { resolveModelString } from '@automaker/model-resolver';
|
||||
import {
|
||||
CLAUDE_MODEL_MAP,
|
||||
isCursorModel,
|
||||
stripProviderPrefix,
|
||||
ThinkingLevel,
|
||||
getThinkingTokenBudget,
|
||||
} from '@automaker/types';
|
||||
@@ -99,14 +98,12 @@ async function extractTextFromStream(
|
||||
*/
|
||||
async function executeWithCursor(prompt: string, model: string): Promise<string> {
|
||||
const provider = ProviderFactory.getProviderForModel(model);
|
||||
// Strip provider prefix - providers expect bare model IDs
|
||||
const bareModel = stripProviderPrefix(model);
|
||||
|
||||
let responseText = '';
|
||||
|
||||
for await (const msg of provider.executeQuery({
|
||||
prompt,
|
||||
model: bareModel,
|
||||
model,
|
||||
cwd: process.cwd(), // Enhancement doesn't need a specific working directory
|
||||
readOnly: true, // Prompt enhancement only generates text, doesn't write files
|
||||
})) {
|
||||
|
||||
@@ -9,7 +9,6 @@ import { createListHandler } from './routes/list.js';
|
||||
import { createGetHandler } from './routes/get.js';
|
||||
import { createCreateHandler } from './routes/create.js';
|
||||
import { createUpdateHandler } from './routes/update.js';
|
||||
import { createBulkUpdateHandler } from './routes/bulk-update.js';
|
||||
import { createDeleteHandler } from './routes/delete.js';
|
||||
import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent-output.js';
|
||||
import { createGenerateTitleHandler } from './routes/generate-title.js';
|
||||
@@ -21,11 +20,6 @@ export function createFeaturesRoutes(featureLoader: FeatureLoader): Router {
|
||||
router.post('/get', validatePathParams('projectPath'), createGetHandler(featureLoader));
|
||||
router.post('/create', validatePathParams('projectPath'), createCreateHandler(featureLoader));
|
||||
router.post('/update', validatePathParams('projectPath'), createUpdateHandler(featureLoader));
|
||||
router.post(
|
||||
'/bulk-update',
|
||||
validatePathParams('projectPath'),
|
||||
createBulkUpdateHandler(featureLoader)
|
||||
);
|
||||
router.post('/delete', validatePathParams('projectPath'), createDeleteHandler(featureLoader));
|
||||
router.post('/agent-output', createAgentOutputHandler(featureLoader));
|
||||
router.post('/raw-output', createRawOutputHandler(featureLoader));
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* POST /bulk-update endpoint - Update multiple features at once
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import type { Feature } from '@automaker/types';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
interface BulkUpdateRequest {
|
||||
projectPath: string;
|
||||
featureIds: string[];
|
||||
updates: Partial<Feature>;
|
||||
}
|
||||
|
||||
interface BulkUpdateResult {
|
||||
featureId: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function createBulkUpdateHandler(featureLoader: FeatureLoader) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, featureIds, updates } = req.body as BulkUpdateRequest;
|
||||
|
||||
if (!projectPath || !featureIds || !Array.isArray(featureIds) || featureIds.length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'projectPath and featureIds (non-empty array) are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!updates || Object.keys(updates).length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'updates object with at least one field is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const results: BulkUpdateResult[] = [];
|
||||
const updatedFeatures: Feature[] = [];
|
||||
|
||||
for (const featureId of featureIds) {
|
||||
try {
|
||||
const updated = await featureLoader.update(projectPath, featureId, updates);
|
||||
results.push({ featureId, success: true });
|
||||
updatedFeatures.push(updated);
|
||||
} catch (error) {
|
||||
results.push({
|
||||
featureId,
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const successCount = results.filter((r) => r.success).length;
|
||||
const failureCount = results.filter((r) => !r.success).length;
|
||||
|
||||
res.json({
|
||||
success: failureCount === 0,
|
||||
updatedCount: successCount,
|
||||
failedCount: failureCount,
|
||||
results,
|
||||
features: updatedFeatures,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Bulk update features failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -10,14 +10,11 @@ import { getErrorMessage, logError } from '../common.js';
|
||||
export function createUpdateHandler(featureLoader: FeatureLoader) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, featureId, updates, descriptionHistorySource, enhancementMode } =
|
||||
req.body as {
|
||||
projectPath: string;
|
||||
featureId: string;
|
||||
updates: Partial<Feature>;
|
||||
descriptionHistorySource?: 'enhance' | 'edit';
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance';
|
||||
};
|
||||
const { projectPath, featureId, updates } = req.body as {
|
||||
projectPath: string;
|
||||
featureId: string;
|
||||
updates: Partial<Feature>;
|
||||
};
|
||||
|
||||
if (!projectPath || !featureId || !updates) {
|
||||
res.status(400).json({
|
||||
@@ -27,13 +24,7 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await featureLoader.update(
|
||||
projectPath,
|
||||
featureId,
|
||||
updates,
|
||||
descriptionHistorySource,
|
||||
enhancementMode
|
||||
);
|
||||
const updated = await featureLoader.update(projectPath, featureId, updates);
|
||||
res.json({ success: true, feature: updated });
|
||||
} catch (error) {
|
||||
logError(error, 'Update feature failed');
|
||||
|
||||
@@ -18,7 +18,7 @@ import type {
|
||||
LinkedPRInfo,
|
||||
ThinkingLevel,
|
||||
} from '@automaker/types';
|
||||
import { isCursorModel, DEFAULT_PHASE_MODELS, stripProviderPrefix } from '@automaker/types';
|
||||
import { isCursorModel, DEFAULT_PHASE_MODELS } from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { createSuggestionsOptions } from '../../../lib/sdk-options.js';
|
||||
import { extractJson } from '../../../lib/json-extractor.js';
|
||||
@@ -120,8 +120,6 @@ async function runValidation(
|
||||
logger.info(`Using Cursor provider for validation with model: ${model}`);
|
||||
|
||||
const provider = ProviderFactory.getProviderForModel(model);
|
||||
// Strip provider prefix - providers expect bare model IDs
|
||||
const bareModel = stripProviderPrefix(model);
|
||||
|
||||
// For Cursor, include the system prompt and schema in the user prompt
|
||||
const cursorPrompt = `${ISSUE_VALIDATION_SYSTEM_PROMPT}
|
||||
@@ -139,7 +137,7 @@ ${prompt}`;
|
||||
|
||||
for await (const msg of provider.executeQuery({
|
||||
prompt: cursorPrompt,
|
||||
model: bareModel,
|
||||
model,
|
||||
cwd: projectPath,
|
||||
readOnly: true, // Issue validation only reads code, doesn't write
|
||||
})) {
|
||||
|
||||
@@ -23,7 +23,6 @@ import { createGetProjectHandler } from './routes/get-project.js';
|
||||
import { createUpdateProjectHandler } from './routes/update-project.js';
|
||||
import { createMigrateHandler } from './routes/migrate.js';
|
||||
import { createStatusHandler } from './routes/status.js';
|
||||
import { createDiscoverAgentsHandler } from './routes/discover-agents.js';
|
||||
|
||||
/**
|
||||
* Create settings router with all endpoints
|
||||
@@ -40,7 +39,6 @@ import { createDiscoverAgentsHandler } from './routes/discover-agents.js';
|
||||
* - POST /project - Get project settings (requires projectPath in body)
|
||||
* - PUT /project - Update project settings
|
||||
* - POST /migrate - Migrate settings from localStorage
|
||||
* - POST /agents/discover - Discover filesystem agents from .claude/agents/ (read-only)
|
||||
*
|
||||
* @param settingsService - Instance of SettingsService for file I/O
|
||||
* @returns Express Router configured with all settings endpoints
|
||||
@@ -74,8 +72,5 @@ export function createSettingsRoutes(settingsService: SettingsService): Router {
|
||||
// Migration from localStorage
|
||||
router.post('/migrate', createMigrateHandler(settingsService));
|
||||
|
||||
// Filesystem agents discovery (read-only)
|
||||
router.post('/agents/discover', createDiscoverAgentsHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
/**
|
||||
* Discover Agents Route - Returns filesystem-based agents from .claude/agents/
|
||||
*
|
||||
* Scans both user-level (~/.claude/agents/) and project-level (.claude/agents/)
|
||||
* directories for AGENT.md files and returns parsed agent definitions.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { discoverFilesystemAgents } from '../../../lib/agent-discovery.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('DiscoverAgentsRoute');
|
||||
|
||||
interface DiscoverAgentsRequest {
|
||||
projectPath?: string;
|
||||
sources?: Array<'user' | 'project'>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create handler for discovering filesystem agents
|
||||
*
|
||||
* POST /api/settings/agents/discover
|
||||
* Body: { projectPath?: string, sources?: ['user', 'project'] }
|
||||
*
|
||||
* Returns:
|
||||
* {
|
||||
* success: true,
|
||||
* agents: Array<{
|
||||
* name: string,
|
||||
* definition: AgentDefinition,
|
||||
* source: 'user' | 'project',
|
||||
* filePath: string
|
||||
* }>
|
||||
* }
|
||||
*/
|
||||
export function createDiscoverAgentsHandler() {
|
||||
return async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { projectPath, sources = ['user', 'project'] } = req.body as DiscoverAgentsRequest;
|
||||
|
||||
logger.info(
|
||||
`Discovering agents from sources: ${sources.join(', ')}${projectPath ? ` (project: ${projectPath})` : ''}`
|
||||
);
|
||||
|
||||
const agents = await discoverFilesystemAgents(projectPath, sources);
|
||||
|
||||
logger.info(`Discovered ${agents.length} filesystem agents`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
agents,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to discover agents:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to discover agents',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import type { GlobalSettings } from '../../../types/settings.js';
|
||||
import { getErrorMessage, logError, logger } from '../common.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
/**
|
||||
* Create handler factory for PUT /api/settings/global
|
||||
@@ -32,18 +32,6 @@ export function createUpdateGlobalHandler(settingsService: SettingsService) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Minimal debug logging to help diagnose accidental wipes.
|
||||
if ('projects' in updates || 'theme' in updates || 'localStorageMigrated' in updates) {
|
||||
const projectsLen = Array.isArray((updates as any).projects)
|
||||
? (updates as any).projects.length
|
||||
: undefined;
|
||||
logger.info(
|
||||
`Update global settings request: projects=${projectsLen ?? 'n/a'}, theme=${
|
||||
(updates as any).theme ?? 'n/a'
|
||||
}, localStorageMigrated=${(updates as any).localStorageMigrated ?? 'n/a'}`
|
||||
);
|
||||
}
|
||||
|
||||
const settings = await settingsService.updateGlobalSettings(updates);
|
||||
|
||||
res.json({
|
||||
|
||||
@@ -6,24 +6,9 @@ import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { getClaudeCliPaths, getClaudeAuthIndicators, systemPathAccess } from '@automaker/platform';
|
||||
import { getApiKey } from './common.js';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const DISCONNECTED_MARKER_FILE = '.claude-disconnected';
|
||||
|
||||
function isDisconnectedFromApp(): boolean {
|
||||
try {
|
||||
// Check if we're in a project directory
|
||||
const projectRoot = process.cwd();
|
||||
const markerPath = path.join(projectRoot, '.automaker', DISCONNECTED_MARKER_FILE);
|
||||
return fs.existsSync(markerPath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getClaudeStatus() {
|
||||
let installed = false;
|
||||
let version = '';
|
||||
@@ -75,30 +60,6 @@ export async function getClaudeStatus() {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user has manually disconnected from the app
|
||||
if (isDisconnectedFromApp()) {
|
||||
return {
|
||||
status: installed ? 'installed' : 'not_installed',
|
||||
installed,
|
||||
method,
|
||||
version,
|
||||
path: cliPath,
|
||||
auth: {
|
||||
authenticated: false,
|
||||
method: 'none',
|
||||
hasCredentialsFile: false,
|
||||
hasToken: false,
|
||||
hasStoredOAuthToken: false,
|
||||
hasStoredApiKey: false,
|
||||
hasEnvApiKey: false,
|
||||
oauthTokenValid: false,
|
||||
apiKeyValid: false,
|
||||
hasCliAuth: false,
|
||||
hasRecentActivity: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Check authentication - detect all possible auth methods
|
||||
// Note: apiKeys.anthropic_oauth_token stores OAuth tokens from subscription auth
|
||||
// apiKeys.anthropic stores direct API keys for pay-per-use
|
||||
|
||||
@@ -11,19 +11,8 @@ import { createDeleteApiKeyHandler } from './routes/delete-api-key.js';
|
||||
import { createApiKeysHandler } from './routes/api-keys.js';
|
||||
import { createPlatformHandler } from './routes/platform.js';
|
||||
import { createVerifyClaudeAuthHandler } from './routes/verify-claude-auth.js';
|
||||
import { createVerifyCodexAuthHandler } from './routes/verify-codex-auth.js';
|
||||
import { createGhStatusHandler } from './routes/gh-status.js';
|
||||
import { createCursorStatusHandler } from './routes/cursor-status.js';
|
||||
import { createCodexStatusHandler } from './routes/codex-status.js';
|
||||
import { createInstallCodexHandler } from './routes/install-codex.js';
|
||||
import { createAuthCodexHandler } from './routes/auth-codex.js';
|
||||
import { createAuthCursorHandler } from './routes/auth-cursor.js';
|
||||
import { createDeauthClaudeHandler } from './routes/deauth-claude.js';
|
||||
import { createDeauthCodexHandler } from './routes/deauth-codex.js';
|
||||
import { createDeauthCursorHandler } from './routes/deauth-cursor.js';
|
||||
import { createAuthOpencodeHandler } from './routes/auth-opencode.js';
|
||||
import { createDeauthOpencodeHandler } from './routes/deauth-opencode.js';
|
||||
import { createOpencodeStatusHandler } from './routes/opencode-status.js';
|
||||
import {
|
||||
createGetCursorConfigHandler,
|
||||
createSetCursorDefaultModelHandler,
|
||||
@@ -41,30 +30,15 @@ export function createSetupRoutes(): Router {
|
||||
router.get('/claude-status', createClaudeStatusHandler());
|
||||
router.post('/install-claude', createInstallClaudeHandler());
|
||||
router.post('/auth-claude', createAuthClaudeHandler());
|
||||
router.post('/deauth-claude', createDeauthClaudeHandler());
|
||||
router.post('/store-api-key', createStoreApiKeyHandler());
|
||||
router.post('/delete-api-key', createDeleteApiKeyHandler());
|
||||
router.get('/api-keys', createApiKeysHandler());
|
||||
router.get('/platform', createPlatformHandler());
|
||||
router.post('/verify-claude-auth', createVerifyClaudeAuthHandler());
|
||||
router.post('/verify-codex-auth', createVerifyCodexAuthHandler());
|
||||
router.get('/gh-status', createGhStatusHandler());
|
||||
|
||||
// Cursor CLI routes
|
||||
router.get('/cursor-status', createCursorStatusHandler());
|
||||
router.post('/auth-cursor', createAuthCursorHandler());
|
||||
router.post('/deauth-cursor', createDeauthCursorHandler());
|
||||
|
||||
// Codex CLI routes
|
||||
router.get('/codex-status', createCodexStatusHandler());
|
||||
router.post('/install-codex', createInstallCodexHandler());
|
||||
router.post('/auth-codex', createAuthCodexHandler());
|
||||
router.post('/deauth-codex', createDeauthCodexHandler());
|
||||
|
||||
// OpenCode CLI routes
|
||||
router.get('/opencode-status', createOpencodeStatusHandler());
|
||||
router.post('/auth-opencode', createAuthOpencodeHandler());
|
||||
router.post('/deauth-opencode', createDeauthOpencodeHandler());
|
||||
router.get('/cursor-config', createGetCursorConfigHandler());
|
||||
router.post('/cursor-config/default-model', createSetCursorDefaultModelHandler());
|
||||
router.post('/cursor-config/models', createSetCursorModelsHandler());
|
||||
|
||||
@@ -11,7 +11,6 @@ export function createApiKeysHandler() {
|
||||
res.json({
|
||||
success: true,
|
||||
hasAnthropicKey: !!getApiKey('anthropic') || !!process.env.ANTHROPIC_API_KEY,
|
||||
hasOpenaiKey: !!getApiKey('openai') || !!process.env.OPENAI_API_KEY,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get API keys failed');
|
||||
|
||||
@@ -4,54 +4,19 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export function createAuthClaudeHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Remove the disconnected marker file to reconnect the app to the CLI
|
||||
const markerPath = path.join(process.cwd(), '.automaker', '.claude-disconnected');
|
||||
if (fs.existsSync(markerPath)) {
|
||||
fs.unlinkSync(markerPath);
|
||||
}
|
||||
|
||||
// Check if CLI is already authenticated by checking auth indicators
|
||||
const { getClaudeAuthIndicators } = await import('@automaker/platform');
|
||||
const indicators = await getClaudeAuthIndicators();
|
||||
const isAlreadyAuthenticated =
|
||||
indicators.hasStatsCacheWithActivity ||
|
||||
(indicators.hasSettingsFile && indicators.hasProjectsSessions) ||
|
||||
indicators.hasCredentialsFile;
|
||||
|
||||
if (isAlreadyAuthenticated) {
|
||||
// CLI is already authenticated, just reconnect
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Claude CLI is now linked with the app',
|
||||
wasAlreadyAuthenticated: true,
|
||||
});
|
||||
} else {
|
||||
// CLI needs authentication - but we can't run claude login here
|
||||
// because it requires browser OAuth. Just reconnect and let the user authenticate if needed.
|
||||
res.json({
|
||||
success: true,
|
||||
message:
|
||||
'Claude CLI is now linked with the app. If prompted, please authenticate with "claude login" in your terminal.',
|
||||
requiresManualAuth: true,
|
||||
});
|
||||
}
|
||||
res.json({
|
||||
success: true,
|
||||
requiresManualAuth: true,
|
||||
command: 'claude login',
|
||||
message: "Please run 'claude login' in your terminal to authenticate",
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Auth Claude failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
message: 'Failed to link Claude CLI with the app',
|
||||
});
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* POST /auth-codex endpoint - Authenticate Codex CLI
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { logError, getErrorMessage } from '../common.js';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export function createAuthCodexHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Remove the disconnected marker file to reconnect the app to the CLI
|
||||
const markerPath = path.join(process.cwd(), '.automaker', '.codex-disconnected');
|
||||
if (fs.existsSync(markerPath)) {
|
||||
fs.unlinkSync(markerPath);
|
||||
}
|
||||
|
||||
// Use the same detection logic as the Codex provider
|
||||
const { getCodexAuthIndicators } = await import('@automaker/platform');
|
||||
const indicators = await getCodexAuthIndicators();
|
||||
|
||||
const isAlreadyAuthenticated =
|
||||
indicators.hasApiKey || indicators.hasAuthFile || indicators.hasOAuthToken;
|
||||
|
||||
if (isAlreadyAuthenticated) {
|
||||
// Already has authentication, just reconnect
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Codex CLI is now linked with the app',
|
||||
wasAlreadyAuthenticated: true,
|
||||
});
|
||||
} else {
|
||||
res.json({
|
||||
success: true,
|
||||
message:
|
||||
'Codex CLI is now linked with the app. If prompted, please authenticate with "codex login" in your terminal.',
|
||||
requiresManualAuth: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error, 'Auth Codex failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
message: 'Failed to link Codex CLI with the app',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
/**
|
||||
* POST /auth-cursor endpoint - Authenticate Cursor CLI
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { logError, getErrorMessage } from '../common.js';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
export function createAuthCursorHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Remove the disconnected marker file to reconnect the app to the CLI
|
||||
const markerPath = path.join(process.cwd(), '.automaker', '.cursor-disconnected');
|
||||
if (fs.existsSync(markerPath)) {
|
||||
fs.unlinkSync(markerPath);
|
||||
}
|
||||
|
||||
// Check if Cursor is already authenticated using the same logic as CursorProvider
|
||||
const isAlreadyAuthenticated = (): boolean => {
|
||||
// Check for API key in environment
|
||||
if (process.env.CURSOR_API_KEY) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for credentials files
|
||||
const credentialPaths = [
|
||||
path.join(os.homedir(), '.cursor', 'credentials.json'),
|
||||
path.join(os.homedir(), '.config', 'cursor', 'credentials.json'),
|
||||
];
|
||||
|
||||
for (const credPath of credentialPaths) {
|
||||
if (fs.existsSync(credPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(credPath, 'utf8');
|
||||
const creds = JSON.parse(content);
|
||||
if (creds.accessToken || creds.token) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Invalid credentials file, continue checking
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
if (isAlreadyAuthenticated()) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Cursor CLI is now linked with the app',
|
||||
wasAlreadyAuthenticated: true,
|
||||
});
|
||||
} else {
|
||||
res.json({
|
||||
success: true,
|
||||
message:
|
||||
'Cursor CLI is now linked with the app. If prompted, please authenticate with "cursor auth" in your terminal.',
|
||||
requiresManualAuth: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error, 'Auth Cursor failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
message: 'Failed to link Cursor CLI with the app',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* POST /auth-opencode endpoint - Authenticate OpenCode CLI
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { logError, getErrorMessage } from '../common.js';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export function createAuthOpencodeHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Remove the disconnected marker file to reconnect the app to the CLI
|
||||
const markerPath = path.join(process.cwd(), '.automaker', '.opencode-disconnected');
|
||||
if (fs.existsSync(markerPath)) {
|
||||
fs.unlinkSync(markerPath);
|
||||
}
|
||||
|
||||
// Check if OpenCode is already authenticated
|
||||
// For OpenCode, check if there's an auth token or API key
|
||||
const hasApiKey = !!process.env.OPENCODE_API_KEY;
|
||||
|
||||
if (hasApiKey) {
|
||||
// Already has authentication, just reconnect
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'OpenCode CLI is now linked with the app',
|
||||
wasAlreadyAuthenticated: true,
|
||||
});
|
||||
} else {
|
||||
res.json({
|
||||
success: true,
|
||||
message:
|
||||
'OpenCode CLI is now linked with the app. If prompted, please authenticate with OpenCode.',
|
||||
requiresManualAuth: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error, 'Auth OpenCode failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
message: 'Failed to link OpenCode CLI with the app',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
/**
|
||||
* GET /codex-status endpoint - Get Codex CLI installation and auth status
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { CodexProvider } from '../../../providers/codex-provider.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const DISCONNECTED_MARKER_FILE = '.codex-disconnected';
|
||||
|
||||
function isCodexDisconnectedFromApp(): boolean {
|
||||
try {
|
||||
const projectRoot = process.cwd();
|
||||
const markerPath = path.join(projectRoot, '.automaker', DISCONNECTED_MARKER_FILE);
|
||||
return fs.existsSync(markerPath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates handler for GET /api/setup/codex-status
|
||||
* Returns Codex CLI installation and authentication status
|
||||
*/
|
||||
export function createCodexStatusHandler() {
|
||||
const installCommand = 'npm install -g @openai/codex';
|
||||
const loginCommand = 'codex login';
|
||||
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Check if user has manually disconnected from the app
|
||||
if (isCodexDisconnectedFromApp()) {
|
||||
res.json({
|
||||
success: true,
|
||||
installed: true,
|
||||
version: null,
|
||||
path: null,
|
||||
auth: {
|
||||
authenticated: false,
|
||||
method: 'none',
|
||||
hasApiKey: false,
|
||||
},
|
||||
installCommand,
|
||||
loginCommand,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = new CodexProvider();
|
||||
const status = await provider.detectInstallation();
|
||||
|
||||
// Derive auth method from authenticated status and API key presence
|
||||
let authMethod = 'none';
|
||||
if (status.authenticated) {
|
||||
authMethod = status.hasApiKey ? 'api_key_env' : 'cli_authenticated';
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
installed: status.installed,
|
||||
version: status.version || null,
|
||||
path: status.path || null,
|
||||
auth: {
|
||||
authenticated: status.authenticated || false,
|
||||
method: authMethod,
|
||||
hasApiKey: status.hasApiKey || false,
|
||||
},
|
||||
installCommand,
|
||||
loginCommand,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get Codex status failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -5,20 +5,6 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { CursorProvider } from '../../../providers/cursor-provider.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const DISCONNECTED_MARKER_FILE = '.cursor-disconnected';
|
||||
|
||||
function isCursorDisconnectedFromApp(): boolean {
|
||||
try {
|
||||
const projectRoot = process.cwd();
|
||||
const markerPath = path.join(projectRoot, '.automaker', DISCONNECTED_MARKER_FILE);
|
||||
return fs.existsSync(markerPath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates handler for GET /api/setup/cursor-status
|
||||
@@ -30,30 +16,6 @@ export function createCursorStatusHandler() {
|
||||
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Check if user has manually disconnected from the app
|
||||
if (isCursorDisconnectedFromApp()) {
|
||||
const provider = new CursorProvider();
|
||||
const [installed, version] = await Promise.all([
|
||||
provider.isInstalled(),
|
||||
provider.getVersion(),
|
||||
]);
|
||||
const cliPath = installed ? provider.getCliPath() : null;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
installed,
|
||||
version: version || null,
|
||||
path: cliPath,
|
||||
auth: {
|
||||
authenticated: false,
|
||||
method: 'none',
|
||||
},
|
||||
installCommand,
|
||||
loginCommand,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = new CursorProvider();
|
||||
|
||||
const [installed, version, auth] = await Promise.all([
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
* POST /deauth-claude endpoint - Sign out from Claude CLI
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export function createDeauthClaudeHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Create a marker file to indicate the CLI is disconnected from the app
|
||||
const automakerDir = path.join(process.cwd(), '.automaker');
|
||||
const markerPath = path.join(automakerDir, '.claude-disconnected');
|
||||
|
||||
// Ensure .automaker directory exists
|
||||
if (!fs.existsSync(automakerDir)) {
|
||||
fs.mkdirSync(automakerDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Create the marker file with timestamp
|
||||
fs.writeFileSync(
|
||||
markerPath,
|
||||
JSON.stringify({
|
||||
disconnectedAt: new Date().toISOString(),
|
||||
message: 'Claude CLI is disconnected from the app',
|
||||
})
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Claude CLI is now disconnected from the app',
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Deauth Claude failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
message: 'Failed to disconnect Claude CLI from the app',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
* POST /deauth-codex endpoint - Sign out from Codex CLI
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { logError, getErrorMessage } from '../common.js';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export function createDeauthCodexHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Create a marker file to indicate the CLI is disconnected from the app
|
||||
const automakerDir = path.join(process.cwd(), '.automaker');
|
||||
const markerPath = path.join(automakerDir, '.codex-disconnected');
|
||||
|
||||
// Ensure .automaker directory exists
|
||||
if (!fs.existsSync(automakerDir)) {
|
||||
fs.mkdirSync(automakerDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Create the marker file with timestamp
|
||||
fs.writeFileSync(
|
||||
markerPath,
|
||||
JSON.stringify({
|
||||
disconnectedAt: new Date().toISOString(),
|
||||
message: 'Codex CLI is disconnected from the app',
|
||||
})
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Codex CLI is now disconnected from the app',
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Deauth Codex failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
message: 'Failed to disconnect Codex CLI from the app',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
* POST /deauth-cursor endpoint - Sign out from Cursor CLI
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { logError, getErrorMessage } from '../common.js';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export function createDeauthCursorHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Create a marker file to indicate the CLI is disconnected from the app
|
||||
const automakerDir = path.join(process.cwd(), '.automaker');
|
||||
const markerPath = path.join(automakerDir, '.cursor-disconnected');
|
||||
|
||||
// Ensure .automaker directory exists
|
||||
if (!fs.existsSync(automakerDir)) {
|
||||
fs.mkdirSync(automakerDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Create the marker file with timestamp
|
||||
fs.writeFileSync(
|
||||
markerPath,
|
||||
JSON.stringify({
|
||||
disconnectedAt: new Date().toISOString(),
|
||||
message: 'Cursor CLI is disconnected from the app',
|
||||
})
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Cursor CLI is now disconnected from the app',
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Deauth Cursor failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
message: 'Failed to disconnect Cursor CLI from the app',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { logError, getErrorMessage } from '../common.js';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export function createDeauthOpencodeHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Create a marker file to indicate the CLI is disconnected from the app
|
||||
const automakerDir = path.join(process.cwd(), '.automaker');
|
||||
const markerPath = path.join(automakerDir, '.opencode-disconnected');
|
||||
|
||||
// Ensure .automaker directory exists
|
||||
if (!fs.existsSync(automakerDir)) {
|
||||
fs.mkdirSync(automakerDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Create the marker file with timestamp
|
||||
fs.writeFileSync(
|
||||
markerPath,
|
||||
JSON.stringify({
|
||||
disconnectedAt: new Date().toISOString(),
|
||||
message: 'OpenCode CLI is disconnected from the app',
|
||||
})
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'OpenCode CLI is now disconnected from the app',
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Deauth OpenCode failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
message: 'Failed to disconnect OpenCode CLI from the app',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -46,14 +46,13 @@ export function createDeleteApiKeyHandler() {
|
||||
// Map provider to env key name
|
||||
const envKeyMap: Record<string, string> = {
|
||||
anthropic: 'ANTHROPIC_API_KEY',
|
||||
openai: 'OPENAI_API_KEY',
|
||||
};
|
||||
|
||||
const envKey = envKeyMap[provider];
|
||||
if (!envKey) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Unknown provider: ${provider}. Only anthropic and openai are supported.`,
|
||||
error: `Unknown provider: ${provider}. Only anthropic is supported.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* POST /install-codex endpoint - Install Codex CLI
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { logError, getErrorMessage } from '../common.js';
|
||||
|
||||
/**
|
||||
* Creates handler for POST /api/setup/install-codex
|
||||
* Installs Codex CLI (currently returns instructions for manual install)
|
||||
*/
|
||||
export function createInstallCodexHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// For now, return manual installation instructions
|
||||
// In the future, this could potentially trigger npm global install
|
||||
const installCommand = 'npm install -g @openai/codex';
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Please install Codex CLI manually by running: ${installCommand}`,
|
||||
requiresManualInstall: true,
|
||||
installCommand,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Install Codex failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* GET /opencode-status endpoint - Get OpenCode CLI installation and auth status
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { OpencodeProvider } from '../../../providers/opencode-provider.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
/**
|
||||
* Creates handler for GET /api/setup/opencode-status
|
||||
* Returns OpenCode CLI installation and authentication status
|
||||
*/
|
||||
export function createOpencodeStatusHandler() {
|
||||
const installCommand = 'curl -fsSL https://opencode.ai/install | bash';
|
||||
const loginCommand = 'opencode auth login';
|
||||
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const provider = new OpencodeProvider();
|
||||
const status = await provider.detectInstallation();
|
||||
|
||||
// Derive auth method from authenticated status and API key presence
|
||||
let authMethod = 'none';
|
||||
if (status.authenticated) {
|
||||
authMethod = status.hasApiKey ? 'api_key_env' : 'cli_authenticated';
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
installed: status.installed,
|
||||
version: status.version || null,
|
||||
path: status.path || null,
|
||||
auth: {
|
||||
authenticated: status.authenticated || false,
|
||||
method: authMethod,
|
||||
hasApiKey: status.hasApiKey || false,
|
||||
hasEnvApiKey: !!process.env.ANTHROPIC_API_KEY || !!process.env.OPENAI_API_KEY,
|
||||
hasOAuthToken: status.hasOAuthToken || false,
|
||||
},
|
||||
recommendation: status.installed
|
||||
? undefined
|
||||
: 'Install OpenCode CLI to use multi-provider AI models.',
|
||||
installCommand,
|
||||
loginCommand,
|
||||
installCommands: {
|
||||
macos: installCommand,
|
||||
linux: installCommand,
|
||||
npm: 'npm install -g opencode-ai',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get OpenCode status failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -7,16 +7,8 @@ import type { Request, Response } from 'express';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getApiKey } from '../common.js';
|
||||
import {
|
||||
createSecureAuthEnv,
|
||||
AuthSessionManager,
|
||||
AuthRateLimiter,
|
||||
validateApiKey,
|
||||
createTempEnvOverride,
|
||||
} from '../../../lib/auth-utils.js';
|
||||
|
||||
const logger = createLogger('Setup');
|
||||
const rateLimiter = new AuthRateLimiter();
|
||||
|
||||
// Known error patterns that indicate auth failure
|
||||
const AUTH_ERROR_PATTERNS = [
|
||||
@@ -85,19 +77,6 @@ export function createVerifyClaudeAuthHandler() {
|
||||
apiKey?: string;
|
||||
};
|
||||
|
||||
// Rate limiting to prevent abuse
|
||||
const clientIp = req.ip || req.socket.remoteAddress || 'unknown';
|
||||
if (!rateLimiter.canAttempt(clientIp)) {
|
||||
const resetTime = rateLimiter.getResetTime(clientIp);
|
||||
res.status(429).json({
|
||||
success: false,
|
||||
authenticated: false,
|
||||
error: 'Too many authentication attempts. Please try again later.',
|
||||
resetTime,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[Setup] Verifying Claude authentication using method: ${authMethod || 'auto'}${apiKey ? ' (with provided key)' : ''}`
|
||||
);
|
||||
@@ -110,48 +89,37 @@ export function createVerifyClaudeAuthHandler() {
|
||||
let errorMessage = '';
|
||||
let receivedAnyContent = false;
|
||||
|
||||
// Create secure auth session
|
||||
const sessionId = `claude-auth-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
// Save original env values
|
||||
const originalAnthropicKey = process.env.ANTHROPIC_API_KEY;
|
||||
|
||||
try {
|
||||
// For API key verification, validate the key first
|
||||
if (authMethod === 'api_key' && apiKey) {
|
||||
const validation = validateApiKey(apiKey, 'anthropic');
|
||||
if (!validation.isValid) {
|
||||
res.json({
|
||||
success: true,
|
||||
authenticated: false,
|
||||
error: validation.error,
|
||||
});
|
||||
return;
|
||||
// Configure environment based on auth method
|
||||
if (authMethod === 'cli') {
|
||||
// For CLI verification, remove any API key so it uses CLI credentials only
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
logger.info('[Setup] Cleared API key environment for CLI verification');
|
||||
} else if (authMethod === 'api_key') {
|
||||
// For API key verification, use provided key, stored key, or env var (in order of priority)
|
||||
if (apiKey) {
|
||||
// Use the provided API key (allows testing unsaved keys)
|
||||
process.env.ANTHROPIC_API_KEY = apiKey;
|
||||
logger.info('[Setup] Using provided API key for verification');
|
||||
} else {
|
||||
const storedApiKey = getApiKey('anthropic');
|
||||
if (storedApiKey) {
|
||||
process.env.ANTHROPIC_API_KEY = storedApiKey;
|
||||
logger.info('[Setup] Using stored API key for verification');
|
||||
} else if (!process.env.ANTHROPIC_API_KEY) {
|
||||
res.json({
|
||||
success: true,
|
||||
authenticated: false,
|
||||
error: 'No API key configured. Please enter an API key first.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create secure environment without modifying process.env
|
||||
const authEnv = createSecureAuthEnv(authMethod || 'api_key', apiKey, 'anthropic');
|
||||
|
||||
// For API key verification without provided key, use stored key or env var
|
||||
if (authMethod === 'api_key' && !apiKey) {
|
||||
const storedApiKey = getApiKey('anthropic');
|
||||
if (storedApiKey) {
|
||||
authEnv.ANTHROPIC_API_KEY = storedApiKey;
|
||||
logger.info('[Setup] Using stored API key for verification');
|
||||
} else if (!authEnv.ANTHROPIC_API_KEY) {
|
||||
res.json({
|
||||
success: true,
|
||||
authenticated: false,
|
||||
error: 'No API key configured. Please enter an API key first.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Store the secure environment in session manager
|
||||
AuthSessionManager.createSession(sessionId, authMethod || 'api_key', apiKey, 'anthropic');
|
||||
|
||||
// Create temporary environment override for SDK call
|
||||
const cleanupEnv = createTempEnvOverride(authEnv);
|
||||
|
||||
// Run a minimal query to verify authentication
|
||||
const stream = query({
|
||||
prompt: "Reply with only the word 'ok'",
|
||||
@@ -310,8 +278,13 @@ export function createVerifyClaudeAuthHandler() {
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
// Clean up the auth session
|
||||
AuthSessionManager.destroySession(sessionId);
|
||||
// Restore original environment
|
||||
if (originalAnthropicKey !== undefined) {
|
||||
process.env.ANTHROPIC_API_KEY = originalAnthropicKey;
|
||||
} else if (authMethod === 'cli') {
|
||||
// If we cleared it and there was no original, keep it cleared
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('[Setup] Verification result:', {
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
/**
|
||||
* POST /verify-codex-auth endpoint - Verify Codex authentication
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { CODEX_MODEL_MAP } from '@automaker/types';
|
||||
import { ProviderFactory } from '../../../providers/provider-factory.js';
|
||||
import { getApiKey } from '../common.js';
|
||||
import { getCodexAuthIndicators } from '@automaker/platform';
|
||||
import {
|
||||
createSecureAuthEnv,
|
||||
AuthSessionManager,
|
||||
AuthRateLimiter,
|
||||
validateApiKey,
|
||||
createTempEnvOverride,
|
||||
} from '../../../lib/auth-utils.js';
|
||||
|
||||
const logger = createLogger('Setup');
|
||||
const rateLimiter = new AuthRateLimiter();
|
||||
const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY';
|
||||
const AUTH_PROMPT = "Reply with only the word 'ok'";
|
||||
const AUTH_TIMEOUT_MS = 30000;
|
||||
const ERROR_BILLING_MESSAGE =
|
||||
'Credit balance is too low. Please add credits to your OpenAI account.';
|
||||
const ERROR_RATE_LIMIT_MESSAGE =
|
||||
'Rate limit reached. Please wait a while before trying again or upgrade your plan.';
|
||||
const ERROR_CLI_AUTH_REQUIRED =
|
||||
"CLI authentication failed. Please run 'codex login' to authenticate.";
|
||||
const ERROR_API_KEY_REQUIRED = 'No API key configured. Please enter an API key first.';
|
||||
const AUTH_ERROR_PATTERNS = [
|
||||
'authentication',
|
||||
'unauthorized',
|
||||
'invalid_api_key',
|
||||
'invalid api key',
|
||||
'api key is invalid',
|
||||
'not authenticated',
|
||||
'login',
|
||||
'auth(',
|
||||
'token refresh',
|
||||
'tokenrefresh',
|
||||
'failed to parse server response',
|
||||
'transport channel closed',
|
||||
];
|
||||
const BILLING_ERROR_PATTERNS = [
|
||||
'credit balance is too low',
|
||||
'credit balance too low',
|
||||
'insufficient credits',
|
||||
'insufficient balance',
|
||||
'no credits',
|
||||
'out of credits',
|
||||
'billing',
|
||||
'payment required',
|
||||
'add credits',
|
||||
];
|
||||
const RATE_LIMIT_PATTERNS = [
|
||||
'limit reached',
|
||||
'rate limit',
|
||||
'rate_limit',
|
||||
'too many requests',
|
||||
'resets',
|
||||
'429',
|
||||
];
|
||||
|
||||
function containsAuthError(text: string): boolean {
|
||||
const lowerText = text.toLowerCase();
|
||||
return AUTH_ERROR_PATTERNS.some((pattern) => lowerText.includes(pattern));
|
||||
}
|
||||
|
||||
function isBillingError(text: string): boolean {
|
||||
const lowerText = text.toLowerCase();
|
||||
return BILLING_ERROR_PATTERNS.some((pattern) => lowerText.includes(pattern));
|
||||
}
|
||||
|
||||
function isRateLimitError(text: string): boolean {
|
||||
if (isBillingError(text)) {
|
||||
return false;
|
||||
}
|
||||
const lowerText = text.toLowerCase();
|
||||
return RATE_LIMIT_PATTERNS.some((pattern) => lowerText.includes(pattern));
|
||||
}
|
||||
|
||||
export function createVerifyCodexAuthHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
const { authMethod, apiKey } = req.body as {
|
||||
authMethod?: 'cli' | 'api_key';
|
||||
apiKey?: string;
|
||||
};
|
||||
|
||||
// Create session ID for cleanup
|
||||
const sessionId = `codex-auth-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
// Rate limiting
|
||||
const clientIp = req.ip || req.socket.remoteAddress || 'unknown';
|
||||
if (!rateLimiter.canAttempt(clientIp)) {
|
||||
const resetTime = rateLimiter.getResetTime(clientIp);
|
||||
res.status(429).json({
|
||||
success: false,
|
||||
authenticated: false,
|
||||
error: 'Too many authentication attempts. Please try again later.',
|
||||
resetTime,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = setTimeout(() => abortController.abort(), AUTH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
// Create secure environment without modifying process.env
|
||||
const authEnv = createSecureAuthEnv(authMethod || 'api_key', apiKey, 'openai');
|
||||
|
||||
// For API key auth, validate and use the provided key or stored key
|
||||
if (authMethod === 'api_key') {
|
||||
if (apiKey) {
|
||||
// Use the provided API key
|
||||
const validation = validateApiKey(apiKey, 'openai');
|
||||
if (!validation.isValid) {
|
||||
res.json({ success: true, authenticated: false, error: validation.error });
|
||||
return;
|
||||
}
|
||||
authEnv[OPENAI_API_KEY_ENV] = validation.normalizedKey;
|
||||
} else {
|
||||
// Try stored key
|
||||
const storedApiKey = getApiKey('openai');
|
||||
if (storedApiKey) {
|
||||
const validation = validateApiKey(storedApiKey, 'openai');
|
||||
if (!validation.isValid) {
|
||||
res.json({ success: true, authenticated: false, error: validation.error });
|
||||
return;
|
||||
}
|
||||
authEnv[OPENAI_API_KEY_ENV] = validation.normalizedKey;
|
||||
} else if (!authEnv[OPENAI_API_KEY_ENV]) {
|
||||
res.json({ success: true, authenticated: false, error: ERROR_API_KEY_REQUIRED });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create session and temporary environment override
|
||||
AuthSessionManager.createSession(sessionId, authMethod || 'api_key', undefined, 'openai');
|
||||
const cleanupEnv = createTempEnvOverride(authEnv);
|
||||
|
||||
try {
|
||||
if (authMethod === 'cli') {
|
||||
const authIndicators = await getCodexAuthIndicators();
|
||||
if (!authIndicators.hasOAuthToken && !authIndicators.hasApiKey) {
|
||||
res.json({
|
||||
success: true,
|
||||
authenticated: false,
|
||||
error: ERROR_CLI_AUTH_REQUIRED,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Use Codex provider explicitly (not ProviderFactory.getProviderForModel)
|
||||
// because Cursor also supports GPT models and has higher priority
|
||||
const provider = ProviderFactory.getProviderByName('codex');
|
||||
if (!provider) {
|
||||
throw new Error('Codex provider not available');
|
||||
}
|
||||
const stream = provider.executeQuery({
|
||||
prompt: AUTH_PROMPT,
|
||||
model: CODEX_MODEL_MAP.gpt52Codex,
|
||||
cwd: process.cwd(),
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
abortController,
|
||||
});
|
||||
|
||||
let receivedAnyContent = false;
|
||||
let errorMessage = '';
|
||||
|
||||
for await (const msg of stream) {
|
||||
if (msg.type === 'error' && msg.error) {
|
||||
if (isBillingError(msg.error)) {
|
||||
errorMessage = ERROR_BILLING_MESSAGE;
|
||||
} else if (isRateLimitError(msg.error)) {
|
||||
errorMessage = ERROR_RATE_LIMIT_MESSAGE;
|
||||
} else {
|
||||
errorMessage = msg.error;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
receivedAnyContent = true;
|
||||
if (isBillingError(block.text)) {
|
||||
errorMessage = ERROR_BILLING_MESSAGE;
|
||||
break;
|
||||
}
|
||||
if (isRateLimitError(block.text)) {
|
||||
errorMessage = ERROR_RATE_LIMIT_MESSAGE;
|
||||
break;
|
||||
}
|
||||
if (containsAuthError(block.text)) {
|
||||
errorMessage = block.text;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.type === 'result' && msg.result) {
|
||||
receivedAnyContent = true;
|
||||
if (isBillingError(msg.result)) {
|
||||
errorMessage = ERROR_BILLING_MESSAGE;
|
||||
} else if (isRateLimitError(msg.result)) {
|
||||
errorMessage = ERROR_RATE_LIMIT_MESSAGE;
|
||||
} else if (containsAuthError(msg.result)) {
|
||||
errorMessage = msg.result;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errorMessage) {
|
||||
// Rate limit and billing errors mean auth succeeded but usage is limited
|
||||
const isUsageLimitError =
|
||||
errorMessage === ERROR_BILLING_MESSAGE || errorMessage === ERROR_RATE_LIMIT_MESSAGE;
|
||||
|
||||
const response: {
|
||||
success: boolean;
|
||||
authenticated: boolean;
|
||||
error: string;
|
||||
details?: string;
|
||||
} = {
|
||||
success: true,
|
||||
authenticated: isUsageLimitError ? true : false,
|
||||
error: isUsageLimitError
|
||||
? errorMessage
|
||||
: authMethod === 'cli'
|
||||
? ERROR_CLI_AUTH_REQUIRED
|
||||
: 'API key is invalid or has been revoked.',
|
||||
};
|
||||
|
||||
// Include detailed error for auth failures so users can debug
|
||||
if (!isUsageLimitError && errorMessage !== response.error) {
|
||||
response.details = errorMessage;
|
||||
}
|
||||
|
||||
res.json(response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!receivedAnyContent) {
|
||||
res.json({
|
||||
success: true,
|
||||
authenticated: false,
|
||||
error: 'No response received from Codex. Please check your authentication.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true, authenticated: true });
|
||||
} finally {
|
||||
// Clean up environment override
|
||||
cleanupEnv();
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const errMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.error('[Setup] Codex auth verification error:', errMessage);
|
||||
const normalizedError = isBillingError(errMessage)
|
||||
? ERROR_BILLING_MESSAGE
|
||||
: isRateLimitError(errMessage)
|
||||
? ERROR_RATE_LIMIT_MESSAGE
|
||||
: errMessage;
|
||||
res.json({
|
||||
success: true,
|
||||
authenticated: false,
|
||||
error: normalizedError,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
// Clean up session
|
||||
AuthSessionManager.destroySession(sessionId);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -8,12 +8,7 @@
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import {
|
||||
DEFAULT_PHASE_MODELS,
|
||||
isCursorModel,
|
||||
stripProviderPrefix,
|
||||
type ThinkingLevel,
|
||||
} from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS, isCursorModel, type ThinkingLevel } from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { createSuggestionsOptions } from '../../lib/sdk-options.js';
|
||||
import { extractJsonWithArray } from '../../lib/json-extractor.js';
|
||||
@@ -212,8 +207,6 @@ The response will be automatically formatted as structured JSON.`;
|
||||
logger.info('[Suggestions] Using Cursor provider');
|
||||
|
||||
const provider = ProviderFactory.getProviderForModel(model);
|
||||
// Strip provider prefix - providers expect bare model IDs
|
||||
const bareModel = stripProviderPrefix(model);
|
||||
|
||||
// For Cursor, include the JSON schema in the prompt with clear instructions
|
||||
const cursorPrompt = `${prompt}
|
||||
@@ -229,7 +222,7 @@ Your entire response should be valid JSON starting with { and ending with }. No
|
||||
|
||||
for await (const msg of provider.executeQuery({
|
||||
prompt: cursorPrompt,
|
||||
model: bareModel,
|
||||
model,
|
||||
cwd: projectPath,
|
||||
maxTurns: 250,
|
||||
allowedTools: ['Read', 'Glob', 'Grep'],
|
||||
|
||||
@@ -11,10 +11,9 @@ import { getGitRepositoryDiffs } from '../../common.js';
|
||||
export function createDiffsHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, featureId, useWorktrees } = req.body as {
|
||||
const { projectPath, featureId } = req.body as {
|
||||
projectPath: string;
|
||||
featureId: string;
|
||||
useWorktrees?: boolean;
|
||||
};
|
||||
|
||||
if (!projectPath || !featureId) {
|
||||
@@ -25,19 +24,6 @@ export function createDiffsHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
// If worktrees aren't enabled, don't probe .worktrees at all.
|
||||
// This avoids noisy logs that make it look like features are "running in worktrees".
|
||||
if (useWorktrees === false) {
|
||||
const result = await getGitRepositoryDiffs(projectPath);
|
||||
res.json({
|
||||
success: true,
|
||||
diff: result.diff,
|
||||
files: result.files,
|
||||
hasChanges: result.hasChanges,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Git worktrees are stored in project directory
|
||||
const worktreePath = path.join(projectPath, '.worktrees', featureId);
|
||||
|
||||
@@ -55,11 +41,7 @@ export function createDiffsHandler() {
|
||||
});
|
||||
} catch (innerError) {
|
||||
// Worktree doesn't exist - fallback to main project path
|
||||
const code = (innerError as NodeJS.ErrnoException | undefined)?.code;
|
||||
// ENOENT is expected when a feature has no worktree; don't log as an error.
|
||||
if (code && code !== 'ENOENT') {
|
||||
logError(innerError, 'Worktree access failed, falling back to main project');
|
||||
}
|
||||
logError(innerError, 'Worktree access failed, falling back to main project');
|
||||
|
||||
try {
|
||||
const result = await getGitRepositoryDiffs(projectPath);
|
||||
|
||||
@@ -15,11 +15,10 @@ const execAsync = promisify(exec);
|
||||
export function createFileDiffHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, featureId, filePath, useWorktrees } = req.body as {
|
||||
const { projectPath, featureId, filePath } = req.body as {
|
||||
projectPath: string;
|
||||
featureId: string;
|
||||
filePath: string;
|
||||
useWorktrees?: boolean;
|
||||
};
|
||||
|
||||
if (!projectPath || !featureId || !filePath) {
|
||||
@@ -30,12 +29,6 @@ export function createFileDiffHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
// If worktrees aren't enabled, don't probe .worktrees at all.
|
||||
if (useWorktrees === false) {
|
||||
res.json({ success: true, diff: '', filePath });
|
||||
return;
|
||||
}
|
||||
|
||||
// Git worktrees are stored in project directory
|
||||
const worktreePath = path.join(projectPath, '.worktrees', featureId);
|
||||
|
||||
@@ -64,11 +57,7 @@ export function createFileDiffHandler() {
|
||||
|
||||
res.json({ success: true, diff, filePath });
|
||||
} catch (innerError) {
|
||||
const code = (innerError as NodeJS.ErrnoException | undefined)?.code;
|
||||
// ENOENT is expected when a feature has no worktree; don't log as an error.
|
||||
if (code && code !== 'ENOENT') {
|
||||
logError(innerError, 'Worktree file diff failed');
|
||||
}
|
||||
logError(innerError, 'Worktree file diff failed');
|
||||
res.json({ success: true, diff: '', filePath });
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -6,16 +6,13 @@
|
||||
import path from 'path';
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
import type { EventEmitter } from '../lib/events.js';
|
||||
import type { ExecuteOptions, ThinkingLevel, ReasoningEffort } from '@automaker/types';
|
||||
import { stripProviderPrefix } from '@automaker/types';
|
||||
import type { ExecuteOptions, ThinkingLevel } from '@automaker/types';
|
||||
import {
|
||||
readImageAsBase64,
|
||||
buildPromptWithImages,
|
||||
isAbortError,
|
||||
loadContextFiles,
|
||||
createLogger,
|
||||
classifyError,
|
||||
getUserFriendlyErrorMessage,
|
||||
} from '@automaker/utils';
|
||||
import { ProviderFactory } from '../providers/provider-factory.js';
|
||||
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
|
||||
@@ -23,12 +20,10 @@ import { PathNotAllowedError } from '@automaker/platform';
|
||||
import type { SettingsService } from './settings-service.js';
|
||||
import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getEnableSandboxModeSetting,
|
||||
filterClaudeMdFromContext,
|
||||
getMCPServersFromSettings,
|
||||
getPromptCustomization,
|
||||
getSkillsConfiguration,
|
||||
getSubagentsConfiguration,
|
||||
getCustomSubagents,
|
||||
} from '../lib/settings-helpers.js';
|
||||
|
||||
interface Message {
|
||||
@@ -60,7 +55,6 @@ interface Session {
|
||||
workingDirectory: string;
|
||||
model?: string;
|
||||
thinkingLevel?: ThinkingLevel; // Thinking level for Claude models
|
||||
reasoningEffort?: ReasoningEffort; // Reasoning effort for Codex models
|
||||
sdkSessionId?: string; // Claude SDK session ID for conversation continuity
|
||||
promptQueue: QueuedPrompt[]; // Queue of prompts to auto-run after current task
|
||||
}
|
||||
@@ -150,7 +144,6 @@ export class AgentService {
|
||||
imagePaths,
|
||||
model,
|
||||
thinkingLevel,
|
||||
reasoningEffort,
|
||||
}: {
|
||||
sessionId: string;
|
||||
message: string;
|
||||
@@ -158,7 +151,6 @@ export class AgentService {
|
||||
imagePaths?: string[];
|
||||
model?: string;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
reasoningEffort?: ReasoningEffort;
|
||||
}) {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
@@ -171,7 +163,7 @@ export class AgentService {
|
||||
throw new Error('Agent is already processing a message');
|
||||
}
|
||||
|
||||
// Update session model, thinking level, and reasoning effort if provided
|
||||
// Update session model and thinking level if provided
|
||||
if (model) {
|
||||
session.model = model;
|
||||
await this.updateSession(sessionId, { model });
|
||||
@@ -179,21 +171,6 @@ export class AgentService {
|
||||
if (thinkingLevel !== undefined) {
|
||||
session.thinkingLevel = thinkingLevel;
|
||||
}
|
||||
if (reasoningEffort !== undefined) {
|
||||
session.reasoningEffort = reasoningEffort;
|
||||
}
|
||||
|
||||
// Validate vision support before processing images
|
||||
const effectiveModel = model || session.model;
|
||||
if (imagePaths && imagePaths.length > 0 && effectiveModel) {
|
||||
const supportsVision = ProviderFactory.modelSupportsVision(effectiveModel);
|
||||
if (!supportsVision) {
|
||||
throw new Error(
|
||||
`This model (${effectiveModel}) does not support image input. ` +
|
||||
`Please switch to a model that supports vision, or remove the images and try again.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Read images and convert to base64
|
||||
const images: Message['images'] = [];
|
||||
@@ -255,34 +232,19 @@ export class AgentService {
|
||||
'[AgentService]'
|
||||
);
|
||||
|
||||
// Load enableSandboxMode setting (global setting only)
|
||||
const enableSandboxMode = await getEnableSandboxModeSetting(
|
||||
this.settingsService,
|
||||
'[AgentService]'
|
||||
);
|
||||
|
||||
// Load MCP servers from settings (global setting only)
|
||||
const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AgentService]');
|
||||
|
||||
// Get Skills configuration from settings
|
||||
const skillsConfig = this.settingsService
|
||||
? await getSkillsConfiguration(this.settingsService)
|
||||
: { enabled: false, sources: [] as Array<'user' | 'project'>, shouldIncludeInTools: false };
|
||||
|
||||
// Get Subagents configuration from settings
|
||||
const subagentsConfig = this.settingsService
|
||||
? await getSubagentsConfiguration(this.settingsService)
|
||||
: { enabled: false, sources: [] as Array<'user' | 'project'>, shouldIncludeInTools: false };
|
||||
|
||||
// Get custom subagents from settings (merge global + project-level) only if enabled
|
||||
const customSubagents =
|
||||
this.settingsService && subagentsConfig.enabled
|
||||
? await getCustomSubagents(this.settingsService, effectiveWorkDir)
|
||||
: undefined;
|
||||
|
||||
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) and memory files
|
||||
// Use the user's message as task context for smart memory selection
|
||||
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.)
|
||||
const contextResult = await loadContextFiles({
|
||||
projectPath: effectiveWorkDir,
|
||||
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
|
||||
taskContext: {
|
||||
title: message.substring(0, 200), // Use first 200 chars as title
|
||||
description: message,
|
||||
},
|
||||
});
|
||||
|
||||
// When autoLoadClaudeMd is enabled, filter out CLAUDE.md to avoid duplication
|
||||
@@ -296,9 +258,8 @@ export class AgentService {
|
||||
: baseSystemPrompt;
|
||||
|
||||
// Build SDK options using centralized configuration
|
||||
// Use thinking level and reasoning effort from request, or fall back to session's stored values
|
||||
// Use thinking level from request, or fall back to session's stored thinking level
|
||||
const effectiveThinkingLevel = thinkingLevel ?? session.thinkingLevel;
|
||||
const effectiveReasoningEffort = reasoningEffort ?? session.reasoningEffort;
|
||||
const sdkOptions = createChatOptions({
|
||||
cwd: effectiveWorkDir,
|
||||
model: model,
|
||||
@@ -306,6 +267,7 @@ export class AgentService {
|
||||
systemPrompt: combinedSystemPrompt,
|
||||
abortController: session.abortController!,
|
||||
autoLoadClaudeMd,
|
||||
enableSandboxMode,
|
||||
thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
||||
});
|
||||
@@ -313,71 +275,25 @@ export class AgentService {
|
||||
// Extract model, maxTurns, and allowedTools from SDK options
|
||||
const effectiveModel = sdkOptions.model!;
|
||||
const maxTurns = sdkOptions.maxTurns;
|
||||
let allowedTools = sdkOptions.allowedTools as string[] | undefined;
|
||||
const allowedTools = sdkOptions.allowedTools as string[] | undefined;
|
||||
|
||||
// Build merged settingSources array using Set for automatic deduplication
|
||||
const sdkSettingSources = (sdkOptions.settingSources ?? []).filter(
|
||||
(source): source is 'user' | 'project' => source === 'user' || source === 'project'
|
||||
);
|
||||
const skillSettingSources = skillsConfig.enabled ? skillsConfig.sources : [];
|
||||
const settingSources = [...new Set([...sdkSettingSources, ...skillSettingSources])];
|
||||
|
||||
// Enhance allowedTools with Skills and Subagents tools
|
||||
// These tools are not in the provider's default set - they're added dynamically based on settings
|
||||
const needsSkillTool = skillsConfig.shouldIncludeInTools;
|
||||
const needsTaskTool =
|
||||
subagentsConfig.shouldIncludeInTools &&
|
||||
customSubagents &&
|
||||
Object.keys(customSubagents).length > 0;
|
||||
|
||||
// Base tools that match the provider's default set
|
||||
const baseTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'];
|
||||
|
||||
if (allowedTools) {
|
||||
allowedTools = [...allowedTools]; // Create a copy to avoid mutating SDK options
|
||||
// Add Skill tool if skills are enabled
|
||||
if (needsSkillTool && !allowedTools.includes('Skill')) {
|
||||
allowedTools.push('Skill');
|
||||
}
|
||||
// Add Task tool if custom subagents are configured
|
||||
if (needsTaskTool && !allowedTools.includes('Task')) {
|
||||
allowedTools.push('Task');
|
||||
}
|
||||
} else if (needsSkillTool || needsTaskTool) {
|
||||
// If no allowedTools specified but we need to add Skill/Task tools,
|
||||
// build the full list including base tools
|
||||
allowedTools = [...baseTools];
|
||||
if (needsSkillTool) {
|
||||
allowedTools.push('Skill');
|
||||
}
|
||||
if (needsTaskTool) {
|
||||
allowedTools.push('Task');
|
||||
}
|
||||
}
|
||||
|
||||
// Get provider for this model (with prefix)
|
||||
// Get provider for this model
|
||||
const provider = ProviderFactory.getProviderForModel(effectiveModel);
|
||||
|
||||
// Strip provider prefix - providers should receive bare model IDs
|
||||
const bareModel = stripProviderPrefix(effectiveModel);
|
||||
|
||||
// Build options for provider
|
||||
const options: ExecuteOptions = {
|
||||
prompt: '', // Will be set below based on images
|
||||
model: bareModel, // Bare model ID (e.g., "gpt-5.1-codex-max", "composer-1")
|
||||
originalModel: effectiveModel, // Original with prefix for logging (e.g., "codex-gpt-5.1-codex-max")
|
||||
model: effectiveModel,
|
||||
cwd: effectiveWorkDir,
|
||||
systemPrompt: sdkOptions.systemPrompt,
|
||||
maxTurns: maxTurns,
|
||||
allowedTools: allowedTools,
|
||||
abortController: session.abortController!,
|
||||
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
|
||||
settingSources: settingSources.length > 0 ? settingSources : undefined,
|
||||
settingSources: sdkOptions.settingSources,
|
||||
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
|
||||
sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
|
||||
agents: customSubagents, // Pass custom subagents for task delegation
|
||||
thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models
|
||||
reasoningEffort: effectiveReasoningEffort, // Pass reasoning effort for Codex models
|
||||
};
|
||||
|
||||
// Build prompt content with images
|
||||
@@ -458,53 +374,6 @@ export class AgentService {
|
||||
content: responseText,
|
||||
toolUses,
|
||||
});
|
||||
} else if (msg.type === 'error') {
|
||||
// Some providers (like Codex CLI/SaaS or Cursor CLI) surface failures as
|
||||
// streamed error messages instead of throwing. Handle these here so the
|
||||
// Agent Runner UX matches the Claude/Cursor behavior without changing
|
||||
// their provider implementations.
|
||||
const rawErrorText =
|
||||
(typeof msg.error === 'string' && msg.error.trim()) ||
|
||||
'Unexpected error from provider during agent execution.';
|
||||
|
||||
const errorInfo = classifyError(new Error(rawErrorText));
|
||||
|
||||
// Keep the provider-supplied text intact (Codex already includes helpful tips),
|
||||
// only add a small rate-limit hint when we can detect it.
|
||||
const enhancedText = errorInfo.isRateLimit
|
||||
? `${rawErrorText}\n\nTip: It looks like you hit a rate limit. Try waiting a bit or reducing concurrent Agent Runner / Auto Mode tasks.`
|
||||
: rawErrorText;
|
||||
|
||||
this.logger.error('Provider error during agent execution:', {
|
||||
type: errorInfo.type,
|
||||
message: errorInfo.message,
|
||||
});
|
||||
|
||||
// Mark session as no longer running so the UI and queue stay in sync
|
||||
session.isRunning = false;
|
||||
session.abortController = null;
|
||||
|
||||
const errorMessage: Message = {
|
||||
id: this.generateId(),
|
||||
role: 'assistant',
|
||||
content: `Error: ${enhancedText}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
isError: true,
|
||||
};
|
||||
|
||||
session.messages.push(errorMessage);
|
||||
await this.saveSession(sessionId, session.messages);
|
||||
|
||||
this.emitAgentEvent(sessionId, {
|
||||
type: 'error',
|
||||
error: enhancedText,
|
||||
message: errorMessage,
|
||||
});
|
||||
|
||||
// Don't continue streaming after an error message
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,17 +14,17 @@ import type {
|
||||
ExecuteOptions,
|
||||
Feature,
|
||||
ModelProvider,
|
||||
PipelineConfig,
|
||||
PipelineStep,
|
||||
ThinkingLevel,
|
||||
PlanningMode,
|
||||
} from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS, stripProviderPrefix } from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
|
||||
import {
|
||||
buildPromptWithImages,
|
||||
isAbortError,
|
||||
classifyError,
|
||||
loadContextFiles,
|
||||
appendLearning,
|
||||
recordMemoryUsage,
|
||||
createLogger,
|
||||
} from '@automaker/utils';
|
||||
|
||||
@@ -47,6 +47,7 @@ import type { SettingsService } from './settings-service.js';
|
||||
import { pipelineService, PipelineService } from './pipeline-service.js';
|
||||
import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getEnableSandboxModeSetting,
|
||||
filterClaudeMdFromContext,
|
||||
getMCPServersFromSettings,
|
||||
getPromptCustomization,
|
||||
@@ -322,8 +323,6 @@ export class AutoModeService {
|
||||
projectPath,
|
||||
});
|
||||
|
||||
// Note: Memory folder initialization is now handled by loadContextFiles
|
||||
|
||||
// Run the loop in the background
|
||||
this.runAutoLoop().catch((error) => {
|
||||
logger.error('Loop error:', error);
|
||||
@@ -515,21 +514,15 @@ export class AutoModeService {
|
||||
|
||||
// Build the prompt - use continuation prompt if provided (for recovery after plan approval)
|
||||
let prompt: string;
|
||||
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) and memory files
|
||||
// Context loader uses task context to select relevant memory files
|
||||
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) - passed as system prompt
|
||||
const contextResult = await loadContextFiles({
|
||||
projectPath,
|
||||
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
|
||||
taskContext: {
|
||||
title: feature.title ?? '',
|
||||
description: feature.description ?? '',
|
||||
},
|
||||
});
|
||||
|
||||
// When autoLoadClaudeMd is enabled, filter out CLAUDE.md to avoid duplication
|
||||
// (SDK handles CLAUDE.md via settingSources), but keep other context files like CODE_QUALITY.md
|
||||
// Note: contextResult.formattedPrompt now includes both context AND memory
|
||||
const combinedSystemPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);
|
||||
const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);
|
||||
|
||||
if (options?.continuationPrompt) {
|
||||
// Continuation prompt is used when recovering from a plan approval
|
||||
@@ -582,7 +575,7 @@ export class AutoModeService {
|
||||
projectPath,
|
||||
planningMode: feature.planningMode,
|
||||
requirePlanApproval: feature.requirePlanApproval,
|
||||
systemPrompt: combinedSystemPrompt || undefined,
|
||||
systemPrompt: contextFilesPrompt || undefined,
|
||||
autoLoadClaudeMd,
|
||||
thinkingLevel: feature.thinkingLevel,
|
||||
}
|
||||
@@ -614,36 +607,6 @@ export class AutoModeService {
|
||||
// Record success to reset consecutive failure tracking
|
||||
this.recordSuccess();
|
||||
|
||||
// Record learnings and memory usage after successful feature completion
|
||||
try {
|
||||
const featureDir = getFeatureDir(projectPath, featureId);
|
||||
const outputPath = path.join(featureDir, 'agent-output.md');
|
||||
let agentOutput = '';
|
||||
try {
|
||||
const outputContent = await secureFs.readFile(outputPath, 'utf-8');
|
||||
agentOutput =
|
||||
typeof outputContent === 'string' ? outputContent : outputContent.toString();
|
||||
} catch {
|
||||
// Agent output might not exist yet
|
||||
}
|
||||
|
||||
// Record memory usage if we loaded any memory files
|
||||
if (contextResult.memoryFiles.length > 0 && agentOutput) {
|
||||
await recordMemoryUsage(
|
||||
projectPath,
|
||||
contextResult.memoryFiles,
|
||||
agentOutput,
|
||||
true, // success
|
||||
secureFs as Parameters<typeof recordMemoryUsage>[4]
|
||||
);
|
||||
}
|
||||
|
||||
// Extract and record learnings from the agent output
|
||||
await this.recordLearningsFromFeature(projectPath, feature, agentOutput);
|
||||
} catch (learningError) {
|
||||
console.warn('[AutoMode] Failed to record learnings:', learningError);
|
||||
}
|
||||
|
||||
this.emitAutoModeEvent('auto_mode_feature_complete', {
|
||||
featureId,
|
||||
passes: true,
|
||||
@@ -712,14 +675,10 @@ export class AutoModeService {
|
||||
): Promise<void> {
|
||||
logger.info(`Executing ${steps.length} pipeline step(s) for feature ${featureId}`);
|
||||
|
||||
// Load context files once with feature context for smart memory selection
|
||||
// Load context files once
|
||||
const contextResult = await loadContextFiles({
|
||||
projectPath,
|
||||
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
|
||||
taskContext: {
|
||||
title: feature.title ?? '',
|
||||
description: feature.description ?? '',
|
||||
},
|
||||
});
|
||||
const contextFilesPrompt = filterClaudeMdFromContext(contextResult, autoLoadClaudeMd);
|
||||
|
||||
@@ -952,10 +911,6 @@ Complete the pipeline step instructions above. Review the previous work and appl
|
||||
const contextResult = await loadContextFiles({
|
||||
projectPath,
|
||||
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
|
||||
taskContext: {
|
||||
title: feature?.title ?? prompt.substring(0, 200),
|
||||
description: feature?.description ?? prompt,
|
||||
},
|
||||
});
|
||||
|
||||
// When autoLoadClaudeMd is enabled, filter out CLAUDE.md to avoid duplication
|
||||
@@ -1359,6 +1314,7 @@ Format your response as a structured markdown document.`;
|
||||
allowedTools: sdkOptions.allowedTools as string[],
|
||||
abortController,
|
||||
settingSources: sdkOptions.settingSources,
|
||||
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
|
||||
thinkingLevel: analysisThinkingLevel, // Pass thinking level
|
||||
};
|
||||
|
||||
@@ -1828,13 +1784,9 @@ Format your response as a structured markdown document.`;
|
||||
// Apply dependency-aware ordering
|
||||
const { orderedFeatures } = resolveDependencies(pendingFeatures);
|
||||
|
||||
// Get skipVerificationInAutoMode setting
|
||||
const settings = await this.settingsService?.getGlobalSettings();
|
||||
const skipVerification = settings?.skipVerificationInAutoMode ?? false;
|
||||
|
||||
// Filter to only features with satisfied dependencies
|
||||
const readyFeatures = orderedFeatures.filter((feature: Feature) =>
|
||||
areDependenciesSatisfied(feature, allFeatures, { skipVerification })
|
||||
areDependenciesSatisfied(feature, allFeatures)
|
||||
);
|
||||
|
||||
return readyFeatures;
|
||||
@@ -2037,18 +1989,6 @@ This helps parse your summary correctly in the output logs.`;
|
||||
const planningMode = options?.planningMode || 'skip';
|
||||
const previousContent = options?.previousContent;
|
||||
|
||||
// Validate vision support before processing images
|
||||
const effectiveModel = model || 'claude-sonnet-4-20250514';
|
||||
if (imagePaths && imagePaths.length > 0) {
|
||||
const supportsVision = ProviderFactory.modelSupportsVision(effectiveModel);
|
||||
if (!supportsVision) {
|
||||
throw new Error(
|
||||
`This model (${effectiveModel}) does not support image input. ` +
|
||||
`Please switch to a model that supports vision (like Claude models), or remove the images and try again.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this planning mode can generate a spec/plan that needs approval
|
||||
// - spec and full always generate specs
|
||||
// - lite only generates approval-ready content when requirePlanApproval is true
|
||||
@@ -2122,6 +2062,9 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
||||
? options.autoLoadClaudeMd
|
||||
: await getAutoLoadClaudeMdSetting(finalProjectPath, this.settingsService, '[AutoMode]');
|
||||
|
||||
// Load enableSandboxMode setting (global setting only)
|
||||
const enableSandboxMode = await getEnableSandboxModeSetting(this.settingsService, '[AutoMode]');
|
||||
|
||||
// Load MCP servers from settings (global setting only)
|
||||
const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AutoMode]');
|
||||
|
||||
@@ -2133,6 +2076,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
||||
model: model,
|
||||
abortController,
|
||||
autoLoadClaudeMd,
|
||||
enableSandboxMode,
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
||||
thinkingLevel: options?.thinkingLevel,
|
||||
});
|
||||
@@ -2149,12 +2093,7 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
||||
// Get provider for this model
|
||||
const provider = ProviderFactory.getProviderForModel(finalModel);
|
||||
|
||||
// Strip provider prefix - providers should receive bare model IDs
|
||||
const bareModel = stripProviderPrefix(finalModel);
|
||||
|
||||
logger.info(
|
||||
`Using provider "${provider.getName()}" for model "${finalModel}" (bare: ${bareModel})`
|
||||
);
|
||||
logger.info(`Using provider "${provider.getName()}" for model "${finalModel}"`);
|
||||
|
||||
// Build prompt content with images using utility
|
||||
const { content: promptContent } = await buildPromptWithImages(
|
||||
@@ -2173,13 +2112,14 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
||||
|
||||
const executeOptions: ExecuteOptions = {
|
||||
prompt: promptContent,
|
||||
model: bareModel,
|
||||
model: finalModel,
|
||||
maxTurns: maxTurns,
|
||||
cwd: workDir,
|
||||
allowedTools: allowedTools,
|
||||
abortController,
|
||||
systemPrompt: sdkOptions.systemPrompt,
|
||||
settingSources: sdkOptions.settingSources,
|
||||
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
|
||||
thinkingLevel: options?.thinkingLevel, // Pass thinking level for extended thinking
|
||||
};
|
||||
@@ -2262,23 +2202,9 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
||||
}, WRITE_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
// Heartbeat logging so "silent" model calls are visible.
|
||||
// Some runs can take a while before the first streamed message arrives.
|
||||
const streamStartTime = Date.now();
|
||||
let receivedAnyStreamMessage = false;
|
||||
const STREAM_HEARTBEAT_MS = 15_000;
|
||||
const streamHeartbeat = setInterval(() => {
|
||||
if (receivedAnyStreamMessage) return;
|
||||
const elapsedSeconds = Math.round((Date.now() - streamStartTime) / 1000);
|
||||
logger.info(
|
||||
`Waiting for first model response for feature ${featureId} (${elapsedSeconds}s elapsed)...`
|
||||
);
|
||||
}, STREAM_HEARTBEAT_MS);
|
||||
|
||||
// Wrap stream processing in try/finally to ensure timeout cleanup on any error/abort
|
||||
try {
|
||||
streamLoop: for await (const msg of stream) {
|
||||
receivedAnyStreamMessage = true;
|
||||
// Log raw stream event for debugging
|
||||
appendRawEvent(msg);
|
||||
|
||||
@@ -2478,7 +2404,7 @@ After generating the revised spec, output:
|
||||
// Make revision call
|
||||
const revisionStream = provider.executeQuery({
|
||||
prompt: revisionPrompt,
|
||||
model: bareModel,
|
||||
model: finalModel,
|
||||
maxTurns: maxTurns || 100,
|
||||
cwd: workDir,
|
||||
allowedTools: allowedTools,
|
||||
@@ -2616,7 +2542,7 @@ After generating the revised spec, output:
|
||||
// Execute task with dedicated agent
|
||||
const taskStream = provider.executeQuery({
|
||||
prompt: taskPrompt,
|
||||
model: bareModel,
|
||||
model: finalModel,
|
||||
maxTurns: Math.min(maxTurns || 100, 50), // Limit turns per task
|
||||
cwd: workDir,
|
||||
allowedTools: allowedTools,
|
||||
@@ -2704,7 +2630,7 @@ Implement all the changes described in the plan above.`;
|
||||
|
||||
const continuationStream = provider.executeQuery({
|
||||
prompt: continuationPrompt,
|
||||
model: bareModel,
|
||||
model: finalModel,
|
||||
maxTurns: maxTurns,
|
||||
cwd: workDir,
|
||||
allowedTools: allowedTools,
|
||||
@@ -2795,7 +2721,6 @@ Implement all the changes described in the plan above.`;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearInterval(streamHeartbeat);
|
||||
// ALWAYS clear pending timeouts to prevent memory leaks
|
||||
// This runs on success, error, or abort
|
||||
if (writeTimeout) {
|
||||
@@ -2949,207 +2874,4 @@ Begin implementing task ${task.id} now.`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract and record learnings from a completed feature
|
||||
* Uses a quick Claude call to identify important decisions and patterns
|
||||
*/
|
||||
private async recordLearningsFromFeature(
|
||||
projectPath: string,
|
||||
feature: Feature,
|
||||
agentOutput: string
|
||||
): Promise<void> {
|
||||
if (!agentOutput || agentOutput.length < 100) {
|
||||
// Not enough output to extract learnings from
|
||||
console.log(
|
||||
`[AutoMode] Skipping learning extraction - output too short (${agentOutput?.length || 0} chars)`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[AutoMode] Extracting learnings from feature "${feature.title}" (${agentOutput.length} chars)`
|
||||
);
|
||||
|
||||
// Limit output to avoid token limits
|
||||
const truncatedOutput = agentOutput.length > 10000 ? agentOutput.slice(-10000) : agentOutput;
|
||||
|
||||
const userPrompt = `You are an Architecture Decision Record (ADR) extractor. Analyze this implementation and return ONLY JSON with learnings. No explanations.
|
||||
|
||||
Feature: "${feature.title}"
|
||||
|
||||
Implementation log:
|
||||
${truncatedOutput}
|
||||
|
||||
Extract MEANINGFUL learnings - not obvious things. For each, capture:
|
||||
- DECISIONS: Why this approach vs alternatives? What would break if changed?
|
||||
- GOTCHAS: What was unexpected? What's the root cause? How to avoid?
|
||||
- PATTERNS: Why this pattern? What problem does it solve? Trade-offs?
|
||||
|
||||
JSON format ONLY (no markdown, no text):
|
||||
{"learnings": [{
|
||||
"category": "architecture|api|ui|database|auth|testing|performance|security|gotchas",
|
||||
"type": "decision|gotcha|pattern",
|
||||
"content": "What was done/learned",
|
||||
"context": "Problem being solved or situation faced",
|
||||
"why": "Reasoning - why this approach",
|
||||
"rejected": "Alternative considered and why rejected",
|
||||
"tradeoffs": "What became easier/harder",
|
||||
"breaking": "What breaks if this is changed/removed"
|
||||
}]}
|
||||
|
||||
IMPORTANT: Only include NON-OBVIOUS learnings with real reasoning. Skip trivial patterns.
|
||||
If nothing notable: {"learnings": []}`;
|
||||
|
||||
try {
|
||||
// Import query dynamically to avoid circular dependencies
|
||||
const { query } = await import('@anthropic-ai/claude-agent-sdk');
|
||||
|
||||
// Get model from phase settings
|
||||
const settings = await this.settingsService?.getGlobalSettings();
|
||||
const phaseModelEntry =
|
||||
settings?.phaseModels?.memoryExtractionModel || DEFAULT_PHASE_MODELS.memoryExtractionModel;
|
||||
const { model } = resolvePhaseModel(phaseModelEntry);
|
||||
|
||||
const stream = query({
|
||||
prompt: userPrompt,
|
||||
options: {
|
||||
model,
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
permissionMode: 'acceptEdits',
|
||||
systemPrompt:
|
||||
'You are a JSON extraction assistant. You MUST respond with ONLY valid JSON, no explanations, no markdown, no other text. Extract learnings from the provided implementation context and return them as JSON.',
|
||||
},
|
||||
});
|
||||
|
||||
// Extract text from stream
|
||||
let responseText = '';
|
||||
for await (const msg of stream) {
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
responseText += block.text;
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'result' && msg.subtype === 'success') {
|
||||
responseText = msg.result || responseText;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[AutoMode] Learning extraction response: ${responseText.length} chars`);
|
||||
console.log(`[AutoMode] Response preview: ${responseText.substring(0, 300)}`);
|
||||
|
||||
// Parse the response - handle JSON in markdown code blocks or raw
|
||||
let jsonStr: string | null = null;
|
||||
|
||||
// First try to find JSON in markdown code blocks
|
||||
const codeBlockMatch = responseText.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/);
|
||||
if (codeBlockMatch) {
|
||||
console.log('[AutoMode] Found JSON in code block');
|
||||
jsonStr = codeBlockMatch[1];
|
||||
} else {
|
||||
// Fall back to finding balanced braces containing "learnings"
|
||||
// Use a more precise approach: find the opening brace before "learnings"
|
||||
const learningsIndex = responseText.indexOf('"learnings"');
|
||||
if (learningsIndex !== -1) {
|
||||
// Find the opening brace before "learnings"
|
||||
let braceStart = responseText.lastIndexOf('{', learningsIndex);
|
||||
if (braceStart !== -1) {
|
||||
// Find matching closing brace
|
||||
let braceCount = 0;
|
||||
let braceEnd = -1;
|
||||
for (let i = braceStart; i < responseText.length; i++) {
|
||||
if (responseText[i] === '{') braceCount++;
|
||||
if (responseText[i] === '}') braceCount--;
|
||||
if (braceCount === 0) {
|
||||
braceEnd = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (braceEnd !== -1) {
|
||||
jsonStr = responseText.substring(braceStart, braceEnd + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!jsonStr) {
|
||||
console.log('[AutoMode] Could not extract JSON from response');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[AutoMode] Extracted JSON: ${jsonStr.substring(0, 200)}`);
|
||||
|
||||
let parsed: { learnings?: unknown[] };
|
||||
try {
|
||||
parsed = JSON.parse(jsonStr);
|
||||
} catch {
|
||||
console.warn('[AutoMode] Failed to parse learnings JSON:', jsonStr.substring(0, 200));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!parsed.learnings || !Array.isArray(parsed.learnings)) {
|
||||
console.log('[AutoMode] No learnings array in parsed response');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[AutoMode] Found ${parsed.learnings.length} potential learnings`);
|
||||
|
||||
// Valid learning types
|
||||
const validTypes = new Set(['decision', 'learning', 'pattern', 'gotcha']);
|
||||
|
||||
// Record each learning
|
||||
for (const item of parsed.learnings) {
|
||||
// Validate required fields with proper type narrowing
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
|
||||
const learning = item as Record<string, unknown>;
|
||||
if (
|
||||
!learning.category ||
|
||||
typeof learning.category !== 'string' ||
|
||||
!learning.content ||
|
||||
typeof learning.content !== 'string' ||
|
||||
!learning.content.trim()
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate and normalize type
|
||||
const typeStr = typeof learning.type === 'string' ? learning.type : 'learning';
|
||||
const learningType = validTypes.has(typeStr)
|
||||
? (typeStr as 'decision' | 'learning' | 'pattern' | 'gotcha')
|
||||
: 'learning';
|
||||
|
||||
console.log(
|
||||
`[AutoMode] Appending learning: category=${learning.category}, type=${learningType}`
|
||||
);
|
||||
await appendLearning(
|
||||
projectPath,
|
||||
{
|
||||
category: learning.category,
|
||||
type: learningType,
|
||||
content: learning.content.trim(),
|
||||
context: typeof learning.context === 'string' ? learning.context : undefined,
|
||||
why: typeof learning.why === 'string' ? learning.why : undefined,
|
||||
rejected: typeof learning.rejected === 'string' ? learning.rejected : undefined,
|
||||
tradeoffs: typeof learning.tradeoffs === 'string' ? learning.tradeoffs : undefined,
|
||||
breaking: typeof learning.breaking === 'string' ? learning.breaking : undefined,
|
||||
},
|
||||
secureFs as Parameters<typeof appendLearning>[2]
|
||||
);
|
||||
}
|
||||
|
||||
const validLearnings = parsed.learnings.filter(
|
||||
(l) => l && typeof l === 'object' && (l as Record<string, unknown>).content
|
||||
);
|
||||
if (validLearnings.length > 0) {
|
||||
console.log(
|
||||
`[AutoMode] Recorded ${parsed.learnings.length} learning(s) from feature ${feature.id}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[AutoMode] Failed to extract learnings from feature ${feature.id}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,404 +0,0 @@
|
||||
import {
|
||||
findCodexCliPath,
|
||||
spawnProcess,
|
||||
getCodexAuthPath,
|
||||
systemPathExists,
|
||||
systemPathReadFile,
|
||||
} from '@automaker/platform';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('CodexUsage');
|
||||
|
||||
export interface CodexRateLimitWindow {
|
||||
limit: number;
|
||||
used: number;
|
||||
remaining: number;
|
||||
usedPercent: number;
|
||||
windowDurationMins: number;
|
||||
resetsAt: number;
|
||||
}
|
||||
|
||||
export interface CodexCreditsSnapshot {
|
||||
balance?: string;
|
||||
unlimited?: boolean;
|
||||
hasCredits?: boolean;
|
||||
}
|
||||
|
||||
export type CodexPlanType = 'free' | 'plus' | 'pro' | 'team' | 'enterprise' | 'edu' | 'unknown';
|
||||
|
||||
export interface CodexUsageData {
|
||||
rateLimits: {
|
||||
primary?: CodexRateLimitWindow;
|
||||
secondary?: CodexRateLimitWindow;
|
||||
credits?: CodexCreditsSnapshot;
|
||||
planType?: CodexPlanType;
|
||||
} | null;
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex Usage Service
|
||||
*
|
||||
* Attempts to fetch usage data from Codex CLI and OpenAI API.
|
||||
* Codex CLI doesn't provide a direct usage command, but we can:
|
||||
* 1. Parse usage info from error responses (rate limit errors contain plan info)
|
||||
* 2. Check for OpenAI API usage if API key is available
|
||||
*/
|
||||
export class CodexUsageService {
|
||||
private cachedCliPath: string | null = null;
|
||||
|
||||
/**
|
||||
* Check if Codex CLI is available on the system
|
||||
*/
|
||||
async isAvailable(): Promise<boolean> {
|
||||
this.cachedCliPath = await findCodexCliPath();
|
||||
return Boolean(this.cachedCliPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to fetch usage data
|
||||
*
|
||||
* Tries multiple approaches:
|
||||
* 1. Always try to get plan type from auth file first (authoritative source)
|
||||
* 2. Check for OpenAI API key in environment for API usage
|
||||
* 3. Make a test request to capture rate limit headers from CLI
|
||||
* 4. Combine results from auth file and CLI
|
||||
*/
|
||||
async fetchUsageData(): Promise<CodexUsageData> {
|
||||
const cliPath = this.cachedCliPath || (await findCodexCliPath());
|
||||
|
||||
if (!cliPath) {
|
||||
throw new Error('Codex CLI not found. Please install it with: npm install -g @openai/codex');
|
||||
}
|
||||
|
||||
// Always try to get plan type from auth file first - this is the authoritative source
|
||||
const authPlanType = await this.getPlanTypeFromAuthFile();
|
||||
|
||||
// Check if user has an API key that we can use
|
||||
const hasApiKey = !!process.env.OPENAI_API_KEY;
|
||||
|
||||
if (hasApiKey) {
|
||||
// Try to get usage from OpenAI API
|
||||
const openaiUsage = await this.fetchOpenAIUsage();
|
||||
if (openaiUsage) {
|
||||
// Merge with auth file plan type if available
|
||||
if (authPlanType && openaiUsage.rateLimits) {
|
||||
openaiUsage.rateLimits.planType = authPlanType;
|
||||
}
|
||||
return openaiUsage;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to get usage from Codex CLI by making a simple request
|
||||
const codexUsage = await this.fetchCodexUsage(cliPath, authPlanType);
|
||||
if (codexUsage) {
|
||||
return codexUsage;
|
||||
}
|
||||
|
||||
// Fallback: try to parse full usage from auth file
|
||||
const authUsage = await this.fetchFromAuthFile();
|
||||
if (authUsage) {
|
||||
return authUsage;
|
||||
}
|
||||
|
||||
// If all else fails, return a message with helpful information
|
||||
throw new Error(
|
||||
'Codex usage statistics require additional configuration. ' +
|
||||
'To enable usage tracking:\n\n' +
|
||||
'1. Set your OpenAI API key in the environment:\n' +
|
||||
' export OPENAI_API_KEY=sk-...\n\n' +
|
||||
'2. Or check your usage at:\n' +
|
||||
' https://platform.openai.com/usage\n\n' +
|
||||
'Note: If using Codex CLI with ChatGPT OAuth authentication, ' +
|
||||
'usage data must be queried through your OpenAI account.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract plan type from auth file JWT token
|
||||
* Returns the actual plan type or 'unknown' if not available
|
||||
*/
|
||||
private async getPlanTypeFromAuthFile(): Promise<CodexPlanType> {
|
||||
try {
|
||||
const authFilePath = getCodexAuthPath();
|
||||
const exists = await systemPathExists(authFilePath);
|
||||
|
||||
if (!exists) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
const authContent = await systemPathReadFile(authFilePath);
|
||||
const authData = JSON.parse(authContent);
|
||||
|
||||
if (!authData.tokens?.id_token) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
const claims = this.parseJwt(authData.tokens.id_token);
|
||||
if (!claims) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
// Extract plan type from nested OpenAI auth object with type validation
|
||||
const openaiAuthClaim = claims['https://api.openai.com/auth'];
|
||||
|
||||
let accountType: string | undefined;
|
||||
let isSubscriptionExpired = false;
|
||||
|
||||
if (
|
||||
openaiAuthClaim &&
|
||||
typeof openaiAuthClaim === 'object' &&
|
||||
!Array.isArray(openaiAuthClaim)
|
||||
) {
|
||||
const openaiAuth = openaiAuthClaim as Record<string, unknown>;
|
||||
|
||||
if (typeof openaiAuth.chatgpt_plan_type === 'string') {
|
||||
accountType = openaiAuth.chatgpt_plan_type;
|
||||
}
|
||||
|
||||
// Check if subscription has expired
|
||||
if (typeof openaiAuth.chatgpt_subscription_active_until === 'string') {
|
||||
const expiryDate = new Date(openaiAuth.chatgpt_subscription_active_until);
|
||||
if (!isNaN(expiryDate.getTime())) {
|
||||
isSubscriptionExpired = expiryDate < new Date();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: try top-level claim names
|
||||
const possibleClaimNames = [
|
||||
'https://chatgpt.com/account_type',
|
||||
'account_type',
|
||||
'plan',
|
||||
'plan_type',
|
||||
];
|
||||
|
||||
for (const claimName of possibleClaimNames) {
|
||||
const claimValue = claims[claimName];
|
||||
if (claimValue && typeof claimValue === 'string') {
|
||||
accountType = claimValue;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If subscription is expired, treat as free plan
|
||||
if (isSubscriptionExpired && accountType && accountType !== 'free') {
|
||||
logger.info(`Subscription expired, using "free" instead of "${accountType}"`);
|
||||
accountType = 'free';
|
||||
}
|
||||
|
||||
if (accountType) {
|
||||
const normalizedType = accountType.toLowerCase();
|
||||
if (['free', 'plus', 'pro', 'team', 'enterprise', 'edu'].includes(normalizedType)) {
|
||||
return normalizedType as CodexPlanType;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to get plan type from auth file:', error);
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to fetch usage from OpenAI API using the API key
|
||||
*/
|
||||
private async fetchOpenAIUsage(): Promise<CodexUsageData | null> {
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const endTime = Math.floor(Date.now() / 1000);
|
||||
const startTime = endTime - 7 * 24 * 60 * 60; // Last 7 days
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.openai.com/v1/organization/usage/completions?start_time=${startTime}&end_time=${endTime}&limit=1`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return this.parseOpenAIUsage(data);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch from OpenAI API:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse OpenAI usage API response
|
||||
*/
|
||||
private parseOpenAIUsage(data: any): CodexUsageData {
|
||||
let totalInputTokens = 0;
|
||||
let totalOutputTokens = 0;
|
||||
|
||||
if (data.data && Array.isArray(data.data)) {
|
||||
for (const bucket of data.data) {
|
||||
if (bucket.results && Array.isArray(bucket.results)) {
|
||||
for (const result of bucket.results) {
|
||||
totalInputTokens += result.input_tokens || 0;
|
||||
totalOutputTokens += result.output_tokens || 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
rateLimits: {
|
||||
planType: 'unknown',
|
||||
credits: {
|
||||
hasCredits: true,
|
||||
},
|
||||
},
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to fetch usage by making a test request to Codex CLI
|
||||
* and parsing rate limit information from the response
|
||||
*/
|
||||
private async fetchCodexUsage(
|
||||
cliPath: string,
|
||||
authPlanType: CodexPlanType
|
||||
): Promise<CodexUsageData | null> {
|
||||
try {
|
||||
// Make a simple request to trigger rate limit info if at limit
|
||||
const result = await spawnProcess({
|
||||
command: cliPath,
|
||||
args: ['exec', '--', 'echo', 'test'],
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: 'dumb',
|
||||
},
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Parse the output for rate limit information
|
||||
const combinedOutput = (result.stdout + result.stderr).toLowerCase();
|
||||
|
||||
// Check if we got a rate limit error
|
||||
const rateLimitMatch = combinedOutput.match(
|
||||
/usage_limit_reached.*?"plan_type":"([^"]+)".*?"resets_at":(\d+).*?"resets_in_seconds":(\d+)/
|
||||
);
|
||||
|
||||
if (rateLimitMatch) {
|
||||
// Rate limit error contains the plan type - use that as it's the most authoritative
|
||||
const planType = rateLimitMatch[1] as CodexPlanType;
|
||||
const resetsAt = parseInt(rateLimitMatch[2], 10);
|
||||
const resetsInSeconds = parseInt(rateLimitMatch[3], 10);
|
||||
|
||||
logger.info(
|
||||
`Rate limit hit - plan: ${planType}, resets in ${Math.ceil(resetsInSeconds / 60)} mins`
|
||||
);
|
||||
|
||||
return {
|
||||
rateLimits: {
|
||||
planType,
|
||||
primary: {
|
||||
limit: 0,
|
||||
used: 0,
|
||||
remaining: 0,
|
||||
usedPercent: 100,
|
||||
windowDurationMins: Math.ceil(resetsInSeconds / 60),
|
||||
resetsAt,
|
||||
},
|
||||
},
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// No rate limit error - use the plan type from auth file
|
||||
const isFreePlan = authPlanType === 'free';
|
||||
|
||||
return {
|
||||
rateLimits: {
|
||||
planType: authPlanType,
|
||||
credits: {
|
||||
hasCredits: true,
|
||||
unlimited: !isFreePlan && authPlanType !== 'unknown',
|
||||
},
|
||||
},
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch from Codex CLI:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to extract usage info from the Codex auth file
|
||||
* Reuses getPlanTypeFromAuthFile to avoid code duplication
|
||||
*/
|
||||
private async fetchFromAuthFile(): Promise<CodexUsageData | null> {
|
||||
try {
|
||||
const planType = await this.getPlanTypeFromAuthFile();
|
||||
|
||||
if (planType === 'unknown') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isFreePlan = planType === 'free';
|
||||
|
||||
return {
|
||||
rateLimits: {
|
||||
planType,
|
||||
credits: {
|
||||
hasCredits: true,
|
||||
unlimited: !isFreePlan,
|
||||
},
|
||||
},
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse auth file:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse JWT token to extract claims
|
||||
*/
|
||||
private parseJwt(token: string): any {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
|
||||
if (parts.length !== 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const base64Url = parts[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
|
||||
// Use Buffer for Node.js environment instead of atob
|
||||
let jsonPayload: string;
|
||||
if (typeof Buffer !== 'undefined') {
|
||||
jsonPayload = Buffer.from(base64, 'base64').toString('utf-8');
|
||||
} else {
|
||||
jsonPayload = decodeURIComponent(
|
||||
atob(base64)
|
||||
.split('')
|
||||
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
||||
.join('')
|
||||
);
|
||||
}
|
||||
|
||||
return JSON.parse(jsonPayload);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import type { Feature, DescriptionHistoryEntry } from '@automaker/types';
|
||||
import type { Feature } from '@automaker/types';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
import {
|
||||
@@ -274,16 +274,6 @@ export class FeatureLoader {
|
||||
featureData.imagePaths
|
||||
);
|
||||
|
||||
// Initialize description history with the initial description
|
||||
const initialHistory: DescriptionHistoryEntry[] = [];
|
||||
if (featureData.description && featureData.description.trim()) {
|
||||
initialHistory.push({
|
||||
description: featureData.description,
|
||||
timestamp: new Date().toISOString(),
|
||||
source: 'initial',
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure feature has required fields
|
||||
const feature: Feature = {
|
||||
category: featureData.category || 'Uncategorized',
|
||||
@@ -291,7 +281,6 @@ export class FeatureLoader {
|
||||
...featureData,
|
||||
id: featureId,
|
||||
imagePaths: migratedImagePaths,
|
||||
descriptionHistory: initialHistory,
|
||||
};
|
||||
|
||||
// Write feature.json
|
||||
@@ -303,18 +292,11 @@ export class FeatureLoader {
|
||||
|
||||
/**
|
||||
* Update a feature (partial updates supported)
|
||||
* @param projectPath - Path to the project
|
||||
* @param featureId - ID of the feature to update
|
||||
* @param updates - Partial feature updates
|
||||
* @param descriptionHistorySource - Source of description change ('enhance' or 'edit')
|
||||
* @param enhancementMode - Enhancement mode if source is 'enhance'
|
||||
*/
|
||||
async update(
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
updates: Partial<Feature>,
|
||||
descriptionHistorySource?: 'enhance' | 'edit',
|
||||
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'
|
||||
updates: Partial<Feature>
|
||||
): Promise<Feature> {
|
||||
const feature = await this.get(projectPath, featureId);
|
||||
if (!feature) {
|
||||
@@ -331,28 +313,11 @@ export class FeatureLoader {
|
||||
updatedImagePaths = await this.migrateImages(projectPath, featureId, updates.imagePaths);
|
||||
}
|
||||
|
||||
// Track description history if description changed
|
||||
let updatedHistory = feature.descriptionHistory || [];
|
||||
if (
|
||||
updates.description !== undefined &&
|
||||
updates.description !== feature.description &&
|
||||
updates.description.trim()
|
||||
) {
|
||||
const historyEntry: DescriptionHistoryEntry = {
|
||||
description: updates.description,
|
||||
timestamp: new Date().toISOString(),
|
||||
source: descriptionHistorySource || 'edit',
|
||||
...(descriptionHistorySource === 'enhance' && enhancementMode ? { enhancementMode } : {}),
|
||||
};
|
||||
updatedHistory = [...updatedHistory, historyEntry];
|
||||
}
|
||||
|
||||
// Merge updates
|
||||
const updatedFeature: Feature = {
|
||||
...feature,
|
||||
...updates,
|
||||
...(updatedImagePaths !== undefined ? { imagePaths: updatedImagePaths } : {}),
|
||||
descriptionHistory: updatedHistory,
|
||||
};
|
||||
|
||||
// Write back to file
|
||||
|
||||
@@ -40,7 +40,6 @@ import type { SettingsService } from './settings-service.js';
|
||||
import type { FeatureLoader } from './feature-loader.js';
|
||||
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
|
||||
import { resolveModelString } from '@automaker/model-resolver';
|
||||
import { stripProviderPrefix } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('IdeationService');
|
||||
|
||||
@@ -202,7 +201,7 @@ export class IdeationService {
|
||||
existingWorkContext
|
||||
);
|
||||
|
||||
// Resolve model alias to canonical identifier (with prefix)
|
||||
// Resolve model alias to canonical identifier
|
||||
const modelId = resolveModelString(options?.model ?? 'sonnet');
|
||||
|
||||
// Create SDK options
|
||||
@@ -215,13 +214,9 @@ export class IdeationService {
|
||||
|
||||
const provider = ProviderFactory.getProviderForModel(modelId);
|
||||
|
||||
// Strip provider prefix - providers need bare model IDs
|
||||
const bareModel = stripProviderPrefix(modelId);
|
||||
|
||||
const executeOptions: ExecuteOptions = {
|
||||
prompt: message,
|
||||
model: bareModel,
|
||||
originalModel: modelId,
|
||||
model: modelId,
|
||||
cwd: projectPath,
|
||||
systemPrompt: sdkOptions.systemPrompt,
|
||||
maxTurns: 1, // Single turn for ideation
|
||||
@@ -653,7 +648,7 @@ export class IdeationService {
|
||||
existingWorkContext
|
||||
);
|
||||
|
||||
// Resolve model alias to canonical identifier (with prefix)
|
||||
// Resolve model alias to canonical identifier
|
||||
const modelId = resolveModelString('sonnet');
|
||||
|
||||
// Create SDK options
|
||||
@@ -666,13 +661,9 @@ export class IdeationService {
|
||||
|
||||
const provider = ProviderFactory.getProviderForModel(modelId);
|
||||
|
||||
// Strip provider prefix - providers need bare model IDs
|
||||
const bareModel = stripProviderPrefix(modelId);
|
||||
|
||||
const executeOptions: ExecuteOptions = {
|
||||
prompt: prompt.prompt,
|
||||
model: bareModel,
|
||||
originalModel: modelId,
|
||||
model: modelId,
|
||||
cwd: projectPath,
|
||||
systemPrompt: sdkOptions.systemPrompt,
|
||||
maxTurns: 1,
|
||||
|
||||
673
apps/server/src/services/performance-monitor-service.ts
Normal file
673
apps/server/src/services/performance-monitor-service.ts
Normal file
@@ -0,0 +1,673 @@
|
||||
/**
|
||||
* Performance Monitor Service
|
||||
*
|
||||
* Collects and streams server-side performance metrics including:
|
||||
* - Memory usage (heap, rss, external)
|
||||
* - CPU usage (user, system, percentage)
|
||||
* - Event loop lag detection
|
||||
* - Memory leak trend analysis
|
||||
*
|
||||
* Emits debug events for real-time streaming to connected clients.
|
||||
*/
|
||||
|
||||
import v8 from 'v8';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { EventEmitter } from '../lib/events.js';
|
||||
import type {
|
||||
ServerMemoryMetrics,
|
||||
ServerCPUMetrics,
|
||||
MemoryMetrics,
|
||||
CPUMetrics,
|
||||
MemoryTrend,
|
||||
DebugMetricsConfig,
|
||||
DebugMetricsSnapshot,
|
||||
ProcessSummary,
|
||||
TrackedProcess,
|
||||
} from '@automaker/types';
|
||||
import { DEFAULT_DEBUG_METRICS_CONFIG, formatBytes } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('PerformanceMonitor');
|
||||
|
||||
/**
|
||||
* Circular buffer for time-series data storage
|
||||
* Uses index-based ring buffer for O(1) push operations instead of O(n) shift().
|
||||
* Efficiently stores a fixed number of data points, automatically discarding old ones.
|
||||
*/
|
||||
class CircularBuffer<T> {
|
||||
private buffer: (T | undefined)[];
|
||||
private maxSize: number;
|
||||
private head = 0; // Write position
|
||||
private count = 0; // Number of items
|
||||
|
||||
constructor(maxSize: number) {
|
||||
this.maxSize = maxSize;
|
||||
this.buffer = new Array(maxSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add item to buffer - O(1) operation
|
||||
*/
|
||||
push(item: T): void {
|
||||
this.buffer[this.head] = item;
|
||||
this.head = (this.head + 1) % this.maxSize;
|
||||
if (this.count < this.maxSize) {
|
||||
this.count++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all items in chronological order - O(n) but only called when needed
|
||||
*/
|
||||
getAll(): T[] {
|
||||
if (this.count === 0) return [];
|
||||
|
||||
const result: T[] = new Array(this.count);
|
||||
const start = this.count < this.maxSize ? 0 : this.head;
|
||||
|
||||
for (let i = 0; i < this.count; i++) {
|
||||
const idx = (start + i) % this.maxSize;
|
||||
result[i] = this.buffer[idx] as T;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get most recent item - O(1)
|
||||
*/
|
||||
getLast(): T | undefined {
|
||||
if (this.count === 0) return undefined;
|
||||
const idx = (this.head - 1 + this.maxSize) % this.maxSize;
|
||||
return this.buffer[idx];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get oldest item - O(1)
|
||||
*/
|
||||
getFirst(): T | undefined {
|
||||
if (this.count === 0) return undefined;
|
||||
const start = this.count < this.maxSize ? 0 : this.head;
|
||||
return this.buffer[start];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current count - O(1)
|
||||
*/
|
||||
size(): number {
|
||||
return this.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all items - O(1)
|
||||
*/
|
||||
clear(): void {
|
||||
this.head = 0;
|
||||
this.count = 0;
|
||||
// Don't reallocate array, just reset indices
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize buffer, preserving existing data
|
||||
*/
|
||||
resize(newSize: number): void {
|
||||
const oldData = this.getAll();
|
||||
this.maxSize = newSize;
|
||||
this.buffer = new Array(newSize);
|
||||
this.head = 0;
|
||||
this.count = 0;
|
||||
|
||||
// Copy over data (trim if necessary, keep most recent)
|
||||
const startIdx = Math.max(0, oldData.length - newSize);
|
||||
for (let i = startIdx; i < oldData.length; i++) {
|
||||
this.push(oldData[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Memory data point for trend analysis
|
||||
*/
|
||||
interface MemoryDataPoint {
|
||||
timestamp: number;
|
||||
heapUsed: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* CPU data point for tracking
|
||||
*/
|
||||
interface CPUDataPoint {
|
||||
timestamp: number;
|
||||
user: number;
|
||||
system: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* PerformanceMonitorService - Collects server-side performance metrics
|
||||
*
|
||||
* This service runs in the Node.js server process and periodically collects:
|
||||
* - Memory metrics from process.memoryUsage()
|
||||
* - CPU metrics from process.cpuUsage()
|
||||
* - Event loop lag using setTimeout deviation
|
||||
*
|
||||
* It streams metrics to connected clients via the event emitter and
|
||||
* analyzes memory trends to detect potential leaks.
|
||||
*/
|
||||
export class PerformanceMonitorService {
|
||||
private events: EventEmitter;
|
||||
private config: DebugMetricsConfig;
|
||||
private isRunning = false;
|
||||
private collectionInterval: NodeJS.Timeout | null = null;
|
||||
private eventLoopCheckInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
// Data storage
|
||||
private memoryHistory: CircularBuffer<MemoryDataPoint>;
|
||||
private cpuHistory: CircularBuffer<CPUDataPoint>;
|
||||
|
||||
// CPU tracking state
|
||||
private lastCpuUsage: NodeJS.CpuUsage | null = null;
|
||||
private lastCpuTime: number = 0;
|
||||
|
||||
// Event loop lag tracking
|
||||
private lastEventLoopLag = 0;
|
||||
private eventLoopLagThreshold = 100; // ms - threshold for warning
|
||||
|
||||
// Memory warning thresholds (percentage of heap limit)
|
||||
private memoryWarningThreshold = 70; // 70% of heap limit
|
||||
private memoryCriticalThreshold = 90; // 90% of heap limit
|
||||
private lastMemoryWarningTime = 0;
|
||||
private memoryWarningCooldown = 30000; // 30 seconds between warnings
|
||||
|
||||
// Process tracking (will be populated by ProcessRegistryService)
|
||||
private getProcesses: () => TrackedProcess[] = () => [];
|
||||
|
||||
constructor(events: EventEmitter, config?: Partial<DebugMetricsConfig>) {
|
||||
this.events = events;
|
||||
this.config = { ...DEFAULT_DEBUG_METRICS_CONFIG, ...config };
|
||||
this.memoryHistory = new CircularBuffer(this.config.maxDataPoints);
|
||||
this.cpuHistory = new CircularBuffer(this.config.maxDataPoints);
|
||||
|
||||
logger.info('PerformanceMonitorService initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the process provider function (called by ProcessRegistryService)
|
||||
*/
|
||||
setProcessProvider(provider: () => TrackedProcess[]): void {
|
||||
this.getProcesses = provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start metrics collection
|
||||
*/
|
||||
start(): void {
|
||||
if (this.isRunning) {
|
||||
logger.warn('PerformanceMonitorService is already running');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isRunning = true;
|
||||
this.lastCpuUsage = process.cpuUsage();
|
||||
this.lastCpuTime = Date.now();
|
||||
|
||||
// Start periodic metrics collection
|
||||
this.collectionInterval = setInterval(() => {
|
||||
this.collectAndEmitMetrics();
|
||||
}, this.config.collectionInterval);
|
||||
|
||||
// Start event loop lag monitoring (more frequent for accurate detection)
|
||||
this.startEventLoopMonitoring();
|
||||
|
||||
logger.info('PerformanceMonitorService started', {
|
||||
interval: this.config.collectionInterval,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop metrics collection
|
||||
*/
|
||||
stop(): void {
|
||||
if (!this.isRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isRunning = false;
|
||||
|
||||
if (this.collectionInterval) {
|
||||
clearInterval(this.collectionInterval);
|
||||
this.collectionInterval = null;
|
||||
}
|
||||
|
||||
if (this.eventLoopCheckInterval) {
|
||||
clearInterval(this.eventLoopCheckInterval);
|
||||
this.eventLoopCheckInterval = null;
|
||||
}
|
||||
|
||||
logger.info('PerformanceMonitorService stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration
|
||||
*/
|
||||
updateConfig(config: Partial<DebugMetricsConfig>): void {
|
||||
const wasRunning = this.isRunning;
|
||||
|
||||
if (wasRunning) {
|
||||
this.stop();
|
||||
}
|
||||
|
||||
this.config = { ...this.config, ...config };
|
||||
|
||||
// Resize buffers if maxDataPoints changed
|
||||
if (config.maxDataPoints) {
|
||||
this.memoryHistory.resize(config.maxDataPoints);
|
||||
this.cpuHistory.resize(config.maxDataPoints);
|
||||
}
|
||||
|
||||
if (wasRunning) {
|
||||
this.start();
|
||||
}
|
||||
|
||||
logger.info('PerformanceMonitorService configuration updated', config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current configuration
|
||||
*/
|
||||
getConfig(): DebugMetricsConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get whether monitoring is active
|
||||
*/
|
||||
isActive(): boolean {
|
||||
return this.isRunning;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect and emit current metrics
|
||||
*/
|
||||
private collectAndEmitMetrics(): void {
|
||||
const timestamp = Date.now();
|
||||
const memoryMetrics = this.collectMemoryMetrics(timestamp);
|
||||
const cpuMetrics = this.collectCPUMetrics(timestamp);
|
||||
|
||||
// Store in history
|
||||
if (this.config.memoryEnabled && memoryMetrics.server) {
|
||||
this.memoryHistory.push({
|
||||
timestamp,
|
||||
heapUsed: memoryMetrics.server.heapUsed,
|
||||
});
|
||||
}
|
||||
|
||||
// Analyze memory trend
|
||||
const memoryTrend = this.analyzeMemoryTrend();
|
||||
|
||||
// Get process information
|
||||
const processes = this.getProcesses();
|
||||
const processSummary = this.calculateProcessSummary(processes);
|
||||
|
||||
// Build snapshot
|
||||
const snapshot: DebugMetricsSnapshot = {
|
||||
timestamp,
|
||||
memory: memoryMetrics,
|
||||
cpu: cpuMetrics,
|
||||
processes,
|
||||
processSummary,
|
||||
memoryTrend,
|
||||
};
|
||||
|
||||
// Emit metrics event
|
||||
this.events.emit('debug:metrics', {
|
||||
type: 'debug:metrics',
|
||||
timestamp,
|
||||
metrics: snapshot,
|
||||
});
|
||||
|
||||
// Check for memory warnings
|
||||
this.checkMemoryThresholds(memoryMetrics);
|
||||
|
||||
// Check for memory leak
|
||||
if (memoryTrend && memoryTrend.isLeaking) {
|
||||
this.events.emit('debug:leak-detected', {
|
||||
type: 'debug:leak-detected',
|
||||
timestamp,
|
||||
trend: memoryTrend,
|
||||
message: `Potential memory leak detected: ${formatBytes(memoryTrend.growthRate)}/s sustained growth`,
|
||||
});
|
||||
}
|
||||
|
||||
// Check for high CPU
|
||||
if (cpuMetrics.server && cpuMetrics.server.percentage > 80) {
|
||||
this.events.emit('debug:high-cpu', {
|
||||
type: 'debug:high-cpu',
|
||||
timestamp,
|
||||
cpu: cpuMetrics,
|
||||
usagePercent: cpuMetrics.server.percentage,
|
||||
threshold: 80,
|
||||
message: `High CPU usage: ${cpuMetrics.server.percentage.toFixed(1)}%`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect memory metrics from Node.js process
|
||||
*/
|
||||
private collectMemoryMetrics(timestamp: number): MemoryMetrics {
|
||||
if (!this.config.memoryEnabled) {
|
||||
return { timestamp };
|
||||
}
|
||||
|
||||
const usage = process.memoryUsage();
|
||||
const serverMetrics: ServerMemoryMetrics = {
|
||||
heapTotal: usage.heapTotal,
|
||||
heapUsed: usage.heapUsed,
|
||||
external: usage.external,
|
||||
rss: usage.rss,
|
||||
arrayBuffers: usage.arrayBuffers,
|
||||
};
|
||||
|
||||
return {
|
||||
timestamp,
|
||||
server: serverMetrics,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect CPU metrics from Node.js process
|
||||
*/
|
||||
private collectCPUMetrics(timestamp: number): CPUMetrics {
|
||||
if (!this.config.cpuEnabled) {
|
||||
return { timestamp };
|
||||
}
|
||||
|
||||
const currentCpuUsage = process.cpuUsage();
|
||||
const currentTime = Date.now();
|
||||
|
||||
let serverMetrics: ServerCPUMetrics | undefined;
|
||||
|
||||
if (this.lastCpuUsage) {
|
||||
// Calculate CPU usage since last measurement
|
||||
const userDiff = currentCpuUsage.user - this.lastCpuUsage.user;
|
||||
const systemDiff = currentCpuUsage.system - this.lastCpuUsage.system;
|
||||
const timeDiff = (currentTime - this.lastCpuTime) * 1000; // Convert to microseconds
|
||||
|
||||
// Calculate percentage (CPU usage is in microseconds)
|
||||
// For multi-core systems, this can exceed 100%
|
||||
const percentage = timeDiff > 0 ? ((userDiff + systemDiff) / timeDiff) * 100 : 0;
|
||||
|
||||
serverMetrics = {
|
||||
percentage: Math.min(100, percentage), // Cap at 100% for single-core representation
|
||||
user: userDiff,
|
||||
system: systemDiff,
|
||||
};
|
||||
|
||||
// Store in history
|
||||
this.cpuHistory.push({
|
||||
timestamp,
|
||||
user: userDiff,
|
||||
system: systemDiff,
|
||||
});
|
||||
}
|
||||
|
||||
this.lastCpuUsage = currentCpuUsage;
|
||||
this.lastCpuTime = currentTime;
|
||||
|
||||
return {
|
||||
timestamp,
|
||||
server: serverMetrics,
|
||||
eventLoopLag: this.lastEventLoopLag,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start event loop lag monitoring
|
||||
* Uses setTimeout deviation to detect when the event loop is blocked
|
||||
*/
|
||||
private startEventLoopMonitoring(): void {
|
||||
const checkInterval = 100; // Check every 100ms
|
||||
|
||||
const measureLag = () => {
|
||||
if (!this.isRunning) return;
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
// setImmediate runs after I/O events, giving us event loop lag
|
||||
setImmediate(() => {
|
||||
const lag = Date.now() - start;
|
||||
this.lastEventLoopLag = lag;
|
||||
|
||||
// Emit warning if lag exceeds threshold
|
||||
if (lag > this.eventLoopLagThreshold) {
|
||||
this.events.emit('debug:event-loop-blocked', {
|
||||
type: 'debug:event-loop-blocked',
|
||||
timestamp: Date.now(),
|
||||
lag,
|
||||
threshold: this.eventLoopLagThreshold,
|
||||
message: `Event loop blocked for ${lag}ms`,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
this.eventLoopCheckInterval = setInterval(measureLag, checkInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze memory trend for leak detection
|
||||
*/
|
||||
private analyzeMemoryTrend(): MemoryTrend | undefined {
|
||||
const history = this.memoryHistory.getAll();
|
||||
if (history.length < 10) {
|
||||
return undefined; // Need at least 10 samples for meaningful analysis
|
||||
}
|
||||
|
||||
const first = history[0];
|
||||
const last = history[history.length - 1];
|
||||
const windowDuration = last.timestamp - first.timestamp;
|
||||
|
||||
if (windowDuration === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Calculate linear regression for growth rate
|
||||
const n = history.length;
|
||||
let sumX = 0;
|
||||
let sumY = 0;
|
||||
let sumXY = 0;
|
||||
let sumXX = 0;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = history[i].timestamp - first.timestamp;
|
||||
const y = history[i].heapUsed;
|
||||
sumX += x;
|
||||
sumY += y;
|
||||
sumXY += x * y;
|
||||
sumXX += x * x;
|
||||
}
|
||||
|
||||
// Slope of linear regression (bytes per millisecond)
|
||||
const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX);
|
||||
const growthRate = slope * 1000; // Convert to bytes per second
|
||||
|
||||
// Calculate R² for confidence
|
||||
const meanY = sumY / n;
|
||||
let ssRes = 0;
|
||||
let ssTot = 0;
|
||||
const intercept = (sumY - slope * sumX) / n;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = history[i].timestamp - first.timestamp;
|
||||
const y = history[i].heapUsed;
|
||||
const yPred = slope * x + intercept;
|
||||
ssRes += (y - yPred) ** 2;
|
||||
ssTot += (y - meanY) ** 2;
|
||||
}
|
||||
|
||||
const rSquared = ssTot > 0 ? 1 - ssRes / ssTot : 0;
|
||||
const confidence = Math.max(0, Math.min(1, rSquared));
|
||||
|
||||
// Consider it a leak if:
|
||||
// 1. Growth rate exceeds threshold
|
||||
// 2. R² is high (indicating consistent growth, not just fluctuation)
|
||||
const isLeaking =
|
||||
growthRate > this.config.leakThreshold && confidence > 0.7 && windowDuration > 30000; // At least 30 seconds of data
|
||||
|
||||
return {
|
||||
growthRate,
|
||||
isLeaking,
|
||||
confidence,
|
||||
sampleCount: n,
|
||||
windowDuration,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check memory thresholds and emit warnings
|
||||
*/
|
||||
private checkMemoryThresholds(memory: MemoryMetrics): void {
|
||||
if (!memory.server) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - this.lastMemoryWarningTime < this.memoryWarningCooldown) {
|
||||
return; // Don't spam warnings
|
||||
}
|
||||
|
||||
// Get V8 heap statistics for limit
|
||||
const heapStats = v8.getHeapStatistics();
|
||||
const heapLimit = heapStats.heap_size_limit;
|
||||
const usagePercent = (memory.server.heapUsed / heapLimit) * 100;
|
||||
|
||||
if (usagePercent >= this.memoryCriticalThreshold) {
|
||||
this.lastMemoryWarningTime = now;
|
||||
this.events.emit('debug:memory-critical', {
|
||||
type: 'debug:memory-critical',
|
||||
timestamp: now,
|
||||
memory,
|
||||
usagePercent,
|
||||
threshold: this.memoryCriticalThreshold,
|
||||
message: `Critical memory usage: ${usagePercent.toFixed(1)}% of heap limit`,
|
||||
});
|
||||
} else if (usagePercent >= this.memoryWarningThreshold) {
|
||||
this.lastMemoryWarningTime = now;
|
||||
this.events.emit('debug:memory-warning', {
|
||||
type: 'debug:memory-warning',
|
||||
timestamp: now,
|
||||
memory,
|
||||
usagePercent,
|
||||
threshold: this.memoryWarningThreshold,
|
||||
message: `High memory usage: ${usagePercent.toFixed(1)}% of heap limit`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate process summary from tracked processes
|
||||
*/
|
||||
private calculateProcessSummary(processes: TrackedProcess[]): ProcessSummary {
|
||||
const summary: ProcessSummary = {
|
||||
total: processes.length,
|
||||
running: 0,
|
||||
idle: 0,
|
||||
stopped: 0,
|
||||
errored: 0,
|
||||
byType: {
|
||||
agent: 0,
|
||||
cli: 0,
|
||||
terminal: 0,
|
||||
worker: 0,
|
||||
},
|
||||
};
|
||||
|
||||
for (const process of processes) {
|
||||
// Count by status
|
||||
switch (process.status) {
|
||||
case 'running':
|
||||
case 'starting':
|
||||
summary.running++;
|
||||
break;
|
||||
case 'idle':
|
||||
summary.idle++;
|
||||
break;
|
||||
case 'stopped':
|
||||
case 'stopping':
|
||||
summary.stopped++;
|
||||
break;
|
||||
case 'error':
|
||||
summary.errored++;
|
||||
break;
|
||||
}
|
||||
|
||||
// Count by type
|
||||
if (process.type in summary.byType) {
|
||||
summary.byType[process.type]++;
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get latest metrics snapshot
|
||||
*/
|
||||
getLatestSnapshot(): DebugMetricsSnapshot | null {
|
||||
const timestamp = Date.now();
|
||||
const lastMemory = this.memoryHistory.getLast();
|
||||
|
||||
if (!lastMemory) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const memoryMetrics = this.collectMemoryMetrics(timestamp);
|
||||
const cpuMetrics = this.collectCPUMetrics(timestamp);
|
||||
const memoryTrend = this.analyzeMemoryTrend();
|
||||
const processes = this.getProcesses();
|
||||
const processSummary = this.calculateProcessSummary(processes);
|
||||
|
||||
return {
|
||||
timestamp,
|
||||
memory: memoryMetrics,
|
||||
cpu: cpuMetrics,
|
||||
processes,
|
||||
processSummary,
|
||||
memoryTrend,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get memory history for charting
|
||||
*/
|
||||
getMemoryHistory(): MemoryDataPoint[] {
|
||||
return this.memoryHistory.getAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CPU history for charting
|
||||
*/
|
||||
getCPUHistory(): CPUDataPoint[] {
|
||||
return this.cpuHistory.getAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a garbage collection (if --expose-gc flag is used)
|
||||
* Returns true if GC was triggered, false if not available
|
||||
*/
|
||||
forceGC(): boolean {
|
||||
if (global.gc) {
|
||||
global.gc();
|
||||
logger.info('Forced garbage collection');
|
||||
return true;
|
||||
}
|
||||
logger.warn('Garbage collection not available (start with --expose-gc flag)');
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear collected history
|
||||
*/
|
||||
clearHistory(): void {
|
||||
this.memoryHistory.clear();
|
||||
this.cpuHistory.clear();
|
||||
logger.info('Performance history cleared');
|
||||
}
|
||||
}
|
||||
982
apps/server/src/services/process-registry-service.ts
Normal file
982
apps/server/src/services/process-registry-service.ts
Normal file
@@ -0,0 +1,982 @@
|
||||
/**
|
||||
* Process Registry Service
|
||||
*
|
||||
* Tracks spawned agents, CLIs, and terminal processes for debugging and monitoring.
|
||||
* Emits debug events for real-time updates to connected clients.
|
||||
*
|
||||
* This service provides:
|
||||
* - Process registration and unregistration
|
||||
* - Status updates for tracked processes
|
||||
* - Integration with PerformanceMonitorService for metrics snapshots
|
||||
* - Filtering and querying of tracked processes
|
||||
* - Automatic cleanup of stopped processes after a retention period
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { EventEmitter } from '../lib/events.js';
|
||||
import type {
|
||||
TrackedProcess,
|
||||
ProcessType,
|
||||
ProcessStatus,
|
||||
ProcessSummary,
|
||||
AgentResourceMetrics,
|
||||
FileIOOperation,
|
||||
} from '@automaker/types';
|
||||
import { createEmptyAgentResourceMetrics } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('ProcessRegistry');
|
||||
|
||||
/**
|
||||
* Options for recording a tool invocation
|
||||
*/
|
||||
export interface RecordToolUseOptions {
|
||||
/** Tool name */
|
||||
toolName: string;
|
||||
/** Execution time in milliseconds */
|
||||
executionTime?: number;
|
||||
/** Whether the tool invocation failed */
|
||||
failed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for recording a file operation
|
||||
*/
|
||||
export interface RecordFileOperationOptions {
|
||||
/** Type of file operation */
|
||||
operation: FileIOOperation;
|
||||
/** File path accessed */
|
||||
filePath: string;
|
||||
/** Bytes read or written */
|
||||
bytes?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for recording a bash command
|
||||
*/
|
||||
export interface RecordBashCommandOptions {
|
||||
/** Command executed */
|
||||
command: string;
|
||||
/** Execution time in milliseconds */
|
||||
executionTime: number;
|
||||
/** Exit code (null if still running or killed) */
|
||||
exitCode: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for registering a new process
|
||||
*/
|
||||
export interface RegisterProcessOptions {
|
||||
/** Unique identifier for the process */
|
||||
id: string;
|
||||
/** Process ID from the operating system (-1 if not applicable) */
|
||||
pid: number;
|
||||
/** Type of process */
|
||||
type: ProcessType;
|
||||
/** Human-readable name/label */
|
||||
name: string;
|
||||
/** Associated feature ID (for agent processes) */
|
||||
featureId?: string;
|
||||
/** Associated session ID (for agent/terminal processes) */
|
||||
sessionId?: string;
|
||||
/** Command that was executed */
|
||||
command?: string;
|
||||
/** Working directory */
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for updating a process
|
||||
*/
|
||||
export interface UpdateProcessOptions {
|
||||
/** New status */
|
||||
status?: ProcessStatus;
|
||||
/** Memory usage in bytes */
|
||||
memoryUsage?: number;
|
||||
/** CPU usage percentage */
|
||||
cpuUsage?: number;
|
||||
/** Exit code (when stopping) */
|
||||
exitCode?: number;
|
||||
/** Error message */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for querying processes
|
||||
*/
|
||||
export interface QueryProcessOptions {
|
||||
/** Filter by process type */
|
||||
type?: ProcessType;
|
||||
/** Filter by status */
|
||||
status?: ProcessStatus;
|
||||
/** Include stopped processes (default: false) */
|
||||
includeStopped?: boolean;
|
||||
/** Filter by session ID */
|
||||
sessionId?: string;
|
||||
/** Filter by feature ID */
|
||||
featureId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for the ProcessRegistryService
|
||||
*/
|
||||
export interface ProcessRegistryConfig {
|
||||
/** How long to keep stopped processes in the registry (ms) */
|
||||
stoppedProcessRetention: number;
|
||||
/** Interval for cleanup of old stopped processes (ms) */
|
||||
cleanupInterval: number;
|
||||
/** Maximum number of stopped processes to retain */
|
||||
maxStoppedProcesses: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: ProcessRegistryConfig = {
|
||||
stoppedProcessRetention: 5 * 60 * 1000, // 5 minutes
|
||||
cleanupInterval: 60 * 1000, // 1 minute
|
||||
maxStoppedProcesses: 100,
|
||||
};
|
||||
|
||||
/**
|
||||
* ProcessRegistryService - Tracks spawned processes for debugging
|
||||
*
|
||||
* This service maintains a registry of all tracked processes including:
|
||||
* - Agent sessions (AI conversations)
|
||||
* - CLI processes (one-off commands)
|
||||
* - Terminal sessions (persistent PTY sessions)
|
||||
* - Worker processes (background tasks)
|
||||
*
|
||||
* It emits events when processes are spawned, updated, or stopped,
|
||||
* allowing real-time monitoring in the debug panel.
|
||||
*/
|
||||
export class ProcessRegistryService {
|
||||
private events: EventEmitter;
|
||||
private config: ProcessRegistryConfig;
|
||||
private processes: Map<string, TrackedProcess> = new Map();
|
||||
private cleanupIntervalId: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(events: EventEmitter, config?: Partial<ProcessRegistryConfig>) {
|
||||
this.events = events;
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
|
||||
logger.info('ProcessRegistryService initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the process registry service
|
||||
* Begins periodic cleanup of old stopped processes
|
||||
*/
|
||||
start(): void {
|
||||
if (this.cleanupIntervalId) {
|
||||
logger.warn('ProcessRegistryService is already running');
|
||||
return;
|
||||
}
|
||||
|
||||
this.cleanupIntervalId = setInterval(() => {
|
||||
this.cleanupStoppedProcesses();
|
||||
}, this.config.cleanupInterval);
|
||||
|
||||
logger.info('ProcessRegistryService started');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the process registry service
|
||||
*/
|
||||
stop(): void {
|
||||
if (this.cleanupIntervalId) {
|
||||
clearInterval(this.cleanupIntervalId);
|
||||
this.cleanupIntervalId = null;
|
||||
}
|
||||
|
||||
logger.info('ProcessRegistryService stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new process
|
||||
*/
|
||||
registerProcess(options: RegisterProcessOptions): TrackedProcess {
|
||||
const now = Date.now();
|
||||
|
||||
const process: TrackedProcess = {
|
||||
id: options.id,
|
||||
pid: options.pid,
|
||||
type: options.type,
|
||||
name: options.name,
|
||||
status: 'starting',
|
||||
startedAt: now,
|
||||
featureId: options.featureId,
|
||||
sessionId: options.sessionId,
|
||||
command: options.command,
|
||||
cwd: options.cwd,
|
||||
};
|
||||
|
||||
this.processes.set(options.id, process);
|
||||
|
||||
logger.info('Process registered', {
|
||||
id: process.id,
|
||||
type: process.type,
|
||||
name: process.name,
|
||||
pid: process.pid,
|
||||
});
|
||||
|
||||
// Emit process spawned event
|
||||
this.events.emit('debug:process-spawned', {
|
||||
type: 'debug:process-spawned',
|
||||
timestamp: now,
|
||||
process,
|
||||
message: `Process ${process.name} (${process.type}) started`,
|
||||
});
|
||||
|
||||
return process;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing process
|
||||
*/
|
||||
updateProcess(id: string, updates: UpdateProcessOptions): TrackedProcess | null {
|
||||
const process = this.processes.get(id);
|
||||
if (!process) {
|
||||
logger.warn('Attempted to update non-existent process', { id });
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Apply updates
|
||||
if (updates.status !== undefined) {
|
||||
process.status = updates.status;
|
||||
|
||||
// Set stoppedAt timestamp when process stops
|
||||
if (updates.status === 'stopped' || updates.status === 'error') {
|
||||
process.stoppedAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.memoryUsage !== undefined) {
|
||||
process.memoryUsage = updates.memoryUsage;
|
||||
}
|
||||
|
||||
if (updates.cpuUsage !== undefined) {
|
||||
process.cpuUsage = updates.cpuUsage;
|
||||
}
|
||||
|
||||
if (updates.exitCode !== undefined) {
|
||||
process.exitCode = updates.exitCode;
|
||||
}
|
||||
|
||||
if (updates.error !== undefined) {
|
||||
process.error = updates.error;
|
||||
}
|
||||
|
||||
logger.debug('Process updated', {
|
||||
id,
|
||||
updates,
|
||||
});
|
||||
|
||||
// Emit appropriate event based on status
|
||||
if (updates.status === 'stopped') {
|
||||
this.events.emit('debug:process-stopped', {
|
||||
type: 'debug:process-stopped',
|
||||
timestamp: now,
|
||||
process,
|
||||
message: `Process ${process.name} stopped${updates.exitCode !== undefined ? ` (exit code: ${updates.exitCode})` : ''}`,
|
||||
});
|
||||
} else if (updates.status === 'error') {
|
||||
this.events.emit('debug:process-error', {
|
||||
type: 'debug:process-error',
|
||||
timestamp: now,
|
||||
process,
|
||||
message: `Process ${process.name} encountered an error: ${updates.error || 'Unknown error'}`,
|
||||
});
|
||||
} else {
|
||||
this.events.emit('debug:process-updated', {
|
||||
type: 'debug:process-updated',
|
||||
timestamp: now,
|
||||
process,
|
||||
});
|
||||
}
|
||||
|
||||
return process;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a process as running
|
||||
*/
|
||||
markRunning(id: string): TrackedProcess | null {
|
||||
return this.updateProcess(id, { status: 'running' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a process as idle
|
||||
*/
|
||||
markIdle(id: string): TrackedProcess | null {
|
||||
return this.updateProcess(id, { status: 'idle' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a process as stopping
|
||||
*/
|
||||
markStopping(id: string): TrackedProcess | null {
|
||||
return this.updateProcess(id, { status: 'stopping' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a process as stopped
|
||||
*/
|
||||
markStopped(id: string, exitCode?: number): TrackedProcess | null {
|
||||
return this.updateProcess(id, { status: 'stopped', exitCode });
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a process as errored
|
||||
*/
|
||||
markError(id: string, error: string): TrackedProcess | null {
|
||||
return this.updateProcess(id, { status: 'error', error });
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a process (remove immediately without retention)
|
||||
*/
|
||||
unregisterProcess(id: string): boolean {
|
||||
const process = this.processes.get(id);
|
||||
if (!process) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.processes.delete(id);
|
||||
|
||||
logger.info('Process unregistered', {
|
||||
id,
|
||||
type: process.type,
|
||||
name: process.name,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a process by ID
|
||||
*/
|
||||
getProcess(id: string): TrackedProcess | undefined {
|
||||
return this.processes.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tracked processes, optionally filtered
|
||||
* Optimized single-pass filtering to avoid multiple array allocations
|
||||
*/
|
||||
getProcesses(options?: QueryProcessOptions): TrackedProcess[] {
|
||||
// Pre-allocate array with estimated capacity
|
||||
const result: TrackedProcess[] = [];
|
||||
|
||||
// Single-pass filtering
|
||||
for (const process of this.processes.values()) {
|
||||
// Filter by type
|
||||
if (options?.type && process.type !== options.type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter by status
|
||||
if (options?.status && process.status !== options.status) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter out stopped processes by default
|
||||
if (!options?.includeStopped) {
|
||||
if (process.status === 'stopped' || process.status === 'error') {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by session ID
|
||||
if (options?.sessionId && process.sessionId !== options.sessionId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter by feature ID
|
||||
if (options?.featureId && process.featureId !== options.featureId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
result.push(process);
|
||||
}
|
||||
|
||||
// Sort by start time (most recent first)
|
||||
result.sort((a, b) => b.startedAt - a.startedAt);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all processes (for PerformanceMonitorService integration)
|
||||
* This is used as the process provider function
|
||||
*/
|
||||
getAllProcesses(): TrackedProcess[] {
|
||||
return Array.from(this.processes.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get process provider function for PerformanceMonitorService
|
||||
*/
|
||||
getProcessProvider(): () => TrackedProcess[] {
|
||||
return () => this.getAllProcesses();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate summary statistics for tracked processes
|
||||
*/
|
||||
getProcessSummary(): ProcessSummary {
|
||||
const processes = this.getAllProcesses();
|
||||
|
||||
const summary: ProcessSummary = {
|
||||
total: processes.length,
|
||||
running: 0,
|
||||
idle: 0,
|
||||
stopped: 0,
|
||||
errored: 0,
|
||||
byType: {
|
||||
agent: 0,
|
||||
cli: 0,
|
||||
terminal: 0,
|
||||
worker: 0,
|
||||
},
|
||||
};
|
||||
|
||||
for (const process of processes) {
|
||||
// Count by status
|
||||
switch (process.status) {
|
||||
case 'running':
|
||||
case 'starting':
|
||||
summary.running++;
|
||||
break;
|
||||
case 'idle':
|
||||
summary.idle++;
|
||||
break;
|
||||
case 'stopped':
|
||||
case 'stopping':
|
||||
summary.stopped++;
|
||||
break;
|
||||
case 'error':
|
||||
summary.errored++;
|
||||
break;
|
||||
}
|
||||
|
||||
// Count by type
|
||||
if (process.type in summary.byType) {
|
||||
summary.byType[process.type]++;
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of active (non-stopped) processes
|
||||
*/
|
||||
getActiveCount(): number {
|
||||
let count = 0;
|
||||
for (const process of this.processes.values()) {
|
||||
if (process.status !== 'stopped' && process.status !== 'error') {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of processes by type
|
||||
*/
|
||||
getCountByType(type: ProcessType): number {
|
||||
let count = 0;
|
||||
for (const process of this.processes.values()) {
|
||||
if (process.type === type) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a process exists
|
||||
*/
|
||||
hasProcess(id: string): boolean {
|
||||
return this.processes.has(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration
|
||||
*/
|
||||
updateConfig(config: Partial<ProcessRegistryConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
logger.info('ProcessRegistryService configuration updated', config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current configuration
|
||||
*/
|
||||
getConfig(): ProcessRegistryConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old stopped processes
|
||||
*/
|
||||
private cleanupStoppedProcesses(): void {
|
||||
const now = Date.now();
|
||||
const stoppedProcesses: Array<{ id: string; stoppedAt: number }> = [];
|
||||
|
||||
// Find all stopped processes
|
||||
for (const [id, process] of this.processes.entries()) {
|
||||
if ((process.status === 'stopped' || process.status === 'error') && process.stoppedAt) {
|
||||
stoppedProcesses.push({ id, stoppedAt: process.stoppedAt });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by stoppedAt (oldest first)
|
||||
stoppedProcesses.sort((a, b) => a.stoppedAt - b.stoppedAt);
|
||||
|
||||
let removedCount = 0;
|
||||
|
||||
// Remove processes that exceed retention time
|
||||
for (const { id, stoppedAt } of stoppedProcesses) {
|
||||
const age = now - stoppedAt;
|
||||
if (age > this.config.stoppedProcessRetention) {
|
||||
this.processes.delete(id);
|
||||
removedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// If still over max, remove oldest stopped processes
|
||||
const remainingStoppedCount = stoppedProcesses.length - removedCount;
|
||||
if (remainingStoppedCount > this.config.maxStoppedProcesses) {
|
||||
const toRemove = remainingStoppedCount - this.config.maxStoppedProcesses;
|
||||
let removed = 0;
|
||||
|
||||
for (const { id } of stoppedProcesses) {
|
||||
if (this.processes.has(id) && removed < toRemove) {
|
||||
this.processes.delete(id);
|
||||
removedCount++;
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (removedCount > 0) {
|
||||
logger.debug('Cleaned up stopped processes', { removedCount });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all tracked processes
|
||||
*/
|
||||
clear(): void {
|
||||
this.processes.clear();
|
||||
logger.info('All tracked processes cleared');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Agent Resource Metrics Tracking
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Initialize resource metrics for an agent process
|
||||
* Call this when an agent starts to begin tracking its resource usage
|
||||
*/
|
||||
initializeAgentMetrics(
|
||||
processId: string,
|
||||
options?: { sessionId?: string; featureId?: string }
|
||||
): AgentResourceMetrics | null {
|
||||
const process = this.processes.get(processId);
|
||||
if (!process) {
|
||||
logger.warn('Cannot initialize metrics for non-existent process', { processId });
|
||||
return null;
|
||||
}
|
||||
|
||||
if (process.type !== 'agent') {
|
||||
logger.warn('Cannot initialize agent metrics for non-agent process', {
|
||||
processId,
|
||||
type: process.type,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const metrics = createEmptyAgentResourceMetrics(processId, {
|
||||
sessionId: options?.sessionId || process.sessionId,
|
||||
featureId: options?.featureId || process.featureId,
|
||||
});
|
||||
|
||||
process.resourceMetrics = metrics;
|
||||
|
||||
logger.debug('Agent metrics initialized', { processId });
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resource metrics for an agent process
|
||||
*/
|
||||
getAgentMetrics(processId: string): AgentResourceMetrics | undefined {
|
||||
const process = this.processes.get(processId);
|
||||
return process?.resourceMetrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a tool invocation for an agent
|
||||
*/
|
||||
recordToolUse(processId: string, options: RecordToolUseOptions): void {
|
||||
const process = this.processes.get(processId);
|
||||
if (!process?.resourceMetrics) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metrics = process.resourceMetrics;
|
||||
const now = Date.now();
|
||||
|
||||
// Update tool metrics
|
||||
metrics.tools.totalInvocations++;
|
||||
metrics.tools.byTool[options.toolName] = (metrics.tools.byTool[options.toolName] || 0) + 1;
|
||||
|
||||
if (options.executionTime !== undefined) {
|
||||
metrics.tools.totalExecutionTime += options.executionTime;
|
||||
metrics.tools.avgExecutionTime =
|
||||
metrics.tools.totalExecutionTime / metrics.tools.totalInvocations;
|
||||
}
|
||||
|
||||
if (options.failed) {
|
||||
metrics.tools.failedInvocations++;
|
||||
}
|
||||
|
||||
// Update memory snapshot
|
||||
this.updateMemorySnapshot(processId);
|
||||
|
||||
metrics.lastUpdatedAt = now;
|
||||
metrics.duration = now - metrics.startedAt;
|
||||
|
||||
logger.debug('Tool use recorded', {
|
||||
processId,
|
||||
tool: options.toolName,
|
||||
totalInvocations: metrics.tools.totalInvocations,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a file operation for an agent
|
||||
*/
|
||||
recordFileOperation(processId: string, options: RecordFileOperationOptions): void {
|
||||
const process = this.processes.get(processId);
|
||||
if (!process?.resourceMetrics) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metrics = process.resourceMetrics;
|
||||
const now = Date.now();
|
||||
|
||||
// Update file I/O metrics based on operation type
|
||||
switch (options.operation) {
|
||||
case 'read':
|
||||
metrics.fileIO.reads++;
|
||||
if (options.bytes) {
|
||||
metrics.fileIO.bytesRead += options.bytes;
|
||||
}
|
||||
break;
|
||||
case 'write':
|
||||
metrics.fileIO.writes++;
|
||||
if (options.bytes) {
|
||||
metrics.fileIO.bytesWritten += options.bytes;
|
||||
}
|
||||
break;
|
||||
case 'edit':
|
||||
metrics.fileIO.edits++;
|
||||
if (options.bytes) {
|
||||
metrics.fileIO.bytesWritten += options.bytes;
|
||||
}
|
||||
break;
|
||||
case 'glob':
|
||||
metrics.fileIO.globs++;
|
||||
break;
|
||||
case 'grep':
|
||||
metrics.fileIO.greps++;
|
||||
break;
|
||||
}
|
||||
|
||||
// Track unique files accessed
|
||||
if (!metrics.fileIO.filesAccessed.includes(options.filePath)) {
|
||||
// Limit to 100 files to prevent memory bloat
|
||||
if (metrics.fileIO.filesAccessed.length < 100) {
|
||||
metrics.fileIO.filesAccessed.push(options.filePath);
|
||||
}
|
||||
}
|
||||
|
||||
metrics.lastUpdatedAt = now;
|
||||
metrics.duration = now - metrics.startedAt;
|
||||
|
||||
logger.debug('File operation recorded', {
|
||||
processId,
|
||||
operation: options.operation,
|
||||
filePath: options.filePath,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a bash command execution for an agent
|
||||
*/
|
||||
recordBashCommand(processId: string, options: RecordBashCommandOptions): void {
|
||||
const process = this.processes.get(processId);
|
||||
if (!process?.resourceMetrics) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metrics = process.resourceMetrics;
|
||||
const now = Date.now();
|
||||
|
||||
metrics.bash.commandCount++;
|
||||
metrics.bash.totalExecutionTime += options.executionTime;
|
||||
|
||||
if (options.exitCode !== null && options.exitCode !== 0) {
|
||||
metrics.bash.failedCommands++;
|
||||
}
|
||||
|
||||
// Keep only last 20 commands to prevent memory bloat
|
||||
if (metrics.bash.commands.length >= 20) {
|
||||
metrics.bash.commands.shift();
|
||||
}
|
||||
|
||||
metrics.bash.commands.push({
|
||||
command: options.command.substring(0, 200), // Truncate long commands
|
||||
exitCode: options.exitCode,
|
||||
duration: options.executionTime,
|
||||
timestamp: now,
|
||||
});
|
||||
|
||||
// Update memory snapshot
|
||||
this.updateMemorySnapshot(processId);
|
||||
|
||||
metrics.lastUpdatedAt = now;
|
||||
metrics.duration = now - metrics.startedAt;
|
||||
|
||||
logger.debug('Bash command recorded', {
|
||||
processId,
|
||||
command: options.command.substring(0, 50),
|
||||
exitCode: options.exitCode,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an API turn/iteration for an agent
|
||||
*/
|
||||
recordAPITurn(
|
||||
processId: string,
|
||||
options?: {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
thinkingTokens?: number;
|
||||
duration?: number;
|
||||
error?: boolean;
|
||||
}
|
||||
): void {
|
||||
const process = this.processes.get(processId);
|
||||
if (!process?.resourceMetrics) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metrics = process.resourceMetrics;
|
||||
const now = Date.now();
|
||||
|
||||
metrics.api.turns++;
|
||||
|
||||
if (options?.inputTokens !== undefined) {
|
||||
metrics.api.inputTokens = (metrics.api.inputTokens || 0) + options.inputTokens;
|
||||
}
|
||||
|
||||
if (options?.outputTokens !== undefined) {
|
||||
metrics.api.outputTokens = (metrics.api.outputTokens || 0) + options.outputTokens;
|
||||
}
|
||||
|
||||
if (options?.thinkingTokens !== undefined) {
|
||||
metrics.api.thinkingTokens = (metrics.api.thinkingTokens || 0) + options.thinkingTokens;
|
||||
}
|
||||
|
||||
if (options?.duration !== undefined) {
|
||||
metrics.api.totalDuration += options.duration;
|
||||
}
|
||||
|
||||
if (options?.error) {
|
||||
metrics.api.errors++;
|
||||
}
|
||||
|
||||
// Update memory snapshot
|
||||
this.updateMemorySnapshot(processId);
|
||||
|
||||
metrics.lastUpdatedAt = now;
|
||||
metrics.duration = now - metrics.startedAt;
|
||||
|
||||
logger.debug('API turn recorded', {
|
||||
processId,
|
||||
turns: metrics.api.turns,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update memory snapshot for an agent process
|
||||
* Takes a memory sample and updates peak/delta values
|
||||
*/
|
||||
updateMemorySnapshot(processId: string): void {
|
||||
const process = this.processes.get(processId);
|
||||
if (!process?.resourceMetrics) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metrics = process.resourceMetrics;
|
||||
const now = Date.now();
|
||||
const heapUsed = process.memoryUsage || 0;
|
||||
|
||||
// Update current heap
|
||||
metrics.memory.currentHeapUsed = heapUsed;
|
||||
|
||||
// Update peak if higher
|
||||
if (heapUsed > metrics.memory.peakHeapUsed) {
|
||||
metrics.memory.peakHeapUsed = heapUsed;
|
||||
}
|
||||
|
||||
// Calculate delta from start
|
||||
metrics.memory.deltaHeapUsed = heapUsed - metrics.memory.startHeapUsed;
|
||||
|
||||
// Add sample (keep max 60 samples = 1 minute at 1 sample/second)
|
||||
if (metrics.memory.samples.length >= 60) {
|
||||
metrics.memory.samples.shift();
|
||||
}
|
||||
metrics.memory.samples.push({ timestamp: now, heapUsed });
|
||||
|
||||
metrics.lastUpdatedAt = now;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark agent metrics as completed (agent finished running)
|
||||
*/
|
||||
finalizeAgentMetrics(processId: string): void {
|
||||
const process = this.processes.get(processId);
|
||||
if (!process?.resourceMetrics) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metrics = process.resourceMetrics;
|
||||
const now = Date.now();
|
||||
|
||||
metrics.isRunning = false;
|
||||
metrics.lastUpdatedAt = now;
|
||||
metrics.duration = now - metrics.startedAt;
|
||||
|
||||
// Final memory snapshot
|
||||
this.updateMemorySnapshot(processId);
|
||||
|
||||
logger.debug('Agent metrics finalized', {
|
||||
processId,
|
||||
duration: metrics.duration,
|
||||
toolInvocations: metrics.tools.totalInvocations,
|
||||
fileReads: metrics.fileIO.reads,
|
||||
fileWrites: metrics.fileIO.writes,
|
||||
bashCommands: metrics.bash.commandCount,
|
||||
apiTurns: metrics.api.turns,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all agent processes with their resource metrics
|
||||
*/
|
||||
getAgentProcessesWithMetrics(): TrackedProcess[] {
|
||||
const result: TrackedProcess[] = [];
|
||||
|
||||
for (const process of this.processes.values()) {
|
||||
if (process.type === 'agent' && process.resourceMetrics) {
|
||||
result.push(process);
|
||||
}
|
||||
}
|
||||
|
||||
return result.sort((a, b) => b.startedAt - a.startedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary of resource usage across all running agents
|
||||
*/
|
||||
getAgentResourceSummary(): {
|
||||
totalAgents: number;
|
||||
runningAgents: number;
|
||||
totalFileReads: number;
|
||||
totalFileWrites: number;
|
||||
totalBytesRead: number;
|
||||
totalBytesWritten: number;
|
||||
totalToolInvocations: number;
|
||||
totalBashCommands: number;
|
||||
totalAPITurns: number;
|
||||
peakMemoryUsage: number;
|
||||
totalDuration: number;
|
||||
} {
|
||||
const summary = {
|
||||
totalAgents: 0,
|
||||
runningAgents: 0,
|
||||
totalFileReads: 0,
|
||||
totalFileWrites: 0,
|
||||
totalBytesRead: 0,
|
||||
totalBytesWritten: 0,
|
||||
totalToolInvocations: 0,
|
||||
totalBashCommands: 0,
|
||||
totalAPITurns: 0,
|
||||
peakMemoryUsage: 0,
|
||||
totalDuration: 0,
|
||||
};
|
||||
|
||||
for (const process of this.processes.values()) {
|
||||
if (process.type !== 'agent' || !process.resourceMetrics) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const metrics = process.resourceMetrics;
|
||||
summary.totalAgents++;
|
||||
|
||||
if (metrics.isRunning) {
|
||||
summary.runningAgents++;
|
||||
}
|
||||
|
||||
summary.totalFileReads += metrics.fileIO.reads;
|
||||
summary.totalFileWrites += metrics.fileIO.writes;
|
||||
summary.totalBytesRead += metrics.fileIO.bytesRead;
|
||||
summary.totalBytesWritten += metrics.fileIO.bytesWritten;
|
||||
summary.totalToolInvocations += metrics.tools.totalInvocations;
|
||||
summary.totalBashCommands += metrics.bash.commandCount;
|
||||
summary.totalAPITurns += metrics.api.turns;
|
||||
summary.totalDuration += metrics.duration;
|
||||
|
||||
if (metrics.memory.peakHeapUsed > summary.peakMemoryUsage) {
|
||||
summary.peakMemoryUsage = metrics.memory.peakHeapUsed;
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let processRegistryService: ProcessRegistryService | null = null;
|
||||
|
||||
/**
|
||||
* Get or create the ProcessRegistryService singleton
|
||||
*/
|
||||
export function getProcessRegistryService(
|
||||
events?: EventEmitter,
|
||||
config?: Partial<ProcessRegistryConfig>
|
||||
): ProcessRegistryService {
|
||||
if (!processRegistryService) {
|
||||
if (!events) {
|
||||
throw new Error('EventEmitter is required to initialize ProcessRegistryService');
|
||||
}
|
||||
processRegistryService = new ProcessRegistryService(events, config);
|
||||
}
|
||||
return processRegistryService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the singleton (for testing)
|
||||
*/
|
||||
export function resetProcessRegistryService(): void {
|
||||
if (processRegistryService) {
|
||||
processRegistryService.stop();
|
||||
processRegistryService = null;
|
||||
}
|
||||
}
|
||||
@@ -153,6 +153,14 @@ export class SettingsService {
|
||||
const storedVersion = settings.version || 1;
|
||||
let needsSave = false;
|
||||
|
||||
// Migration v1 -> v2: Force enableSandboxMode to false for existing users
|
||||
// Sandbox mode can cause issues on some systems, so we're disabling it by default
|
||||
if (storedVersion < 2) {
|
||||
logger.info('Migrating settings from v1 to v2: disabling sandbox mode');
|
||||
result.enableSandboxMode = false;
|
||||
needsSave = true;
|
||||
}
|
||||
|
||||
// Migration v2 -> v3: Convert string phase models to PhaseModelEntry objects
|
||||
// Note: migratePhaseModels() handles the actual conversion for both v1 and v2 formats
|
||||
if (storedVersion < 3) {
|
||||
@@ -162,16 +170,6 @@ export class SettingsService {
|
||||
needsSave = true;
|
||||
}
|
||||
|
||||
// Migration v3 -> v4: Add onboarding/setup wizard state fields
|
||||
// Older settings files never stored setup state in settings.json (it lived in localStorage),
|
||||
// so default to "setup complete" for existing installs to avoid forcing re-onboarding.
|
||||
if (storedVersion < 4) {
|
||||
if (settings.setupComplete === undefined) result.setupComplete = true;
|
||||
if (settings.isFirstRun === undefined) result.isFirstRun = false;
|
||||
if (settings.skipClaudeSetup === undefined) result.skipClaudeSetup = false;
|
||||
needsSave = true;
|
||||
}
|
||||
|
||||
// Update version if any migration occurred
|
||||
if (needsSave) {
|
||||
result.version = SETTINGS_VERSION;
|
||||
@@ -266,79 +264,25 @@ export class SettingsService {
|
||||
const settingsPath = getGlobalSettingsPath(this.dataDir);
|
||||
|
||||
const current = await this.getGlobalSettings();
|
||||
|
||||
// Guard against destructive "empty array/object" overwrites.
|
||||
// During auth transitions, the UI can briefly have default/empty state and accidentally
|
||||
// sync it, wiping persisted settings (especially `projects`).
|
||||
const sanitizedUpdates: Partial<GlobalSettings> = { ...updates };
|
||||
let attemptedProjectWipe = false;
|
||||
|
||||
const ignoreEmptyArrayOverwrite = <K extends keyof GlobalSettings>(key: K): void => {
|
||||
const nextVal = sanitizedUpdates[key] as unknown;
|
||||
const curVal = current[key] as unknown;
|
||||
if (
|
||||
Array.isArray(nextVal) &&
|
||||
nextVal.length === 0 &&
|
||||
Array.isArray(curVal) &&
|
||||
curVal.length > 0
|
||||
) {
|
||||
delete sanitizedUpdates[key];
|
||||
}
|
||||
};
|
||||
|
||||
const currentProjectsLen = Array.isArray(current.projects) ? current.projects.length : 0;
|
||||
if (
|
||||
Array.isArray(sanitizedUpdates.projects) &&
|
||||
sanitizedUpdates.projects.length === 0 &&
|
||||
currentProjectsLen > 0
|
||||
) {
|
||||
attemptedProjectWipe = true;
|
||||
delete sanitizedUpdates.projects;
|
||||
}
|
||||
|
||||
ignoreEmptyArrayOverwrite('trashedProjects');
|
||||
ignoreEmptyArrayOverwrite('projectHistory');
|
||||
ignoreEmptyArrayOverwrite('recentFolders');
|
||||
ignoreEmptyArrayOverwrite('aiProfiles');
|
||||
ignoreEmptyArrayOverwrite('mcpServers');
|
||||
ignoreEmptyArrayOverwrite('enabledCursorModels');
|
||||
|
||||
// Empty object overwrite guard
|
||||
if (
|
||||
sanitizedUpdates.lastSelectedSessionByProject &&
|
||||
typeof sanitizedUpdates.lastSelectedSessionByProject === 'object' &&
|
||||
!Array.isArray(sanitizedUpdates.lastSelectedSessionByProject) &&
|
||||
Object.keys(sanitizedUpdates.lastSelectedSessionByProject).length === 0 &&
|
||||
current.lastSelectedSessionByProject &&
|
||||
Object.keys(current.lastSelectedSessionByProject).length > 0
|
||||
) {
|
||||
delete sanitizedUpdates.lastSelectedSessionByProject;
|
||||
}
|
||||
|
||||
// If a request attempted to wipe projects, also ignore theme changes in that same request.
|
||||
if (attemptedProjectWipe) {
|
||||
delete sanitizedUpdates.theme;
|
||||
}
|
||||
|
||||
const updated: GlobalSettings = {
|
||||
...current,
|
||||
...sanitizedUpdates,
|
||||
...updates,
|
||||
version: SETTINGS_VERSION,
|
||||
};
|
||||
|
||||
// Deep merge keyboard shortcuts if provided
|
||||
if (sanitizedUpdates.keyboardShortcuts) {
|
||||
if (updates.keyboardShortcuts) {
|
||||
updated.keyboardShortcuts = {
|
||||
...current.keyboardShortcuts,
|
||||
...sanitizedUpdates.keyboardShortcuts,
|
||||
...updates.keyboardShortcuts,
|
||||
};
|
||||
}
|
||||
|
||||
// Deep merge phaseModels if provided
|
||||
if (sanitizedUpdates.phaseModels) {
|
||||
if (updates.phaseModels) {
|
||||
updated.phaseModels = {
|
||||
...current.phaseModels,
|
||||
...sanitizedUpdates.phaseModels,
|
||||
...updates.phaseModels,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -579,26 +523,8 @@ export class SettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
// Parse setup wizard state (previously stored in localStorage)
|
||||
let setupState: Record<string, unknown> = {};
|
||||
if (localStorageData['automaker-setup']) {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorageData['automaker-setup']);
|
||||
setupState = parsed.state || parsed;
|
||||
} catch (e) {
|
||||
errors.push(`Failed to parse automaker-setup: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract global settings
|
||||
const globalSettings: Partial<GlobalSettings> = {
|
||||
setupComplete:
|
||||
setupState.setupComplete !== undefined ? (setupState.setupComplete as boolean) : false,
|
||||
isFirstRun: setupState.isFirstRun !== undefined ? (setupState.isFirstRun as boolean) : true,
|
||||
skipClaudeSetup:
|
||||
setupState.skipClaudeSetup !== undefined
|
||||
? (setupState.skipClaudeSetup as boolean)
|
||||
: false,
|
||||
theme: (appState.theme as GlobalSettings['theme']) || 'dark',
|
||||
sidebarOpen: appState.sidebarOpen !== undefined ? (appState.sidebarOpen as boolean) : true,
|
||||
chatHistoryOpen: (appState.chatHistoryOpen as boolean) || false,
|
||||
@@ -611,12 +537,7 @@ export class SettingsService {
|
||||
appState.enableDependencyBlocking !== undefined
|
||||
? (appState.enableDependencyBlocking as boolean)
|
||||
: true,
|
||||
skipVerificationInAutoMode:
|
||||
appState.skipVerificationInAutoMode !== undefined
|
||||
? (appState.skipVerificationInAutoMode as boolean)
|
||||
: false,
|
||||
useWorktrees:
|
||||
appState.useWorktrees !== undefined ? (appState.useWorktrees as boolean) : true,
|
||||
useWorktrees: (appState.useWorktrees as boolean) || false,
|
||||
showProfilesOnly: (appState.showProfilesOnly as boolean) || false,
|
||||
defaultPlanningMode:
|
||||
(appState.defaultPlanningMode as GlobalSettings['defaultPlanningMode']) || 'skip',
|
||||
|
||||
@@ -1,373 +0,0 @@
|
||||
/**
|
||||
* CLI Integration Tests
|
||||
*
|
||||
* Comprehensive tests for CLI detection, authentication, and operations
|
||||
* across all providers (Claude, Codex, Cursor)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
detectCli,
|
||||
detectAllCLis,
|
||||
findCommand,
|
||||
getCliVersion,
|
||||
getInstallInstructions,
|
||||
validateCliInstallation,
|
||||
} from '../lib/cli-detection.js';
|
||||
import { classifyError, getUserFriendlyErrorMessage } from '../lib/error-handler.js';
|
||||
|
||||
describe('CLI Detection Framework', () => {
|
||||
describe('findCommand', () => {
|
||||
it('should find existing command', async () => {
|
||||
// Test with a command that should exist
|
||||
const result = await findCommand(['node']);
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return null for non-existent command', async () => {
|
||||
const result = await findCommand(['nonexistent-command-12345']);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should find first available command from alternatives', async () => {
|
||||
const result = await findCommand(['nonexistent-command-12345', 'node']);
|
||||
expect(result).toBeTruthy();
|
||||
expect(result).toContain('node');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCliVersion', () => {
|
||||
it('should get version for existing command', async () => {
|
||||
const version = await getCliVersion('node', ['--version'], 5000);
|
||||
expect(version).toBeTruthy();
|
||||
expect(typeof version).toBe('string');
|
||||
});
|
||||
|
||||
it('should timeout for non-responsive command', async () => {
|
||||
await expect(getCliVersion('sleep', ['10'], 1000)).rejects.toThrow();
|
||||
}, 15000); // Give extra time for test timeout
|
||||
|
||||
it("should handle command that doesn't exist", async () => {
|
||||
await expect(
|
||||
getCliVersion('nonexistent-command-12345', ['--version'], 2000)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInstallInstructions', () => {
|
||||
it('should return instructions for supported platforms', () => {
|
||||
const claudeInstructions = getInstallInstructions('claude', 'darwin');
|
||||
expect(claudeInstructions).toContain('brew install');
|
||||
|
||||
const codexInstructions = getInstallInstructions('codex', 'linux');
|
||||
expect(codexInstructions).toContain('npm install');
|
||||
});
|
||||
|
||||
it('should handle unsupported platform', () => {
|
||||
const instructions = getInstallInstructions('claude', 'unknown-platform' as any);
|
||||
expect(instructions).toContain('No installation instructions available');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateCliInstallation', () => {
|
||||
it('should validate properly installed CLI', () => {
|
||||
const cliInfo = {
|
||||
name: 'Test CLI',
|
||||
command: 'node',
|
||||
version: 'v18.0.0',
|
||||
path: '/usr/bin/node',
|
||||
installed: true,
|
||||
authenticated: true,
|
||||
authMethod: 'cli' as const,
|
||||
};
|
||||
|
||||
const result = validateCliInstallation(cliInfo);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.issues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should detect issues with installation', () => {
|
||||
const cliInfo = {
|
||||
name: 'Test CLI',
|
||||
command: '',
|
||||
version: '',
|
||||
path: '',
|
||||
installed: false,
|
||||
authenticated: false,
|
||||
authMethod: 'none' as const,
|
||||
};
|
||||
|
||||
const result = validateCliInstallation(cliInfo);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues.length).toBeGreaterThan(0);
|
||||
expect(result.issues).toContain('CLI is not installed');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling System', () => {
|
||||
describe('classifyError', () => {
|
||||
it('should classify authentication errors', () => {
|
||||
const authError = new Error('invalid_api_key: Your API key is invalid');
|
||||
const result = classifyError(authError, 'claude');
|
||||
|
||||
expect(result.type).toBe('authentication');
|
||||
expect(result.severity).toBe('high');
|
||||
expect(result.userMessage).toContain('Authentication failed');
|
||||
expect(result.retryable).toBe(false);
|
||||
expect(result.provider).toBe('claude');
|
||||
});
|
||||
|
||||
it('should classify billing errors', () => {
|
||||
const billingError = new Error('credit balance is too low');
|
||||
const result = classifyError(billingError);
|
||||
|
||||
expect(result.type).toBe('billing');
|
||||
expect(result.severity).toBe('high');
|
||||
expect(result.userMessage).toContain('insufficient credits');
|
||||
expect(result.retryable).toBe(false);
|
||||
});
|
||||
|
||||
it('should classify rate limit errors', () => {
|
||||
const rateLimitError = new Error('Rate limit reached. Try again later.');
|
||||
const result = classifyError(rateLimitError);
|
||||
|
||||
expect(result.type).toBe('rate_limit');
|
||||
expect(result.severity).toBe('medium');
|
||||
expect(result.userMessage).toContain('Rate limit reached');
|
||||
expect(result.retryable).toBe(true);
|
||||
});
|
||||
|
||||
it('should classify network errors', () => {
|
||||
const networkError = new Error('ECONNREFUSED: Connection refused');
|
||||
const result = classifyError(networkError);
|
||||
|
||||
expect(result.type).toBe('network');
|
||||
expect(result.severity).toBe('medium');
|
||||
expect(result.userMessage).toContain('Network connection issue');
|
||||
expect(result.retryable).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle unknown errors', () => {
|
||||
const unknownError = new Error('Something completely unexpected happened');
|
||||
const result = classifyError(unknownError);
|
||||
|
||||
expect(result.type).toBe('unknown');
|
||||
expect(result.severity).toBe('medium');
|
||||
expect(result.userMessage).toContain('unexpected error');
|
||||
expect(result.retryable).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserFriendlyErrorMessage', () => {
|
||||
it('should include provider name in message', () => {
|
||||
const error = new Error('invalid_api_key');
|
||||
const message = getUserFriendlyErrorMessage(error, 'claude');
|
||||
|
||||
expect(message).toContain('[CLAUDE]');
|
||||
});
|
||||
|
||||
it('should include suggested action when available', () => {
|
||||
const error = new Error('invalid_api_key');
|
||||
const message = getUserFriendlyErrorMessage(error);
|
||||
|
||||
expect(message).toContain('Verify your API key');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Provider-Specific Tests', () => {
|
||||
describe('Claude CLI Detection', () => {
|
||||
it('should detect Claude CLI if installed', async () => {
|
||||
const result = await detectCli('claude');
|
||||
|
||||
if (result.detected) {
|
||||
expect(result.cli.name).toBe('Claude CLI');
|
||||
expect(result.cli.installed).toBe(true);
|
||||
expect(result.cli.command).toBeTruthy();
|
||||
}
|
||||
// If not installed, that's also a valid test result
|
||||
});
|
||||
|
||||
it('should handle missing Claude CLI gracefully', async () => {
|
||||
// This test will pass regardless of whether Claude is installed
|
||||
const result = await detectCli('claude');
|
||||
expect(typeof result.detected).toBe('boolean');
|
||||
expect(Array.isArray(result.issues)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Codex CLI Detection', () => {
|
||||
it('should detect Codex CLI if installed', async () => {
|
||||
const result = await detectCli('codex');
|
||||
|
||||
if (result.detected) {
|
||||
expect(result.cli.name).toBe('Codex CLI');
|
||||
expect(result.cli.installed).toBe(true);
|
||||
expect(result.cli.command).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cursor CLI Detection', () => {
|
||||
it('should detect Cursor CLI if installed', async () => {
|
||||
const result = await detectCli('cursor');
|
||||
|
||||
if (result.detected) {
|
||||
expect(result.cli.name).toBe('Cursor CLI');
|
||||
expect(result.cli.installed).toBe(true);
|
||||
expect(result.cli.command).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration Tests', () => {
|
||||
describe('detectAllCLis', () => {
|
||||
it('should detect all available CLIs', async () => {
|
||||
const results = await detectAllCLis();
|
||||
|
||||
expect(results).toHaveProperty('claude');
|
||||
expect(results).toHaveProperty('codex');
|
||||
expect(results).toHaveProperty('cursor');
|
||||
|
||||
// Each should have the expected structure
|
||||
Object.values(results).forEach((result) => {
|
||||
expect(result).toHaveProperty('cli');
|
||||
expect(result).toHaveProperty('detected');
|
||||
expect(result).toHaveProperty('issues');
|
||||
expect(result.cli).toHaveProperty('name');
|
||||
expect(result.cli).toHaveProperty('installed');
|
||||
expect(result.cli).toHaveProperty('authenticated');
|
||||
});
|
||||
}, 30000); // Longer timeout for CLI detection
|
||||
|
||||
it('should handle concurrent CLI detection', async () => {
|
||||
// Run detection multiple times concurrently
|
||||
const promises = [detectAllCLis(), detectAllCLis(), detectAllCLis()];
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
// All should return consistent results
|
||||
expect(results).toHaveLength(3);
|
||||
results.forEach((result) => {
|
||||
expect(result).toHaveProperty('claude');
|
||||
expect(result).toHaveProperty('codex');
|
||||
expect(result).toHaveProperty('cursor');
|
||||
});
|
||||
}, 45000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Recovery Tests', () => {
|
||||
it('should handle partial CLI detection failures', async () => {
|
||||
// Mock a scenario where some CLIs fail to detect
|
||||
const results = await detectAllCLis();
|
||||
|
||||
// Should still return results for all providers
|
||||
expect(results).toHaveProperty('claude');
|
||||
expect(results).toHaveProperty('codex');
|
||||
expect(results).toHaveProperty('cursor');
|
||||
|
||||
// Should provide error information for failures
|
||||
Object.entries(results).forEach(([provider, result]) => {
|
||||
if (!result.detected && result.issues.length > 0) {
|
||||
expect(result.issues.length).toBeGreaterThan(0);
|
||||
expect(result.issues[0]).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle timeout during CLI detection', async () => {
|
||||
// Test with very short timeout
|
||||
const result = await detectCli('claude', { timeout: 1 });
|
||||
|
||||
// Should handle gracefully without throwing
|
||||
expect(typeof result.detected).toBe('boolean');
|
||||
expect(Array.isArray(result.issues)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Security Tests', () => {
|
||||
it('should not expose sensitive information in error messages', () => {
|
||||
const errorWithKey = new Error('invalid_api_key: sk-ant-abc123secret456');
|
||||
const message = getUserFriendlyErrorMessage(errorWithKey);
|
||||
|
||||
// Should not expose the actual API key
|
||||
expect(message).not.toContain('sk-ant-abc123secret456');
|
||||
expect(message).toContain('Authentication failed');
|
||||
});
|
||||
|
||||
it('should sanitize file paths in error messages', () => {
|
||||
const errorWithPath = new Error('Permission denied: /home/user/.ssh/id_rsa');
|
||||
const message = getUserFriendlyErrorMessage(errorWithPath);
|
||||
|
||||
// Should not expose sensitive file paths
|
||||
expect(message).not.toContain('/home/user/.ssh/id_rsa');
|
||||
});
|
||||
});
|
||||
|
||||
// Performance Tests
|
||||
describe('Performance Tests', () => {
|
||||
it('should detect CLIs within reasonable time', async () => {
|
||||
const startTime = Date.now();
|
||||
const results = await detectAllCLis();
|
||||
const endTime = Date.now();
|
||||
|
||||
const duration = endTime - startTime;
|
||||
expect(duration).toBeLessThan(10000); // Should complete in under 10 seconds
|
||||
expect(results).toHaveProperty('claude');
|
||||
expect(results).toHaveProperty('codex');
|
||||
expect(results).toHaveProperty('cursor');
|
||||
}, 15000);
|
||||
|
||||
it('should handle rapid repeated calls', async () => {
|
||||
// Make multiple rapid calls
|
||||
const promises = Array.from({ length: 10 }, () => detectAllCLis());
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
// All should complete successfully
|
||||
expect(results).toHaveLength(10);
|
||||
results.forEach((result) => {
|
||||
expect(result).toHaveProperty('claude');
|
||||
expect(result).toHaveProperty('codex');
|
||||
expect(result).toHaveProperty('cursor');
|
||||
});
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
// Edge Cases
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty CLI names', async () => {
|
||||
await expect(detectCli('' as any)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should handle null CLI names', async () => {
|
||||
await expect(detectCli(null as any)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should handle undefined CLI names', async () => {
|
||||
await expect(detectCli(undefined as any)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should handle malformed error objects', () => {
|
||||
const testCases = [
|
||||
null,
|
||||
undefined,
|
||||
'',
|
||||
123,
|
||||
[],
|
||||
{ nested: { error: { message: 'test' } } },
|
||||
{ error: 'simple string error' },
|
||||
];
|
||||
|
||||
testCases.forEach((error) => {
|
||||
expect(() => {
|
||||
const result = classifyError(error);
|
||||
expect(result).toHaveProperty('type');
|
||||
expect(result).toHaveProperty('severity');
|
||||
expect(result).toHaveProperty('userMessage');
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -277,7 +277,7 @@ describe('auth.ts', () => {
|
||||
const options = getSessionCookieOptions();
|
||||
|
||||
expect(options.httpOnly).toBe(true);
|
||||
expect(options.sameSite).toBe('lax');
|
||||
expect(options.sameSite).toBe('strict');
|
||||
expect(options.path).toBe('/');
|
||||
expect(options.maxAge).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -1,15 +1,161 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import os from 'os';
|
||||
|
||||
describe('sdk-options.ts', () => {
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
let homedirSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
originalEnv = { ...process.env };
|
||||
vi.resetModules();
|
||||
// Spy on os.homedir and set default return value
|
||||
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue('/Users/test');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
homedirSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('isCloudStoragePath', () => {
|
||||
it('should detect Dropbox paths on macOS', async () => {
|
||||
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
|
||||
expect(isCloudStoragePath('/Users/test/Library/CloudStorage/Dropbox-Personal/project')).toBe(
|
||||
true
|
||||
);
|
||||
expect(isCloudStoragePath('/Users/test/Library/CloudStorage/Dropbox/project')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect Google Drive paths on macOS', async () => {
|
||||
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
|
||||
expect(
|
||||
isCloudStoragePath('/Users/test/Library/CloudStorage/GoogleDrive-user@gmail.com/project')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect OneDrive paths on macOS', async () => {
|
||||
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
|
||||
expect(isCloudStoragePath('/Users/test/Library/CloudStorage/OneDrive-Personal/project')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('should detect iCloud Drive paths on macOS', async () => {
|
||||
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
|
||||
expect(
|
||||
isCloudStoragePath('/Users/test/Library/Mobile Documents/com~apple~CloudDocs/project')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect home-anchored Dropbox paths', async () => {
|
||||
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
|
||||
expect(isCloudStoragePath('/Users/test/Dropbox')).toBe(true);
|
||||
expect(isCloudStoragePath('/Users/test/Dropbox/project')).toBe(true);
|
||||
expect(isCloudStoragePath('/Users/test/Dropbox/nested/deep/project')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect home-anchored Google Drive paths', async () => {
|
||||
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
|
||||
expect(isCloudStoragePath('/Users/test/Google Drive')).toBe(true);
|
||||
expect(isCloudStoragePath('/Users/test/Google Drive/project')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect home-anchored OneDrive paths', async () => {
|
||||
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
|
||||
expect(isCloudStoragePath('/Users/test/OneDrive')).toBe(true);
|
||||
expect(isCloudStoragePath('/Users/test/OneDrive/project')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for local paths', async () => {
|
||||
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
|
||||
expect(isCloudStoragePath('/Users/test/projects/myapp')).toBe(false);
|
||||
expect(isCloudStoragePath('/home/user/code/project')).toBe(false);
|
||||
expect(isCloudStoragePath('/var/www/app')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for relative paths not in cloud storage', async () => {
|
||||
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
|
||||
expect(isCloudStoragePath('./project')).toBe(false);
|
||||
expect(isCloudStoragePath('../other-project')).toBe(false);
|
||||
});
|
||||
|
||||
// Tests for false positive prevention - paths that contain cloud storage names but aren't cloud storage
|
||||
it('should NOT flag paths that merely contain "dropbox" in the name', async () => {
|
||||
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
|
||||
// Projects with dropbox-like names
|
||||
expect(isCloudStoragePath('/home/user/my-project-about-dropbox')).toBe(false);
|
||||
expect(isCloudStoragePath('/Users/test/projects/dropbox-clone')).toBe(false);
|
||||
expect(isCloudStoragePath('/Users/test/projects/Dropbox-backup-tool')).toBe(false);
|
||||
// Dropbox folder that's NOT in the home directory
|
||||
expect(isCloudStoragePath('/var/shared/Dropbox/project')).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT flag paths that merely contain "Google Drive" in the name', async () => {
|
||||
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
|
||||
expect(isCloudStoragePath('/Users/test/projects/google-drive-api-client')).toBe(false);
|
||||
expect(isCloudStoragePath('/home/user/Google Drive API Tests')).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT flag paths that merely contain "OneDrive" in the name', async () => {
|
||||
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
|
||||
expect(isCloudStoragePath('/Users/test/projects/onedrive-sync-tool')).toBe(false);
|
||||
expect(isCloudStoragePath('/home/user/OneDrive-migration-scripts')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle different home directories correctly', async () => {
|
||||
// Change the mocked home directory
|
||||
homedirSpy.mockReturnValue('/home/linuxuser');
|
||||
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
|
||||
|
||||
// Should detect Dropbox under the Linux home directory
|
||||
expect(isCloudStoragePath('/home/linuxuser/Dropbox/project')).toBe(true);
|
||||
// Should NOT detect Dropbox under the old home directory (since home changed)
|
||||
expect(isCloudStoragePath('/Users/test/Dropbox/project')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkSandboxCompatibility', () => {
|
||||
it('should return enabled=false when user disables sandbox', async () => {
|
||||
const { checkSandboxCompatibility } = await import('@/lib/sdk-options.js');
|
||||
const result = checkSandboxCompatibility('/Users/test/project', false);
|
||||
expect(result.enabled).toBe(false);
|
||||
expect(result.disabledReason).toBe('user_setting');
|
||||
});
|
||||
|
||||
it('should return enabled=false for cloud storage paths even when sandbox enabled', async () => {
|
||||
const { checkSandboxCompatibility } = await import('@/lib/sdk-options.js');
|
||||
const result = checkSandboxCompatibility(
|
||||
'/Users/test/Library/CloudStorage/Dropbox-Personal/project',
|
||||
true
|
||||
);
|
||||
expect(result.enabled).toBe(false);
|
||||
expect(result.disabledReason).toBe('cloud_storage');
|
||||
expect(result.message).toContain('cloud storage');
|
||||
});
|
||||
|
||||
it('should return enabled=true for local paths when sandbox enabled', async () => {
|
||||
const { checkSandboxCompatibility } = await import('@/lib/sdk-options.js');
|
||||
const result = checkSandboxCompatibility('/Users/test/projects/myapp', true);
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.disabledReason).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return enabled=true when enableSandboxMode is undefined for local paths', async () => {
|
||||
const { checkSandboxCompatibility } = await import('@/lib/sdk-options.js');
|
||||
const result = checkSandboxCompatibility('/Users/test/project', undefined);
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.disabledReason).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return enabled=false for cloud storage paths when enableSandboxMode is undefined', async () => {
|
||||
const { checkSandboxCompatibility } = await import('@/lib/sdk-options.js');
|
||||
const result = checkSandboxCompatibility(
|
||||
'/Users/test/Library/CloudStorage/Dropbox-Personal/project',
|
||||
undefined
|
||||
);
|
||||
expect(result.enabled).toBe(false);
|
||||
expect(result.disabledReason).toBe('cloud_storage');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TOOL_PRESETS', () => {
|
||||
@@ -179,15 +325,19 @@ describe('sdk-options.ts', () => {
|
||||
it('should create options with chat settings', async () => {
|
||||
const { createChatOptions, TOOL_PRESETS, MAX_TURNS } = await import('@/lib/sdk-options.js');
|
||||
|
||||
const options = createChatOptions({ cwd: '/test/path' });
|
||||
const options = createChatOptions({ cwd: '/test/path', enableSandboxMode: true });
|
||||
|
||||
expect(options.cwd).toBe('/test/path');
|
||||
expect(options.maxTurns).toBe(MAX_TURNS.standard);
|
||||
expect(options.allowedTools).toEqual([...TOOL_PRESETS.chat]);
|
||||
expect(options.sandbox).toEqual({
|
||||
enabled: true,
|
||||
autoAllowBashIfSandboxed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should prefer explicit model over session model', async () => {
|
||||
const { createChatOptions } = await import('@/lib/sdk-options.js');
|
||||
const { createChatOptions, getModelForUseCase } = await import('@/lib/sdk-options.js');
|
||||
|
||||
const options = createChatOptions({
|
||||
cwd: '/test/path',
|
||||
@@ -208,6 +358,41 @@ describe('sdk-options.ts', () => {
|
||||
|
||||
expect(options.model).toBe('claude-sonnet-4-20250514');
|
||||
});
|
||||
|
||||
it('should not set sandbox when enableSandboxMode is false', async () => {
|
||||
const { createChatOptions } = await import('@/lib/sdk-options.js');
|
||||
|
||||
const options = createChatOptions({
|
||||
cwd: '/test/path',
|
||||
enableSandboxMode: false,
|
||||
});
|
||||
|
||||
expect(options.sandbox).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should enable sandbox by default when enableSandboxMode is not provided', async () => {
|
||||
const { createChatOptions } = await import('@/lib/sdk-options.js');
|
||||
|
||||
const options = createChatOptions({
|
||||
cwd: '/test/path',
|
||||
});
|
||||
|
||||
expect(options.sandbox).toEqual({
|
||||
enabled: true,
|
||||
autoAllowBashIfSandboxed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should auto-disable sandbox for cloud storage paths', async () => {
|
||||
const { createChatOptions } = await import('@/lib/sdk-options.js');
|
||||
|
||||
const options = createChatOptions({
|
||||
cwd: '/Users/test/Library/CloudStorage/Dropbox-Personal/project',
|
||||
enableSandboxMode: true,
|
||||
});
|
||||
|
||||
expect(options.sandbox).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAutoModeOptions', () => {
|
||||
@@ -215,11 +400,15 @@ describe('sdk-options.ts', () => {
|
||||
const { createAutoModeOptions, TOOL_PRESETS, MAX_TURNS } =
|
||||
await import('@/lib/sdk-options.js');
|
||||
|
||||
const options = createAutoModeOptions({ cwd: '/test/path' });
|
||||
const options = createAutoModeOptions({ cwd: '/test/path', enableSandboxMode: true });
|
||||
|
||||
expect(options.cwd).toBe('/test/path');
|
||||
expect(options.maxTurns).toBe(MAX_TURNS.maximum);
|
||||
expect(options.allowedTools).toEqual([...TOOL_PRESETS.fullAccess]);
|
||||
expect(options.sandbox).toEqual({
|
||||
enabled: true,
|
||||
autoAllowBashIfSandboxed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should include systemPrompt when provided', async () => {
|
||||
@@ -244,6 +433,62 @@ describe('sdk-options.ts', () => {
|
||||
|
||||
expect(options.abortController).toBe(abortController);
|
||||
});
|
||||
|
||||
it('should not set sandbox when enableSandboxMode is false', async () => {
|
||||
const { createAutoModeOptions } = await import('@/lib/sdk-options.js');
|
||||
|
||||
const options = createAutoModeOptions({
|
||||
cwd: '/test/path',
|
||||
enableSandboxMode: false,
|
||||
});
|
||||
|
||||
expect(options.sandbox).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should enable sandbox by default when enableSandboxMode is not provided', async () => {
|
||||
const { createAutoModeOptions } = await import('@/lib/sdk-options.js');
|
||||
|
||||
const options = createAutoModeOptions({
|
||||
cwd: '/test/path',
|
||||
});
|
||||
|
||||
expect(options.sandbox).toEqual({
|
||||
enabled: true,
|
||||
autoAllowBashIfSandboxed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should auto-disable sandbox for cloud storage paths', async () => {
|
||||
const { createAutoModeOptions } = await import('@/lib/sdk-options.js');
|
||||
|
||||
const options = createAutoModeOptions({
|
||||
cwd: '/Users/test/Library/CloudStorage/Dropbox-Personal/project',
|
||||
enableSandboxMode: true,
|
||||
});
|
||||
|
||||
expect(options.sandbox).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should auto-disable sandbox for cloud storage paths even when enableSandboxMode is not provided', async () => {
|
||||
const { createAutoModeOptions } = await import('@/lib/sdk-options.js');
|
||||
|
||||
const options = createAutoModeOptions({
|
||||
cwd: '/Users/test/Library/CloudStorage/Dropbox-Personal/project',
|
||||
});
|
||||
|
||||
expect(options.sandbox).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should auto-disable sandbox for iCloud paths', async () => {
|
||||
const { createAutoModeOptions } = await import('@/lib/sdk-options.js');
|
||||
|
||||
const options = createAutoModeOptions({
|
||||
cwd: '/Users/test/Library/Mobile Documents/com~apple~CloudDocs/project',
|
||||
enableSandboxMode: true,
|
||||
});
|
||||
|
||||
expect(options.sandbox).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCustomOptions', () => {
|
||||
@@ -254,11 +499,13 @@ describe('sdk-options.ts', () => {
|
||||
cwd: '/test/path',
|
||||
maxTurns: 10,
|
||||
allowedTools: ['Read', 'Write'],
|
||||
sandbox: { enabled: true },
|
||||
});
|
||||
|
||||
expect(options.cwd).toBe('/test/path');
|
||||
expect(options.maxTurns).toBe(10);
|
||||
expect(options.allowedTools).toEqual(['Read', 'Write']);
|
||||
expect(options.sandbox).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it('should use defaults when optional params not provided', async () => {
|
||||
@@ -270,6 +517,20 @@ describe('sdk-options.ts', () => {
|
||||
expect(options.allowedTools).toEqual([...TOOL_PRESETS.readOnly]);
|
||||
});
|
||||
|
||||
it('should include sandbox when provided', async () => {
|
||||
const { createCustomOptions } = await import('@/lib/sdk-options.js');
|
||||
|
||||
const options = createCustomOptions({
|
||||
cwd: '/test/path',
|
||||
sandbox: { enabled: true, autoAllowBashIfSandboxed: false },
|
||||
});
|
||||
|
||||
expect(options.sandbox).toEqual({
|
||||
enabled: true,
|
||||
autoAllowBashIfSandboxed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should include systemPrompt when provided', async () => {
|
||||
const { createCustomOptions } = await import('@/lib/sdk-options.js');
|
||||
|
||||
|
||||
@@ -179,7 +179,8 @@ describe('validation-storage.ts', () => {
|
||||
});
|
||||
|
||||
it('should return false for validation exactly at 24 hours', () => {
|
||||
const exactDate = new Date(Date.now() - 24 * 60 * 60 * 1000 + 100);
|
||||
const exactDate = new Date();
|
||||
exactDate.setHours(exactDate.getHours() - 24);
|
||||
|
||||
const validation = createMockValidation({
|
||||
validatedAt: exactDate.toISOString(),
|
||||
|
||||
@@ -37,7 +37,6 @@ describe('claude-provider.ts', () => {
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: 'Hello',
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
cwd: '/test',
|
||||
});
|
||||
|
||||
@@ -80,7 +79,7 @@ describe('claude-provider.ts', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should not include allowedTools when not specified (caller decides via sdk-options)', async () => {
|
||||
it('should use default allowed tools when not specified', async () => {
|
||||
vi.mocked(sdk.query).mockReturnValue(
|
||||
(async function* () {
|
||||
yield { type: 'text', text: 'test' };
|
||||
@@ -89,7 +88,6 @@ describe('claude-provider.ts', () => {
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: 'Test',
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
cwd: '/test',
|
||||
});
|
||||
|
||||
@@ -97,8 +95,37 @@ describe('claude-provider.ts', () => {
|
||||
|
||||
expect(sdk.query).toHaveBeenCalledWith({
|
||||
prompt: 'Test',
|
||||
options: expect.not.objectContaining({
|
||||
allowedTools: expect.anything(),
|
||||
options: expect.objectContaining({
|
||||
allowedTools: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass sandbox configuration when provided', async () => {
|
||||
vi.mocked(sdk.query).mockReturnValue(
|
||||
(async function* () {
|
||||
yield { type: 'text', text: 'test' };
|
||||
})()
|
||||
);
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: 'Test',
|
||||
cwd: '/test',
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
autoAllowBashIfSandboxed: true,
|
||||
},
|
||||
});
|
||||
|
||||
await collectAsyncGenerator(generator);
|
||||
|
||||
expect(sdk.query).toHaveBeenCalledWith({
|
||||
prompt: 'Test',
|
||||
options: expect.objectContaining({
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
autoAllowBashIfSandboxed: true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
@@ -114,7 +141,6 @@ describe('claude-provider.ts', () => {
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: 'Test',
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
cwd: '/test',
|
||||
abortController,
|
||||
});
|
||||
@@ -143,7 +169,6 @@ describe('claude-provider.ts', () => {
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: 'Current message',
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
cwd: '/test',
|
||||
conversationHistory,
|
||||
sdkSessionId: 'test-session-id',
|
||||
@@ -174,7 +199,6 @@ describe('claude-provider.ts', () => {
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: arrayPrompt as any,
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
cwd: '/test',
|
||||
});
|
||||
|
||||
@@ -194,7 +218,6 @@ describe('claude-provider.ts', () => {
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: 'Test',
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
cwd: '/test',
|
||||
});
|
||||
|
||||
@@ -220,7 +243,6 @@ describe('claude-provider.ts', () => {
|
||||
|
||||
const generator = provider.executeQuery({
|
||||
prompt: 'Test',
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
cwd: '/test',
|
||||
});
|
||||
|
||||
|
||||
@@ -1,293 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { CodexProvider } from '../../../src/providers/codex-provider.js';
|
||||
import type { ProviderMessage } from '../../../src/providers/types.js';
|
||||
import { collectAsyncGenerator } from '../../utils/helpers.js';
|
||||
import {
|
||||
spawnJSONLProcess,
|
||||
findCodexCliPath,
|
||||
secureFs,
|
||||
getCodexConfigDir,
|
||||
getCodexAuthIndicators,
|
||||
} from '@automaker/platform';
|
||||
|
||||
const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY';
|
||||
const originalOpenAIKey = process.env[OPENAI_API_KEY_ENV];
|
||||
|
||||
const codexRunMock = vi.fn();
|
||||
|
||||
vi.mock('@openai/codex-sdk', () => ({
|
||||
Codex: class {
|
||||
constructor(_opts: { apiKey: string }) {}
|
||||
startThread() {
|
||||
return {
|
||||
id: 'thread-123',
|
||||
run: codexRunMock,
|
||||
};
|
||||
}
|
||||
resumeThread() {
|
||||
return {
|
||||
id: 'thread-123',
|
||||
run: codexRunMock,
|
||||
};
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const EXEC_SUBCOMMAND = 'exec';
|
||||
|
||||
vi.mock('@automaker/platform', () => ({
|
||||
spawnJSONLProcess: vi.fn(),
|
||||
spawnProcess: vi.fn(),
|
||||
findCodexCliPath: vi.fn(),
|
||||
getCodexAuthIndicators: vi.fn().mockResolvedValue({
|
||||
hasAuthFile: false,
|
||||
hasOAuthToken: false,
|
||||
hasApiKey: false,
|
||||
}),
|
||||
getCodexConfigDir: vi.fn().mockReturnValue('/home/test/.codex'),
|
||||
secureFs: {
|
||||
readFile: vi.fn(),
|
||||
mkdir: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
},
|
||||
getDataDirectory: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/settings-service.js', () => ({
|
||||
SettingsService: class {
|
||||
async getGlobalSettings() {
|
||||
return {
|
||||
codexAutoLoadAgents: false,
|
||||
codexSandboxMode: 'workspace-write',
|
||||
codexApprovalPolicy: 'on-request',
|
||||
};
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
describe('codex-provider.ts', () => {
|
||||
let provider: CodexProvider;
|
||||
|
||||
afterAll(() => {
|
||||
if (originalOpenAIKey !== undefined) {
|
||||
process.env[OPENAI_API_KEY_ENV] = originalOpenAIKey;
|
||||
} else {
|
||||
delete process.env[OPENAI_API_KEY_ENV];
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getCodexConfigDir).mockReturnValue('/home/test/.codex');
|
||||
vi.mocked(findCodexCliPath).mockResolvedValue('/usr/bin/codex');
|
||||
vi.mocked(getCodexAuthIndicators).mockResolvedValue({
|
||||
hasAuthFile: true,
|
||||
hasOAuthToken: true,
|
||||
hasApiKey: false,
|
||||
});
|
||||
delete process.env[OPENAI_API_KEY_ENV];
|
||||
provider = new CodexProvider();
|
||||
});
|
||||
|
||||
describe('executeQuery', () => {
|
||||
it('emits tool_use and tool_result with shared tool_use_id for command execution', async () => {
|
||||
const mockEvents = [
|
||||
{
|
||||
type: 'item.started',
|
||||
item: {
|
||||
type: 'command_execution',
|
||||
id: 'cmd-1',
|
||||
command: 'ls',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'item.completed',
|
||||
item: {
|
||||
type: 'command_execution',
|
||||
id: 'cmd-1',
|
||||
output: 'file1\nfile2',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(spawnJSONLProcess).mockReturnValue(
|
||||
(async function* () {
|
||||
for (const event of mockEvents) {
|
||||
yield event;
|
||||
}
|
||||
})()
|
||||
);
|
||||
const results = await collectAsyncGenerator<ProviderMessage>(
|
||||
provider.executeQuery({
|
||||
prompt: 'List files',
|
||||
model: 'gpt-5.2',
|
||||
cwd: '/tmp',
|
||||
})
|
||||
);
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
const toolUse = results[0];
|
||||
const toolResult = results[1];
|
||||
|
||||
expect(toolUse.type).toBe('assistant');
|
||||
expect(toolUse.message?.content[0].type).toBe('tool_use');
|
||||
const toolUseId = toolUse.message?.content[0].tool_use_id;
|
||||
expect(toolUseId).toBeDefined();
|
||||
|
||||
expect(toolResult.type).toBe('assistant');
|
||||
expect(toolResult.message?.content[0].type).toBe('tool_result');
|
||||
expect(toolResult.message?.content[0].tool_use_id).toBe(toolUseId);
|
||||
expect(toolResult.message?.content[0].content).toBe('file1\nfile2');
|
||||
});
|
||||
|
||||
it('adds output schema and max turn overrides when configured', async () => {
|
||||
// Note: With full-permissions always on, these flags are no longer used
|
||||
// This test now only verifies the basic CLI structure
|
||||
// Using gpt-5.1-codex-max which should route to Codex (not Cursor)
|
||||
vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());
|
||||
|
||||
await collectAsyncGenerator(
|
||||
provider.executeQuery({
|
||||
prompt: 'Test config',
|
||||
model: 'gpt-5.1-codex-max',
|
||||
cwd: '/tmp',
|
||||
allowedTools: ['Read', 'Write'],
|
||||
maxTurns: 5,
|
||||
})
|
||||
);
|
||||
|
||||
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
|
||||
expect(call.args).toContain('exec'); // Should have exec subcommand
|
||||
expect(call.args).toContain('--dangerously-bypass-approvals-and-sandbox'); // Should have YOLO flag
|
||||
expect(call.args).toContain('--model');
|
||||
expect(call.args).toContain('--json');
|
||||
});
|
||||
|
||||
it('overrides approval policy when MCP auto-approval is enabled', async () => {
|
||||
// Note: With full-permissions always on (--dangerously-bypass-approvals-and-sandbox),
|
||||
// approval policy is bypassed, not configured via --config
|
||||
vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());
|
||||
|
||||
await collectAsyncGenerator(
|
||||
provider.executeQuery({
|
||||
prompt: 'Test approvals',
|
||||
model: 'gpt-5.1-codex-max',
|
||||
cwd: '/tmp',
|
||||
mcpServers: { mock: { type: 'stdio', command: 'node' } },
|
||||
mcpAutoApproveTools: true,
|
||||
codexSettings: { approvalPolicy: 'untrusted' },
|
||||
})
|
||||
);
|
||||
|
||||
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
|
||||
const execIndex = call.args.indexOf(EXEC_SUBCOMMAND);
|
||||
expect(call.args).toContain('--dangerously-bypass-approvals-and-sandbox'); // YOLO flag bypasses approval
|
||||
expect(call.args).toContain('--model');
|
||||
expect(call.args).toContain('--json');
|
||||
});
|
||||
|
||||
it('injects user and project instructions when auto-load is enabled', async () => {
|
||||
vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());
|
||||
|
||||
const userPath = path.join('/home/test/.codex', 'AGENTS.md');
|
||||
const projectPath = path.join('/tmp/project', '.codex', 'AGENTS.md');
|
||||
vi.mocked(secureFs.readFile).mockImplementation(async (filePath: string) => {
|
||||
if (filePath === userPath) {
|
||||
return 'User rules';
|
||||
}
|
||||
if (filePath === projectPath) {
|
||||
return 'Project rules';
|
||||
}
|
||||
throw new Error('missing');
|
||||
});
|
||||
|
||||
await collectAsyncGenerator(
|
||||
provider.executeQuery({
|
||||
prompt: 'Hello',
|
||||
model: 'gpt-5.2',
|
||||
cwd: '/tmp/project',
|
||||
codexSettings: { autoLoadAgents: true },
|
||||
})
|
||||
);
|
||||
|
||||
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
|
||||
const promptText = call.stdinData;
|
||||
expect(promptText).toContain('User rules');
|
||||
expect(promptText).toContain('Project rules');
|
||||
});
|
||||
|
||||
it('disables sandbox mode when running in cloud storage paths', async () => {
|
||||
// Note: With full-permissions always on (--dangerously-bypass-approvals-and-sandbox),
|
||||
// sandbox mode is bypassed, not configured via --sandbox flag
|
||||
vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());
|
||||
|
||||
const cloudPath = path.join(os.homedir(), 'Dropbox', 'project');
|
||||
await collectAsyncGenerator(
|
||||
provider.executeQuery({
|
||||
prompt: 'Hello',
|
||||
model: 'gpt-5.1-codex-max',
|
||||
cwd: cloudPath,
|
||||
codexSettings: { sandboxMode: 'workspace-write' },
|
||||
})
|
||||
);
|
||||
|
||||
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
|
||||
// YOLO flag bypasses sandbox entirely
|
||||
expect(call.args).toContain('--dangerously-bypass-approvals-and-sandbox');
|
||||
expect(call.args).toContain('--model');
|
||||
expect(call.args).toContain('--json');
|
||||
});
|
||||
|
||||
it('uses the SDK when no tools are requested and an API key is present', async () => {
|
||||
process.env[OPENAI_API_KEY_ENV] = 'sk-test';
|
||||
codexRunMock.mockResolvedValue({ finalResponse: 'Hello from SDK' });
|
||||
|
||||
const results = await collectAsyncGenerator<ProviderMessage>(
|
||||
provider.executeQuery({
|
||||
prompt: 'Hello',
|
||||
model: 'gpt-5.2',
|
||||
cwd: '/tmp',
|
||||
allowedTools: [],
|
||||
})
|
||||
);
|
||||
|
||||
expect(results[0].message?.content[0].text).toBe('Hello from SDK');
|
||||
expect(results[1].result).toBe('Hello from SDK');
|
||||
});
|
||||
|
||||
it('uses the CLI when tools are requested even if an API key is present', async () => {
|
||||
process.env[OPENAI_API_KEY_ENV] = 'sk-test';
|
||||
vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());
|
||||
|
||||
await collectAsyncGenerator(
|
||||
provider.executeQuery({
|
||||
prompt: 'Read files',
|
||||
model: 'gpt-5.2',
|
||||
cwd: '/tmp',
|
||||
allowedTools: ['Read'],
|
||||
})
|
||||
);
|
||||
|
||||
expect(codexRunMock).not.toHaveBeenCalled();
|
||||
expect(spawnJSONLProcess).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to CLI when no tools are requested and no API key is available', async () => {
|
||||
vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());
|
||||
|
||||
await collectAsyncGenerator(
|
||||
provider.executeQuery({
|
||||
prompt: 'Hello',
|
||||
model: 'gpt-5.2',
|
||||
cwd: '/tmp',
|
||||
allowedTools: [],
|
||||
})
|
||||
);
|
||||
|
||||
expect(codexRunMock).not.toHaveBeenCalled();
|
||||
expect(spawnJSONLProcess).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,42 +2,18 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { ProviderFactory } from '@/providers/provider-factory.js';
|
||||
import { ClaudeProvider } from '@/providers/claude-provider.js';
|
||||
import { CursorProvider } from '@/providers/cursor-provider.js';
|
||||
import { CodexProvider } from '@/providers/codex-provider.js';
|
||||
import { OpencodeProvider } from '@/providers/opencode-provider.js';
|
||||
|
||||
describe('provider-factory.ts', () => {
|
||||
let consoleSpy: any;
|
||||
let detectClaudeSpy: any;
|
||||
let detectCursorSpy: any;
|
||||
let detectCodexSpy: any;
|
||||
let detectOpencodeSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleSpy = {
|
||||
warn: vi.spyOn(console, 'warn').mockImplementation(() => {}),
|
||||
};
|
||||
|
||||
// Avoid hitting real CLI / filesystem checks during unit tests
|
||||
detectClaudeSpy = vi
|
||||
.spyOn(ClaudeProvider.prototype, 'detectInstallation')
|
||||
.mockResolvedValue({ installed: true });
|
||||
detectCursorSpy = vi
|
||||
.spyOn(CursorProvider.prototype, 'detectInstallation')
|
||||
.mockResolvedValue({ installed: true });
|
||||
detectCodexSpy = vi
|
||||
.spyOn(CodexProvider.prototype, 'detectInstallation')
|
||||
.mockResolvedValue({ installed: true });
|
||||
detectOpencodeSpy = vi
|
||||
.spyOn(OpencodeProvider.prototype, 'detectInstallation')
|
||||
.mockResolvedValue({ installed: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleSpy.warn.mockRestore();
|
||||
detectClaudeSpy.mockRestore();
|
||||
detectCursorSpy.mockRestore();
|
||||
detectCodexSpy.mockRestore();
|
||||
detectOpencodeSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('getProviderForModel', () => {
|
||||
@@ -135,11 +111,10 @@ describe('provider-factory.ts', () => {
|
||||
});
|
||||
|
||||
describe('Cursor models via model ID lookup', () => {
|
||||
it('should return CodexProvider for gpt-5.2 (Codex model, not Cursor)', () => {
|
||||
// gpt-5.2 is in both CURSOR_MODEL_MAP and CODEX_MODEL_CONFIG_MAP
|
||||
// It should route to Codex since Codex models take priority
|
||||
it('should return CursorProvider for gpt-5.2 (valid Cursor model)', () => {
|
||||
// gpt-5.2 is in CURSOR_MODEL_MAP
|
||||
const provider = ProviderFactory.getProviderForModel('gpt-5.2');
|
||||
expect(provider).toBeInstanceOf(CodexProvider);
|
||||
expect(provider).toBeInstanceOf(CursorProvider);
|
||||
});
|
||||
|
||||
it('should return CursorProvider for grok (valid Cursor model)', () => {
|
||||
@@ -166,9 +141,9 @@ describe('provider-factory.ts', () => {
|
||||
expect(hasClaudeProvider).toBe(true);
|
||||
});
|
||||
|
||||
it('should return exactly 4 providers', () => {
|
||||
it('should return exactly 2 providers', () => {
|
||||
const providers = ProviderFactory.getAllProviders();
|
||||
expect(providers).toHaveLength(4);
|
||||
expect(providers).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should include CursorProvider', () => {
|
||||
@@ -204,9 +179,7 @@ describe('provider-factory.ts', () => {
|
||||
|
||||
expect(keys).toContain('claude');
|
||||
expect(keys).toContain('cursor');
|
||||
expect(keys).toContain('codex');
|
||||
expect(keys).toContain('opencode');
|
||||
expect(keys).toHaveLength(4);
|
||||
expect(keys).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should include cursor status', async () => {
|
||||
|
||||
318
apps/server/tests/unit/routes/debug/metrics.test.ts
Normal file
318
apps/server/tests/unit/routes/debug/metrics.test.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Request, Response } from 'express';
|
||||
import {
|
||||
createGetMetricsHandler,
|
||||
createStartMetricsHandler,
|
||||
createStopMetricsHandler,
|
||||
createForceGCHandler,
|
||||
createClearHistoryHandler,
|
||||
} from '@/routes/debug/routes/metrics.js';
|
||||
import type { PerformanceMonitorService } from '@/services/performance-monitor-service.js';
|
||||
import type { DebugMetricsConfig, DebugMetricsSnapshot } from '@automaker/types';
|
||||
import { DEFAULT_DEBUG_METRICS_CONFIG } from '@automaker/types';
|
||||
|
||||
describe('Debug Metrics Routes', () => {
|
||||
let mockPerformanceMonitor: Partial<PerformanceMonitorService>;
|
||||
let mockReq: Partial<Request>;
|
||||
let mockRes: Partial<Response>;
|
||||
let jsonFn: ReturnType<typeof vi.fn>;
|
||||
let statusFn: ReturnType<typeof vi.fn>;
|
||||
|
||||
const mockConfig: DebugMetricsConfig = { ...DEFAULT_DEBUG_METRICS_CONFIG };
|
||||
const mockSnapshot: DebugMetricsSnapshot = {
|
||||
timestamp: Date.now(),
|
||||
memory: {
|
||||
timestamp: Date.now(),
|
||||
server: {
|
||||
heapTotal: 100 * 1024 * 1024,
|
||||
heapUsed: 50 * 1024 * 1024,
|
||||
external: 5 * 1024 * 1024,
|
||||
rss: 150 * 1024 * 1024,
|
||||
arrayBuffers: 1 * 1024 * 1024,
|
||||
},
|
||||
},
|
||||
cpu: {
|
||||
timestamp: Date.now(),
|
||||
server: {
|
||||
percentage: 25.5,
|
||||
user: 1000,
|
||||
system: 500,
|
||||
},
|
||||
eventLoopLag: 5,
|
||||
},
|
||||
processes: [],
|
||||
processSummary: {
|
||||
total: 0,
|
||||
running: 0,
|
||||
idle: 0,
|
||||
stopped: 0,
|
||||
errored: 0,
|
||||
byType: { agent: 0, cli: 0, terminal: 0, worker: 0 },
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jsonFn = vi.fn();
|
||||
statusFn = vi.fn(() => ({ json: jsonFn }));
|
||||
|
||||
mockPerformanceMonitor = {
|
||||
getLatestSnapshot: vi.fn(() => mockSnapshot),
|
||||
getConfig: vi.fn(() => mockConfig),
|
||||
isActive: vi.fn(() => true),
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
updateConfig: vi.fn(),
|
||||
forceGC: vi.fn(() => true),
|
||||
clearHistory: vi.fn(),
|
||||
};
|
||||
|
||||
mockReq = {
|
||||
body: {},
|
||||
query: {},
|
||||
params: {},
|
||||
};
|
||||
|
||||
mockRes = {
|
||||
json: jsonFn,
|
||||
status: statusFn,
|
||||
};
|
||||
});
|
||||
|
||||
describe('GET /api/debug/metrics', () => {
|
||||
it('should return current metrics snapshot', () => {
|
||||
const handler = createGetMetricsHandler(mockPerformanceMonitor as PerformanceMonitorService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(jsonFn).toHaveBeenCalledWith({
|
||||
active: true,
|
||||
config: mockConfig,
|
||||
snapshot: mockSnapshot,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined snapshot when no data available', () => {
|
||||
(mockPerformanceMonitor.getLatestSnapshot as ReturnType<typeof vi.fn>).mockReturnValue(null);
|
||||
|
||||
const handler = createGetMetricsHandler(mockPerformanceMonitor as PerformanceMonitorService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(jsonFn).toHaveBeenCalledWith({
|
||||
active: true,
|
||||
config: mockConfig,
|
||||
snapshot: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return active status correctly', () => {
|
||||
(mockPerformanceMonitor.isActive as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
||||
|
||||
const handler = createGetMetricsHandler(mockPerformanceMonitor as PerformanceMonitorService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(jsonFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
active: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/debug/metrics/start', () => {
|
||||
it('should start metrics collection', () => {
|
||||
const handler = createStartMetricsHandler(
|
||||
mockPerformanceMonitor as PerformanceMonitorService
|
||||
);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockPerformanceMonitor.start).toHaveBeenCalled();
|
||||
expect(jsonFn).toHaveBeenCalledWith({
|
||||
active: true,
|
||||
config: mockConfig,
|
||||
});
|
||||
});
|
||||
|
||||
it('should apply config overrides when provided', () => {
|
||||
mockReq.body = {
|
||||
config: {
|
||||
collectionInterval: 5000,
|
||||
maxDataPoints: 500,
|
||||
},
|
||||
};
|
||||
|
||||
const handler = createStartMetricsHandler(
|
||||
mockPerformanceMonitor as PerformanceMonitorService
|
||||
);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockPerformanceMonitor.updateConfig).toHaveBeenCalledWith({
|
||||
collectionInterval: 5000,
|
||||
maxDataPoints: 500,
|
||||
});
|
||||
});
|
||||
|
||||
it('should sanitize config values - clamp collectionInterval to min 100ms', () => {
|
||||
mockReq.body = {
|
||||
config: {
|
||||
collectionInterval: 10, // Below minimum of 100ms
|
||||
},
|
||||
};
|
||||
|
||||
const handler = createStartMetricsHandler(
|
||||
mockPerformanceMonitor as PerformanceMonitorService
|
||||
);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockPerformanceMonitor.updateConfig).toHaveBeenCalledWith({
|
||||
collectionInterval: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('should sanitize config values - clamp collectionInterval to max 60000ms', () => {
|
||||
mockReq.body = {
|
||||
config: {
|
||||
collectionInterval: 100000, // Above maximum of 60000ms
|
||||
},
|
||||
};
|
||||
|
||||
const handler = createStartMetricsHandler(
|
||||
mockPerformanceMonitor as PerformanceMonitorService
|
||||
);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockPerformanceMonitor.updateConfig).toHaveBeenCalledWith({
|
||||
collectionInterval: 60000,
|
||||
});
|
||||
});
|
||||
|
||||
it('should sanitize config values - clamp maxDataPoints to bounds', () => {
|
||||
mockReq.body = {
|
||||
config: {
|
||||
maxDataPoints: 5, // Below minimum of 10
|
||||
},
|
||||
};
|
||||
|
||||
const handler = createStartMetricsHandler(
|
||||
mockPerformanceMonitor as PerformanceMonitorService
|
||||
);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockPerformanceMonitor.updateConfig).toHaveBeenCalledWith({
|
||||
maxDataPoints: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('should sanitize config values - clamp maxDataPoints to max', () => {
|
||||
mockReq.body = {
|
||||
config: {
|
||||
maxDataPoints: 50000, // Above maximum of 10000
|
||||
},
|
||||
};
|
||||
|
||||
const handler = createStartMetricsHandler(
|
||||
mockPerformanceMonitor as PerformanceMonitorService
|
||||
);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockPerformanceMonitor.updateConfig).toHaveBeenCalledWith({
|
||||
maxDataPoints: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore non-object config', () => {
|
||||
mockReq.body = {
|
||||
config: 'not-an-object',
|
||||
};
|
||||
|
||||
const handler = createStartMetricsHandler(
|
||||
mockPerformanceMonitor as PerformanceMonitorService
|
||||
);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockPerformanceMonitor.updateConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should ignore empty config object', () => {
|
||||
mockReq.body = {
|
||||
config: {},
|
||||
};
|
||||
|
||||
const handler = createStartMetricsHandler(
|
||||
mockPerformanceMonitor as PerformanceMonitorService
|
||||
);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockPerformanceMonitor.updateConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should only accept boolean flags as actual booleans', () => {
|
||||
mockReq.body = {
|
||||
config: {
|
||||
memoryEnabled: 'true', // String, not boolean - should be ignored
|
||||
cpuEnabled: true, // Boolean - should be accepted
|
||||
},
|
||||
};
|
||||
|
||||
const handler = createStartMetricsHandler(
|
||||
mockPerformanceMonitor as PerformanceMonitorService
|
||||
);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockPerformanceMonitor.updateConfig).toHaveBeenCalledWith({
|
||||
cpuEnabled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/debug/metrics/stop', () => {
|
||||
it('should stop metrics collection', () => {
|
||||
const handler = createStopMetricsHandler(mockPerformanceMonitor as PerformanceMonitorService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockPerformanceMonitor.stop).toHaveBeenCalled();
|
||||
expect(jsonFn).toHaveBeenCalledWith({
|
||||
active: false,
|
||||
config: mockConfig,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/debug/metrics/gc', () => {
|
||||
it('should trigger garbage collection when available', () => {
|
||||
const handler = createForceGCHandler(mockPerformanceMonitor as PerformanceMonitorService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockPerformanceMonitor.forceGC).toHaveBeenCalled();
|
||||
expect(jsonFn).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
message: 'Garbage collection triggered',
|
||||
});
|
||||
});
|
||||
|
||||
it('should report when garbage collection is not available', () => {
|
||||
(mockPerformanceMonitor.forceGC as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
||||
|
||||
const handler = createForceGCHandler(mockPerformanceMonitor as PerformanceMonitorService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(jsonFn).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
message: 'Garbage collection not available (start Node.js with --expose-gc flag)',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/debug/metrics/clear', () => {
|
||||
it('should clear metrics history', () => {
|
||||
const handler = createClearHistoryHandler(
|
||||
mockPerformanceMonitor as PerformanceMonitorService
|
||||
);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockPerformanceMonitor.clearHistory).toHaveBeenCalled();
|
||||
expect(jsonFn).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
message: 'Metrics history cleared',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
293
apps/server/tests/unit/routes/debug/processes.test.ts
Normal file
293
apps/server/tests/unit/routes/debug/processes.test.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Request, Response } from 'express';
|
||||
import {
|
||||
createGetProcessesHandler,
|
||||
createGetProcessHandler,
|
||||
createGetSummaryHandler,
|
||||
} from '@/routes/debug/routes/processes.js';
|
||||
import type { ProcessRegistryService } from '@/services/process-registry-service.js';
|
||||
import type { TrackedProcess, ProcessSummary } from '@automaker/types';
|
||||
|
||||
describe('Debug Processes Routes', () => {
|
||||
let mockProcessRegistry: Partial<ProcessRegistryService>;
|
||||
let mockReq: Partial<Request>;
|
||||
let mockRes: Partial<Response>;
|
||||
let jsonFn: ReturnType<typeof vi.fn>;
|
||||
let statusFn: ReturnType<typeof vi.fn>;
|
||||
|
||||
const mockProcesses: TrackedProcess[] = [
|
||||
{
|
||||
id: 'process-1',
|
||||
pid: 1234,
|
||||
type: 'agent',
|
||||
name: 'Agent 1',
|
||||
status: 'running',
|
||||
startedAt: Date.now() - 60000,
|
||||
featureId: 'feature-1',
|
||||
sessionId: 'session-1',
|
||||
},
|
||||
{
|
||||
id: 'process-2',
|
||||
pid: 5678,
|
||||
type: 'terminal',
|
||||
name: 'Terminal 1',
|
||||
status: 'idle',
|
||||
startedAt: Date.now() - 30000,
|
||||
sessionId: 'session-1',
|
||||
},
|
||||
{
|
||||
id: 'process-3',
|
||||
pid: 9012,
|
||||
type: 'cli',
|
||||
name: 'CLI 1',
|
||||
status: 'stopped',
|
||||
startedAt: Date.now() - 120000,
|
||||
stoppedAt: Date.now() - 60000,
|
||||
exitCode: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const mockSummary: ProcessSummary = {
|
||||
total: 3,
|
||||
running: 1,
|
||||
idle: 1,
|
||||
stopped: 1,
|
||||
errored: 0,
|
||||
byType: {
|
||||
agent: 1,
|
||||
cli: 1,
|
||||
terminal: 1,
|
||||
worker: 0,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jsonFn = vi.fn();
|
||||
statusFn = vi.fn(() => ({ json: jsonFn }));
|
||||
|
||||
mockProcessRegistry = {
|
||||
getProcesses: vi.fn(() => mockProcesses),
|
||||
getProcess: vi.fn((id: string) => mockProcesses.find((p) => p.id === id)),
|
||||
getProcessSummary: vi.fn(() => mockSummary),
|
||||
};
|
||||
|
||||
mockReq = {
|
||||
body: {},
|
||||
query: {},
|
||||
params: {},
|
||||
};
|
||||
|
||||
mockRes = {
|
||||
json: jsonFn,
|
||||
status: statusFn,
|
||||
};
|
||||
});
|
||||
|
||||
describe('GET /api/debug/processes', () => {
|
||||
it('should return list of processes with summary', () => {
|
||||
const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockProcessRegistry.getProcesses).toHaveBeenCalled();
|
||||
expect(mockProcessRegistry.getProcessSummary).toHaveBeenCalled();
|
||||
expect(jsonFn).toHaveBeenCalledWith({
|
||||
processes: mockProcesses,
|
||||
summary: mockSummary,
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass type filter to service', () => {
|
||||
mockReq.query = { type: 'agent' };
|
||||
|
||||
const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'agent',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass status filter to service', () => {
|
||||
mockReq.query = { status: 'running' };
|
||||
|
||||
const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: 'running',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass includeStopped flag when set to "true"', () => {
|
||||
mockReq.query = { includeStopped: 'true' };
|
||||
|
||||
const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
includeStopped: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should not pass includeStopped when not "true"', () => {
|
||||
mockReq.query = { includeStopped: 'false' };
|
||||
|
||||
const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
includeStopped: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass sessionId filter to service', () => {
|
||||
mockReq.query = { sessionId: 'session-1' };
|
||||
|
||||
const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionId: 'session-1',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass featureId filter to service', () => {
|
||||
mockReq.query = { featureId: 'feature-1' };
|
||||
|
||||
const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
featureId: 'feature-1',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle multiple filters', () => {
|
||||
mockReq.query = {
|
||||
type: 'agent',
|
||||
status: 'running',
|
||||
sessionId: 'session-1',
|
||||
includeStopped: 'true',
|
||||
};
|
||||
|
||||
const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith({
|
||||
type: 'agent',
|
||||
status: 'running',
|
||||
sessionId: 'session-1',
|
||||
includeStopped: true,
|
||||
featureId: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/debug/processes/:id', () => {
|
||||
it('should return a specific process by ID', () => {
|
||||
mockReq.params = { id: 'process-1' };
|
||||
|
||||
const handler = createGetProcessHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockProcessRegistry.getProcess).toHaveBeenCalledWith('process-1');
|
||||
expect(jsonFn).toHaveBeenCalledWith(mockProcesses[0]);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent process', () => {
|
||||
mockReq.params = { id: 'non-existent' };
|
||||
(mockProcessRegistry.getProcess as ReturnType<typeof vi.fn>).mockReturnValue(undefined);
|
||||
|
||||
const handler = createGetProcessHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(statusFn).toHaveBeenCalledWith(404);
|
||||
expect(jsonFn).toHaveBeenCalledWith({
|
||||
error: 'Process not found',
|
||||
id: 'non-existent',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 for empty process ID', () => {
|
||||
mockReq.params = { id: '' };
|
||||
|
||||
const handler = createGetProcessHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(statusFn).toHaveBeenCalledWith(400);
|
||||
expect(jsonFn).toHaveBeenCalledWith({
|
||||
error: 'Invalid process ID format',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 for process ID exceeding max length', () => {
|
||||
mockReq.params = { id: 'a'.repeat(257) };
|
||||
|
||||
const handler = createGetProcessHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(statusFn).toHaveBeenCalledWith(400);
|
||||
expect(jsonFn).toHaveBeenCalledWith({
|
||||
error: 'Invalid process ID format',
|
||||
});
|
||||
});
|
||||
|
||||
it('should accept process ID at max length', () => {
|
||||
mockReq.params = { id: 'a'.repeat(256) };
|
||||
(mockProcessRegistry.getProcess as ReturnType<typeof vi.fn>).mockReturnValue(undefined);
|
||||
|
||||
const handler = createGetProcessHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
// Should pass validation but process not found
|
||||
expect(statusFn).toHaveBeenCalledWith(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/debug/processes/summary', () => {
|
||||
it('should return process summary', () => {
|
||||
const handler = createGetSummaryHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockProcessRegistry.getProcessSummary).toHaveBeenCalled();
|
||||
expect(jsonFn).toHaveBeenCalledWith(mockSummary);
|
||||
});
|
||||
|
||||
it('should return correct counts', () => {
|
||||
const customSummary: ProcessSummary = {
|
||||
total: 10,
|
||||
running: 5,
|
||||
idle: 2,
|
||||
stopped: 2,
|
||||
errored: 1,
|
||||
byType: {
|
||||
agent: 4,
|
||||
cli: 3,
|
||||
terminal: 2,
|
||||
worker: 1,
|
||||
},
|
||||
};
|
||||
|
||||
(mockProcessRegistry.getProcessSummary as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
customSummary
|
||||
);
|
||||
|
||||
const handler = createGetSummaryHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(jsonFn).toHaveBeenCalledWith(customSummary);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,418 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { PerformanceMonitorService } from '@/services/performance-monitor-service.js';
|
||||
import { createEventEmitter } from '@/lib/events.js';
|
||||
import type { EventEmitter } from '@/lib/events.js';
|
||||
import type { TrackedProcess, DebugMetricsConfig } from '@automaker/types';
|
||||
import { DEFAULT_DEBUG_METRICS_CONFIG } from '@automaker/types';
|
||||
|
||||
// Mock the logger to prevent console output during tests
|
||||
vi.mock('@automaker/utils', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('PerformanceMonitorService', () => {
|
||||
let service: PerformanceMonitorService;
|
||||
let events: EventEmitter;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
events = createEventEmitter();
|
||||
service = new PerformanceMonitorService(events);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
service.stop();
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should initialize with default configuration', () => {
|
||||
const config = service.getConfig();
|
||||
expect(config.collectionInterval).toBe(DEFAULT_DEBUG_METRICS_CONFIG.collectionInterval);
|
||||
expect(config.maxDataPoints).toBe(DEFAULT_DEBUG_METRICS_CONFIG.maxDataPoints);
|
||||
expect(config.memoryEnabled).toBe(DEFAULT_DEBUG_METRICS_CONFIG.memoryEnabled);
|
||||
expect(config.cpuEnabled).toBe(DEFAULT_DEBUG_METRICS_CONFIG.cpuEnabled);
|
||||
});
|
||||
|
||||
it('should accept custom configuration on initialization', () => {
|
||||
const customConfig: Partial<DebugMetricsConfig> = {
|
||||
collectionInterval: 5000,
|
||||
maxDataPoints: 500,
|
||||
memoryEnabled: false,
|
||||
};
|
||||
|
||||
const customService = new PerformanceMonitorService(events, customConfig);
|
||||
const config = customService.getConfig();
|
||||
|
||||
expect(config.collectionInterval).toBe(5000);
|
||||
expect(config.maxDataPoints).toBe(500);
|
||||
expect(config.memoryEnabled).toBe(false);
|
||||
expect(config.cpuEnabled).toBe(DEFAULT_DEBUG_METRICS_CONFIG.cpuEnabled);
|
||||
|
||||
customService.stop();
|
||||
});
|
||||
|
||||
it('should not be running initially', () => {
|
||||
expect(service.isActive()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('start/stop', () => {
|
||||
it('should start metrics collection', () => {
|
||||
service.start();
|
||||
expect(service.isActive()).toBe(true);
|
||||
});
|
||||
|
||||
it('should stop metrics collection', () => {
|
||||
service.start();
|
||||
expect(service.isActive()).toBe(true);
|
||||
|
||||
service.stop();
|
||||
expect(service.isActive()).toBe(false);
|
||||
});
|
||||
|
||||
it('should not start again if already running', () => {
|
||||
service.start();
|
||||
const isActive1 = service.isActive();
|
||||
|
||||
service.start(); // Should log warning but not throw
|
||||
const isActive2 = service.isActive();
|
||||
|
||||
expect(isActive1).toBe(true);
|
||||
expect(isActive2).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle stop when not running', () => {
|
||||
// Should not throw
|
||||
expect(() => service.stop()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('configuration updates', () => {
|
||||
it('should update configuration', () => {
|
||||
service.updateConfig({ collectionInterval: 2000 });
|
||||
expect(service.getConfig().collectionInterval).toBe(2000);
|
||||
});
|
||||
|
||||
it('should restart collection if running when config is updated', () => {
|
||||
service.start();
|
||||
expect(service.isActive()).toBe(true);
|
||||
|
||||
service.updateConfig({ collectionInterval: 5000 });
|
||||
|
||||
// Should still be running after config update
|
||||
expect(service.isActive()).toBe(true);
|
||||
expect(service.getConfig().collectionInterval).toBe(5000);
|
||||
});
|
||||
|
||||
it('should resize data buffers when maxDataPoints changes', () => {
|
||||
// Start and collect some data
|
||||
service.start();
|
||||
|
||||
// Collect multiple data points
|
||||
for (let i = 0; i < 50; i++) {
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
}
|
||||
|
||||
// Reduce max data points
|
||||
service.updateConfig({ maxDataPoints: 10 });
|
||||
|
||||
const history = service.getMemoryHistory();
|
||||
expect(history.length).toBeLessThanOrEqual(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('metrics collection', () => {
|
||||
it('should emit debug:metrics event on collection', () => {
|
||||
const callback = vi.fn();
|
||||
events.subscribe(callback);
|
||||
|
||||
service.start();
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
|
||||
expect(callback).toHaveBeenCalled();
|
||||
const [eventType, eventData] = callback.mock.calls[0];
|
||||
expect(eventType).toBe('debug:metrics');
|
||||
expect(eventData).toHaveProperty('timestamp');
|
||||
expect(eventData).toHaveProperty('metrics');
|
||||
});
|
||||
|
||||
it('should collect memory metrics when memoryEnabled is true', () => {
|
||||
const callback = vi.fn();
|
||||
events.subscribe(callback);
|
||||
|
||||
service.start();
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
|
||||
const [, eventData] = callback.mock.calls[0];
|
||||
expect(eventData.metrics.memory.server).toBeDefined();
|
||||
expect(eventData.metrics.memory.server.heapUsed).toBeGreaterThan(0);
|
||||
expect(eventData.metrics.memory.server.heapTotal).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should not collect memory metrics when memoryEnabled is false', () => {
|
||||
const customService = new PerformanceMonitorService(events, { memoryEnabled: false });
|
||||
const callback = vi.fn();
|
||||
events.subscribe(callback);
|
||||
|
||||
customService.start();
|
||||
vi.advanceTimersByTime(customService.getConfig().collectionInterval);
|
||||
|
||||
const [, eventData] = callback.mock.calls[0];
|
||||
expect(eventData.metrics.memory.server).toBeUndefined();
|
||||
|
||||
customService.stop();
|
||||
});
|
||||
|
||||
it('should collect CPU metrics when cpuEnabled is true', () => {
|
||||
const callback = vi.fn();
|
||||
events.subscribe(callback);
|
||||
|
||||
service.start();
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
|
||||
// Need at least 2 collections for CPU diff
|
||||
const lastCall = callback.mock.calls[callback.mock.calls.length - 1];
|
||||
const [, eventData] = lastCall;
|
||||
expect(eventData.metrics.cpu.server).toBeDefined();
|
||||
});
|
||||
|
||||
it('should track event loop lag', () => {
|
||||
const callback = vi.fn();
|
||||
events.subscribe(callback);
|
||||
|
||||
service.start();
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
|
||||
const [, eventData] = callback.mock.calls[0];
|
||||
expect(eventData.metrics.cpu.eventLoopLag).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('memory history', () => {
|
||||
it('should return empty history initially', () => {
|
||||
const history = service.getMemoryHistory();
|
||||
expect(history).toEqual([]);
|
||||
});
|
||||
|
||||
it('should accumulate memory history over time', () => {
|
||||
service.start();
|
||||
|
||||
// Collect multiple data points
|
||||
for (let i = 0; i < 5; i++) {
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
}
|
||||
|
||||
const history = service.getMemoryHistory();
|
||||
expect(history.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should limit history to maxDataPoints', () => {
|
||||
const maxPoints = 10;
|
||||
const customService = new PerformanceMonitorService(events, { maxDataPoints: maxPoints });
|
||||
customService.start();
|
||||
|
||||
// Collect more data points than max
|
||||
for (let i = 0; i < maxPoints + 10; i++) {
|
||||
vi.advanceTimersByTime(customService.getConfig().collectionInterval);
|
||||
}
|
||||
|
||||
const history = customService.getMemoryHistory();
|
||||
expect(history.length).toBeLessThanOrEqual(maxPoints);
|
||||
|
||||
customService.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CPU history', () => {
|
||||
it('should return empty CPU history initially', () => {
|
||||
const history = service.getCPUHistory();
|
||||
expect(history).toEqual([]);
|
||||
});
|
||||
|
||||
it('should accumulate CPU history over time', () => {
|
||||
service.start();
|
||||
|
||||
// Collect multiple data points (need at least 2 for CPU diff)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
}
|
||||
|
||||
const history = service.getCPUHistory();
|
||||
expect(history.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('process provider', () => {
|
||||
it('should use provided process provider', () => {
|
||||
const mockProcesses: TrackedProcess[] = [
|
||||
{
|
||||
id: 'test-1',
|
||||
type: 'agent',
|
||||
name: 'TestAgent',
|
||||
status: 'running',
|
||||
startedAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: 'test-2',
|
||||
type: 'terminal',
|
||||
name: 'TestTerminal',
|
||||
status: 'idle',
|
||||
startedAt: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
const provider = vi.fn(() => mockProcesses);
|
||||
service.setProcessProvider(provider);
|
||||
|
||||
const callback = vi.fn();
|
||||
events.subscribe(callback);
|
||||
|
||||
service.start();
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
|
||||
const [, eventData] = callback.mock.calls[0];
|
||||
expect(eventData.metrics.processes).toEqual(mockProcesses);
|
||||
expect(eventData.metrics.processSummary.total).toBe(2);
|
||||
expect(eventData.metrics.processSummary.running).toBe(1);
|
||||
expect(eventData.metrics.processSummary.idle).toBe(1);
|
||||
expect(eventData.metrics.processSummary.byType.agent).toBe(1);
|
||||
expect(eventData.metrics.processSummary.byType.terminal).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLatestSnapshot', () => {
|
||||
it('should return null when no data collected', () => {
|
||||
const snapshot = service.getLatestSnapshot();
|
||||
expect(snapshot).toBeNull();
|
||||
});
|
||||
|
||||
it('should return snapshot after data collection', () => {
|
||||
service.start();
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
|
||||
const snapshot = service.getLatestSnapshot();
|
||||
expect(snapshot).not.toBeNull();
|
||||
expect(snapshot).toHaveProperty('timestamp');
|
||||
expect(snapshot).toHaveProperty('memory');
|
||||
expect(snapshot).toHaveProperty('cpu');
|
||||
expect(snapshot).toHaveProperty('processes');
|
||||
expect(snapshot).toHaveProperty('processSummary');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearHistory', () => {
|
||||
it('should clear all history', () => {
|
||||
service.start();
|
||||
|
||||
// Collect some data
|
||||
for (let i = 0; i < 5; i++) {
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
}
|
||||
|
||||
expect(service.getMemoryHistory().length).toBeGreaterThan(0);
|
||||
|
||||
service.clearHistory();
|
||||
|
||||
expect(service.getMemoryHistory().length).toBe(0);
|
||||
expect(service.getCPUHistory().length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('forceGC', () => {
|
||||
it('should return false when gc is not available', () => {
|
||||
const originalGc = global.gc;
|
||||
global.gc = undefined;
|
||||
|
||||
const result = service.forceGC();
|
||||
expect(result).toBe(false);
|
||||
|
||||
// Restore
|
||||
global.gc = originalGc;
|
||||
});
|
||||
|
||||
it('should return true and call gc when available', () => {
|
||||
const mockGc = vi.fn();
|
||||
global.gc = mockGc;
|
||||
|
||||
const result = service.forceGC();
|
||||
expect(result).toBe(true);
|
||||
expect(mockGc).toHaveBeenCalled();
|
||||
|
||||
// Cleanup
|
||||
global.gc = undefined;
|
||||
});
|
||||
});
|
||||
|
||||
describe('memory trend analysis', () => {
|
||||
it('should not calculate trend with insufficient data', () => {
|
||||
service.start();
|
||||
|
||||
// Collect only a few data points
|
||||
for (let i = 0; i < 5; i++) {
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
}
|
||||
|
||||
const snapshot = service.getLatestSnapshot();
|
||||
// Trend requires at least 10 samples
|
||||
expect(snapshot?.memoryTrend).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should calculate trend with sufficient data', () => {
|
||||
service.start();
|
||||
|
||||
// Collect enough data points for trend analysis
|
||||
for (let i = 0; i < 15; i++) {
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
}
|
||||
|
||||
const snapshot = service.getLatestSnapshot();
|
||||
expect(snapshot?.memoryTrend).toBeDefined();
|
||||
expect(snapshot?.memoryTrend).toHaveProperty('growthRate');
|
||||
expect(snapshot?.memoryTrend).toHaveProperty('isLeaking');
|
||||
expect(snapshot?.memoryTrend).toHaveProperty('confidence');
|
||||
expect(snapshot?.memoryTrend).toHaveProperty('sampleCount');
|
||||
});
|
||||
});
|
||||
|
||||
describe('process summary calculation', () => {
|
||||
it('should correctly categorize processes by status', () => {
|
||||
const mockProcesses: TrackedProcess[] = [
|
||||
{ id: '1', type: 'agent', name: 'A1', status: 'running', startedAt: Date.now() },
|
||||
{ id: '2', type: 'agent', name: 'A2', status: 'starting', startedAt: Date.now() },
|
||||
{ id: '3', type: 'terminal', name: 'T1', status: 'idle', startedAt: Date.now() },
|
||||
{ id: '4', type: 'terminal', name: 'T2', status: 'stopped', startedAt: Date.now() },
|
||||
{ id: '5', type: 'cli', name: 'C1', status: 'stopping', startedAt: Date.now() },
|
||||
{ id: '6', type: 'worker', name: 'W1', status: 'error', startedAt: Date.now() },
|
||||
];
|
||||
|
||||
service.setProcessProvider(() => mockProcesses);
|
||||
|
||||
const callback = vi.fn();
|
||||
events.subscribe(callback);
|
||||
|
||||
service.start();
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
|
||||
const [, eventData] = callback.mock.calls[0];
|
||||
const summary = eventData.metrics.processSummary;
|
||||
|
||||
expect(summary.total).toBe(6);
|
||||
expect(summary.running).toBe(2); // running + starting
|
||||
expect(summary.idle).toBe(1);
|
||||
expect(summary.stopped).toBe(2); // stopped + stopping
|
||||
expect(summary.errored).toBe(1);
|
||||
expect(summary.byType.agent).toBe(2);
|
||||
expect(summary.byType.terminal).toBe(2);
|
||||
expect(summary.byType.cli).toBe(1);
|
||||
expect(summary.byType.worker).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
538
apps/server/tests/unit/services/process-registry-service.test.ts
Normal file
538
apps/server/tests/unit/services/process-registry-service.test.ts
Normal file
@@ -0,0 +1,538 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
ProcessRegistryService,
|
||||
getProcessRegistryService,
|
||||
resetProcessRegistryService,
|
||||
} from '@/services/process-registry-service.js';
|
||||
import { createEventEmitter } from '@/lib/events.js';
|
||||
import type { EventEmitter } from '@/lib/events.js';
|
||||
import type { TrackedProcess, ProcessType, ProcessStatus } from '@automaker/types';
|
||||
|
||||
// Mock the logger to prevent console output during tests
|
||||
vi.mock('@automaker/utils', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('ProcessRegistryService', () => {
|
||||
let service: ProcessRegistryService;
|
||||
let events: EventEmitter;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
events = createEventEmitter();
|
||||
service = new ProcessRegistryService(events);
|
||||
resetProcessRegistryService();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
service.stop();
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should initialize with default configuration', () => {
|
||||
const config = service.getConfig();
|
||||
expect(config.stoppedProcessRetention).toBe(5 * 60 * 1000);
|
||||
expect(config.cleanupInterval).toBe(60 * 1000);
|
||||
expect(config.maxStoppedProcesses).toBe(100);
|
||||
});
|
||||
|
||||
it('should accept custom configuration', () => {
|
||||
const customService = new ProcessRegistryService(events, {
|
||||
stoppedProcessRetention: 10000,
|
||||
maxStoppedProcesses: 50,
|
||||
});
|
||||
|
||||
const config = customService.getConfig();
|
||||
expect(config.stoppedProcessRetention).toBe(10000);
|
||||
expect(config.maxStoppedProcesses).toBe(50);
|
||||
expect(config.cleanupInterval).toBe(60 * 1000);
|
||||
|
||||
customService.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe('start/stop', () => {
|
||||
it('should start the service', () => {
|
||||
expect(() => service.start()).not.toThrow();
|
||||
});
|
||||
|
||||
it('should stop the service', () => {
|
||||
service.start();
|
||||
expect(() => service.stop()).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not start again if already running', () => {
|
||||
service.start();
|
||||
// Should log warning but not throw
|
||||
expect(() => service.start()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('process registration', () => {
|
||||
it('should register a new process', () => {
|
||||
const process = service.registerProcess({
|
||||
id: 'test-1',
|
||||
pid: 1234,
|
||||
type: 'agent',
|
||||
name: 'TestAgent',
|
||||
});
|
||||
|
||||
expect(process.id).toBe('test-1');
|
||||
expect(process.pid).toBe(1234);
|
||||
expect(process.type).toBe('agent');
|
||||
expect(process.name).toBe('TestAgent');
|
||||
expect(process.status).toBe('starting');
|
||||
expect(process.startedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should register a process with all optional fields', () => {
|
||||
const process = service.registerProcess({
|
||||
id: 'test-2',
|
||||
pid: 5678,
|
||||
type: 'terminal',
|
||||
name: 'TestTerminal',
|
||||
featureId: 'feature-123',
|
||||
sessionId: 'session-456',
|
||||
command: 'bash',
|
||||
cwd: '/home/user',
|
||||
});
|
||||
|
||||
expect(process.featureId).toBe('feature-123');
|
||||
expect(process.sessionId).toBe('session-456');
|
||||
expect(process.command).toBe('bash');
|
||||
expect(process.cwd).toBe('/home/user');
|
||||
});
|
||||
|
||||
it('should emit debug:process-spawned event on registration', () => {
|
||||
const callback = vi.fn();
|
||||
events.subscribe(callback);
|
||||
|
||||
service.registerProcess({
|
||||
id: 'test-3',
|
||||
pid: 111,
|
||||
type: 'cli',
|
||||
name: 'TestCLI',
|
||||
});
|
||||
|
||||
expect(callback).toHaveBeenCalled();
|
||||
const [eventType, eventData] = callback.mock.calls[0];
|
||||
expect(eventType).toBe('debug:process-spawned');
|
||||
expect(eventData.process.id).toBe('test-3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('process retrieval', () => {
|
||||
beforeEach(() => {
|
||||
// Register test processes
|
||||
service.registerProcess({
|
||||
id: 'p1',
|
||||
pid: 1,
|
||||
type: 'agent',
|
||||
name: 'Agent1',
|
||||
featureId: 'f1',
|
||||
sessionId: 's1',
|
||||
});
|
||||
service.registerProcess({
|
||||
id: 'p2',
|
||||
pid: 2,
|
||||
type: 'terminal',
|
||||
name: 'Terminal1',
|
||||
sessionId: 's1',
|
||||
});
|
||||
service.registerProcess({ id: 'p3', pid: 3, type: 'cli', name: 'CLI1', featureId: 'f2' });
|
||||
});
|
||||
|
||||
it('should get a process by ID', () => {
|
||||
const process = service.getProcess('p1');
|
||||
expect(process).toBeDefined();
|
||||
expect(process?.name).toBe('Agent1');
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent process', () => {
|
||||
const process = service.getProcess('non-existent');
|
||||
expect(process).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should check if process exists', () => {
|
||||
expect(service.hasProcess('p1')).toBe(true);
|
||||
expect(service.hasProcess('non-existent')).toBe(false);
|
||||
});
|
||||
|
||||
it('should get all processes without filters', () => {
|
||||
const processes = service.getProcesses({ includeStopped: true });
|
||||
expect(processes.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should filter by type', () => {
|
||||
const agents = service.getProcesses({ type: 'agent', includeStopped: true });
|
||||
expect(agents.length).toBe(1);
|
||||
expect(agents[0].type).toBe('agent');
|
||||
});
|
||||
|
||||
it('should filter by session ID', () => {
|
||||
const sessionProcesses = service.getProcesses({ sessionId: 's1', includeStopped: true });
|
||||
expect(sessionProcesses.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should filter by feature ID', () => {
|
||||
const featureProcesses = service.getProcesses({ featureId: 'f1', includeStopped: true });
|
||||
expect(featureProcesses.length).toBe(1);
|
||||
expect(featureProcesses[0].id).toBe('p1');
|
||||
});
|
||||
|
||||
it('should exclude stopped processes by default', () => {
|
||||
service.markStopped('p1');
|
||||
const processes = service.getProcesses();
|
||||
expect(processes.length).toBe(2);
|
||||
expect(processes.find((p) => p.id === 'p1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should include stopped processes when requested', () => {
|
||||
service.markStopped('p1');
|
||||
const processes = service.getProcesses({ includeStopped: true });
|
||||
expect(processes.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should sort processes by start time (most recent first)', () => {
|
||||
// Re-register processes with different timestamps
|
||||
service.clear();
|
||||
|
||||
// Register p1 at time 0
|
||||
service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'Agent1' });
|
||||
|
||||
// Advance time and register p2
|
||||
vi.advanceTimersByTime(1000);
|
||||
service.registerProcess({ id: 'p2', pid: 2, type: 'terminal', name: 'Terminal1' });
|
||||
|
||||
// Advance time and register p3
|
||||
vi.advanceTimersByTime(1000);
|
||||
service.registerProcess({ id: 'p3', pid: 3, type: 'cli', name: 'CLI1' });
|
||||
|
||||
const processes = service.getProcesses({ includeStopped: true });
|
||||
// p3 was registered last (most recent), so it should be first
|
||||
expect(processes[0].id).toBe('p3');
|
||||
expect(processes[1].id).toBe('p2');
|
||||
expect(processes[2].id).toBe('p1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('process status updates', () => {
|
||||
let process: TrackedProcess;
|
||||
|
||||
beforeEach(() => {
|
||||
process = service.registerProcess({
|
||||
id: 'test-proc',
|
||||
pid: 100,
|
||||
type: 'agent',
|
||||
name: 'TestProcess',
|
||||
});
|
||||
});
|
||||
|
||||
it('should update process status', () => {
|
||||
const updated = service.updateProcess('test-proc', { status: 'running' });
|
||||
expect(updated?.status).toBe('running');
|
||||
});
|
||||
|
||||
it('should update memory usage', () => {
|
||||
const updated = service.updateProcess('test-proc', { memoryUsage: 1024 * 1024 });
|
||||
expect(updated?.memoryUsage).toBe(1024 * 1024);
|
||||
});
|
||||
|
||||
it('should update CPU usage', () => {
|
||||
const updated = service.updateProcess('test-proc', { cpuUsage: 45.5 });
|
||||
expect(updated?.cpuUsage).toBe(45.5);
|
||||
});
|
||||
|
||||
it('should return null for non-existent process', () => {
|
||||
const updated = service.updateProcess('non-existent', { status: 'running' });
|
||||
expect(updated).toBeNull();
|
||||
});
|
||||
|
||||
it('should set stoppedAt when status is stopped', () => {
|
||||
const updated = service.markStopped('test-proc');
|
||||
expect(updated?.stoppedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should set stoppedAt when status is error', () => {
|
||||
const updated = service.markError('test-proc', 'Something went wrong');
|
||||
expect(updated?.stoppedAt).toBeDefined();
|
||||
expect(updated?.error).toBe('Something went wrong');
|
||||
});
|
||||
});
|
||||
|
||||
describe('status shortcut methods', () => {
|
||||
beforeEach(() => {
|
||||
service.registerProcess({
|
||||
id: 'test-proc',
|
||||
pid: 100,
|
||||
type: 'agent',
|
||||
name: 'TestProcess',
|
||||
});
|
||||
});
|
||||
|
||||
it('should mark process as running', () => {
|
||||
const updated = service.markRunning('test-proc');
|
||||
expect(updated?.status).toBe('running');
|
||||
});
|
||||
|
||||
it('should mark process as idle', () => {
|
||||
const updated = service.markIdle('test-proc');
|
||||
expect(updated?.status).toBe('idle');
|
||||
});
|
||||
|
||||
it('should mark process as stopping', () => {
|
||||
const updated = service.markStopping('test-proc');
|
||||
expect(updated?.status).toBe('stopping');
|
||||
});
|
||||
|
||||
it('should mark process as stopped with exit code', () => {
|
||||
const updated = service.markStopped('test-proc', 0);
|
||||
expect(updated?.status).toBe('stopped');
|
||||
expect(updated?.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it('should mark process as error with message', () => {
|
||||
const updated = service.markError('test-proc', 'Process crashed');
|
||||
expect(updated?.status).toBe('error');
|
||||
expect(updated?.error).toBe('Process crashed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('event emissions', () => {
|
||||
let callback: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
callback = vi.fn();
|
||||
events.subscribe(callback);
|
||||
service.registerProcess({
|
||||
id: 'test-proc',
|
||||
pid: 100,
|
||||
type: 'agent',
|
||||
name: 'TestProcess',
|
||||
});
|
||||
callback.mockClear();
|
||||
});
|
||||
|
||||
it('should emit debug:process-stopped when stopped', () => {
|
||||
service.markStopped('test-proc', 0);
|
||||
|
||||
expect(callback).toHaveBeenCalled();
|
||||
const [eventType] = callback.mock.calls[0];
|
||||
expect(eventType).toBe('debug:process-stopped');
|
||||
});
|
||||
|
||||
it('should emit debug:process-error when errored', () => {
|
||||
service.markError('test-proc', 'Error message');
|
||||
|
||||
expect(callback).toHaveBeenCalled();
|
||||
const [eventType, eventData] = callback.mock.calls[0];
|
||||
expect(eventType).toBe('debug:process-error');
|
||||
expect(eventData.message).toContain('Error message');
|
||||
});
|
||||
|
||||
it('should emit debug:process-updated for other status changes', () => {
|
||||
service.markRunning('test-proc');
|
||||
|
||||
expect(callback).toHaveBeenCalled();
|
||||
const [eventType] = callback.mock.calls[0];
|
||||
expect(eventType).toBe('debug:process-updated');
|
||||
});
|
||||
});
|
||||
|
||||
describe('process unregistration', () => {
|
||||
it('should unregister an existing process', () => {
|
||||
service.registerProcess({
|
||||
id: 'test-proc',
|
||||
pid: 100,
|
||||
type: 'agent',
|
||||
name: 'TestProcess',
|
||||
});
|
||||
|
||||
const result = service.unregisterProcess('test-proc');
|
||||
expect(result).toBe(true);
|
||||
expect(service.getProcess('test-proc')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return false for non-existent process', () => {
|
||||
const result = service.unregisterProcess('non-existent');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('process summary', () => {
|
||||
beforeEach(() => {
|
||||
service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'A1' });
|
||||
service.registerProcess({ id: 'p2', pid: 2, type: 'agent', name: 'A2' });
|
||||
service.registerProcess({ id: 'p3', pid: 3, type: 'terminal', name: 'T1' });
|
||||
service.registerProcess({ id: 'p4', pid: 4, type: 'cli', name: 'C1' });
|
||||
service.registerProcess({ id: 'p5', pid: 5, type: 'worker', name: 'W1' });
|
||||
|
||||
// Update statuses
|
||||
service.markRunning('p1');
|
||||
service.markIdle('p2');
|
||||
service.markStopped('p3');
|
||||
service.markError('p4', 'error');
|
||||
service.markRunning('p5');
|
||||
});
|
||||
|
||||
it('should calculate correct summary statistics', () => {
|
||||
const summary = service.getProcessSummary();
|
||||
|
||||
expect(summary.total).toBe(5);
|
||||
expect(summary.running).toBe(2); // p1 running, p5 running
|
||||
expect(summary.idle).toBe(1); // p2 idle
|
||||
expect(summary.stopped).toBe(1); // p3 stopped
|
||||
expect(summary.errored).toBe(1); // p4 error
|
||||
});
|
||||
|
||||
it('should count processes by type', () => {
|
||||
const summary = service.getProcessSummary();
|
||||
|
||||
expect(summary.byType.agent).toBe(2);
|
||||
expect(summary.byType.terminal).toBe(1);
|
||||
expect(summary.byType.cli).toBe(1);
|
||||
expect(summary.byType.worker).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('active count', () => {
|
||||
beforeEach(() => {
|
||||
service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'A1' });
|
||||
service.registerProcess({ id: 'p2', pid: 2, type: 'agent', name: 'A2' });
|
||||
service.registerProcess({ id: 'p3', pid: 3, type: 'terminal', name: 'T1' });
|
||||
|
||||
service.markRunning('p1');
|
||||
service.markStopped('p2');
|
||||
service.markIdle('p3');
|
||||
});
|
||||
|
||||
it('should return count of active processes', () => {
|
||||
expect(service.getActiveCount()).toBe(2); // p1 running, p3 idle
|
||||
});
|
||||
|
||||
it('should return count by type', () => {
|
||||
expect(service.getCountByType('agent')).toBe(2);
|
||||
expect(service.getCountByType('terminal')).toBe(1);
|
||||
expect(service.getCountByType('cli')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('process provider', () => {
|
||||
it('should return a process provider function', () => {
|
||||
service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'A1' });
|
||||
|
||||
const provider = service.getProcessProvider();
|
||||
expect(typeof provider).toBe('function');
|
||||
|
||||
const processes = provider();
|
||||
expect(processes.length).toBe(1);
|
||||
expect(processes[0].id).toBe('p1');
|
||||
});
|
||||
|
||||
it('should return all processes including stopped', () => {
|
||||
service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'A1' });
|
||||
service.registerProcess({ id: 'p2', pid: 2, type: 'agent', name: 'A2' });
|
||||
service.markStopped('p2');
|
||||
|
||||
const provider = service.getProcessProvider();
|
||||
const processes = provider();
|
||||
|
||||
expect(processes.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('should clean up old stopped processes', () => {
|
||||
// Register and stop a process
|
||||
service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'A1' });
|
||||
service.markStopped('p1');
|
||||
|
||||
// Start service to enable cleanup
|
||||
service.start();
|
||||
|
||||
// Advance time past retention period
|
||||
vi.advanceTimersByTime(6 * 60 * 1000); // 6 minutes (past default 5 min retention)
|
||||
|
||||
// Process should be cleaned up
|
||||
expect(service.getProcess('p1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should enforce max stopped processes limit', () => {
|
||||
const customService = new ProcessRegistryService(events, {
|
||||
maxStoppedProcesses: 3,
|
||||
cleanupInterval: 1000,
|
||||
});
|
||||
|
||||
// Register and stop more processes than max
|
||||
for (let i = 0; i < 5; i++) {
|
||||
customService.registerProcess({ id: `p${i}`, pid: i, type: 'agent', name: `A${i}` });
|
||||
customService.markStopped(`p${i}`);
|
||||
}
|
||||
|
||||
customService.start();
|
||||
|
||||
// Trigger cleanup
|
||||
vi.advanceTimersByTime(1000);
|
||||
|
||||
// Should only have max stopped processes
|
||||
const allProcesses = customService.getAllProcesses();
|
||||
expect(allProcesses.length).toBeLessThanOrEqual(3);
|
||||
|
||||
customService.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe('configuration update', () => {
|
||||
it('should update configuration', () => {
|
||||
service.updateConfig({ maxStoppedProcesses: 200 });
|
||||
expect(service.getConfig().maxStoppedProcesses).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('should clear all tracked processes', () => {
|
||||
service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'A1' });
|
||||
service.registerProcess({ id: 'p2', pid: 2, type: 'terminal', name: 'T1' });
|
||||
|
||||
service.clear();
|
||||
|
||||
expect(service.getAllProcesses().length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('singleton pattern', () => {
|
||||
beforeEach(() => {
|
||||
resetProcessRegistryService();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetProcessRegistryService();
|
||||
});
|
||||
|
||||
it('should create singleton instance', () => {
|
||||
const instance1 = getProcessRegistryService(events);
|
||||
const instance2 = getProcessRegistryService();
|
||||
|
||||
expect(instance1).toBe(instance2);
|
||||
});
|
||||
|
||||
it('should throw if no events provided on first call', () => {
|
||||
expect(() => getProcessRegistryService()).toThrow();
|
||||
});
|
||||
|
||||
it('should reset singleton', () => {
|
||||
const instance1 = getProcessRegistryService(events);
|
||||
resetProcessRegistryService();
|
||||
const instance2 = getProcessRegistryService(events);
|
||||
|
||||
expect(instance1).not.toBe(instance2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -144,33 +144,6 @@ describe('settings-service.ts', () => {
|
||||
expect(updated.keyboardShortcuts.agent).toBe(DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts.agent);
|
||||
});
|
||||
|
||||
it('should not overwrite non-empty projects with an empty array (data loss guard)', async () => {
|
||||
const initial: GlobalSettings = {
|
||||
...DEFAULT_GLOBAL_SETTINGS,
|
||||
theme: 'solarized' as GlobalSettings['theme'],
|
||||
projects: [
|
||||
{
|
||||
id: 'proj1',
|
||||
name: 'Project 1',
|
||||
path: '/tmp/project-1',
|
||||
lastOpened: new Date().toISOString(),
|
||||
},
|
||||
] as any,
|
||||
};
|
||||
const settingsPath = path.join(testDataDir, 'settings.json');
|
||||
await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2));
|
||||
|
||||
const updated = await settingsService.updateGlobalSettings({
|
||||
projects: [],
|
||||
theme: 'light',
|
||||
} as any);
|
||||
|
||||
expect(updated.projects.length).toBe(1);
|
||||
expect((updated.projects as any)[0]?.id).toBe('proj1');
|
||||
// Theme should be preserved in the same request if it attempted to wipe projects
|
||||
expect(updated.theme).toBe('solarized');
|
||||
});
|
||||
|
||||
it('should create data directory if it does not exist', async () => {
|
||||
const newDataDir = path.join(os.tmpdir(), `new-data-dir-${Date.now()}`);
|
||||
const newService = new SettingsService(newDataDir);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@automaker/ui",
|
||||
"version": "0.9.0",
|
||||
"version": "0.7.3",
|
||||
"description": "An autonomous AI development studio that helps you build software faster using AI-powered agents",
|
||||
"homepage": "https://github.com/AutoMaker-Org/automaker",
|
||||
"repository": {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { defineConfig, devices } from '@playwright/test';
|
||||
const port = process.env.TEST_PORT || 3007;
|
||||
const serverPort = process.env.TEST_SERVER_PORT || 3008;
|
||||
const reuseServer = process.env.TEST_REUSE_SERVER === 'true';
|
||||
const useExternalBackend = !!process.env.VITE_SERVER_URL;
|
||||
// Always use mock agent for tests (disables rate limiting, uses mock Claude responses)
|
||||
const mockAgent = true;
|
||||
|
||||
@@ -34,38 +33,31 @@ export default defineConfig({
|
||||
webServer: [
|
||||
// Backend server - runs with mock agent enabled in CI
|
||||
// Uses dev:test (no file watching) to avoid port conflicts from server restarts
|
||||
...(useExternalBackend
|
||||
? []
|
||||
: [
|
||||
{
|
||||
command: `cd ../server && npm run dev:test`,
|
||||
url: `http://localhost:${serverPort}/api/health`,
|
||||
// Don't reuse existing server to ensure we use the test API key
|
||||
reuseExistingServer: false,
|
||||
timeout: 60000,
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(serverPort),
|
||||
// Enable mock agent in CI to avoid real API calls
|
||||
AUTOMAKER_MOCK_AGENT: mockAgent ? 'true' : 'false',
|
||||
// Set a test API key for web mode authentication
|
||||
AUTOMAKER_API_KEY:
|
||||
process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests',
|
||||
// Hide the API key banner to reduce log noise
|
||||
AUTOMAKER_HIDE_API_KEY: 'true',
|
||||
// Explicitly unset ALLOWED_ROOT_DIRECTORY to allow all paths for testing
|
||||
// (prevents inheriting /projects from Docker or other environments)
|
||||
ALLOWED_ROOT_DIRECTORY: '',
|
||||
// Simulate containerized environment to skip sandbox confirmation dialogs
|
||||
IS_CONTAINERIZED: 'true',
|
||||
},
|
||||
},
|
||||
]),
|
||||
{
|
||||
command: `cd ../server && npm run dev:test`,
|
||||
url: `http://localhost:${serverPort}/api/health`,
|
||||
// Don't reuse existing server to ensure we use the test API key
|
||||
reuseExistingServer: false,
|
||||
timeout: 60000,
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(serverPort),
|
||||
// Enable mock agent in CI to avoid real API calls
|
||||
AUTOMAKER_MOCK_AGENT: mockAgent ? 'true' : 'false',
|
||||
// Set a test API key for web mode authentication
|
||||
AUTOMAKER_API_KEY: process.env.AUTOMAKER_API_KEY || 'test-api-key-for-e2e-tests',
|
||||
// Hide the API key banner to reduce log noise
|
||||
AUTOMAKER_HIDE_API_KEY: 'true',
|
||||
// No ALLOWED_ROOT_DIRECTORY restriction - allow all paths for testing
|
||||
// Simulate containerized environment to skip sandbox confirmation dialogs
|
||||
IS_CONTAINERIZED: 'true',
|
||||
},
|
||||
},
|
||||
// Frontend Vite dev server
|
||||
{
|
||||
command: `npm run dev`,
|
||||
url: `http://localhost:${port}`,
|
||||
reuseExistingServer: false,
|
||||
reuseExistingServer: true,
|
||||
timeout: 120000,
|
||||
env: {
|
||||
...process.env,
|
||||
|
||||
@@ -10,42 +10,24 @@ const execAsync = promisify(exec);
|
||||
|
||||
const SERVER_PORT = process.env.TEST_SERVER_PORT || 3008;
|
||||
const UI_PORT = process.env.TEST_PORT || 3007;
|
||||
const USE_EXTERNAL_SERVER = !!process.env.VITE_SERVER_URL;
|
||||
|
||||
async function killProcessOnPort(port) {
|
||||
try {
|
||||
const hasLsof = await execAsync('command -v lsof').then(
|
||||
() => true,
|
||||
() => false
|
||||
);
|
||||
const { stdout } = await execAsync(`lsof -ti:${port}`);
|
||||
const pids = stdout.trim().split('\n').filter(Boolean);
|
||||
|
||||
if (hasLsof) {
|
||||
const { stdout } = await execAsync(`lsof -ti:${port}`);
|
||||
const pids = stdout.trim().split('\n').filter(Boolean);
|
||||
|
||||
if (pids.length > 0) {
|
||||
console.log(`[KillTestServers] Found process(es) on port ${port}: ${pids.join(', ')}`);
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
await execAsync(`kill -9 ${pid}`);
|
||||
console.log(`[KillTestServers] Killed process ${pid}`);
|
||||
} catch (error) {
|
||||
// Process might have already exited
|
||||
}
|
||||
if (pids.length > 0) {
|
||||
console.log(`[KillTestServers] Found process(es) on port ${port}: ${pids.join(', ')}`);
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
await execAsync(`kill -9 ${pid}`);
|
||||
console.log(`[KillTestServers] Killed process ${pid}`);
|
||||
} catch (error) {
|
||||
// Process might have already exited
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const hasFuser = await execAsync('command -v fuser').then(
|
||||
() => true,
|
||||
() => false
|
||||
);
|
||||
if (hasFuser) {
|
||||
await execAsync(`fuser -k -9 ${port}/tcp`).catch(() => undefined);
|
||||
// Wait a moment for the port to be released
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
// No process on port, which is fine
|
||||
@@ -54,9 +36,7 @@ async function killProcessOnPort(port) {
|
||||
|
||||
async function main() {
|
||||
console.log('[KillTestServers] Checking for existing test servers...');
|
||||
if (!USE_EXTERNAL_SERVER) {
|
||||
await killProcessOnPort(Number(SERVER_PORT));
|
||||
}
|
||||
await killProcessOnPort(Number(SERVER_PORT));
|
||||
await killProcessOnPort(Number(UI_PORT));
|
||||
console.log('[KillTestServers] Done');
|
||||
}
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
/**
|
||||
* Setup script for E2E test fixtures
|
||||
* Creates the necessary test fixture directories and files before running Playwright tests
|
||||
* Also resets the server's settings.json to a known state for test isolation
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
@@ -18,9 +16,6 @@ const __dirname = path.dirname(__filename);
|
||||
const WORKSPACE_ROOT = path.resolve(__dirname, '../../..');
|
||||
const FIXTURE_PATH = path.join(WORKSPACE_ROOT, 'test/fixtures/projectA');
|
||||
const SPEC_FILE_PATH = path.join(FIXTURE_PATH, '.automaker/app_spec.txt');
|
||||
const SERVER_SETTINGS_PATH = path.join(WORKSPACE_ROOT, 'apps/server/data/settings.json');
|
||||
// Create a shared test workspace directory that will be used as default for project creation
|
||||
const TEST_WORKSPACE_DIR = path.join(os.tmpdir(), 'automaker-e2e-workspace');
|
||||
|
||||
const SPEC_CONTENT = `<app_spec>
|
||||
<name>Test Project A</name>
|
||||
@@ -32,154 +27,10 @@ const SPEC_CONTENT = `<app_spec>
|
||||
</app_spec>
|
||||
`;
|
||||
|
||||
// Clean settings.json for E2E tests - no current project so localStorage can control state
|
||||
const E2E_SETTINGS = {
|
||||
version: 4,
|
||||
setupComplete: true,
|
||||
isFirstRun: false,
|
||||
skipClaudeSetup: false,
|
||||
theme: 'dark',
|
||||
sidebarOpen: true,
|
||||
chatHistoryOpen: false,
|
||||
kanbanCardDetailLevel: 'standard',
|
||||
maxConcurrency: 3,
|
||||
defaultSkipTests: true,
|
||||
enableDependencyBlocking: true,
|
||||
skipVerificationInAutoMode: false,
|
||||
useWorktrees: true,
|
||||
showProfilesOnly: false,
|
||||
defaultPlanningMode: 'skip',
|
||||
defaultRequirePlanApproval: false,
|
||||
defaultAIProfileId: null,
|
||||
muteDoneSound: false,
|
||||
phaseModels: {
|
||||
enhancementModel: { model: 'sonnet' },
|
||||
fileDescriptionModel: { model: 'haiku' },
|
||||
imageDescriptionModel: { model: 'haiku' },
|
||||
validationModel: { model: 'sonnet' },
|
||||
specGenerationModel: { model: 'opus' },
|
||||
featureGenerationModel: { model: 'sonnet' },
|
||||
backlogPlanningModel: { model: 'sonnet' },
|
||||
projectAnalysisModel: { model: 'sonnet' },
|
||||
suggestionsModel: { model: 'sonnet' },
|
||||
},
|
||||
enhancementModel: 'sonnet',
|
||||
validationModel: 'opus',
|
||||
enabledCursorModels: ['auto', 'composer-1'],
|
||||
cursorDefaultModel: 'auto',
|
||||
keyboardShortcuts: {
|
||||
board: 'K',
|
||||
agent: 'A',
|
||||
spec: 'D',
|
||||
context: 'C',
|
||||
settings: 'S',
|
||||
profiles: 'M',
|
||||
terminal: 'T',
|
||||
toggleSidebar: '`',
|
||||
addFeature: 'N',
|
||||
addContextFile: 'N',
|
||||
startNext: 'G',
|
||||
newSession: 'N',
|
||||
openProject: 'O',
|
||||
projectPicker: 'P',
|
||||
cyclePrevProject: 'Q',
|
||||
cycleNextProject: 'E',
|
||||
addProfile: 'N',
|
||||
splitTerminalRight: 'Alt+D',
|
||||
splitTerminalDown: 'Alt+S',
|
||||
closeTerminal: 'Alt+W',
|
||||
tools: 'T',
|
||||
ideation: 'I',
|
||||
githubIssues: 'G',
|
||||
githubPrs: 'R',
|
||||
newTerminalTab: 'Alt+T',
|
||||
},
|
||||
aiProfiles: [
|
||||
{
|
||||
id: 'profile-heavy-task',
|
||||
name: 'Heavy Task',
|
||||
description:
|
||||
'Claude Opus with Ultrathink for complex architecture, migrations, or deep debugging.',
|
||||
model: 'opus',
|
||||
thinkingLevel: 'ultrathink',
|
||||
provider: 'claude',
|
||||
isBuiltIn: true,
|
||||
icon: 'Brain',
|
||||
},
|
||||
{
|
||||
id: 'profile-balanced',
|
||||
name: 'Balanced',
|
||||
description: 'Claude Sonnet with medium thinking for typical development tasks.',
|
||||
model: 'sonnet',
|
||||
thinkingLevel: 'medium',
|
||||
provider: 'claude',
|
||||
isBuiltIn: true,
|
||||
icon: 'Scale',
|
||||
},
|
||||
{
|
||||
id: 'profile-quick-edit',
|
||||
name: 'Quick Edit',
|
||||
description: 'Claude Haiku for fast, simple edits and minor fixes.',
|
||||
model: 'haiku',
|
||||
thinkingLevel: 'none',
|
||||
provider: 'claude',
|
||||
isBuiltIn: true,
|
||||
icon: 'Zap',
|
||||
},
|
||||
{
|
||||
id: 'profile-cursor-refactoring',
|
||||
name: 'Cursor Refactoring',
|
||||
description: 'Cursor Composer 1 for refactoring tasks.',
|
||||
provider: 'cursor',
|
||||
cursorModel: 'composer-1',
|
||||
isBuiltIn: true,
|
||||
icon: 'Sparkles',
|
||||
},
|
||||
],
|
||||
// Default test project using the fixture path - tests can override via route mocking if needed
|
||||
projects: [
|
||||
{
|
||||
id: 'e2e-default-project',
|
||||
name: 'E2E Test Project',
|
||||
path: FIXTURE_PATH,
|
||||
lastOpened: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
trashedProjects: [],
|
||||
currentProjectId: 'e2e-default-project',
|
||||
projectHistory: [],
|
||||
projectHistoryIndex: 0,
|
||||
lastProjectDir: TEST_WORKSPACE_DIR,
|
||||
recentFolders: [],
|
||||
worktreePanelCollapsed: false,
|
||||
lastSelectedSessionByProject: {},
|
||||
autoLoadClaudeMd: false,
|
||||
skipSandboxWarning: true,
|
||||
codexAutoLoadAgents: false,
|
||||
codexSandboxMode: 'workspace-write',
|
||||
codexApprovalPolicy: 'on-request',
|
||||
codexEnableWebSearch: false,
|
||||
codexEnableImages: true,
|
||||
codexAdditionalDirs: [],
|
||||
mcpServers: [],
|
||||
enableSandboxMode: false,
|
||||
mcpAutoApproveTools: true,
|
||||
mcpUnrestrictedTools: true,
|
||||
promptCustomization: {},
|
||||
localStorageMigrated: true,
|
||||
};
|
||||
|
||||
function setupFixtures() {
|
||||
console.log('Setting up E2E test fixtures...');
|
||||
console.log(`Workspace root: ${WORKSPACE_ROOT}`);
|
||||
console.log(`Fixture path: ${FIXTURE_PATH}`);
|
||||
console.log(`Test workspace dir: ${TEST_WORKSPACE_DIR}`);
|
||||
|
||||
// Create test workspace directory for project creation tests
|
||||
if (!fs.existsSync(TEST_WORKSPACE_DIR)) {
|
||||
fs.mkdirSync(TEST_WORKSPACE_DIR, { recursive: true });
|
||||
console.log(`Created test workspace directory: ${TEST_WORKSPACE_DIR}`);
|
||||
}
|
||||
|
||||
// Create fixture directory
|
||||
const specDir = path.dirname(SPEC_FILE_PATH);
|
||||
@@ -192,15 +43,6 @@ function setupFixtures() {
|
||||
fs.writeFileSync(SPEC_FILE_PATH, SPEC_CONTENT);
|
||||
console.log(`Created fixture file: ${SPEC_FILE_PATH}`);
|
||||
|
||||
// Reset server settings.json to a clean state for E2E tests
|
||||
const settingsDir = path.dirname(SERVER_SETTINGS_PATH);
|
||||
if (!fs.existsSync(settingsDir)) {
|
||||
fs.mkdirSync(settingsDir, { recursive: true });
|
||||
console.log(`Created directory: ${settingsDir}`);
|
||||
}
|
||||
fs.writeFileSync(SERVER_SETTINGS_PATH, JSON.stringify(E2E_SETTINGS, null, 2));
|
||||
console.log(`Reset server settings: ${SERVER_SETTINGS_PATH}`);
|
||||
|
||||
console.log('E2E test fixtures setup complete!');
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { RouterProvider } from '@tanstack/react-router';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { router } from './utils/router';
|
||||
import { SplashScreen } from './components/splash-screen';
|
||||
import { useSettingsSync } from './hooks/use-settings-sync';
|
||||
import { useSettingsMigration } from './hooks/use-settings-migration';
|
||||
import { useCursorStatusInit } from './hooks/use-cursor-status-init';
|
||||
import './styles/global.css';
|
||||
import './styles/theme-imports';
|
||||
@@ -32,14 +32,10 @@ export default function App() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Settings are now loaded in __root.tsx after successful session verification
|
||||
// This ensures a unified flow: verify session → load settings → redirect
|
||||
// We no longer block router rendering here - settings loading happens in __root.tsx
|
||||
|
||||
// Sync settings changes back to server (API-first persistence)
|
||||
const settingsSyncState = useSettingsSync();
|
||||
if (settingsSyncState.error) {
|
||||
logger.error('Settings sync error:', settingsSyncState.error);
|
||||
// Run settings migration on startup (localStorage -> file storage)
|
||||
const migrationState = useSettingsMigration();
|
||||
if (migrationState.migrated) {
|
||||
logger.info('Settings migrated to file storage');
|
||||
}
|
||||
|
||||
// Initialize Cursor CLI status at startup
|
||||
|
||||
@@ -1,405 +0,0 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
|
||||
// Error codes for distinguishing failure modes
|
||||
const ERROR_CODES = {
|
||||
API_BRIDGE_UNAVAILABLE: 'API_BRIDGE_UNAVAILABLE',
|
||||
AUTH_ERROR: 'AUTH_ERROR',
|
||||
NOT_AVAILABLE: 'NOT_AVAILABLE',
|
||||
UNKNOWN: 'UNKNOWN',
|
||||
} as const;
|
||||
|
||||
type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
|
||||
|
||||
type UsageError = {
|
||||
code: ErrorCode;
|
||||
message: string;
|
||||
};
|
||||
|
||||
// Fixed refresh interval (45 seconds)
|
||||
const REFRESH_INTERVAL_SECONDS = 45;
|
||||
|
||||
// Helper to format reset time
|
||||
function formatResetTime(unixTimestamp: number): string {
|
||||
const date = new Date(unixTimestamp * 1000);
|
||||
const now = new Date();
|
||||
const diff = date.getTime() - now.getTime();
|
||||
|
||||
// If less than 1 hour, show minutes
|
||||
if (diff < 3600000) {
|
||||
const mins = Math.ceil(diff / 60000);
|
||||
return `Resets in ${mins}m`;
|
||||
}
|
||||
|
||||
// If less than 24 hours, show hours and minutes
|
||||
if (diff < 86400000) {
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const mins = Math.ceil((diff % 3600000) / 60000);
|
||||
return `Resets in ${hours}h ${mins > 0 ? `${mins}m` : ''}`;
|
||||
}
|
||||
|
||||
// Otherwise show date
|
||||
return `Resets ${date.toLocaleDateString()} at ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
|
||||
}
|
||||
|
||||
// Helper to format window duration
|
||||
function getWindowLabel(durationMins: number): { title: string; subtitle: string } {
|
||||
if (durationMins < 60) {
|
||||
return { title: `${durationMins}min Window`, subtitle: 'Rate limit' };
|
||||
}
|
||||
if (durationMins < 1440) {
|
||||
const hours = Math.round(durationMins / 60);
|
||||
return { title: `${hours}h Window`, subtitle: 'Rate limit' };
|
||||
}
|
||||
const days = Math.round(durationMins / 1440);
|
||||
return { title: `${days}d Window`, subtitle: 'Rate limit' };
|
||||
}
|
||||
|
||||
export function CodexUsagePopover() {
|
||||
const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore();
|
||||
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<UsageError | null>(null);
|
||||
|
||||
// Check if Codex is authenticated
|
||||
const isCodexAuthenticated = codexAuthStatus?.authenticated;
|
||||
|
||||
// Check if data is stale (older than 2 minutes)
|
||||
const isStale = useMemo(() => {
|
||||
return !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000;
|
||||
}, [codexUsageLastUpdated]);
|
||||
|
||||
const fetchUsage = useCallback(
|
||||
async (isAutoRefresh = false) => {
|
||||
if (!isAutoRefresh) setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.codex) {
|
||||
setError({
|
||||
code: ERROR_CODES.API_BRIDGE_UNAVAILABLE,
|
||||
message: 'Codex API bridge not available',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const data = await api.codex.getUsage();
|
||||
if ('error' in data) {
|
||||
// Check if it's the "not available" error
|
||||
if (
|
||||
data.message?.includes('not available') ||
|
||||
data.message?.includes('does not provide')
|
||||
) {
|
||||
setError({
|
||||
code: ERROR_CODES.NOT_AVAILABLE,
|
||||
message: data.message || data.error,
|
||||
});
|
||||
} else {
|
||||
setError({
|
||||
code: ERROR_CODES.AUTH_ERROR,
|
||||
message: data.message || data.error,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
setCodexUsage(data);
|
||||
} catch (err) {
|
||||
setError({
|
||||
code: ERROR_CODES.UNKNOWN,
|
||||
message: err instanceof Error ? err.message : 'Failed to fetch usage',
|
||||
});
|
||||
} finally {
|
||||
if (!isAutoRefresh) setLoading(false);
|
||||
}
|
||||
},
|
||||
[setCodexUsage]
|
||||
);
|
||||
|
||||
// Auto-fetch on mount if data is stale (only if authenticated)
|
||||
useEffect(() => {
|
||||
if (isStale && isCodexAuthenticated) {
|
||||
fetchUsage(true);
|
||||
}
|
||||
}, [isStale, isCodexAuthenticated, fetchUsage]);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip if not authenticated
|
||||
if (!isCodexAuthenticated) return;
|
||||
|
||||
// Initial fetch when opened
|
||||
if (open) {
|
||||
if (!codexUsage || isStale) {
|
||||
fetchUsage();
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-refresh interval (only when open)
|
||||
let intervalId: NodeJS.Timeout | null = null;
|
||||
if (open) {
|
||||
intervalId = setInterval(() => {
|
||||
fetchUsage(true);
|
||||
}, REFRESH_INTERVAL_SECONDS * 1000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
};
|
||||
}, [open, codexUsage, isStale, isCodexAuthenticated, fetchUsage]);
|
||||
|
||||
// Derived status color/icon helper
|
||||
const getStatusInfo = (percentage: number) => {
|
||||
if (percentage >= 75) return { color: 'text-red-500', icon: XCircle, bg: 'bg-red-500' };
|
||||
if (percentage >= 50)
|
||||
return { color: 'text-orange-500', icon: AlertTriangle, bg: 'bg-orange-500' };
|
||||
return { color: 'text-green-500', icon: CheckCircle, bg: 'bg-green-500' };
|
||||
};
|
||||
|
||||
// Helper component for the progress bar
|
||||
const ProgressBar = ({ percentage, colorClass }: { percentage: number; colorClass: string }) => (
|
||||
<div className="h-2 w-full bg-secondary/50 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full transition-all duration-500', colorClass)}
|
||||
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const UsageCard = ({
|
||||
title,
|
||||
subtitle,
|
||||
percentage,
|
||||
resetText,
|
||||
isPrimary = false,
|
||||
stale = false,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
percentage: number;
|
||||
resetText?: string;
|
||||
isPrimary?: boolean;
|
||||
stale?: boolean;
|
||||
}) => {
|
||||
const isValidPercentage =
|
||||
typeof percentage === 'number' && !isNaN(percentage) && isFinite(percentage);
|
||||
const safePercentage = isValidPercentage ? percentage : 0;
|
||||
|
||||
const status = getStatusInfo(safePercentage);
|
||||
const StatusIcon = status.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border bg-card/50 p-4 transition-opacity',
|
||||
isPrimary ? 'border-border/60 shadow-sm' : 'border-border/40',
|
||||
(stale || !isValidPercentage) && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h4 className={cn('font-semibold', isPrimary ? 'text-sm' : 'text-xs')}>{title}</h4>
|
||||
<p className="text-[10px] text-muted-foreground">{subtitle}</p>
|
||||
</div>
|
||||
{isValidPercentage ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StatusIcon className={cn('w-3.5 h-3.5', status.color)} />
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono font-bold',
|
||||
status.color,
|
||||
isPrimary ? 'text-base' : 'text-sm'
|
||||
)}
|
||||
>
|
||||
{Math.round(safePercentage)}%
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">N/A</span>
|
||||
)}
|
||||
</div>
|
||||
<ProgressBar
|
||||
percentage={safePercentage}
|
||||
colorClass={isValidPercentage ? status.bg : 'bg-muted-foreground/30'}
|
||||
/>
|
||||
{resetText && (
|
||||
<div className="mt-2 flex justify-end">
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{resetText}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Header Button
|
||||
const maxPercentage = codexUsage?.rateLimits
|
||||
? Math.max(
|
||||
codexUsage.rateLimits.primary?.usedPercent || 0,
|
||||
codexUsage.rateLimits.secondary?.usedPercent || 0
|
||||
)
|
||||
: 0;
|
||||
|
||||
const getProgressBarColor = (percentage: number) => {
|
||||
if (percentage >= 80) return 'bg-red-500';
|
||||
if (percentage >= 50) return 'bg-yellow-500';
|
||||
return 'bg-green-500';
|
||||
};
|
||||
|
||||
const trigger = (
|
||||
<Button variant="ghost" size="sm" className="h-9 gap-3 bg-secondary border border-border px-3">
|
||||
<span className="text-sm font-medium">Codex</span>
|
||||
{codexUsage && codexUsage.rateLimits && (
|
||||
<div
|
||||
className={cn(
|
||||
'h-1.5 w-16 bg-muted-foreground/20 rounded-full overflow-hidden transition-opacity',
|
||||
isStale && 'opacity-60'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn('h-full transition-all duration-500', getProgressBarColor(maxPercentage))}
|
||||
style={{ width: `${Math.min(maxPercentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-80 p-0 overflow-hidden bg-background/95 backdrop-blur-xl border-border shadow-2xl"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border/50 bg-secondary/10">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold">Codex Usage</span>
|
||||
</div>
|
||||
{error && error.code !== ERROR_CODES.NOT_AVAILABLE && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('h-6 w-6', loading && 'opacity-80')}
|
||||
onClick={() => !loading && fetchUsage(false)}
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-4">
|
||||
{error ? (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center space-y-3">
|
||||
<AlertTriangle className="w-8 h-8 text-yellow-500/80" />
|
||||
<div className="space-y-1 flex flex-col items-center">
|
||||
<p className="text-sm font-medium">
|
||||
{error.code === ERROR_CODES.NOT_AVAILABLE ? 'Usage not available' : error.message}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{error.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? (
|
||||
'Ensure the Electron bridge is running or restart the app'
|
||||
) : error.code === ERROR_CODES.NOT_AVAILABLE ? (
|
||||
<>
|
||||
Codex CLI doesn't provide usage statistics. Check{' '}
|
||||
<a
|
||||
href="https://platform.openai.com/usage"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline hover:text-foreground"
|
||||
>
|
||||
OpenAI dashboard
|
||||
</a>{' '}
|
||||
for usage details.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Make sure Codex CLI is installed and authenticated via{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">codex login</code>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : !codexUsage ? (
|
||||
// Loading state
|
||||
<div className="flex flex-col items-center justify-center py-8 space-y-2">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-muted-foreground/50" />
|
||||
<p className="text-xs text-muted-foreground">Loading usage data...</p>
|
||||
</div>
|
||||
) : codexUsage.rateLimits ? (
|
||||
<>
|
||||
{/* Primary Window Card */}
|
||||
{codexUsage.rateLimits.primary && (
|
||||
<UsageCard
|
||||
title={getWindowLabel(codexUsage.rateLimits.primary.windowDurationMins).title}
|
||||
subtitle={
|
||||
getWindowLabel(codexUsage.rateLimits.primary.windowDurationMins).subtitle
|
||||
}
|
||||
percentage={codexUsage.rateLimits.primary.usedPercent}
|
||||
resetText={formatResetTime(codexUsage.rateLimits.primary.resetsAt)}
|
||||
isPrimary={true}
|
||||
stale={isStale}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Secondary Window Card */}
|
||||
{codexUsage.rateLimits.secondary && (
|
||||
<UsageCard
|
||||
title={getWindowLabel(codexUsage.rateLimits.secondary.windowDurationMins).title}
|
||||
subtitle={
|
||||
getWindowLabel(codexUsage.rateLimits.secondary.windowDurationMins).subtitle
|
||||
}
|
||||
percentage={codexUsage.rateLimits.secondary.usedPercent}
|
||||
resetText={formatResetTime(codexUsage.rateLimits.secondary.resetsAt)}
|
||||
stale={isStale}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Plan Type */}
|
||||
{codexUsage.rateLimits.planType && (
|
||||
<div className="rounded-xl border border-border/40 bg-secondary/20 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Plan:{' '}
|
||||
<span className="text-foreground font-medium">
|
||||
{codexUsage.rateLimits.planType.charAt(0).toUpperCase() +
|
||||
codexUsage.rateLimits.planType.slice(1)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||
<AlertTriangle className="w-8 h-8 text-yellow-500/80" />
|
||||
<p className="text-sm font-medium mt-3">No usage data available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-secondary/10 border-t border-border/50">
|
||||
<a
|
||||
href="https://platform.openai.com/usage"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground flex items-center gap-1 transition-colors"
|
||||
>
|
||||
OpenAI Dashboard <ExternalLink className="w-2.5 h-2.5" />
|
||||
</a>
|
||||
|
||||
<span className="text-[10px] text-muted-foreground">Updates every minute</span>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@/components/ui/command';
|
||||
import {
|
||||
Plus,
|
||||
Sparkles,
|
||||
Play,
|
||||
Square,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Terminal,
|
||||
Bot,
|
||||
Settings,
|
||||
Github,
|
||||
BookOpen,
|
||||
Wand2,
|
||||
Search,
|
||||
LayoutGrid,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface CommandPaletteProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
|
||||
const navigate = useNavigate();
|
||||
const { currentProject, getAutoModeState, setAutoModeRunning } = useAppStore();
|
||||
|
||||
const autoModeState = currentProject ? getAutoModeState(currentProject.id) : null;
|
||||
const isAutoModeRunning = autoModeState?.isRunning ?? false;
|
||||
|
||||
const runCommand = useCallback(
|
||||
(command: () => void) => {
|
||||
onOpenChange(false);
|
||||
command();
|
||||
},
|
||||
[onOpenChange]
|
||||
);
|
||||
|
||||
const handleNavigate = useCallback(
|
||||
(path: string) => {
|
||||
runCommand(() => navigate({ to: path }));
|
||||
},
|
||||
[navigate, runCommand]
|
||||
);
|
||||
|
||||
const handleToggleAutoMode = useCallback(() => {
|
||||
if (currentProject) {
|
||||
runCommand(() => setAutoModeRunning(currentProject.id, !isAutoModeRunning));
|
||||
}
|
||||
}, [currentProject, isAutoModeRunning, setAutoModeRunning, runCommand]);
|
||||
|
||||
return (
|
||||
<CommandDialog open={open} onOpenChange={onOpenChange}>
|
||||
<CommandInput placeholder="Search commands..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
|
||||
{currentProject && (
|
||||
<>
|
||||
<CommandGroup heading="Quick Actions">
|
||||
<CommandItem onSelect={() => handleNavigate('/board')}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
<span>Add Feature</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => handleNavigate('/ideation')}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
<span>Generate Ideas</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleToggleAutoMode}>
|
||||
{isAutoModeRunning ? (
|
||||
<>
|
||||
<Square className="mr-2 h-4 w-4" />
|
||||
<span>Stop Auto Mode</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
<span>Start Auto Mode</span>
|
||||
</>
|
||||
)}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<CommandSeparator />
|
||||
|
||||
<CommandGroup heading="Navigation">
|
||||
<CommandItem onSelect={() => handleNavigate('/board')}>
|
||||
<LayoutGrid className="mr-2 h-4 w-4" />
|
||||
<span>Kanban Board</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => handleNavigate('/running-agents')}>
|
||||
<Bot className="mr-2 h-4 w-4" />
|
||||
<span>Running Agents</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => handleNavigate('/terminal')}>
|
||||
<Terminal className="mr-2 h-4 w-4" />
|
||||
<span>Terminal</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<CommandSeparator />
|
||||
|
||||
<CommandGroup heading="Project">
|
||||
<CommandItem onSelect={() => handleNavigate('/spec')}>
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
<span>App Specification</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => handleNavigate('/context')}>
|
||||
<FolderOpen className="mr-2 h-4 w-4" />
|
||||
<span>Context Files</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => handleNavigate('/github-issues')}>
|
||||
<Github className="mr-2 h-4 w-4" />
|
||||
<span>GitHub Issues</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => handleNavigate('/github-prs')}>
|
||||
<Github className="mr-2 h-4 w-4" />
|
||||
<span>Pull Requests</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<CommandSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<CommandGroup heading="Settings">
|
||||
<CommandItem onSelect={() => handleNavigate('/profiles')}>
|
||||
<Wand2 className="mr-2 h-4 w-4" />
|
||||
<span>AI Profiles</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => handleNavigate('/settings')}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
<span>Settings</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => handleNavigate('/wiki')}>
|
||||
<BookOpen className="mr-2 h-4 w-4" />
|
||||
<span>Documentation</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
|
||||
<CommandSeparator />
|
||||
|
||||
<CommandGroup heading="Projects">
|
||||
<CommandItem onSelect={() => handleNavigate('/dashboard')}>
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
<span>All Projects</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { CommandPalette } from './command-palette';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user