mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-23 12:03:07 +00:00
Compare commits
42 Commits
dfa719079f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d37ced1c6e | ||
|
|
0311130a8a | ||
|
|
7be8163b84 | ||
|
|
26b73df097 | ||
|
|
20e7c74b17 | ||
|
|
dd7108a7a0 | ||
|
|
ae48065820 | ||
|
|
b2915f4de1 | ||
|
|
cf3d312eef | ||
|
|
341a6534e6 | ||
|
|
4a128efbf4 | ||
|
|
54d69e907b | ||
|
|
1c3d6434a8 | ||
|
|
8218c48e67 | ||
|
|
59b100b5cc | ||
|
|
c11f390764 | ||
|
|
33a2e04bf0 | ||
|
|
34161ccc08 | ||
|
|
57bcb2802d | ||
|
|
63b0a4fb38 | ||
|
|
1c0e460dd1 | ||
|
|
0196911d59 | ||
|
|
70d400793b | ||
|
|
dd7654c254 | ||
|
|
6a824a9ff0 | ||
|
|
5e15f4120c | ||
|
|
82e9396cb8 | ||
|
|
9747faf1b9 | ||
|
|
70c9fd77f6 | ||
|
|
46ee34d499 | ||
|
|
6408f514a4 | ||
|
|
6b97219f55 | ||
|
|
09a4d3f15a | ||
|
|
51e9a23ba1 | ||
|
|
0330c70261 | ||
|
|
e7504b247f | ||
|
|
9305ecc242 | ||
|
|
2f071a1ba3 | ||
|
|
1d732916f1 | ||
|
|
629fd24d9f | ||
|
|
72cb942788 | ||
|
|
91bff21d58 |
14
.geminiignore
Normal file
14
.geminiignore
Normal file
@@ -0,0 +1,14 @@
|
||||
# Auto-generated by Automaker to speed up Gemini CLI startup
|
||||
# Prevents Gemini CLI from scanning large directories during context discovery
|
||||
.git
|
||||
node_modules
|
||||
dist
|
||||
build
|
||||
.next
|
||||
.nuxt
|
||||
coverage
|
||||
.automaker
|
||||
.worktrees
|
||||
.vscode
|
||||
.idea
|
||||
*.lock
|
||||
53
.github/workflows/e2e-tests.yml
vendored
53
.github/workflows/e2e-tests.yml
vendored
@@ -13,6 +13,13 @@ jobs:
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# shardIndex: [1, 2, 3]
|
||||
# shardTotal: [3]
|
||||
shardIndex: [1]
|
||||
shardTotal: [1]
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -46,7 +53,8 @@ jobs:
|
||||
echo "SERVER_PID=$SERVER_PID" >> $GITHUB_ENV
|
||||
|
||||
env:
|
||||
PORT: 3008
|
||||
PORT: 3108
|
||||
TEST_SERVER_PORT: 3108
|
||||
NODE_ENV: test
|
||||
# Use a deterministic API key so Playwright can log in reliably
|
||||
AUTOMAKER_API_KEY: test-api-key-for-e2e-tests
|
||||
@@ -81,16 +89,16 @@ jobs:
|
||||
|
||||
# Wait for health endpoint
|
||||
for i in {1..60}; do
|
||||
if curl -s -f http://localhost:3008/api/health > /dev/null 2>&1; then
|
||||
if curl -s -f http://localhost:3108/api/health > /dev/null 2>&1; then
|
||||
echo "Backend server is ready!"
|
||||
echo "=== Backend logs ==="
|
||||
cat backend.log
|
||||
echo ""
|
||||
echo "Health check response:"
|
||||
curl -s http://localhost:3008/api/health | jq . 2>/dev/null || echo "Health check: $(curl -s http://localhost:3008/api/health 2>/dev/null || echo 'No response')"
|
||||
curl -s http://localhost:3108/api/health | jq . 2>/dev/null || echo "Health check: $(curl -s http://localhost:3108/api/health 2>/dev/null || echo 'No response')"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
# Check if server process is still running
|
||||
if ! kill -0 $SERVER_PID 2>/dev/null; then
|
||||
echo "ERROR: Server process died during wait!"
|
||||
@@ -98,7 +106,7 @@ jobs:
|
||||
cat backend.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
echo "Waiting... ($i/60)"
|
||||
sleep 1
|
||||
done
|
||||
@@ -111,11 +119,11 @@ jobs:
|
||||
ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found"
|
||||
echo ""
|
||||
echo "=== Port status ==="
|
||||
netstat -tlnp 2>/dev/null | grep :3008 || echo "Port 3008 not listening"
|
||||
lsof -i :3008 2>/dev/null || echo "lsof not available or port not in use"
|
||||
netstat -tlnp 2>/dev/null | grep :3108 || echo "Port 3108 not listening"
|
||||
lsof -i :3108 2>/dev/null || echo "lsof not available or port not in use"
|
||||
echo ""
|
||||
echo "=== Health endpoint test ==="
|
||||
curl -v http://localhost:3008/api/health 2>&1 || echo "Health endpoint failed"
|
||||
curl -v http://localhost:3108/api/health 2>&1 || echo "Health endpoint failed"
|
||||
|
||||
# Kill the server process if it's still hanging
|
||||
if kill -0 $SERVER_PID 2>/dev/null; then
|
||||
@@ -126,17 +134,23 @@ jobs:
|
||||
|
||||
exit 1
|
||||
|
||||
- name: Run E2E tests
|
||||
- name: Run E2E tests (shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
||||
# Playwright automatically starts the Vite frontend via webServer config
|
||||
# (see apps/ui/playwright.config.ts) - no need to start it manually
|
||||
run: npm run test --workspace=apps/ui
|
||||
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
working-directory: apps/ui
|
||||
env:
|
||||
CI: true
|
||||
VITE_SERVER_URL: http://localhost:3008
|
||||
SERVER_URL: http://localhost:3008
|
||||
VITE_SKIP_SETUP: 'true'
|
||||
# Keep UI-side login/defaults consistent
|
||||
AUTOMAKER_API_KEY: test-api-key-for-e2e-tests
|
||||
# Backend is already started above - Playwright config sets
|
||||
# AUTOMAKER_SERVER_PORT so the Vite proxy forwards /api/* to the backend.
|
||||
# Do NOT set VITE_SERVER_URL here: it bypasses the Vite proxy and causes
|
||||
# a cookie domain mismatch (cookies are bound to 127.0.0.1, but
|
||||
# VITE_SERVER_URL=http://localhost:3108 makes the frontend call localhost).
|
||||
TEST_USE_EXTERNAL_BACKEND: 'true'
|
||||
TEST_SERVER_PORT: 3108
|
||||
|
||||
- name: Print backend logs on failure
|
||||
if: failure()
|
||||
@@ -148,13 +162,13 @@ jobs:
|
||||
ps aux | grep -E "(node|tsx)" | grep -v grep || echo "No node processes found"
|
||||
echo ""
|
||||
echo "=== Port status ==="
|
||||
netstat -tlnp 2>/dev/null | grep :3008 || echo "Port 3008 not listening"
|
||||
netstat -tlnp 2>/dev/null | grep :3108 || echo "Port 3108 not listening"
|
||||
|
||||
- name: Upload Playwright report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
name: playwright-report-shard-${{ matrix.shardIndex }}-of-${{ matrix.shardTotal }}
|
||||
path: apps/ui/playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
@@ -162,12 +176,21 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: test-results
|
||||
name: test-results-shard-${{ matrix.shardIndex }}-of-${{ matrix.shardTotal }}
|
||||
path: |
|
||||
apps/ui/test-results/
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Upload blob report for merging
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: blob-report-shard-${{ matrix.shardIndex }}-of-${{ matrix.shardTotal }}
|
||||
path: apps/ui/blob-report/
|
||||
retention-days: 1
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Cleanup - Kill backend server
|
||||
if: always()
|
||||
run: |
|
||||
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -65,6 +65,17 @@ coverage/
|
||||
*.lcov
|
||||
playwright-report/
|
||||
blob-report/
|
||||
test/**/test-project-[0-9]*/
|
||||
test/opus-thinking-*/
|
||||
test/agent-session-test-*/
|
||||
test/feature-backlog-test-*/
|
||||
test/running-task-display-test-*/
|
||||
test/agent-output-modal-responsive-*/
|
||||
test/fixtures/
|
||||
test/board-bg-test-*/
|
||||
test/edit-feature-test-*/
|
||||
test/open-project-test-*/
|
||||
|
||||
|
||||
# Environment files (keep .example)
|
||||
.env
|
||||
@@ -102,3 +113,4 @@ data/
|
||||
.planning/
|
||||
.mcp.json
|
||||
.planning
|
||||
.bg-shell/
|
||||
@@ -52,6 +52,12 @@ HOST=0.0.0.0
|
||||
# Port to run the server on
|
||||
PORT=3008
|
||||
|
||||
# Port to run the server on for testing
|
||||
TEST_SERVER_PORT=3108
|
||||
|
||||
# Port to run the UI on for testing
|
||||
TEST_PORT=3107
|
||||
|
||||
# Data directory for sessions and metadata
|
||||
DATA_DIR=./data
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@automaker/server",
|
||||
"version": "0.13.0",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend server for Automaker - provides API for both web and Electron modes",
|
||||
"author": "AutoMaker Team",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
@@ -32,7 +32,7 @@
|
||||
"@automaker/prompts": "1.0.0",
|
||||
"@automaker/types": "1.0.0",
|
||||
"@automaker/utils": "1.0.0",
|
||||
"@github/copilot-sdk": "^0.1.16",
|
||||
"@github/copilot-sdk": "0.1.16",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
"@openai/codex-sdk": "^0.98.0",
|
||||
"cookie-parser": "1.4.7",
|
||||
|
||||
@@ -261,7 +261,10 @@ morgan.token('status-colored', (_req, res) => {
|
||||
app.use(
|
||||
morgan(':method :url :status-colored', {
|
||||
// Skip when request logging is disabled or for health check endpoints
|
||||
skip: (req) => !requestLoggingEnabled || req.url === '/api/health',
|
||||
skip: (req) =>
|
||||
!requestLoggingEnabled ||
|
||||
req.url === '/api/health' ||
|
||||
req.url === '/api/auto-mode/context-exists',
|
||||
})
|
||||
);
|
||||
// CORS configuration
|
||||
@@ -349,7 +352,9 @@ const ideationService = new IdeationService(events, settingsService, featureLoad
|
||||
|
||||
// Initialize DevServerService with event emitter for real-time log streaming
|
||||
const devServerService = getDevServerService();
|
||||
devServerService.setEventEmitter(events);
|
||||
devServerService.initialize(DATA_DIR, events).catch((err) => {
|
||||
logger.error('Failed to initialize DevServerService:', err);
|
||||
});
|
||||
|
||||
// Initialize Notification Service with event emitter for real-time updates
|
||||
const notificationService = getNotificationService();
|
||||
@@ -434,21 +439,18 @@ eventHookService.initialize(events, settingsService, eventHistoryService, featur
|
||||
logger.info('[STARTUP] Feature state reconciliation complete - no stale states found');
|
||||
}
|
||||
|
||||
// Resume interrupted features in the background after reconciliation.
|
||||
// This uses the saved execution state to identify features that were running
|
||||
// before the restart (their statuses have been reset to ready/backlog by
|
||||
// reconciliation above). Running in background so it doesn't block startup.
|
||||
if (totalReconciled > 0) {
|
||||
for (const project of globalSettings.projects) {
|
||||
autoModeService.resumeInterruptedFeatures(project.path).catch((err) => {
|
||||
logger.warn(
|
||||
`[STARTUP] Failed to resume interrupted features for ${project.path}:`,
|
||||
err
|
||||
);
|
||||
});
|
||||
}
|
||||
logger.info('[STARTUP] Initiated background resume of interrupted features');
|
||||
// Resume interrupted features in the background for all projects.
|
||||
// This handles features stuck in transient states (in_progress, pipeline_*)
|
||||
// or explicitly marked as interrupted. Running in background so it doesn't block startup.
|
||||
for (const project of globalSettings.projects) {
|
||||
autoModeService.resumeInterruptedFeatures(project.path).catch((err) => {
|
||||
logger.warn(
|
||||
`[STARTUP] Failed to resume interrupted features for ${project.path}:`,
|
||||
err
|
||||
);
|
||||
});
|
||||
}
|
||||
logger.info('[STARTUP] Initiated background resume of interrupted features');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('[STARTUP] Failed to reconcile feature states:', err);
|
||||
@@ -494,7 +496,7 @@ app.use(
|
||||
);
|
||||
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
|
||||
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
|
||||
app.use('/api/worktree', createWorktreeRoutes(events, settingsService));
|
||||
app.use('/api/worktree', createWorktreeRoutes(events, settingsService, featureLoader));
|
||||
app.use('/api/git', createGitRoutes());
|
||||
app.use('/api/models', createModelsRoutes());
|
||||
app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events, settingsService));
|
||||
@@ -596,24 +598,23 @@ wss.on('connection', (ws: WebSocket) => {
|
||||
|
||||
// Subscribe to all events and forward to this client
|
||||
const unsubscribe = events.subscribe((type, payload) => {
|
||||
logger.info('Event received:', {
|
||||
// Use debug level for high-frequency events to avoid log spam
|
||||
// that causes progressive memory growth and server slowdown
|
||||
const isHighFrequency =
|
||||
type === 'dev-server:output' || type === 'test-runner:output' || type === 'feature:progress';
|
||||
const log = isHighFrequency ? logger.debug.bind(logger) : logger.info.bind(logger);
|
||||
|
||||
log('Event received:', {
|
||||
type,
|
||||
hasPayload: !!payload,
|
||||
payloadKeys: payload ? Object.keys(payload) : [],
|
||||
wsReadyState: ws.readyState,
|
||||
wsOpen: ws.readyState === WebSocket.OPEN,
|
||||
});
|
||||
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
const message = JSON.stringify({ type, payload });
|
||||
logger.info('Sending event to client:', {
|
||||
type,
|
||||
messageLength: message.length,
|
||||
sessionId: (payload as Record<string, unknown>)?.sessionId,
|
||||
});
|
||||
ws.send(message);
|
||||
} else {
|
||||
logger.info('WARNING: Cannot send event, WebSocket not open. ReadyState:', ws.readyState);
|
||||
logger.warn('Cannot send event, WebSocket not open. ReadyState:', ws.readyState);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -13,6 +13,27 @@ import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('GitLib');
|
||||
|
||||
// Extended PATH so git is found when the process does not inherit a full shell PATH
|
||||
// (e.g. Electron, some CI, or IDE-launched processes).
|
||||
const pathSeparator = process.platform === 'win32' ? ';' : ':';
|
||||
const extraPaths: string[] =
|
||||
process.platform === 'win32'
|
||||
? ([
|
||||
process.env.LOCALAPPDATA && `${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`,
|
||||
process.env.PROGRAMFILES && `${process.env.PROGRAMFILES}\\Git\\cmd`,
|
||||
process.env['ProgramFiles(x86)'] && `${process.env['ProgramFiles(x86)']}\\Git\\cmd`,
|
||||
].filter(Boolean) as string[])
|
||||
: [
|
||||
'/opt/homebrew/bin',
|
||||
'/usr/local/bin',
|
||||
'/usr/bin',
|
||||
'/home/linuxbrew/.linuxbrew/bin',
|
||||
process.env.HOME ? `${process.env.HOME}/.local/bin` : '',
|
||||
].filter(Boolean);
|
||||
|
||||
const extendedPath = [process.env.PATH, ...extraPaths].filter(Boolean).join(pathSeparator);
|
||||
const gitEnv = { ...process.env, PATH: extendedPath };
|
||||
|
||||
// ============================================================================
|
||||
// Secure Command Execution
|
||||
// ============================================================================
|
||||
@@ -65,7 +86,14 @@ export async function execGitCommand(
|
||||
command: 'git',
|
||||
args,
|
||||
cwd,
|
||||
...(env !== undefined ? { env } : {}),
|
||||
env:
|
||||
env !== undefined
|
||||
? {
|
||||
...gitEnv,
|
||||
...env,
|
||||
PATH: [gitEnv.PATH, env.PATH].filter(Boolean).join(pathSeparator),
|
||||
}
|
||||
: gitEnv,
|
||||
...(abortController !== undefined ? { abortController } : {}),
|
||||
});
|
||||
|
||||
|
||||
@@ -133,12 +133,16 @@ export const TOOL_PRESETS = {
|
||||
'Read',
|
||||
'Write',
|
||||
'Edit',
|
||||
'MultiEdit',
|
||||
'Glob',
|
||||
'Grep',
|
||||
'LS',
|
||||
'Bash',
|
||||
'WebSearch',
|
||||
'WebFetch',
|
||||
'TodoWrite',
|
||||
'Task',
|
||||
'Skill',
|
||||
] as const,
|
||||
|
||||
/** Tools for chat/interactive mode */
|
||||
@@ -146,12 +150,16 @@ export const TOOL_PRESETS = {
|
||||
'Read',
|
||||
'Write',
|
||||
'Edit',
|
||||
'MultiEdit',
|
||||
'Glob',
|
||||
'Grep',
|
||||
'LS',
|
||||
'Bash',
|
||||
'WebSearch',
|
||||
'WebFetch',
|
||||
'TodoWrite',
|
||||
'Task',
|
||||
'Skill',
|
||||
] as const,
|
||||
} as const;
|
||||
|
||||
@@ -282,11 +290,15 @@ function buildThinkingOptions(thinkingLevel?: ThinkingLevel): Partial<Options> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build system prompt configuration based on autoLoadClaudeMd setting.
|
||||
* When autoLoadClaudeMd is true:
|
||||
* - Uses preset mode with 'claude_code' to enable CLAUDE.md auto-loading
|
||||
* - If there's a custom systemPrompt, appends it to the preset
|
||||
* - Sets settingSources to ['project'] for SDK to load CLAUDE.md files
|
||||
* Build system prompt and settingSources based on two independent settings:
|
||||
* - useClaudeCodeSystemPrompt: controls whether to use the 'claude_code' preset as the base prompt
|
||||
* - autoLoadClaudeMd: controls whether to add settingSources for SDK to load CLAUDE.md files
|
||||
*
|
||||
* These combine independently (4 possible states):
|
||||
* 1. Both ON: preset + settingSources (full Claude Code experience)
|
||||
* 2. useClaudeCodeSystemPrompt ON, autoLoadClaudeMd OFF: preset only (no CLAUDE.md auto-loading)
|
||||
* 3. useClaudeCodeSystemPrompt OFF, autoLoadClaudeMd ON: plain string + settingSources
|
||||
* 4. Both OFF: plain string only
|
||||
*
|
||||
* @param config - The SDK options config
|
||||
* @returns Object with systemPrompt and settingSources for SDK options
|
||||
@@ -295,27 +307,34 @@ function buildClaudeMdOptions(config: CreateSdkOptionsConfig): {
|
||||
systemPrompt?: string | SystemPromptConfig;
|
||||
settingSources?: Array<'user' | 'project' | 'local'>;
|
||||
} {
|
||||
if (!config.autoLoadClaudeMd) {
|
||||
// Standard mode - just pass through the system prompt as-is
|
||||
return config.systemPrompt ? { systemPrompt: config.systemPrompt } : {};
|
||||
}
|
||||
|
||||
// Auto-load CLAUDE.md mode - use preset with settingSources
|
||||
const result: {
|
||||
systemPrompt: SystemPromptConfig;
|
||||
settingSources: Array<'user' | 'project' | 'local'>;
|
||||
} = {
|
||||
systemPrompt: {
|
||||
systemPrompt?: string | SystemPromptConfig;
|
||||
settingSources?: Array<'user' | 'project' | 'local'>;
|
||||
} = {};
|
||||
|
||||
// Determine system prompt format based on useClaudeCodeSystemPrompt
|
||||
if (config.useClaudeCodeSystemPrompt) {
|
||||
// Use Claude Code's built-in system prompt as the base
|
||||
const presetConfig: SystemPromptConfig = {
|
||||
type: 'preset',
|
||||
preset: 'claude_code',
|
||||
},
|
||||
// Load both user (~/.claude/CLAUDE.md) and project (.claude/CLAUDE.md) settings
|
||||
settingSources: ['user', 'project'],
|
||||
};
|
||||
};
|
||||
// If there's a custom system prompt, append it to the preset
|
||||
if (config.systemPrompt) {
|
||||
presetConfig.append = config.systemPrompt;
|
||||
}
|
||||
result.systemPrompt = presetConfig;
|
||||
} else {
|
||||
// Standard mode - just pass through the system prompt as-is
|
||||
if (config.systemPrompt) {
|
||||
result.systemPrompt = config.systemPrompt;
|
||||
}
|
||||
}
|
||||
|
||||
// If there's a custom system prompt, append it to the preset
|
||||
if (config.systemPrompt) {
|
||||
result.systemPrompt.append = config.systemPrompt;
|
||||
// Determine settingSources based on autoLoadClaudeMd
|
||||
if (config.autoLoadClaudeMd) {
|
||||
// Load both user (~/.claude/CLAUDE.md) and project (.claude/CLAUDE.md) settings
|
||||
result.settingSources = ['user', 'project'];
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -323,12 +342,14 @@ function buildClaudeMdOptions(config: CreateSdkOptionsConfig): {
|
||||
|
||||
/**
|
||||
* System prompt configuration for SDK options
|
||||
* When using preset mode with claude_code, CLAUDE.md files are automatically loaded
|
||||
* The 'claude_code' preset provides the system prompt only — it does NOT auto-load
|
||||
* CLAUDE.md files. CLAUDE.md auto-loading is controlled independently by
|
||||
* settingSources (set via autoLoadClaudeMd). These two settings are orthogonal.
|
||||
*/
|
||||
export interface SystemPromptConfig {
|
||||
/** Use preset mode with claude_code to enable CLAUDE.md auto-loading */
|
||||
/** Use preset mode to select the base system prompt */
|
||||
type: 'preset';
|
||||
/** The preset to use - 'claude_code' enables CLAUDE.md loading */
|
||||
/** The preset to use - 'claude_code' uses the Claude Code system prompt */
|
||||
preset: 'claude_code';
|
||||
/** Optional additional prompt to append to the preset */
|
||||
append?: string;
|
||||
@@ -362,6 +383,9 @@ export interface CreateSdkOptionsConfig {
|
||||
/** Enable auto-loading of CLAUDE.md files via SDK's settingSources */
|
||||
autoLoadClaudeMd?: boolean;
|
||||
|
||||
/** Use Claude Code's built-in system prompt (claude_code preset) as the base prompt */
|
||||
useClaudeCodeSystemPrompt?: boolean;
|
||||
|
||||
/** MCP servers to make available to the agent */
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
|
||||
|
||||
@@ -34,10 +34,10 @@ import {
|
||||
const logger = createLogger('SettingsHelper');
|
||||
|
||||
/** Default number of agent turns used when no value is configured. */
|
||||
export const DEFAULT_MAX_TURNS = 1000;
|
||||
export const DEFAULT_MAX_TURNS = 10000;
|
||||
|
||||
/** Upper bound for the max-turns clamp; values above this are capped here. */
|
||||
export const MAX_ALLOWED_TURNS = 2000;
|
||||
export const MAX_ALLOWED_TURNS = 10000;
|
||||
|
||||
/**
|
||||
* Get the autoLoadClaudeMd setting, with project settings taking precedence over global.
|
||||
@@ -80,6 +80,49 @@ export async function getAutoLoadClaudeMdSetting(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the useClaudeCodeSystemPrompt setting, with project settings taking precedence over global.
|
||||
* Falls back to global settings and defaults to true when unset.
|
||||
* Returns true if settings service is not available.
|
||||
*
|
||||
* @param projectPath - Path to the project
|
||||
* @param settingsService - Optional settings service instance
|
||||
* @param logPrefix - Prefix for log messages (e.g., '[AgentService]')
|
||||
* @returns Promise resolving to the useClaudeCodeSystemPrompt setting value
|
||||
*/
|
||||
export async function getUseClaudeCodeSystemPromptSetting(
|
||||
projectPath: string,
|
||||
settingsService?: SettingsService | null,
|
||||
logPrefix = '[SettingsHelper]'
|
||||
): Promise<boolean> {
|
||||
if (!settingsService) {
|
||||
logger.info(
|
||||
`${logPrefix} SettingsService not available, useClaudeCodeSystemPrompt defaulting to true`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check project settings first (takes precedence)
|
||||
const projectSettings = await settingsService.getProjectSettings(projectPath);
|
||||
if (projectSettings.useClaudeCodeSystemPrompt !== undefined) {
|
||||
logger.info(
|
||||
`${logPrefix} useClaudeCodeSystemPrompt from project settings: ${projectSettings.useClaudeCodeSystemPrompt}`
|
||||
);
|
||||
return projectSettings.useClaudeCodeSystemPrompt;
|
||||
}
|
||||
|
||||
// Fall back to global settings
|
||||
const globalSettings = await settingsService.getGlobalSettings();
|
||||
const result = globalSettings.useClaudeCodeSystemPrompt ?? true;
|
||||
logger.info(`${logPrefix} useClaudeCodeSystemPrompt from global settings: ${result}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Failed to load useClaudeCodeSystemPrompt setting:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default max turns setting from global settings.
|
||||
*
|
||||
@@ -646,6 +689,145 @@ export interface ProviderByModelIdResult {
|
||||
resolvedModel: string | undefined;
|
||||
}
|
||||
|
||||
/** Result from resolveProviderContext */
|
||||
export interface ProviderContextResult {
|
||||
/** The provider configuration */
|
||||
provider: ClaudeCompatibleProvider | undefined;
|
||||
/** Credentials for API key resolution */
|
||||
credentials: Credentials | undefined;
|
||||
/** The resolved Claude model ID for SDK configuration */
|
||||
resolvedModel: string | undefined;
|
||||
/** The original model config from the provider if found */
|
||||
modelConfig: import('@automaker/types').ProviderModel | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a provider is enabled.
|
||||
* Providers with enabled: undefined are treated as enabled (default state).
|
||||
* Only explicitly set enabled: false means the provider is disabled.
|
||||
*/
|
||||
function isProviderEnabled(provider: ClaudeCompatibleProvider): boolean {
|
||||
return provider.enabled !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a model config in a provider's models array by ID (case-insensitive).
|
||||
*/
|
||||
function findModelInProvider(
|
||||
provider: ClaudeCompatibleProvider,
|
||||
modelId: string
|
||||
): import('@automaker/types').ProviderModel | undefined {
|
||||
return provider.models?.find(
|
||||
(m) => m.id === modelId || m.id.toLowerCase() === modelId.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the provider and Claude-compatible model configuration.
|
||||
*
|
||||
* This is the central logic for resolving provider context, supporting:
|
||||
* 1. Explicit lookup by providerId (most reliable for persistence)
|
||||
* 2. Fallback lookup by modelId across all enabled providers
|
||||
* 3. Resolution of mapsToClaudeModel for SDK configuration
|
||||
*
|
||||
* @param settingsService - Settings service instance
|
||||
* @param modelId - The model ID to resolve
|
||||
* @param providerId - Optional explicit provider ID
|
||||
* @param logPrefix - Prefix for log messages
|
||||
* @returns Promise resolving to the provider context
|
||||
*/
|
||||
export async function resolveProviderContext(
|
||||
settingsService: SettingsService,
|
||||
modelId: string,
|
||||
providerId?: string,
|
||||
logPrefix = '[SettingsHelper]'
|
||||
): Promise<ProviderContextResult> {
|
||||
try {
|
||||
const globalSettings = await settingsService.getGlobalSettings();
|
||||
const credentials = await settingsService.getCredentials();
|
||||
const providers = globalSettings.claudeCompatibleProviders || [];
|
||||
|
||||
logger.debug(
|
||||
`${logPrefix} Resolving provider context: modelId="${modelId}", providerId="${providerId ?? 'none'}", providers count=${providers.length}`
|
||||
);
|
||||
|
||||
let provider: ClaudeCompatibleProvider | undefined;
|
||||
let modelConfig: import('@automaker/types').ProviderModel | undefined;
|
||||
|
||||
// 1. Try resolving by explicit providerId first (most reliable)
|
||||
if (providerId) {
|
||||
provider = providers.find((p) => p.id === providerId);
|
||||
if (provider) {
|
||||
if (!isProviderEnabled(provider)) {
|
||||
logger.warn(
|
||||
`${logPrefix} Explicitly requested provider "${provider.name}" (${providerId}) is disabled (enabled=${provider.enabled})`
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
`${logPrefix} Found provider "${provider.name}" (${providerId}), enabled=${provider.enabled ?? 'undefined (treated as enabled)'}`
|
||||
);
|
||||
// Find the model config within this provider to check for mappings
|
||||
modelConfig = findModelInProvider(provider, modelId);
|
||||
if (!modelConfig && provider.models && provider.models.length > 0) {
|
||||
logger.debug(
|
||||
`${logPrefix} Model "${modelId}" not found in provider "${provider.name}". Available models: ${provider.models.map((m) => m.id).join(', ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.warn(
|
||||
`${logPrefix} Explicitly requested provider "${providerId}" not found. Available providers: ${providers.map((p) => p.id).join(', ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback to model-based lookup across all providers if modelConfig not found
|
||||
// Note: We still search even if provider was found, to get the modelConfig for mapping
|
||||
if (!modelConfig) {
|
||||
for (const p of providers) {
|
||||
if (!isProviderEnabled(p) || p.id === providerId) continue; // Skip disabled or already checked
|
||||
|
||||
const config = findModelInProvider(p, modelId);
|
||||
|
||||
if (config) {
|
||||
// Only override provider if we didn't find one by explicit ID
|
||||
if (!provider) {
|
||||
provider = p;
|
||||
}
|
||||
modelConfig = config;
|
||||
logger.debug(`${logPrefix} Found model "${modelId}" in provider "${p.name}" (fallback)`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Resolve the mapped Claude model if specified
|
||||
let resolvedModel: string | undefined;
|
||||
if (modelConfig?.mapsToClaudeModel) {
|
||||
const { resolveModelString } = await import('@automaker/model-resolver');
|
||||
resolvedModel = resolveModelString(modelConfig.mapsToClaudeModel);
|
||||
logger.debug(
|
||||
`${logPrefix} Model "${modelId}" maps to Claude model "${modelConfig.mapsToClaudeModel}" -> "${resolvedModel}"`
|
||||
);
|
||||
}
|
||||
|
||||
// Log final result for debugging
|
||||
logger.debug(
|
||||
`${logPrefix} Provider context resolved: provider=${provider?.name ?? 'none'}, modelConfig=${modelConfig ? 'found' : 'not found'}, resolvedModel=${resolvedModel ?? modelId}`
|
||||
);
|
||||
|
||||
return { provider, credentials, resolvedModel, modelConfig };
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Failed to resolve provider context:`, error);
|
||||
return {
|
||||
provider: undefined,
|
||||
credentials: undefined,
|
||||
resolvedModel: undefined,
|
||||
modelConfig: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a ClaudeCompatibleProvider by one of its model IDs.
|
||||
* Searches through all enabled providers to find one that contains the specified model.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Version utility - Reads version from package.json
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
@@ -24,7 +24,20 @@ export function getVersion(): string {
|
||||
}
|
||||
|
||||
try {
|
||||
const packageJsonPath = join(__dirname, '..', '..', 'package.json');
|
||||
const candidatePaths = [
|
||||
// Development via tsx: src/lib -> project root
|
||||
join(__dirname, '..', '..', 'package.json'),
|
||||
// Packaged/build output: lib -> server bundle root
|
||||
join(__dirname, '..', 'package.json'),
|
||||
];
|
||||
|
||||
const packageJsonPath = candidatePaths.find((candidate) => existsSync(candidate));
|
||||
if (!packageJsonPath) {
|
||||
throw new Error(
|
||||
`package.json not found in any expected location: ${candidatePaths.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
||||
const version = packageJson.version || '0.0.0';
|
||||
cachedVersion = version;
|
||||
|
||||
@@ -33,8 +33,23 @@ const logger = createLogger('ClaudeProvider');
|
||||
*/
|
||||
type ProviderConfig = ClaudeApiProfile | ClaudeCompatibleProvider;
|
||||
|
||||
// System vars are always passed from process.env regardless of profile
|
||||
const SYSTEM_ENV_VARS = ['PATH', 'HOME', 'SHELL', 'TERM', 'USER', 'LANG', 'LC_ALL'];
|
||||
// System vars are always passed from process.env regardless of profile.
|
||||
// Includes filesystem, locale, and temp directory vars that the Claude CLI
|
||||
// needs internally for config resolution and temp file creation.
|
||||
const SYSTEM_ENV_VARS = [
|
||||
'PATH',
|
||||
'HOME',
|
||||
'SHELL',
|
||||
'TERM',
|
||||
'USER',
|
||||
'LANG',
|
||||
'LC_ALL',
|
||||
'TMPDIR',
|
||||
'XDG_CONFIG_HOME',
|
||||
'XDG_DATA_HOME',
|
||||
'XDG_CACHE_HOME',
|
||||
'XDG_STATE_HOME',
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if the config is a ClaudeCompatibleProvider (new system)
|
||||
@@ -173,6 +188,7 @@ export class ClaudeProvider extends BaseProvider {
|
||||
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
||||
// Validate that model doesn't have a provider prefix
|
||||
// AgentService should strip prefixes before passing to providers
|
||||
// Claude doesn't use a provider prefix, so we don't need to specify an expected provider
|
||||
validateBareModelId(options.model, 'ClaudeProvider');
|
||||
|
||||
const {
|
||||
@@ -213,6 +229,8 @@ export class ClaudeProvider extends BaseProvider {
|
||||
env: buildEnv(providerConfig, credentials),
|
||||
// Pass through allowedTools if provided by caller (decided by sdk-options.ts)
|
||||
...(allowedTools && { allowedTools }),
|
||||
// Restrict available built-in tools if specified (tools: [] disables all tools)
|
||||
...(options.tools && { tools: options.tools }),
|
||||
// AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation
|
||||
permissionMode: 'bypassPermissions',
|
||||
allowDangerouslySkipPermissions: true,
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
supportsReasoningEffort,
|
||||
validateBareModelId,
|
||||
calculateReasoningTimeout,
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
type CodexApprovalPolicy,
|
||||
type CodexSandboxMode,
|
||||
type CodexAuthStatus,
|
||||
@@ -52,6 +51,7 @@ import { CODEX_MODELS } from './codex-models.js';
|
||||
|
||||
const CODEX_COMMAND = 'codex';
|
||||
const CODEX_EXEC_SUBCOMMAND = 'exec';
|
||||
const CODEX_RESUME_SUBCOMMAND = 'resume';
|
||||
const CODEX_JSON_FLAG = '--json';
|
||||
const CODEX_MODEL_FLAG = '--model';
|
||||
const CODEX_VERSION_FLAG = '--version';
|
||||
@@ -98,7 +98,7 @@ const TEXT_ENCODING = 'utf-8';
|
||||
*
|
||||
* @see calculateReasoningTimeout from @automaker/types
|
||||
*/
|
||||
const CODEX_CLI_TIMEOUT_MS = DEFAULT_TIMEOUT_MS;
|
||||
const CODEX_CLI_TIMEOUT_MS = 120000; // 2 minutes — matches CLI provider base timeout
|
||||
const CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS = 300000; // 5 minutes for feature generation
|
||||
const SYSTEM_PROMPT_SEPARATOR = '\n\n';
|
||||
const CODEX_INSTRUCTIONS_DIR = '.codex';
|
||||
@@ -127,11 +127,16 @@ const DEFAULT_ALLOWED_TOOLS = [
|
||||
'Read',
|
||||
'Write',
|
||||
'Edit',
|
||||
'MultiEdit',
|
||||
'Glob',
|
||||
'Grep',
|
||||
'LS',
|
||||
'Bash',
|
||||
'WebSearch',
|
||||
'WebFetch',
|
||||
'TodoWrite',
|
||||
'Task',
|
||||
'Skill',
|
||||
] as const;
|
||||
const SEARCH_TOOL_NAMES = new Set(['WebSearch', 'WebFetch']);
|
||||
const MIN_MAX_TURNS = 1;
|
||||
@@ -356,9 +361,14 @@ function resolveSystemPrompt(systemPrompt?: unknown): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildPromptText(options: ExecuteOptions): string {
|
||||
return typeof options.prompt === 'string'
|
||||
? options.prompt
|
||||
: extractTextFromContent(options.prompt);
|
||||
}
|
||||
|
||||
function buildCombinedPrompt(options: ExecuteOptions, systemPromptText?: string | null): string {
|
||||
const promptText =
|
||||
typeof options.prompt === 'string' ? options.prompt : extractTextFromContent(options.prompt);
|
||||
const promptText = buildPromptText(options);
|
||||
const historyText = options.conversationHistory
|
||||
? formatHistoryAsText(options.conversationHistory)
|
||||
: '';
|
||||
@@ -371,6 +381,11 @@ function buildCombinedPrompt(options: ExecuteOptions, systemPromptText?: string
|
||||
return `${historyText}${systemSection}${HISTORY_HEADER}${promptText}`;
|
||||
}
|
||||
|
||||
function buildResumePrompt(options: ExecuteOptions): string {
|
||||
const promptText = buildPromptText(options);
|
||||
return `${HISTORY_HEADER}${promptText}`;
|
||||
}
|
||||
|
||||
function formatConfigValue(value: string | number | boolean): string {
|
||||
return String(value);
|
||||
}
|
||||
@@ -724,9 +739,9 @@ export class CodexProvider extends BaseProvider {
|
||||
}
|
||||
|
||||
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
||||
// Validate that model doesn't have a provider prefix
|
||||
// Validate that model doesn't have a provider prefix (except codex- which should already be stripped)
|
||||
// AgentService should strip prefixes before passing to providers
|
||||
validateBareModelId(options.model, 'CodexProvider');
|
||||
validateBareModelId(options.model, 'CodexProvider', 'codex');
|
||||
|
||||
try {
|
||||
const mcpServers = options.mcpServers ?? {};
|
||||
@@ -794,16 +809,22 @@ export class CodexProvider extends BaseProvider {
|
||||
}
|
||||
const searchEnabled =
|
||||
codexSettings.enableWebSearch || resolveSearchEnabled(resolvedAllowedTools, restrictTools);
|
||||
const schemaPath = await writeOutputSchemaFile(options.cwd, options.outputFormat);
|
||||
const imageBlocks = codexSettings.enableImages ? extractImageBlocks(options.prompt) : [];
|
||||
const imagePaths = await writeImageFiles(options.cwd, imageBlocks);
|
||||
const isResumeQuery = Boolean(options.sdkSessionId);
|
||||
const schemaPath = isResumeQuery
|
||||
? null
|
||||
: await writeOutputSchemaFile(options.cwd, options.outputFormat);
|
||||
const imageBlocks =
|
||||
!isResumeQuery && codexSettings.enableImages ? extractImageBlocks(options.prompt) : [];
|
||||
const imagePaths = isResumeQuery ? [] : await writeImageFiles(options.cwd, imageBlocks);
|
||||
const approvalPolicy =
|
||||
hasMcpServers && options.mcpAutoApproveTools !== undefined
|
||||
? options.mcpAutoApproveTools
|
||||
? 'never'
|
||||
: 'on-request'
|
||||
: codexSettings.approvalPolicy;
|
||||
const promptText = buildCombinedPrompt(options, combinedSystemPrompt);
|
||||
const promptText = isResumeQuery
|
||||
? buildResumePrompt(options)
|
||||
: buildCombinedPrompt(options, combinedSystemPrompt);
|
||||
const commandPath = executionPlan.cliPath || CODEX_COMMAND;
|
||||
|
||||
// Build config overrides for max turns and reasoning effort
|
||||
@@ -833,21 +854,30 @@ export class CodexProvider extends BaseProvider {
|
||||
const preExecArgs: string[] = [];
|
||||
|
||||
// Add additional directories with write access
|
||||
if (codexSettings.additionalDirs && codexSettings.additionalDirs.length > 0) {
|
||||
if (
|
||||
!isResumeQuery &&
|
||||
codexSettings.additionalDirs &&
|
||||
codexSettings.additionalDirs.length > 0
|
||||
) {
|
||||
for (const dir of codexSettings.additionalDirs) {
|
||||
preExecArgs.push(CODEX_ADD_DIR_FLAG, dir);
|
||||
}
|
||||
}
|
||||
|
||||
// If images were written to disk, add the image directory so the CLI can access them
|
||||
// If images were written to disk, add the image directory so the CLI can access them.
|
||||
// Note: imagePaths is set to [] when isResumeQuery is true, so this check is sufficient.
|
||||
if (imagePaths.length > 0) {
|
||||
const imageDir = path.join(options.cwd, CODEX_INSTRUCTIONS_DIR, IMAGE_TEMP_DIR);
|
||||
preExecArgs.push(CODEX_ADD_DIR_FLAG, imageDir);
|
||||
}
|
||||
|
||||
// Model is already bare (no prefix) - validated by executeQuery
|
||||
const codexCommand = isResumeQuery
|
||||
? [CODEX_EXEC_SUBCOMMAND, CODEX_RESUME_SUBCOMMAND]
|
||||
: [CODEX_EXEC_SUBCOMMAND];
|
||||
|
||||
const args = [
|
||||
CODEX_EXEC_SUBCOMMAND,
|
||||
...codexCommand,
|
||||
CODEX_YOLO_FLAG,
|
||||
CODEX_SKIP_GIT_REPO_CHECK_FLAG,
|
||||
...preExecArgs,
|
||||
@@ -856,6 +886,7 @@ export class CodexProvider extends BaseProvider {
|
||||
CODEX_JSON_FLAG,
|
||||
...configOverrideArgs,
|
||||
...(schemaPath ? [CODEX_OUTPUT_SCHEMA_FLAG, schemaPath] : []),
|
||||
...(options.sdkSessionId ? [options.sdkSessionId] : []),
|
||||
'-', // Read prompt from stdin to avoid shell escaping issues
|
||||
];
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
type CopilotRuntimeModel,
|
||||
} from '@automaker/types';
|
||||
import { createLogger, isAbortError } from '@automaker/utils';
|
||||
import { resolveModelString } from '@automaker/model-resolver';
|
||||
import { CopilotClient, type PermissionRequest } from '@github/copilot-sdk';
|
||||
import {
|
||||
normalizeTodos,
|
||||
@@ -75,13 +76,18 @@ interface SdkToolExecutionStartEvent extends SdkEvent {
|
||||
};
|
||||
}
|
||||
|
||||
interface SdkToolExecutionEndEvent extends SdkEvent {
|
||||
type: 'tool.execution_end';
|
||||
interface SdkToolExecutionCompleteEvent extends SdkEvent {
|
||||
type: 'tool.execution_complete';
|
||||
data: {
|
||||
toolName: string;
|
||||
toolCallId: string;
|
||||
result?: string;
|
||||
error?: string;
|
||||
success: boolean;
|
||||
result?: {
|
||||
content: string;
|
||||
};
|
||||
error?: {
|
||||
message: string;
|
||||
code?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,6 +99,16 @@ interface SdkSessionErrorEvent extends SdkEvent {
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Constants
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Prefix for error messages in tool results
|
||||
* Consistent with GeminiProvider's error formatting
|
||||
*/
|
||||
const TOOL_ERROR_PREFIX = '[ERROR]' as const;
|
||||
|
||||
// =============================================================================
|
||||
// Error Codes
|
||||
// =============================================================================
|
||||
@@ -116,6 +132,12 @@ export interface CopilotError extends Error {
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
type CopilotSession = Awaited<ReturnType<CopilotClient['createSession']>>;
|
||||
type CopilotSessionOptions = Parameters<CopilotClient['createSession']>[0];
|
||||
type ResumableCopilotClient = CopilotClient & {
|
||||
resumeSession?: (sessionId: string, options: CopilotSessionOptions) => Promise<CopilotSession>;
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Tool Name Normalization
|
||||
// =============================================================================
|
||||
@@ -350,12 +372,19 @@ export class CopilotProvider extends CliProvider {
|
||||
};
|
||||
}
|
||||
|
||||
case 'tool.execution_end': {
|
||||
const toolResultEvent = sdkEvent as SdkToolExecutionEndEvent;
|
||||
const isError = !!toolResultEvent.data.error;
|
||||
const content = isError
|
||||
? `[ERROR] ${toolResultEvent.data.error}`
|
||||
: toolResultEvent.data.result || '';
|
||||
/**
|
||||
* Tool execution completed event
|
||||
* Handles both successful results and errors from tool executions
|
||||
* Error messages optionally include error codes for better debugging
|
||||
*/
|
||||
case 'tool.execution_complete': {
|
||||
const toolResultEvent = sdkEvent as SdkToolExecutionCompleteEvent;
|
||||
const error = toolResultEvent.data.error;
|
||||
|
||||
// Format error message with optional code for better debugging
|
||||
const content = error
|
||||
? `${TOOL_ERROR_PREFIX} ${error.message}${error.code ? ` (${error.code})` : ''}`
|
||||
: toolResultEvent.data.result?.content || '';
|
||||
|
||||
return {
|
||||
type: 'assistant',
|
||||
@@ -382,9 +411,14 @@ export class CopilotProvider extends CliProvider {
|
||||
|
||||
case 'session.error': {
|
||||
const errorEvent = sdkEvent as SdkSessionErrorEvent;
|
||||
const enrichedError =
|
||||
errorEvent.data.message ||
|
||||
(errorEvent.data.code
|
||||
? `Copilot agent error (code: ${errorEvent.data.code})`
|
||||
: 'Copilot agent error');
|
||||
return {
|
||||
type: 'error',
|
||||
error: errorEvent.data.message || 'Unknown error',
|
||||
error: enrichedError,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -516,7 +550,11 @@ export class CopilotProvider extends CliProvider {
|
||||
}
|
||||
|
||||
const promptText = this.extractPromptText(options);
|
||||
const bareModel = options.model || DEFAULT_BARE_MODEL;
|
||||
// resolveModelString may return dash-separated canonical names (e.g. "claude-sonnet-4-6"),
|
||||
// but the Copilot SDK expects dot-separated version suffixes (e.g. "claude-sonnet-4.6").
|
||||
// Normalize by converting the last dash-separated numeric pair to dot notation.
|
||||
const resolvedModel = resolveModelString(options.model || DEFAULT_BARE_MODEL);
|
||||
const bareModel = resolvedModel.replace(/-(\d+)-(\d+)$/, '-$1.$2');
|
||||
const workingDirectory = options.cwd || process.cwd();
|
||||
|
||||
logger.debug(
|
||||
@@ -554,12 +592,14 @@ export class CopilotProvider extends CliProvider {
|
||||
});
|
||||
};
|
||||
|
||||
// Declare session outside try so it's accessible in the catch block for cleanup.
|
||||
let session: CopilotSession | undefined;
|
||||
|
||||
try {
|
||||
await client.start();
|
||||
logger.debug(`CopilotClient started with cwd: ${workingDirectory}`);
|
||||
|
||||
// Create session with streaming enabled for real-time events
|
||||
const session = await client.createSession({
|
||||
const sessionOptions: CopilotSessionOptions = {
|
||||
model: bareModel,
|
||||
streaming: true,
|
||||
// AUTONOMOUS MODE: Auto-approve all permission requests.
|
||||
@@ -572,13 +612,33 @@ export class CopilotProvider extends CliProvider {
|
||||
logger.debug(`Permission request: ${request.kind}`);
|
||||
return { kind: 'approved' };
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const sessionId = session.sessionId;
|
||||
logger.debug(`Session created: ${sessionId}`);
|
||||
// Resume the previous Copilot session when possible; otherwise create a fresh one.
|
||||
const resumableClient = client as ResumableCopilotClient;
|
||||
let sessionResumed = false;
|
||||
if (options.sdkSessionId && typeof resumableClient.resumeSession === 'function') {
|
||||
try {
|
||||
session = await resumableClient.resumeSession(options.sdkSessionId, sessionOptions);
|
||||
sessionResumed = true;
|
||||
logger.debug(`Resumed Copilot session: ${session.sessionId}`);
|
||||
} catch (resumeError) {
|
||||
logger.warn(
|
||||
`Failed to resume Copilot session "${options.sdkSessionId}", creating a new session: ${resumeError}`
|
||||
);
|
||||
session = await client.createSession(sessionOptions);
|
||||
}
|
||||
} else {
|
||||
session = await client.createSession(sessionOptions);
|
||||
}
|
||||
|
||||
// session is always assigned by this point (both branches above assign it)
|
||||
const activeSession = session!;
|
||||
const sessionId = activeSession.sessionId;
|
||||
logger.debug(`Session ${sessionResumed ? 'resumed' : 'created'}: ${sessionId}`);
|
||||
|
||||
// Set up event handler to push events to queue
|
||||
session.on((event: SdkEvent) => {
|
||||
activeSession.on((event: SdkEvent) => {
|
||||
logger.debug(`SDK event: ${event.type}`);
|
||||
|
||||
if (event.type === 'session.idle') {
|
||||
@@ -590,13 +650,13 @@ export class CopilotProvider extends CliProvider {
|
||||
sessionComplete = true;
|
||||
pushEvent(event);
|
||||
} else {
|
||||
// Push all other events (tool.execution_start, tool.execution_end, assistant.message, etc.)
|
||||
// Push all other events (tool.execution_start, tool.execution_complete, assistant.message, etc.)
|
||||
pushEvent(event);
|
||||
}
|
||||
});
|
||||
|
||||
// Send the prompt (non-blocking)
|
||||
await session.send({ prompt: promptText });
|
||||
await activeSession.send({ prompt: promptText });
|
||||
|
||||
// Process events as they arrive
|
||||
while (!sessionComplete || eventQueue.length > 0) {
|
||||
@@ -604,7 +664,7 @@ export class CopilotProvider extends CliProvider {
|
||||
|
||||
// Check for errors first (before processing events to avoid race condition)
|
||||
if (sessionError) {
|
||||
await session.destroy();
|
||||
await activeSession.destroy();
|
||||
await client.stop();
|
||||
throw sessionError;
|
||||
}
|
||||
@@ -624,11 +684,19 @@ export class CopilotProvider extends CliProvider {
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
await session.destroy();
|
||||
await activeSession.destroy();
|
||||
await client.stop();
|
||||
logger.debug('CopilotClient stopped successfully');
|
||||
} catch (error) {
|
||||
// Ensure client is stopped on error
|
||||
// Ensure session is destroyed and client is stopped on error to prevent leaks.
|
||||
// The session may have been created/resumed before the error occurred.
|
||||
if (session) {
|
||||
try {
|
||||
await session.destroy();
|
||||
} catch (sessionCleanupError) {
|
||||
logger.debug(`Failed to destroy session during cleanup: ${sessionCleanupError}`);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await client.stop();
|
||||
} catch (cleanupError) {
|
||||
|
||||
@@ -450,6 +450,11 @@ export class CursorProvider extends CliProvider {
|
||||
cliArgs.push('--model', model);
|
||||
}
|
||||
|
||||
// Resume an existing chat when a provider session ID is available
|
||||
if (options.sdkSessionId) {
|
||||
cliArgs.push('--resume', options.sdkSessionId);
|
||||
}
|
||||
|
||||
// Use '-' to indicate reading prompt from stdin
|
||||
cliArgs.push('-');
|
||||
|
||||
@@ -557,10 +562,14 @@ export class CursorProvider extends CliProvider {
|
||||
const resultEvent = cursorEvent as CursorResultEvent;
|
||||
|
||||
if (resultEvent.is_error) {
|
||||
const errorText = resultEvent.error || resultEvent.result || '';
|
||||
const enrichedError =
|
||||
errorText ||
|
||||
`Cursor agent failed (duration: ${resultEvent.duration_ms}ms, subtype: ${resultEvent.subtype}, session: ${resultEvent.session_id ?? 'none'})`;
|
||||
return {
|
||||
type: 'error',
|
||||
session_id: resultEvent.session_id,
|
||||
error: resultEvent.error || resultEvent.result || 'Unknown error',
|
||||
error: enrichedError,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -834,9 +843,10 @@ export class CursorProvider extends CliProvider {
|
||||
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
||||
this.ensureCliDetected();
|
||||
|
||||
// Validate that model doesn't have a provider prefix
|
||||
// Validate that model doesn't have a provider prefix (except cursor- which should already be stripped)
|
||||
// AgentService should strip prefixes before passing to providers
|
||||
validateBareModelId(options.model, 'CursorProvider');
|
||||
// Note: Cursor's Gemini models (e.g., "gemini-3-pro") legitimately start with "gemini-"
|
||||
validateBareModelId(options.model, 'CursorProvider', 'cursor');
|
||||
|
||||
if (!this.cliPath) {
|
||||
throw this.createError(
|
||||
|
||||
@@ -24,7 +24,7 @@ import type {
|
||||
import { validateBareModelId } from '@automaker/types';
|
||||
import { GEMINI_MODEL_MAP, type GeminiAuthStatus } from '@automaker/types';
|
||||
import { createLogger, isAbortError } from '@automaker/utils';
|
||||
import { spawnJSONLProcess } from '@automaker/platform';
|
||||
import { spawnJSONLProcess, type SubprocessOptions } from '@automaker/platform';
|
||||
import { normalizeTodos } from './tool-normalization.js';
|
||||
|
||||
// Create logger for this module
|
||||
@@ -263,6 +263,14 @@ export class GeminiProvider extends CliProvider {
|
||||
// Use explicit approval-mode for clearer semantics
|
||||
cliArgs.push('--approval-mode', 'yolo');
|
||||
|
||||
// Force headless (non-interactive) mode with --prompt flag.
|
||||
// The actual prompt content is passed via stdin (see buildSubprocessOptions()),
|
||||
// but we MUST include -p to trigger headless mode. Without it, Gemini CLI
|
||||
// starts in interactive mode which adds significant startup overhead
|
||||
// (interactive REPL setup, extra context loading, etc.).
|
||||
// Per Gemini CLI docs: stdin content is "appended to" the -p value.
|
||||
cliArgs.push('--prompt', '');
|
||||
|
||||
// Explicitly include the working directory in allowed workspace directories
|
||||
// This ensures Gemini CLI allows file operations in the project directory,
|
||||
// even if it has a different workspace cached from a previous session
|
||||
@@ -270,13 +278,15 @@ export class GeminiProvider extends CliProvider {
|
||||
cliArgs.push('--include-directories', options.cwd);
|
||||
}
|
||||
|
||||
// Resume an existing Gemini session when one is available
|
||||
if (options.sdkSessionId) {
|
||||
cliArgs.push('--resume', options.sdkSessionId);
|
||||
}
|
||||
|
||||
// Note: Gemini CLI doesn't have a --thinking-level flag.
|
||||
// Thinking capabilities are determined by the model selection (e.g., gemini-2.5-pro).
|
||||
// The model handles thinking internally based on the task complexity.
|
||||
|
||||
// The prompt will be passed as the last positional argument
|
||||
// We'll append it in executeQuery after extracting the text
|
||||
|
||||
return cliArgs;
|
||||
}
|
||||
|
||||
@@ -371,10 +381,13 @@ export class GeminiProvider extends CliProvider {
|
||||
const resultEvent = geminiEvent as GeminiResultEvent;
|
||||
|
||||
if (resultEvent.status === 'error') {
|
||||
const enrichedError =
|
||||
resultEvent.error ||
|
||||
`Gemini agent failed (duration: ${resultEvent.stats?.duration_ms ?? 'unknown'}ms, session: ${resultEvent.session_id ?? 'none'})`;
|
||||
return {
|
||||
type: 'error',
|
||||
session_id: resultEvent.session_id,
|
||||
error: resultEvent.error || 'Unknown error',
|
||||
error: enrichedError,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -391,10 +404,12 @@ export class GeminiProvider extends CliProvider {
|
||||
|
||||
case 'error': {
|
||||
const errorEvent = geminiEvent as GeminiResultEvent;
|
||||
const enrichedError =
|
||||
errorEvent.error || `Gemini agent failed (session: ${errorEvent.session_id ?? 'none'})`;
|
||||
return {
|
||||
type: 'error',
|
||||
session_id: errorEvent.session_id,
|
||||
error: errorEvent.error || 'Unknown error',
|
||||
error: enrichedError,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -408,6 +423,32 @@ export class GeminiProvider extends CliProvider {
|
||||
// CliProvider Overrides
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Build subprocess options with stdin data for prompt and speed-optimized env vars.
|
||||
*
|
||||
* Passes the prompt via stdin instead of --prompt CLI arg to:
|
||||
* - Avoid shell argument size limits with large prompts (system prompt + context)
|
||||
* - Avoid shell escaping issues with special characters in prompts
|
||||
* - Match the pattern used by Cursor, OpenCode, and Codex providers
|
||||
*
|
||||
* Also injects environment variables to reduce Gemini CLI startup overhead:
|
||||
* - GEMINI_TELEMETRY_ENABLED=false: Disables OpenTelemetry collection
|
||||
*/
|
||||
protected buildSubprocessOptions(options: ExecuteOptions, cliArgs: string[]): SubprocessOptions {
|
||||
const subprocessOptions = super.buildSubprocessOptions(options, cliArgs);
|
||||
|
||||
// Pass prompt via stdin to avoid shell interpretation of special characters
|
||||
// and shell argument size limits with large system prompts + context files
|
||||
subprocessOptions.stdinData = this.extractPromptText(options);
|
||||
|
||||
// Disable telemetry to reduce startup overhead
|
||||
if (subprocessOptions.env) {
|
||||
subprocessOptions.env['GEMINI_TELEMETRY_ENABLED'] = 'false';
|
||||
}
|
||||
|
||||
return subprocessOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override error mapping for Gemini-specific error codes
|
||||
*/
|
||||
@@ -505,8 +546,8 @@ export class GeminiProvider extends CliProvider {
|
||||
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
||||
this.ensureCliDetected();
|
||||
|
||||
// Validate that model doesn't have a provider prefix
|
||||
validateBareModelId(options.model, 'GeminiProvider');
|
||||
// Validate that model doesn't have a provider prefix (except gemini- which should already be stripped)
|
||||
validateBareModelId(options.model, 'GeminiProvider', 'gemini');
|
||||
|
||||
if (!this.cliPath) {
|
||||
throw this.createError(
|
||||
@@ -517,14 +558,21 @@ export class GeminiProvider extends CliProvider {
|
||||
);
|
||||
}
|
||||
|
||||
// Extract prompt text to pass as positional argument
|
||||
const promptText = this.extractPromptText(options);
|
||||
// Ensure .geminiignore exists in the working directory to prevent Gemini CLI
|
||||
// from scanning .git and node_modules directories during startup. This reduces
|
||||
// startup time significantly (reported: 35s → 11s) by skipping large directories
|
||||
// that Gemini CLI would otherwise traverse for context discovery.
|
||||
await this.ensureGeminiIgnore(options.cwd || process.cwd());
|
||||
|
||||
// Build CLI args and append the prompt as the last positional argument
|
||||
const cliArgs = this.buildCliArgs(options);
|
||||
cliArgs.push(promptText); // Gemini CLI uses positional args for the prompt
|
||||
// Embed system prompt into the user prompt so Gemini CLI receives
|
||||
// project context (CLAUDE.md, CODE_QUALITY.md, etc.) that would
|
||||
// otherwise be silently dropped since Gemini CLI has no --system-prompt flag.
|
||||
const effectiveOptions = this.embedSystemPromptIntoPrompt(options);
|
||||
|
||||
const subprocessOptions = this.buildSubprocessOptions(options, cliArgs);
|
||||
// Build CLI args for headless execution.
|
||||
const cliArgs = this.buildCliArgs(effectiveOptions);
|
||||
|
||||
const subprocessOptions = this.buildSubprocessOptions(effectiveOptions, cliArgs);
|
||||
|
||||
let sessionId: string | undefined;
|
||||
|
||||
@@ -577,6 +625,49 @@ export class GeminiProvider extends CliProvider {
|
||||
// Gemini-Specific Methods
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Ensure a .geminiignore file exists in the working directory.
|
||||
*
|
||||
* Gemini CLI scans the working directory for context discovery during startup.
|
||||
* Excluding .git and node_modules dramatically reduces startup time by preventing
|
||||
* traversal of large directories (reported improvement: 35s → 11s).
|
||||
*
|
||||
* Only creates the file if it doesn't already exist to avoid overwriting user config.
|
||||
*/
|
||||
private async ensureGeminiIgnore(cwd: string): Promise<void> {
|
||||
const ignorePath = path.join(cwd, '.geminiignore');
|
||||
const content = [
|
||||
'# Auto-generated by Automaker to speed up Gemini CLI startup',
|
||||
'# Prevents Gemini CLI from scanning large directories during context discovery',
|
||||
'.git',
|
||||
'node_modules',
|
||||
'dist',
|
||||
'build',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'coverage',
|
||||
'.automaker',
|
||||
'.worktrees',
|
||||
'.vscode',
|
||||
'.idea',
|
||||
'*.lock',
|
||||
'',
|
||||
].join('\n');
|
||||
try {
|
||||
// Use 'wx' flag for atomic creation - fails if file exists (EEXIST)
|
||||
await fs.writeFile(ignorePath, content, { encoding: 'utf-8', flag: 'wx' });
|
||||
logger.debug(`Created .geminiignore at ${ignorePath}`);
|
||||
} catch (writeError) {
|
||||
// EEXIST means file already exists - that's fine, preserve user's file
|
||||
if ((writeError as NodeJS.ErrnoException).code === 'EEXIST') {
|
||||
logger.debug(`.geminiignore already exists at ${ignorePath}, preserving existing file`);
|
||||
return;
|
||||
}
|
||||
// Non-fatal: startup will just be slower without the ignore file
|
||||
logger.debug(`Failed to create .geminiignore: ${writeError}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a GeminiError with details
|
||||
*/
|
||||
|
||||
53
apps/server/src/providers/mock-provider.ts
Normal file
53
apps/server/src/providers/mock-provider.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Mock Provider - No-op AI provider for E2E and CI testing
|
||||
*
|
||||
* When AUTOMAKER_MOCK_AGENT=true, the server uses this provider instead of
|
||||
* real backends (Claude, Codex, etc.) so tests never call external APIs.
|
||||
*/
|
||||
|
||||
import type { ExecuteOptions } from '@automaker/types';
|
||||
import { BaseProvider } from './base-provider.js';
|
||||
import type { ProviderMessage, InstallationStatus, ModelDefinition } from './types.js';
|
||||
|
||||
const MOCK_TEXT = 'Mock agent output for testing.';
|
||||
|
||||
export class MockProvider extends BaseProvider {
|
||||
getName(): string {
|
||||
return 'mock';
|
||||
}
|
||||
|
||||
async *executeQuery(_options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: MOCK_TEXT }],
|
||||
},
|
||||
};
|
||||
yield {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
};
|
||||
}
|
||||
|
||||
async detectInstallation(): Promise<InstallationStatus> {
|
||||
return {
|
||||
installed: true,
|
||||
method: 'sdk',
|
||||
hasApiKey: true,
|
||||
authenticated: true,
|
||||
};
|
||||
}
|
||||
|
||||
getAvailableModels(): ModelDefinition[] {
|
||||
return [
|
||||
{
|
||||
id: 'mock-model',
|
||||
name: 'Mock Model',
|
||||
modelString: 'mock-model',
|
||||
provider: 'mock',
|
||||
description: 'Mock model for testing',
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1189,8 +1189,26 @@ export class OpencodeProvider extends CliProvider {
|
||||
* Format a display name for a model
|
||||
*/
|
||||
private formatModelDisplayName(model: OpenCodeModelInfo): string {
|
||||
// Extract the last path segment for nested model IDs
|
||||
// e.g., "arcee-ai/trinity-large-preview:free" → "trinity-large-preview:free"
|
||||
let rawName = model.name;
|
||||
if (rawName.includes('/')) {
|
||||
rawName = rawName.split('/').pop()!;
|
||||
}
|
||||
|
||||
// Strip tier/pricing suffixes like ":free", ":extended"
|
||||
const colonIdx = rawName.indexOf(':');
|
||||
let suffix = '';
|
||||
if (colonIdx !== -1) {
|
||||
const tierPart = rawName.slice(colonIdx + 1);
|
||||
if (/^(free|extended|beta|preview)$/i.test(tierPart)) {
|
||||
suffix = ` (${tierPart.charAt(0).toUpperCase() + tierPart.slice(1)})`;
|
||||
}
|
||||
rawName = rawName.slice(0, colonIdx);
|
||||
}
|
||||
|
||||
// Capitalize and format the model name
|
||||
const formattedName = model.name
|
||||
const formattedName = rawName
|
||||
.split('-')
|
||||
.map((part) => {
|
||||
// Handle version numbers like "4-5" -> "4.5"
|
||||
@@ -1218,7 +1236,7 @@ export class OpencodeProvider extends CliProvider {
|
||||
};
|
||||
|
||||
const providerDisplay = providerNames[model.provider] || model.provider;
|
||||
return `${formattedName} (${providerDisplay})`;
|
||||
return `${formattedName}${suffix} (${providerDisplay})`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -67,6 +67,16 @@ export function registerProvider(name: string, registration: ProviderRegistratio
|
||||
providerRegistry.set(name.toLowerCase(), registration);
|
||||
}
|
||||
|
||||
/** Cached mock provider instance when AUTOMAKER_MOCK_AGENT is set (E2E/CI). */
|
||||
let mockProviderInstance: BaseProvider | null = null;
|
||||
|
||||
function getMockProvider(): BaseProvider {
|
||||
if (!mockProviderInstance) {
|
||||
mockProviderInstance = new MockProvider();
|
||||
}
|
||||
return mockProviderInstance;
|
||||
}
|
||||
|
||||
export class ProviderFactory {
|
||||
/**
|
||||
* Determine which provider to use for a given model
|
||||
@@ -75,6 +85,9 @@ export class ProviderFactory {
|
||||
* @returns Provider name (ModelProvider type)
|
||||
*/
|
||||
static getProviderNameForModel(model: string): ModelProvider {
|
||||
if (process.env.AUTOMAKER_MOCK_AGENT === 'true') {
|
||||
return 'claude' as ModelProvider; // Name only; getProviderForModel returns MockProvider
|
||||
}
|
||||
const lowerModel = model.toLowerCase();
|
||||
|
||||
// Get all registered providers sorted by priority (descending)
|
||||
@@ -113,6 +126,9 @@ export class ProviderFactory {
|
||||
modelId: string,
|
||||
options: { throwOnDisconnected?: boolean } = {}
|
||||
): BaseProvider {
|
||||
if (process.env.AUTOMAKER_MOCK_AGENT === 'true') {
|
||||
return getMockProvider();
|
||||
}
|
||||
const { throwOnDisconnected = true } = options;
|
||||
const providerName = this.getProviderForModelName(modelId);
|
||||
|
||||
@@ -142,6 +158,9 @@ export class ProviderFactory {
|
||||
* Get the provider name for a given model ID (without creating provider instance)
|
||||
*/
|
||||
static getProviderForModelName(modelId: string): string {
|
||||
if (process.env.AUTOMAKER_MOCK_AGENT === 'true') {
|
||||
return 'claude';
|
||||
}
|
||||
const lowerModel = modelId.toLowerCase();
|
||||
|
||||
// Get all registered providers sorted by priority (descending)
|
||||
@@ -272,6 +291,7 @@ export class ProviderFactory {
|
||||
// =============================================================================
|
||||
|
||||
// Import providers for registration side-effects
|
||||
import { MockProvider } from './mock-provider.js';
|
||||
import { ClaudeProvider } from './claude-provider.js';
|
||||
import { CursorProvider } from './cursor-provider.js';
|
||||
import { CodexProvider } from './codex-provider.js';
|
||||
|
||||
@@ -323,7 +323,7 @@ Your entire response should be valid JSON starting with { and ending with }. No
|
||||
}
|
||||
}
|
||||
|
||||
await parseAndCreateFeatures(projectPath, contentForParsing, events);
|
||||
await parseAndCreateFeatures(projectPath, contentForParsing, events, settingsService);
|
||||
|
||||
logger.debug('========== generateFeaturesFromSpec() completed ==========');
|
||||
}
|
||||
|
||||
@@ -9,13 +9,16 @@ import { createLogger, atomicWriteJson, DEFAULT_BACKUP_COUNT } from '@automaker/
|
||||
import { getFeaturesDir } from '@automaker/platform';
|
||||
import { extractJsonWithArray } from '../../lib/json-extractor.js';
|
||||
import { getNotificationService } from '../../services/notification-service.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
|
||||
const logger = createLogger('SpecRegeneration');
|
||||
|
||||
export async function parseAndCreateFeatures(
|
||||
projectPath: string,
|
||||
content: string,
|
||||
events: EventEmitter
|
||||
events: EventEmitter,
|
||||
settingsService?: SettingsService
|
||||
): Promise<void> {
|
||||
logger.info('========== parseAndCreateFeatures() started ==========');
|
||||
logger.info(`Content length: ${content.length} chars`);
|
||||
@@ -23,6 +26,37 @@ export async function parseAndCreateFeatures(
|
||||
logger.info(content);
|
||||
logger.info('========== END CONTENT ==========');
|
||||
|
||||
// Load default model and planning settings from settingsService
|
||||
let defaultModel: string | undefined;
|
||||
let defaultPlanningMode: string = 'skip';
|
||||
let defaultRequirePlanApproval = false;
|
||||
|
||||
if (settingsService) {
|
||||
try {
|
||||
const globalSettings = await settingsService.getGlobalSettings();
|
||||
const projectSettings = await settingsService.getProjectSettings(projectPath);
|
||||
|
||||
const defaultModelEntry =
|
||||
projectSettings.defaultFeatureModel ?? globalSettings.defaultFeatureModel;
|
||||
if (defaultModelEntry) {
|
||||
const resolved = resolvePhaseModel(defaultModelEntry);
|
||||
defaultModel = resolved.model;
|
||||
}
|
||||
|
||||
defaultPlanningMode = globalSettings.defaultPlanningMode ?? 'skip';
|
||||
defaultRequirePlanApproval = globalSettings.defaultRequirePlanApproval ?? false;
|
||||
|
||||
logger.info(
|
||||
`[parseAndCreateFeatures] Using defaults: model=${defaultModel ?? 'none'}, planningMode=${defaultPlanningMode}, requirePlanApproval=${defaultRequirePlanApproval}`
|
||||
);
|
||||
} catch (settingsError) {
|
||||
logger.warn(
|
||||
'[parseAndCreateFeatures] Failed to load settings, using defaults:',
|
||||
settingsError
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Extract JSON from response using shared utility
|
||||
logger.info('Extracting JSON from response using extractJsonWithArray...');
|
||||
@@ -61,7 +95,7 @@ export async function parseAndCreateFeatures(
|
||||
const featureDir = path.join(featuresDir, feature.id);
|
||||
await secureFs.mkdir(featureDir, { recursive: true });
|
||||
|
||||
const featureData = {
|
||||
const featureData: Record<string, unknown> = {
|
||||
id: feature.id,
|
||||
category: feature.category || 'Uncategorized',
|
||||
title: feature.title,
|
||||
@@ -70,10 +104,20 @@ export async function parseAndCreateFeatures(
|
||||
priority: feature.priority || 2,
|
||||
complexity: feature.complexity || 'moderate',
|
||||
dependencies: feature.dependencies || [],
|
||||
planningMode: defaultPlanningMode,
|
||||
requirePlanApproval:
|
||||
defaultPlanningMode === 'skip' || defaultPlanningMode === 'lite'
|
||||
? false
|
||||
: defaultRequirePlanApproval,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Apply default model if available from settings
|
||||
if (defaultModel) {
|
||||
featureData.model = defaultModel;
|
||||
}
|
||||
|
||||
// Use atomic write with backup support for crash protection
|
||||
await atomicWriteJson(path.join(featureDir, 'feature.json'), featureData, {
|
||||
backupCount: DEFAULT_BACKUP_COUNT,
|
||||
|
||||
@@ -19,10 +19,11 @@ export function createAnalyzeProjectHandler(autoModeService: AutoModeServiceComp
|
||||
return;
|
||||
}
|
||||
|
||||
// Start analysis in background
|
||||
autoModeService.analyzeProject(projectPath).catch((error) => {
|
||||
logger.error(`[AutoMode] Project analysis error:`, error);
|
||||
});
|
||||
// Kick off analysis in the background; attach a rejection handler so
|
||||
// unhandled-promise warnings don't surface and errors are at least logged.
|
||||
// Synchronous throws (e.g. "not implemented") still propagate here.
|
||||
const analysisPromise = autoModeService.analyzeProject(projectPath);
|
||||
analysisPromise.catch((err) => logError(err, 'Background analyzeProject failed'));
|
||||
|
||||
res.json({ success: true, message: 'Project analysis started' });
|
||||
} catch (error) {
|
||||
|
||||
@@ -114,9 +114,20 @@ export function mapBacklogPlanError(rawMessage: string): string {
|
||||
return 'Claude CLI could not be launched. Make sure the Claude CLI is installed and available in PATH, or check that Node.js is correctly installed. Try running "which claude" or "claude --version" in your terminal to verify.';
|
||||
}
|
||||
|
||||
// Claude Code process crash
|
||||
// Claude Code process crash - extract exit code for diagnostics
|
||||
if (rawMessage.includes('Claude Code process exited')) {
|
||||
return 'Claude exited unexpectedly. Try again. If it keeps happening, re-run `claude login` or update your API key in Setup.';
|
||||
const exitCodeMatch = rawMessage.match(/exited with code (\d+)/);
|
||||
const exitCode = exitCodeMatch ? exitCodeMatch[1] : 'unknown';
|
||||
logger.error(`[BacklogPlan] Claude process exit code: ${exitCode}`);
|
||||
return `Claude exited unexpectedly (exit code: ${exitCode}). This is usually a transient issue. Try again. If it keeps happening, re-run \`claude login\` or update your API key in Setup.`;
|
||||
}
|
||||
|
||||
// Claude Code process killed by signal
|
||||
if (rawMessage.includes('Claude Code process terminated by signal')) {
|
||||
const signalMatch = rawMessage.match(/terminated by signal (\w+)/);
|
||||
const signal = signalMatch ? signalMatch[1] : 'unknown';
|
||||
logger.error(`[BacklogPlan] Claude process terminated by signal: ${signal}`);
|
||||
return `Claude was terminated by signal ${signal}. This may indicate a resource issue. Try again.`;
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
*
|
||||
* Model is configurable via phaseModels.backlogPlanningModel in settings
|
||||
* (defaults to Sonnet). Can be overridden per-call via model parameter.
|
||||
*
|
||||
* Includes automatic retry for transient CLI failures (e.g., "Claude Code
|
||||
* process exited unexpectedly") to improve reliability.
|
||||
*/
|
||||
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
@@ -12,8 +15,10 @@ import {
|
||||
isCursorModel,
|
||||
stripProviderPrefix,
|
||||
type ThinkingLevel,
|
||||
type SystemPromptPreset,
|
||||
} from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { getCurrentBranch } from '@automaker/git-utils';
|
||||
import { FeatureLoader } from '../../services/feature-loader.js';
|
||||
import { ProviderFactory } from '../../providers/provider-factory.js';
|
||||
import { extractJsonWithArray } from '../../lib/json-extractor.js';
|
||||
@@ -27,10 +32,28 @@ import {
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getUseClaudeCodeSystemPromptSetting,
|
||||
getPromptCustomization,
|
||||
getPhaseModelWithOverrides,
|
||||
getProviderByModelId,
|
||||
} from '../../lib/settings-helpers.js';
|
||||
|
||||
/** Maximum number of retry attempts for transient CLI failures */
|
||||
const MAX_RETRIES = 2;
|
||||
/** Delay between retries in milliseconds */
|
||||
const RETRY_DELAY_MS = 2000;
|
||||
|
||||
/**
|
||||
* Check if an error is retryable (transient CLI process failure)
|
||||
*/
|
||||
function isRetryableError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return (
|
||||
message.includes('Claude Code process exited') ||
|
||||
message.includes('Claude Code process terminated by signal')
|
||||
);
|
||||
}
|
||||
|
||||
const featureLoader = new FeatureLoader();
|
||||
|
||||
/**
|
||||
@@ -84,6 +107,53 @@ function parsePlanResponse(response: string): BacklogPlanResult {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to parse a valid plan response without fallback behavior.
|
||||
* Returns null if parsing fails.
|
||||
*/
|
||||
function tryParsePlanResponse(response: string): BacklogPlanResult | null {
|
||||
if (!response || response.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
return extractJsonWithArray<BacklogPlanResult>(response, 'changes', { logger });
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the most reliable response text between streamed assistant chunks
|
||||
* and provider final result payload.
|
||||
*/
|
||||
function selectBestResponseText(accumulatedText: string, providerResultText: string): string {
|
||||
const hasAccumulated = accumulatedText.trim().length > 0;
|
||||
const hasProviderResult = providerResultText.trim().length > 0;
|
||||
|
||||
if (!hasProviderResult) {
|
||||
return accumulatedText;
|
||||
}
|
||||
if (!hasAccumulated) {
|
||||
return providerResultText;
|
||||
}
|
||||
|
||||
const accumulatedParsed = tryParsePlanResponse(accumulatedText);
|
||||
const providerParsed = tryParsePlanResponse(providerResultText);
|
||||
|
||||
if (providerParsed && !accumulatedParsed) {
|
||||
logger.info('[BacklogPlan] Using provider result (parseable JSON)');
|
||||
return providerResultText;
|
||||
}
|
||||
if (accumulatedParsed && !providerParsed) {
|
||||
logger.info('[BacklogPlan] Keeping accumulated text (parseable JSON)');
|
||||
return accumulatedText;
|
||||
}
|
||||
|
||||
if (providerResultText.length > accumulatedText.length) {
|
||||
logger.info('[BacklogPlan] Using provider result (longer content)');
|
||||
return providerResultText;
|
||||
}
|
||||
|
||||
logger.info('[BacklogPlan] Keeping accumulated text (longer content)');
|
||||
return accumulatedText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a backlog modification plan based on user prompt
|
||||
*/
|
||||
@@ -93,11 +163,40 @@ export async function generateBacklogPlan(
|
||||
events: EventEmitter,
|
||||
abortController: AbortController,
|
||||
settingsService?: SettingsService,
|
||||
model?: string
|
||||
model?: string,
|
||||
branchName?: string
|
||||
): Promise<BacklogPlanResult> {
|
||||
try {
|
||||
// Load current features
|
||||
const features = await featureLoader.getAll(projectPath);
|
||||
const allFeatures = await featureLoader.getAll(projectPath);
|
||||
|
||||
// Filter features by branch if specified (worktree-scoped backlog)
|
||||
let features: Feature[];
|
||||
if (branchName) {
|
||||
// Determine the primary branch so unassigned features show for the main worktree
|
||||
let primaryBranch: string | null = null;
|
||||
try {
|
||||
primaryBranch = await getCurrentBranch(projectPath);
|
||||
} catch {
|
||||
// If git fails, fall back to 'main' so unassigned features are visible
|
||||
// when branchName matches a common default branch name
|
||||
primaryBranch = 'main';
|
||||
}
|
||||
const isMainBranch = branchName === primaryBranch;
|
||||
|
||||
features = allFeatures.filter((f) => {
|
||||
if (!f.branchName) {
|
||||
// Unassigned features belong to the main/primary worktree
|
||||
return isMainBranch;
|
||||
}
|
||||
return f.branchName === branchName;
|
||||
});
|
||||
logger.info(
|
||||
`[BacklogPlan] Filtered to ${features.length}/${allFeatures.length} features for branch: ${branchName}`
|
||||
);
|
||||
} else {
|
||||
features = allFeatures;
|
||||
}
|
||||
|
||||
events.emit('backlog-plan:event', {
|
||||
type: 'backlog_plan_progress',
|
||||
@@ -133,6 +232,35 @@ export async function generateBacklogPlan(
|
||||
effectiveModel = resolved.model;
|
||||
thinkingLevel = resolved.thinkingLevel;
|
||||
credentials = await settingsService?.getCredentials();
|
||||
// Resolve Claude-compatible provider when client sends a model (e.g. MiniMax, GLM)
|
||||
if (settingsService) {
|
||||
const providerResult = await getProviderByModelId(
|
||||
effectiveModel,
|
||||
settingsService,
|
||||
'[BacklogPlan]'
|
||||
);
|
||||
if (providerResult.provider) {
|
||||
claudeCompatibleProvider = providerResult.provider;
|
||||
if (providerResult.credentials) {
|
||||
credentials = providerResult.credentials;
|
||||
}
|
||||
}
|
||||
// Fallback: use phase settings provider if model lookup found nothing (e.g. model
|
||||
// string format differs from provider's model id, but backlog planning phase has providerId).
|
||||
if (!claudeCompatibleProvider) {
|
||||
const phaseResult = await getPhaseModelWithOverrides(
|
||||
'backlogPlanningModel',
|
||||
settingsService,
|
||||
projectPath,
|
||||
'[BacklogPlan]'
|
||||
);
|
||||
const phaseResolved = resolvePhaseModel(phaseResult.phaseModel);
|
||||
if (phaseResult.provider && phaseResolved.model === effectiveModel) {
|
||||
claudeCompatibleProvider = phaseResult.provider;
|
||||
credentials = phaseResult.credentials ?? credentials;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (settingsService) {
|
||||
// Use settings-based model with provider info
|
||||
const phaseResult = await getPhaseModelWithOverrides(
|
||||
@@ -162,17 +290,23 @@ export async function generateBacklogPlan(
|
||||
// Strip provider prefix - providers expect bare model IDs
|
||||
const bareModel = stripProviderPrefix(effectiveModel);
|
||||
|
||||
// Get autoLoadClaudeMd setting
|
||||
// Get autoLoadClaudeMd and useClaudeCodeSystemPrompt settings
|
||||
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
|
||||
projectPath,
|
||||
settingsService,
|
||||
'[BacklogPlan]'
|
||||
);
|
||||
const useClaudeCodeSystemPrompt = await getUseClaudeCodeSystemPromptSetting(
|
||||
projectPath,
|
||||
settingsService,
|
||||
'[BacklogPlan]'
|
||||
);
|
||||
|
||||
// For Cursor models, we need to combine prompts with explicit instructions
|
||||
// because Cursor doesn't support systemPrompt separation like Claude SDK
|
||||
let finalPrompt = userPrompt;
|
||||
let finalSystemPrompt: string | undefined = systemPrompt;
|
||||
let finalSystemPrompt: string | SystemPromptPreset | undefined = systemPrompt;
|
||||
let finalSettingSources: Array<'user' | 'project' | 'local'> | undefined;
|
||||
|
||||
if (isCursorModel(effectiveModel)) {
|
||||
logger.info('[BacklogPlan] Using Cursor model - adding explicit no-file-write instructions');
|
||||
@@ -187,54 +321,145 @@ CRITICAL INSTRUCTIONS:
|
||||
|
||||
${userPrompt}`;
|
||||
finalSystemPrompt = undefined; // System prompt is now embedded in the user prompt
|
||||
} else if (claudeCompatibleProvider) {
|
||||
// Claude-compatible providers (MiniMax, GLM, etc.) use a plain API; do not use
|
||||
// the claude_code preset (which is for Claude CLI/subprocess and can break the request).
|
||||
finalSystemPrompt = systemPrompt;
|
||||
} else if (useClaudeCodeSystemPrompt) {
|
||||
// Use claude_code preset for native Claude so the SDK subprocess
|
||||
// authenticates via CLI OAuth or API key the same way all other SDK calls do.
|
||||
finalSystemPrompt = {
|
||||
type: 'preset',
|
||||
preset: 'claude_code',
|
||||
append: systemPrompt,
|
||||
};
|
||||
}
|
||||
// Include settingSources when autoLoadClaudeMd is enabled
|
||||
if (autoLoadClaudeMd) {
|
||||
finalSettingSources = ['user', 'project'];
|
||||
}
|
||||
|
||||
// Execute the query
|
||||
const stream = provider.executeQuery({
|
||||
// Execute the query with retry logic for transient CLI failures
|
||||
const queryOptions = {
|
||||
prompt: finalPrompt,
|
||||
model: bareModel,
|
||||
cwd: projectPath,
|
||||
systemPrompt: finalSystemPrompt,
|
||||
maxTurns: 1,
|
||||
allowedTools: [], // No tools needed for this
|
||||
tools: [] as string[], // Disable all built-in tools - plan generation only needs text output
|
||||
abortController,
|
||||
settingSources: autoLoadClaudeMd ? ['user', 'project'] : undefined,
|
||||
readOnly: true, // Plan generation only generates text, doesn't write files
|
||||
settingSources: finalSettingSources,
|
||||
thinkingLevel, // Pass thinking level for extended thinking
|
||||
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
});
|
||||
};
|
||||
|
||||
let responseText = '';
|
||||
let bestResponseText = ''; // Preserve best response across all retry attempts
|
||||
let recoveredResult: BacklogPlanResult | null = null;
|
||||
let lastError: unknown = null;
|
||||
|
||||
for await (const msg of stream) {
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
if (abortController.signal.aborted) {
|
||||
throw new Error('Generation aborted');
|
||||
}
|
||||
|
||||
if (msg.type === 'assistant') {
|
||||
if (msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text') {
|
||||
responseText += block.text;
|
||||
if (attempt > 0) {
|
||||
logger.info(
|
||||
`[BacklogPlan] Retry attempt ${attempt}/${MAX_RETRIES} after transient failure`
|
||||
);
|
||||
events.emit('backlog-plan:event', {
|
||||
type: 'backlog_plan_progress',
|
||||
content: `Retrying... (attempt ${attempt + 1}/${MAX_RETRIES + 1})`,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
|
||||
}
|
||||
|
||||
let accumulatedText = '';
|
||||
let providerResultText = '';
|
||||
|
||||
try {
|
||||
const stream = provider.executeQuery(queryOptions);
|
||||
|
||||
for await (const msg of stream) {
|
||||
if (abortController.signal.aborted) {
|
||||
throw new Error('Generation aborted');
|
||||
}
|
||||
|
||||
if (msg.type === 'assistant') {
|
||||
if (msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text') {
|
||||
accumulatedText += block.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
|
||||
providerResultText = msg.result;
|
||||
logger.info(
|
||||
'[BacklogPlan] Received result from provider, length:',
|
||||
providerResultText.length
|
||||
);
|
||||
logger.info('[BacklogPlan] Accumulated response length:', accumulatedText.length);
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
|
||||
// Use result if it's a final accumulated message (from Cursor provider)
|
||||
logger.info('[BacklogPlan] Received result from Cursor, length:', msg.result.length);
|
||||
logger.info('[BacklogPlan] Previous responseText length:', responseText.length);
|
||||
if (msg.result.length > responseText.length) {
|
||||
logger.info('[BacklogPlan] Using Cursor result (longer than accumulated text)');
|
||||
responseText = msg.result;
|
||||
} else {
|
||||
logger.info('[BacklogPlan] Keeping accumulated text (longer than Cursor result)');
|
||||
|
||||
responseText = selectBestResponseText(accumulatedText, providerResultText);
|
||||
|
||||
// If we got here, the stream completed successfully
|
||||
lastError = null;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
responseText = selectBestResponseText(accumulatedText, providerResultText);
|
||||
|
||||
// Preserve the best response text across all attempts so that if a retry
|
||||
// crashes immediately (empty response), we can still recover from an earlier attempt
|
||||
bestResponseText = selectBestResponseText(bestResponseText, responseText);
|
||||
|
||||
// Claude SDK can occasionally exit non-zero after emitting a complete response.
|
||||
// If we already have valid JSON, recover instead of failing the entire planning flow.
|
||||
if (isRetryableError(error)) {
|
||||
const parsed = tryParsePlanResponse(bestResponseText);
|
||||
if (parsed) {
|
||||
logger.warn(
|
||||
'[BacklogPlan] Recovered from transient CLI exit using accumulated valid response'
|
||||
);
|
||||
recoveredResult = parsed;
|
||||
lastError = null;
|
||||
break;
|
||||
}
|
||||
|
||||
// On final retryable failure, degrade gracefully if we have text from any attempt.
|
||||
if (attempt >= MAX_RETRIES && bestResponseText.trim().length > 0) {
|
||||
logger.warn(
|
||||
'[BacklogPlan] Final retryable CLI failure with non-empty response, attempting fallback parse'
|
||||
);
|
||||
recoveredResult = parsePlanResponse(bestResponseText);
|
||||
lastError = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Only retry on transient CLI failures, not on user aborts or other errors
|
||||
if (!isRetryableError(error) || attempt >= MAX_RETRIES) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`[BacklogPlan] Transient CLI failure (attempt ${attempt + 1}/${MAX_RETRIES + 1}): ${errorMessage}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If we exhausted retries, throw the last error
|
||||
if (lastError) {
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
// Parse the response
|
||||
const result = parsePlanResponse(responseText);
|
||||
const result = recoveredResult ?? parsePlanResponse(responseText);
|
||||
|
||||
await saveBacklogPlan(projectPath, {
|
||||
savedAt: new Date().toISOString(),
|
||||
|
||||
@@ -25,7 +25,7 @@ export function createBacklogPlanRoutes(
|
||||
);
|
||||
router.post('/stop', createStopHandler());
|
||||
router.get('/status', validatePathParams('projectPath'), createStatusHandler());
|
||||
router.post('/apply', validatePathParams('projectPath'), createApplyHandler());
|
||||
router.post('/apply', validatePathParams('projectPath'), createApplyHandler(settingsService));
|
||||
router.post('/clear', validatePathParams('projectPath'), createClearHandler());
|
||||
|
||||
return router;
|
||||
|
||||
@@ -3,13 +3,23 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { BacklogPlanResult } from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import type { BacklogPlanResult, PhaseModelEntry, PlanningMode } from '@automaker/types';
|
||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import { clearBacklogPlan, getErrorMessage, logError, logger } from '../common.js';
|
||||
|
||||
const featureLoader = new FeatureLoader();
|
||||
|
||||
export function createApplyHandler() {
|
||||
function normalizePhaseModelEntry(
|
||||
entry: PhaseModelEntry | string | undefined | null
|
||||
): PhaseModelEntry | undefined {
|
||||
if (!entry) return undefined;
|
||||
if (typeof entry === 'string') return { model: entry };
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function createApplyHandler(settingsService?: SettingsService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const {
|
||||
@@ -38,6 +48,23 @@ export function createApplyHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
let defaultPlanningMode: PlanningMode = 'skip';
|
||||
let defaultRequirePlanApproval = false;
|
||||
let defaultModelEntry: PhaseModelEntry | undefined;
|
||||
|
||||
if (settingsService) {
|
||||
const globalSettings = await settingsService.getGlobalSettings();
|
||||
const projectSettings = await settingsService.getProjectSettings(projectPath);
|
||||
|
||||
defaultPlanningMode = globalSettings.defaultPlanningMode ?? 'skip';
|
||||
defaultRequirePlanApproval = globalSettings.defaultRequirePlanApproval ?? false;
|
||||
defaultModelEntry = normalizePhaseModelEntry(
|
||||
projectSettings.defaultFeatureModel ?? globalSettings.defaultFeatureModel
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedDefaultModel = resolvePhaseModel(defaultModelEntry);
|
||||
|
||||
const appliedChanges: string[] = [];
|
||||
|
||||
// Load current features for dependency validation
|
||||
@@ -88,6 +115,12 @@ export function createApplyHandler() {
|
||||
if (!change.feature) continue;
|
||||
|
||||
try {
|
||||
const effectivePlanningMode = change.feature.planningMode ?? defaultPlanningMode;
|
||||
const effectiveRequirePlanApproval =
|
||||
effectivePlanningMode === 'skip' || effectivePlanningMode === 'lite'
|
||||
? false
|
||||
: (change.feature.requirePlanApproval ?? defaultRequirePlanApproval);
|
||||
|
||||
// Create the new feature - use the AI-generated ID if provided
|
||||
const newFeature = await featureLoader.create(projectPath, {
|
||||
id: change.feature.id, // Use descriptive ID from AI if provided
|
||||
@@ -97,6 +130,12 @@ export function createApplyHandler() {
|
||||
dependencies: change.feature.dependencies,
|
||||
priority: change.feature.priority,
|
||||
status: 'backlog',
|
||||
model: change.feature.model ?? resolvedDefaultModel.model,
|
||||
thinkingLevel: change.feature.thinkingLevel ?? resolvedDefaultModel.thinkingLevel,
|
||||
reasoningEffort: change.feature.reasoningEffort ?? resolvedDefaultModel.reasoningEffort,
|
||||
providerId: change.feature.providerId ?? resolvedDefaultModel.providerId,
|
||||
planningMode: effectivePlanningMode,
|
||||
requirePlanApproval: effectiveRequirePlanApproval,
|
||||
branchName,
|
||||
});
|
||||
|
||||
|
||||
@@ -17,10 +17,11 @@ import type { SettingsService } from '../../../services/settings-service.js';
|
||||
export function createGenerateHandler(events: EventEmitter, settingsService?: SettingsService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, prompt, model } = req.body as {
|
||||
const { projectPath, prompt, model, branchName } = req.body as {
|
||||
projectPath: string;
|
||||
prompt: string;
|
||||
model?: string;
|
||||
branchName?: string;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
@@ -42,28 +43,30 @@ export function createGenerateHandler(events: EventEmitter, settingsService?: Se
|
||||
return;
|
||||
}
|
||||
|
||||
setRunningState(true);
|
||||
const abortController = new AbortController();
|
||||
setRunningState(true, abortController);
|
||||
setRunningDetails({
|
||||
projectPath,
|
||||
prompt,
|
||||
model,
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
const abortController = new AbortController();
|
||||
setRunningState(true, abortController);
|
||||
|
||||
// Start generation in background
|
||||
// Note: generateBacklogPlan handles its own error event emission,
|
||||
// so we only log here to avoid duplicate error toasts
|
||||
generateBacklogPlan(projectPath, prompt, events, abortController, settingsService, model)
|
||||
.catch((error) => {
|
||||
// Just log - error event already emitted by generateBacklogPlan
|
||||
logError(error, 'Generate backlog plan failed (background)');
|
||||
})
|
||||
.finally(() => {
|
||||
setRunningState(false, null);
|
||||
setRunningDetails(null);
|
||||
});
|
||||
// Note: generateBacklogPlan handles its own error event emission
|
||||
// and state cleanup in its finally block, so we only log here
|
||||
generateBacklogPlan(
|
||||
projectPath,
|
||||
prompt,
|
||||
events,
|
||||
abortController,
|
||||
settingsService,
|
||||
model,
|
||||
branchName
|
||||
).catch((error) => {
|
||||
// Just log - error event already emitted by generateBacklogPlan
|
||||
logError(error, 'Generate backlog plan failed (background)');
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
|
||||
@@ -142,11 +142,33 @@ function mapDescribeImageError(rawMessage: string | undefined): {
|
||||
|
||||
if (!rawMessage) return baseResponse;
|
||||
|
||||
if (rawMessage.includes('Claude Code process exited')) {
|
||||
if (
|
||||
rawMessage.includes('Claude Code process exited') ||
|
||||
rawMessage.includes('Claude Code process terminated by signal')
|
||||
) {
|
||||
const exitCodeMatch = rawMessage.match(/exited with code (\d+)/);
|
||||
const signalMatch = rawMessage.match(/terminated by signal (\w+)/);
|
||||
const detail = exitCodeMatch
|
||||
? ` (exit code: ${exitCodeMatch[1]})`
|
||||
: signalMatch
|
||||
? ` (signal: ${signalMatch[1]})`
|
||||
: '';
|
||||
|
||||
// Crash/OS-kill signals suggest a process crash, not an auth failure —
|
||||
// omit auth recovery advice and suggest retry/reporting instead.
|
||||
const crashSignals = ['SIGSEGV', 'SIGABRT', 'SIGKILL', 'SIGBUS', 'SIGTRAP'];
|
||||
const isCrashSignal = signalMatch ? crashSignals.includes(signalMatch[1]) : false;
|
||||
|
||||
if (isCrashSignal) {
|
||||
return {
|
||||
statusCode: 503,
|
||||
userMessage: `Claude crashed unexpectedly${detail} while describing the image. This may be a transient condition. Please try again. If the problem persists, collect logs and report the issue.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode: 503,
|
||||
userMessage:
|
||||
'Claude exited unexpectedly while describing the image. Try again. If it keeps happening, re-run `claude login` or update your API key in Setup so Claude can restart cleanly.',
|
||||
userMessage: `Claude exited unexpectedly${detail} while describing the image. This is usually a transient issue. Try again. If it keeps happening, re-run \`claude login\` or update your API key in Setup.`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,11 @@ import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent
|
||||
import { createGenerateTitleHandler } from './routes/generate-title.js';
|
||||
import { createExportHandler } from './routes/export.js';
|
||||
import { createImportHandler, createConflictCheckHandler } from './routes/import.js';
|
||||
import {
|
||||
createOrphanedListHandler,
|
||||
createOrphanedResolveHandler,
|
||||
createOrphanedBulkResolveHandler,
|
||||
} from './routes/orphaned.js';
|
||||
|
||||
export function createFeaturesRoutes(
|
||||
featureLoader: FeatureLoader,
|
||||
@@ -44,7 +49,11 @@ export function createFeaturesRoutes(
|
||||
validatePathParams('projectPath'),
|
||||
createCreateHandler(featureLoader, events)
|
||||
);
|
||||
router.post('/update', validatePathParams('projectPath'), createUpdateHandler(featureLoader));
|
||||
router.post(
|
||||
'/update',
|
||||
validatePathParams('projectPath'),
|
||||
createUpdateHandler(featureLoader, events)
|
||||
);
|
||||
router.post(
|
||||
'/bulk-update',
|
||||
validatePathParams('projectPath'),
|
||||
@@ -66,6 +75,21 @@ export function createFeaturesRoutes(
|
||||
validatePathParams('projectPath'),
|
||||
createConflictCheckHandler(featureLoader)
|
||||
);
|
||||
router.post(
|
||||
'/orphaned',
|
||||
validatePathParams('projectPath'),
|
||||
createOrphanedListHandler(featureLoader, autoModeService)
|
||||
);
|
||||
router.post(
|
||||
'/orphaned/resolve',
|
||||
validatePathParams('projectPath'),
|
||||
createOrphanedResolveHandler(featureLoader, autoModeService)
|
||||
);
|
||||
router.post(
|
||||
'/orphaned/bulk-resolve',
|
||||
validatePathParams('projectPath'),
|
||||
createOrphanedBulkResolveHandler(featureLoader)
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export function createListHandler(
|
||||
// Note: detectOrphanedFeatures handles errors internally and always resolves
|
||||
if (autoModeService) {
|
||||
autoModeService
|
||||
.detectOrphanedFeatures(projectPath)
|
||||
.detectOrphanedFeatures(projectPath, features)
|
||||
.then((orphanedFeatures) => {
|
||||
if (orphanedFeatures.length > 0) {
|
||||
logger.info(
|
||||
|
||||
287
apps/server/src/routes/features/routes/orphaned.ts
Normal file
287
apps/server/src/routes/features/routes/orphaned.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* POST /orphaned endpoint - Detect orphaned features (features with missing branches)
|
||||
* POST /orphaned/resolve endpoint - Resolve an orphaned feature (delete, create-worktree, or move-to-branch)
|
||||
* POST /orphaned/bulk-resolve endpoint - Resolve multiple orphaned features at once
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import path from 'path';
|
||||
import type { Request, Response } from 'express';
|
||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { execGitCommand } from '../../../lib/git.js';
|
||||
import { deleteWorktreeMetadata } from '../../../lib/worktree-metadata.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('OrphanedFeatures');
|
||||
|
||||
type ResolveAction = 'delete' | 'create-worktree' | 'move-to-branch';
|
||||
const VALID_ACTIONS: ResolveAction[] = ['delete', 'create-worktree', 'move-to-branch'];
|
||||
|
||||
export function createOrphanedListHandler(
|
||||
featureLoader: FeatureLoader,
|
||||
autoModeService?: AutoModeServiceCompat
|
||||
) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body as { projectPath: string };
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!autoModeService) {
|
||||
res.status(500).json({ success: false, error: 'Auto-mode service not available' });
|
||||
return;
|
||||
}
|
||||
|
||||
const orphanedFeatures = await autoModeService.detectOrphanedFeatures(projectPath);
|
||||
|
||||
res.json({ success: true, orphanedFeatures });
|
||||
} catch (error) {
|
||||
logError(error, 'Detect orphaned features failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createOrphanedResolveHandler(
|
||||
featureLoader: FeatureLoader,
|
||||
_autoModeService?: AutoModeServiceCompat
|
||||
) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, featureId, action, targetBranch } = req.body as {
|
||||
projectPath: string;
|
||||
featureId: string;
|
||||
action: ResolveAction;
|
||||
targetBranch?: string | null;
|
||||
};
|
||||
|
||||
if (!projectPath || !featureId || !action) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'projectPath, featureId, and action are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!VALID_ACTIONS.includes(action)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `action must be one of: ${VALID_ACTIONS.join(', ')}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await resolveOrphanedFeature(
|
||||
featureLoader,
|
||||
projectPath,
|
||||
featureId,
|
||||
action,
|
||||
targetBranch
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
res.status(result.error === 'Feature not found' ? 404 : 500).json(result);
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logError(error, 'Resolve orphaned feature failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
interface BulkResolveResult {
|
||||
featureId: string;
|
||||
success: boolean;
|
||||
action?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function resolveOrphanedFeature(
|
||||
featureLoader: FeatureLoader,
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
action: ResolveAction,
|
||||
targetBranch?: string | null
|
||||
): Promise<BulkResolveResult> {
|
||||
try {
|
||||
const feature = await featureLoader.get(projectPath, featureId);
|
||||
if (!feature) {
|
||||
return { featureId, success: false, error: 'Feature not found' };
|
||||
}
|
||||
|
||||
const missingBranch = feature.branchName;
|
||||
|
||||
switch (action) {
|
||||
case 'delete': {
|
||||
if (missingBranch) {
|
||||
try {
|
||||
await deleteWorktreeMetadata(projectPath, missingBranch);
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
const success = await featureLoader.delete(projectPath, featureId);
|
||||
if (!success) {
|
||||
return { featureId, success: false, error: 'Deletion failed' };
|
||||
}
|
||||
logger.info(`Deleted orphaned feature ${featureId} (branch: ${missingBranch})`);
|
||||
return { featureId, success: true, action: 'deleted' };
|
||||
}
|
||||
|
||||
case 'create-worktree': {
|
||||
if (!missingBranch) {
|
||||
return { featureId, success: false, error: 'Feature has no branch name to recreate' };
|
||||
}
|
||||
|
||||
const sanitizedName = missingBranch.replace(/[^a-zA-Z0-9_-]/g, '-');
|
||||
const hash = crypto.createHash('sha1').update(missingBranch).digest('hex').slice(0, 8);
|
||||
const worktreesDir = path.join(projectPath, '.worktrees');
|
||||
const worktreePath = path.join(worktreesDir, `${sanitizedName}-${hash}`);
|
||||
|
||||
try {
|
||||
await execGitCommand(['worktree', 'add', '-b', missingBranch, worktreePath], projectPath);
|
||||
} catch (error) {
|
||||
const msg = getErrorMessage(error);
|
||||
if (msg.includes('already exists')) {
|
||||
try {
|
||||
await execGitCommand(['worktree', 'add', worktreePath, missingBranch], projectPath);
|
||||
} catch (innerError) {
|
||||
return {
|
||||
featureId,
|
||||
success: false,
|
||||
error: `Failed to create worktree: ${getErrorMessage(innerError)}`,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
return { featureId, success: false, error: `Failed to create worktree: ${msg}` };
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Created worktree for orphaned feature ${featureId} at ${worktreePath} (branch: ${missingBranch})`
|
||||
);
|
||||
return { featureId, success: true, action: 'worktree-created' };
|
||||
}
|
||||
|
||||
case 'move-to-branch': {
|
||||
// Move the feature to a different branch (or clear branch to use main worktree)
|
||||
const newBranch = targetBranch || null;
|
||||
|
||||
// Validate that the target branch exists if one is specified
|
||||
if (newBranch) {
|
||||
try {
|
||||
await execGitCommand(['rev-parse', '--verify', newBranch], projectPath);
|
||||
} catch {
|
||||
return {
|
||||
featureId,
|
||||
success: false,
|
||||
error: `Target branch "${newBranch}" does not exist`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await featureLoader.update(projectPath, featureId, {
|
||||
branchName: newBranch,
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
// Clean up old worktree metadata
|
||||
if (missingBranch) {
|
||||
try {
|
||||
await deleteWorktreeMetadata(projectPath, missingBranch);
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
const destination = newBranch ?? 'main worktree';
|
||||
logger.info(
|
||||
`Moved orphaned feature ${featureId} to ${destination} (was: ${missingBranch})`
|
||||
);
|
||||
return { featureId, success: true, action: 'moved' };
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return { featureId, success: false, error: getErrorMessage(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export function createOrphanedBulkResolveHandler(featureLoader: FeatureLoader) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, featureIds, action, targetBranch } = req.body as {
|
||||
projectPath: string;
|
||||
featureIds: string[];
|
||||
action: ResolveAction;
|
||||
targetBranch?: string | null;
|
||||
};
|
||||
|
||||
if (
|
||||
!projectPath ||
|
||||
!featureIds ||
|
||||
!Array.isArray(featureIds) ||
|
||||
featureIds.length === 0 ||
|
||||
!action
|
||||
) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'projectPath, featureIds (non-empty array), and action are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!VALID_ACTIONS.includes(action)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `action must be one of: ${VALID_ACTIONS.join(', ')}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Process sequentially for worktree creation (git operations shouldn't race),
|
||||
// in parallel for delete/move-to-branch
|
||||
const results: BulkResolveResult[] = [];
|
||||
|
||||
if (action === 'create-worktree') {
|
||||
for (const featureId of featureIds) {
|
||||
const result = await resolveOrphanedFeature(
|
||||
featureLoader,
|
||||
projectPath,
|
||||
featureId,
|
||||
action,
|
||||
targetBranch
|
||||
);
|
||||
results.push(result);
|
||||
}
|
||||
} else {
|
||||
const batchResults = await Promise.all(
|
||||
featureIds.map((featureId) =>
|
||||
resolveOrphanedFeature(featureLoader, projectPath, featureId, action, targetBranch)
|
||||
)
|
||||
);
|
||||
results.push(...batchResults);
|
||||
}
|
||||
|
||||
const successCount = results.filter((r) => r.success).length;
|
||||
const failedCount = results.length - successCount;
|
||||
|
||||
res.json({
|
||||
success: failedCount === 0,
|
||||
resolvedCount: successCount,
|
||||
failedCount,
|
||||
results,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Bulk resolve orphaned features failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import type { Feature, FeatureStatus } from '@automaker/types';
|
||||
import type { EventEmitter } from '../../../lib/events.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
@@ -13,7 +14,7 @@ const logger = createLogger('features/update');
|
||||
// Statuses that should trigger syncing to app_spec.txt
|
||||
const SYNC_TRIGGER_STATUSES: FeatureStatus[] = ['verified', 'completed'];
|
||||
|
||||
export function createUpdateHandler(featureLoader: FeatureLoader) {
|
||||
export function createUpdateHandler(featureLoader: FeatureLoader, events?: EventEmitter) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const {
|
||||
@@ -42,7 +43,11 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
|
||||
|
||||
// Get the current feature to detect status changes
|
||||
const currentFeature = await featureLoader.get(projectPath, featureId);
|
||||
const previousStatus = currentFeature?.status as FeatureStatus | undefined;
|
||||
if (!currentFeature) {
|
||||
res.status(404).json({ success: false, error: `Feature ${featureId} not found` });
|
||||
return;
|
||||
}
|
||||
const previousStatus = currentFeature.status as FeatureStatus;
|
||||
const newStatus = updates.status as FeatureStatus | undefined;
|
||||
|
||||
const updated = await featureLoader.update(
|
||||
@@ -54,8 +59,18 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
|
||||
preEnhancementDescription
|
||||
);
|
||||
|
||||
// Trigger sync to app_spec.txt when status changes to verified or completed
|
||||
// Emit completion event and sync to app_spec.txt when status transitions to verified/completed
|
||||
if (newStatus && SYNC_TRIGGER_STATUSES.includes(newStatus) && previousStatus !== newStatus) {
|
||||
events?.emit('feature:completed', {
|
||||
featureId,
|
||||
featureName: updated.title,
|
||||
projectPath,
|
||||
passes: true,
|
||||
message:
|
||||
newStatus === 'verified' ? 'Feature verified manually' : 'Feature completed manually',
|
||||
executionMode: 'manual',
|
||||
});
|
||||
|
||||
try {
|
||||
const synced = await featureLoader.syncFeatureToAppSpec(projectPath, updated);
|
||||
if (synced) {
|
||||
|
||||
@@ -3,16 +3,29 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import path from 'path';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
// Optional files that are expected to not exist in new projects
|
||||
// Don't log ENOENT errors for these to reduce noise
|
||||
const OPTIONAL_FILES = ['categories.json', 'app_spec.txt'];
|
||||
const OPTIONAL_FILES = ['categories.json', 'app_spec.txt', 'context-metadata.json'];
|
||||
|
||||
function isOptionalFile(filePath: string): boolean {
|
||||
return OPTIONAL_FILES.some((optionalFile) => filePath.endsWith(optionalFile));
|
||||
const basename = path.basename(filePath);
|
||||
if (OPTIONAL_FILES.some((optionalFile) => basename === optionalFile)) {
|
||||
return true;
|
||||
}
|
||||
// Context and memory files may not exist yet during create/delete or test races
|
||||
if (filePath.includes('.automaker/context/') || filePath.includes('.automaker/memory/')) {
|
||||
const name = path.basename(filePath);
|
||||
const lower = name.toLowerCase();
|
||||
if (lower.endsWith('.md') || lower.endsWith('.txt') || lower.endsWith('.markdown')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isENOENT(error: unknown): boolean {
|
||||
@@ -39,12 +52,14 @@ export function createReadHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't log ENOENT errors for optional files (expected to be missing in new projects)
|
||||
const shouldLog = !(isENOENT(error) && isOptionalFile(req.body?.filePath || ''));
|
||||
if (shouldLog) {
|
||||
const filePath = req.body?.filePath || '';
|
||||
const optionalMissing = isENOENT(error) && isOptionalFile(filePath);
|
||||
if (!optionalMissing) {
|
||||
logError(error, 'Read file failed');
|
||||
}
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
// Return 404 for missing optional files so clients can handle "not found"
|
||||
const status = optionalMissing ? 404 : 500;
|
||||
res.status(status).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,6 +35,16 @@ export function createStatHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
// File or directory does not exist - return 404 so UI can handle missing paths
|
||||
const code =
|
||||
error && typeof error === 'object' && 'code' in error
|
||||
? (error as { code: string }).code
|
||||
: '';
|
||||
if (code === 'ENOENT') {
|
||||
res.status(404).json({ success: false, error: 'File or directory not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
logError(error, 'Get file stats failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
import {
|
||||
getPromptCustomization,
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getProviderByModelId,
|
||||
resolveProviderContext,
|
||||
} from '../../../lib/settings-helpers.js';
|
||||
import {
|
||||
trySetValidationRunning,
|
||||
@@ -64,6 +64,8 @@ interface ValidateIssueRequestBody {
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
/** Reasoning effort for Codex models (ignored for non-Codex models) */
|
||||
reasoningEffort?: ReasoningEffort;
|
||||
/** Optional Claude-compatible provider ID for custom providers (e.g., GLM, MiniMax) */
|
||||
providerId?: string;
|
||||
/** Comments to include in validation analysis */
|
||||
comments?: GitHubComment[];
|
||||
/** Linked pull requests for this issue */
|
||||
@@ -87,6 +89,7 @@ async function runValidation(
|
||||
events: EventEmitter,
|
||||
abortController: AbortController,
|
||||
settingsService?: SettingsService,
|
||||
providerId?: string,
|
||||
comments?: ValidationComment[],
|
||||
linkedPRs?: ValidationLinkedPR[],
|
||||
thinkingLevel?: ThinkingLevel,
|
||||
@@ -176,7 +179,12 @@ ${basePrompt}`;
|
||||
let credentials = await settingsService?.getCredentials();
|
||||
|
||||
if (settingsService) {
|
||||
const providerResult = await getProviderByModelId(model, settingsService, '[ValidateIssue]');
|
||||
const providerResult = await resolveProviderContext(
|
||||
settingsService,
|
||||
model,
|
||||
providerId,
|
||||
'[ValidateIssue]'
|
||||
);
|
||||
if (providerResult.provider) {
|
||||
claudeCompatibleProvider = providerResult.provider;
|
||||
providerResolvedModel = providerResult.resolvedModel;
|
||||
@@ -312,10 +320,16 @@ export function createValidateIssueHandler(
|
||||
model = 'opus',
|
||||
thinkingLevel,
|
||||
reasoningEffort,
|
||||
providerId,
|
||||
comments: rawComments,
|
||||
linkedPRs: rawLinkedPRs,
|
||||
} = req.body as ValidateIssueRequestBody;
|
||||
|
||||
const normalizedProviderId =
|
||||
typeof providerId === 'string' && providerId.trim().length > 0
|
||||
? providerId.trim()
|
||||
: undefined;
|
||||
|
||||
// Transform GitHubComment[] to ValidationComment[] if provided
|
||||
const validationComments: ValidationComment[] | undefined = rawComments?.map((c) => ({
|
||||
author: c.author?.login || 'ghost',
|
||||
@@ -364,12 +378,14 @@ export function createValidateIssueHandler(
|
||||
isClaudeModel(model) ||
|
||||
isCursorModel(model) ||
|
||||
isCodexModel(model) ||
|
||||
isOpencodeModel(model);
|
||||
isOpencodeModel(model) ||
|
||||
!!normalizedProviderId;
|
||||
|
||||
if (!isValidModel) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid model. Must be a Claude, Cursor, Codex, or OpenCode model ID (or alias).',
|
||||
error:
|
||||
'Invalid model. Must be a Claude, Cursor, Codex, or OpenCode model ID (or alias), or provide a valid providerId for custom Claude-compatible models.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -398,6 +414,7 @@ export function createValidateIssueHandler(
|
||||
events,
|
||||
abortController,
|
||||
settingsService,
|
||||
normalizedProviderId,
|
||||
validationComments,
|
||||
validationLinkedPRs,
|
||||
thinkingLevel,
|
||||
|
||||
@@ -80,6 +80,12 @@ function containsAuthError(text: string): boolean {
|
||||
export function createVerifyClaudeAuthHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// In E2E/CI mock mode, skip real API calls
|
||||
if (process.env.AUTOMAKER_MOCK_AGENT === 'true') {
|
||||
res.json({ success: true, authenticated: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the auth method and optional API key from the request body
|
||||
const { authMethod, apiKey } = req.body as {
|
||||
authMethod?: 'cli' | 'api_key';
|
||||
|
||||
@@ -82,6 +82,12 @@ function isRateLimitError(text: string): boolean {
|
||||
|
||||
export function createVerifyCodexAuthHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
// In E2E/CI mock mode, skip real API calls
|
||||
if (process.env.AUTOMAKER_MOCK_AGENT === 'true') {
|
||||
res.json({ success: true, authenticated: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const { authMethod, apiKey } = req.body as {
|
||||
authMethod?: 'cli' | 'api_key';
|
||||
apiKey?: string;
|
||||
|
||||
@@ -67,11 +67,16 @@ import { createAbortOperationHandler } from './routes/abort-operation.js';
|
||||
import { createContinueOperationHandler } from './routes/continue-operation.js';
|
||||
import { createStageFilesHandler } from './routes/stage-files.js';
|
||||
import { createCheckChangesHandler } from './routes/check-changes.js';
|
||||
import { createSetTrackingHandler } from './routes/set-tracking.js';
|
||||
import { createSyncHandler } from './routes/sync.js';
|
||||
import { createUpdatePRNumberHandler } from './routes/update-pr-number.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import type { FeatureLoader } from '../../services/feature-loader.js';
|
||||
|
||||
export function createWorktreeRoutes(
|
||||
events: EventEmitter,
|
||||
settingsService?: SettingsService
|
||||
settingsService?: SettingsService,
|
||||
featureLoader?: FeatureLoader
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
@@ -91,9 +96,19 @@ export function createWorktreeRoutes(
|
||||
validatePathParams('projectPath'),
|
||||
createCreateHandler(events, settingsService)
|
||||
);
|
||||
router.post('/delete', validatePathParams('projectPath', 'worktreePath'), createDeleteHandler());
|
||||
router.post(
|
||||
'/delete',
|
||||
validatePathParams('projectPath', 'worktreePath'),
|
||||
createDeleteHandler(events, featureLoader)
|
||||
);
|
||||
router.post('/create-pr', createCreatePRHandler());
|
||||
router.post('/pr-info', createPRInfoHandler());
|
||||
router.post(
|
||||
'/update-pr-number',
|
||||
validatePathParams('worktreePath', 'projectPath?'),
|
||||
requireValidWorktree,
|
||||
createUpdatePRNumberHandler()
|
||||
);
|
||||
router.post(
|
||||
'/commit',
|
||||
validatePathParams('worktreePath'),
|
||||
@@ -118,6 +133,18 @@ export function createWorktreeRoutes(
|
||||
requireValidWorktree,
|
||||
createPullHandler()
|
||||
);
|
||||
router.post(
|
||||
'/sync',
|
||||
validatePathParams('worktreePath'),
|
||||
requireValidWorktree,
|
||||
createSyncHandler()
|
||||
);
|
||||
router.post(
|
||||
'/set-tracking',
|
||||
validatePathParams('worktreePath'),
|
||||
requireValidWorktree,
|
||||
createSetTrackingHandler()
|
||||
);
|
||||
router.post(
|
||||
'/checkout-branch',
|
||||
validatePathParams('worktreePath'),
|
||||
|
||||
@@ -5,15 +5,18 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import fs from 'fs/promises';
|
||||
import { isGitRepo } from '@automaker/git-utils';
|
||||
import { getErrorMessage, logError, isValidBranchName } from '../common.js';
|
||||
import { execGitCommand } from '../../../lib/git.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import type { EventEmitter } from '../../../lib/events.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const logger = createLogger('Worktree');
|
||||
|
||||
export function createDeleteHandler() {
|
||||
export function createDeleteHandler(events: EventEmitter, featureLoader?: FeatureLoader) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, worktreePath, deleteBranch } = req.body as {
|
||||
@@ -46,20 +49,79 @@ export function createDeleteHandler() {
|
||||
});
|
||||
branchName = stdout.trim();
|
||||
} catch {
|
||||
// Could not get branch name
|
||||
// Could not get branch name - worktree directory may already be gone
|
||||
logger.debug('Could not determine branch for worktree, directory may be missing');
|
||||
}
|
||||
|
||||
// Remove the worktree (using array arguments to prevent injection)
|
||||
let removeSucceeded = false;
|
||||
try {
|
||||
await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath);
|
||||
} catch {
|
||||
// Try with prune if remove fails
|
||||
await execGitCommand(['worktree', 'prune'], projectPath);
|
||||
removeSucceeded = true;
|
||||
} catch (removeError) {
|
||||
// `git worktree remove` can fail if the directory is already missing
|
||||
// or in a bad state. Try pruning stale worktree entries as a fallback.
|
||||
logger.debug('git worktree remove failed, trying prune', {
|
||||
error: getErrorMessage(removeError),
|
||||
});
|
||||
try {
|
||||
await execGitCommand(['worktree', 'prune'], projectPath);
|
||||
|
||||
// Verify the specific worktree is no longer registered after prune.
|
||||
// `git worktree prune` exits 0 even if worktreePath was never registered,
|
||||
// so we must explicitly check the worktree list to avoid false positives.
|
||||
const { stdout: listOut } = await execAsync('git worktree list --porcelain', {
|
||||
cwd: projectPath,
|
||||
});
|
||||
// Parse porcelain output and check for an exact path match.
|
||||
// Using substring .includes() can produce false positives when one
|
||||
// worktree path is a prefix of another (e.g. /foo vs /foobar).
|
||||
const stillRegistered = listOut
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('worktree '))
|
||||
.map((line) => line.slice('worktree '.length).trim())
|
||||
.some((registeredPath) => registeredPath === worktreePath);
|
||||
if (stillRegistered) {
|
||||
// Prune didn't clean up our entry - treat as failure
|
||||
throw removeError;
|
||||
}
|
||||
removeSucceeded = true;
|
||||
} catch (pruneError) {
|
||||
// If pruneError is the original removeError re-thrown, propagate it
|
||||
if (pruneError === removeError) {
|
||||
throw removeError;
|
||||
}
|
||||
logger.warn('git worktree prune also failed', {
|
||||
error: getErrorMessage(pruneError),
|
||||
});
|
||||
// If both remove and prune fail, still try to return success
|
||||
// if the worktree directory no longer exists (it may have been
|
||||
// manually deleted already).
|
||||
let dirExists = false;
|
||||
try {
|
||||
await fs.access(worktreePath);
|
||||
dirExists = true;
|
||||
} catch {
|
||||
// Directory doesn't exist
|
||||
}
|
||||
if (dirExists) {
|
||||
// Directory still exists - this is a real failure
|
||||
throw removeError;
|
||||
}
|
||||
// Directory is gone, treat as success
|
||||
removeSucceeded = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Optionally delete the branch
|
||||
// Optionally delete the branch (only if worktree was successfully removed)
|
||||
let branchDeleted = false;
|
||||
if (deleteBranch && branchName && branchName !== 'main' && branchName !== 'master') {
|
||||
if (
|
||||
removeSucceeded &&
|
||||
deleteBranch &&
|
||||
branchName &&
|
||||
branchName !== 'main' &&
|
||||
branchName !== 'master'
|
||||
) {
|
||||
// Validate branch name to prevent command injection
|
||||
if (!isValidBranchName(branchName)) {
|
||||
logger.warn(`Invalid branch name detected, skipping deletion: ${branchName}`);
|
||||
@@ -74,12 +136,65 @@ export function createDeleteHandler() {
|
||||
}
|
||||
}
|
||||
|
||||
// Emit worktree:deleted event after successful deletion
|
||||
events.emit('worktree:deleted', {
|
||||
worktreePath,
|
||||
projectPath,
|
||||
branchName,
|
||||
branchDeleted,
|
||||
});
|
||||
|
||||
// Move features associated with the deleted branch to the main worktree
|
||||
// This prevents features from being orphaned when a worktree is deleted
|
||||
let featuresMovedToMain = 0;
|
||||
if (featureLoader && branchName) {
|
||||
try {
|
||||
const allFeatures = await featureLoader.getAll(projectPath);
|
||||
const affectedFeatures = allFeatures.filter((f) => f.branchName === branchName);
|
||||
for (const feature of affectedFeatures) {
|
||||
try {
|
||||
await featureLoader.update(projectPath, feature.id, {
|
||||
branchName: null,
|
||||
});
|
||||
featuresMovedToMain++;
|
||||
// Emit feature:migrated event for each successfully migrated feature
|
||||
events.emit('feature:migrated', {
|
||||
featureId: feature.id,
|
||||
status: 'migrated',
|
||||
fromBranch: branchName,
|
||||
toWorktreeId: null, // migrated to main worktree (no specific worktree)
|
||||
projectPath,
|
||||
});
|
||||
} catch (featureUpdateError) {
|
||||
// Non-fatal: log per-feature failure but continue migrating others
|
||||
logger.warn('Failed to move feature to main worktree after deletion', {
|
||||
error: getErrorMessage(featureUpdateError),
|
||||
featureId: feature.id,
|
||||
branchName,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (featuresMovedToMain > 0) {
|
||||
logger.info(
|
||||
`Moved ${featuresMovedToMain} feature(s) to main worktree after deleting worktree with branch: ${branchName}`
|
||||
);
|
||||
}
|
||||
} catch (featureError) {
|
||||
// Non-fatal: log but don't fail the deletion (getAll failed)
|
||||
logger.warn('Failed to load features for migration to main worktree after deletion', {
|
||||
error: getErrorMessage(featureError),
|
||||
branchName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
deleted: {
|
||||
worktreePath,
|
||||
branch: branchDeleted ? branchName : null,
|
||||
branchDeleted,
|
||||
featuresMovedToMain,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
* 1. Discard ALL changes (when no files array is provided)
|
||||
* - Resets staged changes (git reset HEAD)
|
||||
* - Discards modified tracked files (git checkout .)
|
||||
* - Removes untracked files and directories (git clean -fd)
|
||||
* - Removes untracked files and directories (git clean -ffd)
|
||||
*
|
||||
* 2. Discard SELECTED files (when files array is provided)
|
||||
* - Unstages selected staged files (git reset HEAD -- <files>)
|
||||
* - Reverts selected tracked file changes (git checkout -- <files>)
|
||||
* - Removes selected untracked files (git clean -fd -- <files>)
|
||||
* - Removes selected untracked files (git clean -ffd -- <files>)
|
||||
*
|
||||
* Note: Git repository validation (isGitRepo) is handled by
|
||||
* the requireGitRepoOnly middleware in index.ts
|
||||
@@ -52,6 +52,22 @@ function validateFilePath(filePath: string, worktreePath: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a file path from git status --porcelain output, handling renames.
|
||||
* For renamed files (R status), git reports "old_path -> new_path" and
|
||||
* we need the new path to match what parseGitStatus() returns in git-utils.
|
||||
*/
|
||||
function parseFilePath(rawPath: string, indexStatus: string, workTreeStatus: string): string {
|
||||
const trimmedPath = rawPath.trim();
|
||||
if (indexStatus === 'R' || workTreeStatus === 'R') {
|
||||
const arrowIndex = trimmedPath.indexOf(' -> ');
|
||||
if (arrowIndex !== -1) {
|
||||
return trimmedPath.slice(arrowIndex + 4);
|
||||
}
|
||||
}
|
||||
return trimmedPath;
|
||||
}
|
||||
|
||||
export function createDiscardChangesHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
@@ -91,11 +107,16 @@ export function createDiscardChangesHandler() {
|
||||
|
||||
// Parse the status output to categorize files
|
||||
// Git --porcelain format: XY PATH where X=index status, Y=worktree status
|
||||
// Preserve the exact two-character XY status (no trim) to keep index vs worktree info
|
||||
// For renamed files: XY OLD_PATH -> NEW_PATH
|
||||
const statusLines = status.trim().split('\n').filter(Boolean);
|
||||
const allFiles = statusLines.map((line) => {
|
||||
const fileStatus = line.substring(0, 2);
|
||||
const filePath = line.slice(3).trim();
|
||||
const rawPath = line.slice(3);
|
||||
const indexStatus = fileStatus.charAt(0);
|
||||
const workTreeStatus = fileStatus.charAt(1);
|
||||
// Parse path consistently with parseGitStatus() in git-utils,
|
||||
// which extracts the new path for renames
|
||||
const filePath = parseFilePath(rawPath, indexStatus, workTreeStatus);
|
||||
return { status: fileStatus, path: filePath };
|
||||
});
|
||||
|
||||
@@ -122,8 +143,12 @@ export function createDiscardChangesHandler() {
|
||||
const untrackedFiles: string[] = []; // Untracked files (?)
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Track which requested files were matched so we can handle unmatched ones
|
||||
const matchedFiles = new Set<string>();
|
||||
|
||||
for (const file of allFiles) {
|
||||
if (!filesToDiscard.has(file.path)) continue;
|
||||
matchedFiles.add(file.path);
|
||||
|
||||
// file.status is the raw two-character XY git porcelain status (no trim)
|
||||
// X = index/staging status, Y = worktree status
|
||||
@@ -151,6 +176,16 @@ export function createDiscardChangesHandler() {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle files from the UI that didn't match any entry in allFiles.
|
||||
// This can happen due to timing differences between the UI loading diffs
|
||||
// and the discard request, or path format differences.
|
||||
// Attempt to clean unmatched files directly as untracked files.
|
||||
for (const requestedFile of files) {
|
||||
if (!matchedFiles.has(requestedFile)) {
|
||||
untrackedFiles.push(requestedFile);
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Unstage selected staged files (using execFile to bypass shell)
|
||||
if (stagedFiles.length > 0) {
|
||||
try {
|
||||
@@ -174,9 +209,10 @@ export function createDiscardChangesHandler() {
|
||||
}
|
||||
|
||||
// 3. Remove selected untracked files
|
||||
// Use -ffd (double force) to also handle nested git repositories
|
||||
if (untrackedFiles.length > 0) {
|
||||
try {
|
||||
await execGitCommand(['clean', '-fd', '--', ...untrackedFiles], worktreePath);
|
||||
await execGitCommand(['clean', '-ffd', '--', ...untrackedFiles], worktreePath);
|
||||
} catch (error) {
|
||||
const msg = getErrorMessage(error);
|
||||
logError(error, `Failed to clean untracked files: ${msg}`);
|
||||
@@ -234,11 +270,12 @@ export function createDiscardChangesHandler() {
|
||||
}
|
||||
|
||||
// 3. Remove untracked files and directories
|
||||
// Use -ffd (double force) to also handle nested git repositories
|
||||
try {
|
||||
await execGitCommand(['clean', '-fd'], worktreePath);
|
||||
await execGitCommand(['clean', '-ffd', '--'], worktreePath);
|
||||
} catch (error) {
|
||||
const msg = getErrorMessage(error);
|
||||
logError(error, `git clean -fd failed: ${msg}`);
|
||||
logError(error, `git clean -ffd failed: ${msg}`);
|
||||
warnings.push(`Failed to remove untracked files: ${msg}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,9 @@ Rules:
|
||||
- Focus on the user-facing impact when possible
|
||||
- If there are breaking changes, mention them prominently
|
||||
- The diff may include both committed changes and uncommitted working directory changes. Treat all changes as part of the PR since uncommitted changes will be committed when the PR is created
|
||||
- Do NOT distinguish between committed and uncommitted changes in the output - describe all changes as a unified set of PR changes`;
|
||||
- Do NOT distinguish between committed and uncommitted changes in the output - describe all changes as a unified set of PR changes
|
||||
- EXCLUDE any files that are gitignored (e.g., node_modules, dist, build, .env files, lock files, generated files, binary artifacts, coverage reports, cache directories). These should not be mentioned in the description even if they appear in the diff
|
||||
- Focus only on meaningful source code changes that are tracked by git and relevant to reviewers`;
|
||||
|
||||
/**
|
||||
* Wraps an async generator with a timeout.
|
||||
@@ -168,127 +170,125 @@ export function createGeneratePRDescriptionHandler(
|
||||
// Determine the base branch for comparison
|
||||
const base = baseBranch || 'main';
|
||||
|
||||
// Get the diff between current branch and base branch (committed changes)
|
||||
// Track whether the diff method used only includes committed changes.
|
||||
// `git diff base...HEAD` and `git diff origin/base...HEAD` only show committed changes,
|
||||
// while the fallback methods (`git diff HEAD`, `git diff --cached + git diff`) already
|
||||
// include uncommitted working directory changes.
|
||||
let diff = '';
|
||||
let diffIncludesUncommitted = false;
|
||||
// Collect diffs in three layers and combine them:
|
||||
// 1. Committed changes on the branch: `git diff base...HEAD`
|
||||
// 2. Staged (cached) changes not yet committed: `git diff --cached`
|
||||
// 3. Unstaged changes to tracked files: `git diff` (no --cached flag)
|
||||
//
|
||||
// Untracked files are intentionally excluded — they are typically build artifacts,
|
||||
// planning files, hidden dotfiles, or other files unrelated to the PR.
|
||||
// `git diff` and `git diff --cached` only show changes to files already tracked by git,
|
||||
// which is exactly the correct scope.
|
||||
//
|
||||
// We combine all three sources and deduplicate by file path so that a file modified
|
||||
// in commits AND with additional uncommitted changes is not double-counted.
|
||||
|
||||
/** Parse a unified diff into per-file hunks keyed by file path */
|
||||
function parseDiffIntoFileHunks(diffText: string): Map<string, string> {
|
||||
const fileHunks = new Map<string, string>();
|
||||
if (!diffText.trim()) return fileHunks;
|
||||
|
||||
// Split on "diff --git" boundaries (keep the delimiter)
|
||||
const sections = diffText.split(/(?=^diff --git )/m);
|
||||
for (const section of sections) {
|
||||
if (!section.trim()) continue;
|
||||
// Use a back-reference pattern so the "b/" side must match the "a/" capture,
|
||||
// correctly handling paths that contain " b/" in their name.
|
||||
// Falls back to a two-capture pattern to handle renames (a/ and b/ differ).
|
||||
const backrefMatch = section.match(/^diff --git a\/(.+) b\/\1$/m);
|
||||
const renameMatch = !backrefMatch ? section.match(/^diff --git a\/(.+) b\/(.+)$/m) : null;
|
||||
const match = backrefMatch || renameMatch;
|
||||
if (match) {
|
||||
// Prefer the backref capture (identical paths); for renames use the destination (match[2])
|
||||
const filePath = backrefMatch ? match[1] : match[2];
|
||||
// Merge hunks if the same file appears in multiple diff sources
|
||||
const existing = fileHunks.get(filePath) ?? '';
|
||||
fileHunks.set(filePath, existing + section);
|
||||
}
|
||||
}
|
||||
return fileHunks;
|
||||
}
|
||||
|
||||
// --- Step 1: committed changes (branch vs base) ---
|
||||
let committedDiff = '';
|
||||
try {
|
||||
// First, try to get diff against the base branch
|
||||
const { stdout: branchDiff } = await execFileAsync('git', ['diff', `${base}...HEAD`], {
|
||||
const { stdout } = await execFileAsync('git', ['diff', `${base}...HEAD`], {
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 1024 * 1024 * 5, // 5MB buffer
|
||||
maxBuffer: 1024 * 1024 * 5,
|
||||
});
|
||||
diff = branchDiff;
|
||||
// git diff base...HEAD only shows committed changes
|
||||
diffIncludesUncommitted = false;
|
||||
committedDiff = stdout;
|
||||
} catch {
|
||||
// If branch comparison fails (e.g., base branch doesn't exist locally),
|
||||
// try fetching and comparing against remote base
|
||||
// Base branch may not exist locally; try the remote tracking branch
|
||||
try {
|
||||
const { stdout: remoteDiff } = await execFileAsync(
|
||||
'git',
|
||||
['diff', `origin/${base}...HEAD`],
|
||||
{
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 1024 * 1024 * 5,
|
||||
}
|
||||
);
|
||||
diff = remoteDiff;
|
||||
// git diff origin/base...HEAD only shows committed changes
|
||||
diffIncludesUncommitted = false;
|
||||
const { stdout } = await execFileAsync('git', ['diff', `origin/${base}...HEAD`], {
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 1024 * 1024 * 5,
|
||||
});
|
||||
committedDiff = stdout;
|
||||
} catch {
|
||||
// Fall back to getting all uncommitted + committed changes
|
||||
try {
|
||||
const { stdout: allDiff } = await execFileAsync('git', ['diff', 'HEAD'], {
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 1024 * 1024 * 5,
|
||||
});
|
||||
diff = allDiff;
|
||||
// git diff HEAD includes uncommitted changes
|
||||
diffIncludesUncommitted = true;
|
||||
} catch {
|
||||
// Last resort: get staged + unstaged changes
|
||||
const { stdout: stagedDiff } = await execFileAsync('git', ['diff', '--cached'], {
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 1024 * 1024 * 5,
|
||||
});
|
||||
const { stdout: unstagedDiff } = await execFileAsync('git', ['diff'], {
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 1024 * 1024 * 5,
|
||||
});
|
||||
diff = stagedDiff + unstagedDiff;
|
||||
// These already include uncommitted changes
|
||||
diffIncludesUncommitted = true;
|
||||
}
|
||||
// Cannot compare against base — leave committedDiff empty; the uncommitted
|
||||
// changes gathered below will still be included.
|
||||
logger.warn(`Could not get committed diff against ${base} or origin/${base}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for uncommitted changes (staged + unstaged) to include in the description.
|
||||
// When creating a PR, uncommitted changes will be auto-committed, so they should be
|
||||
// reflected in the generated description. We only need to fetch uncommitted diffs
|
||||
// when the primary diff method (base...HEAD) was used, since it only shows committed changes.
|
||||
let hasUncommittedChanges = false;
|
||||
// --- Step 2: staged changes (tracked files only) ---
|
||||
let stagedDiff = '';
|
||||
try {
|
||||
const { stdout: statusOutput } = await execFileAsync('git', ['status', '--porcelain'], {
|
||||
const { stdout } = await execFileAsync('git', ['diff', '--cached'], {
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 1024 * 1024 * 5,
|
||||
});
|
||||
hasUncommittedChanges = statusOutput.trim().length > 0;
|
||||
|
||||
if (hasUncommittedChanges && !diffIncludesUncommitted) {
|
||||
logger.info('Uncommitted changes detected, including in PR description context');
|
||||
|
||||
let uncommittedDiff = '';
|
||||
|
||||
// Get staged changes
|
||||
try {
|
||||
const { stdout: stagedDiff } = await execFileAsync('git', ['diff', '--cached'], {
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 1024 * 1024 * 5,
|
||||
});
|
||||
if (stagedDiff.trim()) {
|
||||
uncommittedDiff += stagedDiff;
|
||||
}
|
||||
} catch {
|
||||
// Ignore staged diff errors
|
||||
}
|
||||
|
||||
// Get unstaged changes (tracked files only)
|
||||
try {
|
||||
const { stdout: unstagedDiff } = await execFileAsync('git', ['diff'], {
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 1024 * 1024 * 5,
|
||||
});
|
||||
if (unstagedDiff.trim()) {
|
||||
uncommittedDiff += unstagedDiff;
|
||||
}
|
||||
} catch {
|
||||
// Ignore unstaged diff errors
|
||||
}
|
||||
|
||||
// Get list of untracked files for context
|
||||
const untrackedFiles = statusOutput
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('??'))
|
||||
.map((line) => line.substring(3).trim());
|
||||
|
||||
if (untrackedFiles.length > 0) {
|
||||
// Add a summary of untracked (new) files as context
|
||||
uncommittedDiff += `\n# New untracked files:\n${untrackedFiles.map((f) => `# + ${f}`).join('\n')}\n`;
|
||||
}
|
||||
|
||||
// Append uncommitted changes to the committed diff
|
||||
if (uncommittedDiff.trim()) {
|
||||
diff = diff + uncommittedDiff;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors checking for uncommitted changes
|
||||
stagedDiff = stdout;
|
||||
} catch (err) {
|
||||
// Non-fatal — staged diff is a best-effort supplement
|
||||
logger.debug('Failed to get staged diff', err);
|
||||
}
|
||||
|
||||
// Also get the commit log for context
|
||||
// --- Step 3: unstaged changes (tracked files only) ---
|
||||
let unstagedDiff = '';
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['diff'], {
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 1024 * 1024 * 5,
|
||||
});
|
||||
unstagedDiff = stdout;
|
||||
} catch (err) {
|
||||
// Non-fatal — unstaged diff is a best-effort supplement
|
||||
logger.debug('Failed to get unstaged diff', err);
|
||||
}
|
||||
|
||||
// --- Combine and deduplicate ---
|
||||
// Build a map of filePath → diff content by concatenating hunks from all sources
|
||||
// in chronological order (committed → staged → unstaged) so that no changes
|
||||
// are lost when a file appears in multiple diff sources.
|
||||
const combinedFileHunks = new Map<string, string>();
|
||||
|
||||
for (const source of [committedDiff, stagedDiff, unstagedDiff]) {
|
||||
const hunks = parseDiffIntoFileHunks(source);
|
||||
for (const [filePath, hunk] of hunks) {
|
||||
if (combinedFileHunks.has(filePath)) {
|
||||
combinedFileHunks.set(filePath, combinedFileHunks.get(filePath)! + hunk);
|
||||
} else {
|
||||
combinedFileHunks.set(filePath, hunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const diff = Array.from(combinedFileHunks.values()).join('');
|
||||
|
||||
// Log what files were included for observability
|
||||
if (combinedFileHunks.size > 0) {
|
||||
logger.info(`PR description scope: ${combinedFileHunks.size} file(s)`);
|
||||
logger.debug(
|
||||
`PR description scope files: ${Array.from(combinedFileHunks.keys()).join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
// Also get the commit log for context — always scoped to the selected base branch
|
||||
// so the log only contains commits that are part of this PR.
|
||||
// We do NOT fall back to an unscoped `git log` because that would include commits
|
||||
// from the base branch itself and produce misleading AI context.
|
||||
let commitLog = '';
|
||||
try {
|
||||
const { stdout: logOutput } = await execFileAsync(
|
||||
@@ -301,11 +301,11 @@ export function createGeneratePRDescriptionHandler(
|
||||
);
|
||||
commitLog = logOutput.trim();
|
||||
} catch {
|
||||
// If comparing against base fails, fall back to recent commits
|
||||
// Base branch not available locally — try the remote tracking branch
|
||||
try {
|
||||
const { stdout: logOutput } = await execFileAsync(
|
||||
'git',
|
||||
['log', '--oneline', '-10', '--no-decorate'],
|
||||
['log', `origin/${base}..HEAD`, '--oneline', '--no-decorate'],
|
||||
{
|
||||
cwd: worktreePath,
|
||||
maxBuffer: 1024 * 1024,
|
||||
@@ -313,7 +313,9 @@ export function createGeneratePRDescriptionHandler(
|
||||
);
|
||||
commitLog = logOutput.trim();
|
||||
} catch {
|
||||
// Ignore commit log errors
|
||||
// Cannot scope commit log to base branch — leave empty rather than
|
||||
// including unscoped commits that would pollute the AI context.
|
||||
logger.warn(`Could not get commit log against ${base} or origin/${base}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,10 +341,6 @@ export function createGeneratePRDescriptionHandler(
|
||||
userPrompt += `\nCommit History:\n${commitLog}\n`;
|
||||
}
|
||||
|
||||
if (hasUncommittedChanges) {
|
||||
userPrompt += `\nNote: This branch has uncommitted changes that will be included in the PR.\n`;
|
||||
}
|
||||
|
||||
if (truncatedDiff) {
|
||||
userPrompt += `\n\`\`\`diff\n${truncatedDiff}\n\`\`\``;
|
||||
}
|
||||
|
||||
@@ -44,13 +44,79 @@ export function createInitGitHandler() {
|
||||
}
|
||||
|
||||
// Initialize git with 'main' as the default branch (matching GitHub's standard since 2020)
|
||||
// and create an initial empty commit
|
||||
await execAsync(
|
||||
`git init --initial-branch=main && git commit --allow-empty -m "Initial commit"`,
|
||||
{
|
||||
cwd: projectPath,
|
||||
// Run commands sequentially so failures can be handled and partial state cleaned up.
|
||||
let gitDirCreated = false;
|
||||
try {
|
||||
// Step 1: initialize the repository
|
||||
try {
|
||||
await execAsync(`git init --initial-branch=main`, { cwd: projectPath });
|
||||
} catch (initError: unknown) {
|
||||
const stderr =
|
||||
initError && typeof initError === 'object' && 'stderr' in initError
|
||||
? String((initError as { stderr?: string }).stderr)
|
||||
: '';
|
||||
// Idempotent: if .git was created by a concurrent request or a stale lock exists,
|
||||
// treat as "repo already exists" instead of failing
|
||||
if (
|
||||
/could not lock config file.*File exists|fatal: could not set 'core\.repositoryformatversion'/.test(
|
||||
stderr
|
||||
)
|
||||
) {
|
||||
try {
|
||||
await secureFs.access(gitDirPath);
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
initialized: false,
|
||||
message: 'Git repository already exists',
|
||||
},
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
// .git still missing, rethrow original error
|
||||
}
|
||||
}
|
||||
throw initError;
|
||||
}
|
||||
);
|
||||
gitDirCreated = true;
|
||||
|
||||
// Step 2: ensure user.name and user.email are set so the commit can succeed.
|
||||
// Check the global/system config first; only set locally if missing.
|
||||
let userName = '';
|
||||
let userEmail = '';
|
||||
try {
|
||||
({ stdout: userName } = await execAsync(`git config user.name`, { cwd: projectPath }));
|
||||
} catch {
|
||||
// not set globally – will configure locally below
|
||||
}
|
||||
try {
|
||||
({ stdout: userEmail } = await execAsync(`git config user.email`, {
|
||||
cwd: projectPath,
|
||||
}));
|
||||
} catch {
|
||||
// not set globally – will configure locally below
|
||||
}
|
||||
|
||||
if (!userName.trim()) {
|
||||
await execAsync(`git config user.name "Automaker"`, { cwd: projectPath });
|
||||
}
|
||||
if (!userEmail.trim()) {
|
||||
await execAsync(`git config user.email "automaker@localhost"`, { cwd: projectPath });
|
||||
}
|
||||
|
||||
// Step 3: create the initial empty commit
|
||||
await execAsync(`git commit --allow-empty -m "Initial commit"`, { cwd: projectPath });
|
||||
} catch (error: unknown) {
|
||||
// Clean up the partial .git directory so subsequent runs behave deterministically
|
||||
if (gitDirCreated) {
|
||||
try {
|
||||
await secureFs.rm(gitDirPath, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort cleanup; ignore errors
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { exec, execFile } from 'child_process';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { getErrorMessage, logWorktreeError } from '../common.js';
|
||||
import { getErrorMessage, logWorktreeError, execGitCommand } from '../common.js';
|
||||
import { getRemotesWithBranch } from '../../../services/worktree-service.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
interface BranchInfo {
|
||||
@@ -35,18 +35,18 @@ export function createListBranchesHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current branch
|
||||
const { stdout: currentBranchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
// Get current branch (execGitCommand avoids spawning /bin/sh; works in sandboxed CI)
|
||||
const currentBranchOutput = await execGitCommand(
|
||||
['rev-parse', '--abbrev-ref', 'HEAD'],
|
||||
worktreePath
|
||||
);
|
||||
const currentBranch = currentBranchOutput.trim();
|
||||
|
||||
// List all local branches
|
||||
// Use double quotes around the format string for cross-platform compatibility
|
||||
// Single quotes are preserved literally on Windows; double quotes work on both
|
||||
const { stdout: branchesOutput } = await execAsync('git branch --format="%(refname:short)"', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
const branchesOutput = await execGitCommand(
|
||||
['branch', '--format=%(refname:short)'],
|
||||
worktreePath
|
||||
);
|
||||
|
||||
const branches: BranchInfo[] = branchesOutput
|
||||
.trim()
|
||||
@@ -67,18 +67,15 @@ export function createListBranchesHandler() {
|
||||
try {
|
||||
// Fetch latest remote refs (silently, don't fail if offline)
|
||||
try {
|
||||
await execAsync('git fetch --all --quiet', {
|
||||
cwd: worktreePath,
|
||||
timeout: 10000, // 10 second timeout
|
||||
});
|
||||
await execGitCommand(['fetch', '--all', '--quiet'], worktreePath);
|
||||
} catch {
|
||||
// Ignore fetch errors - we'll use cached remote refs
|
||||
}
|
||||
|
||||
// List remote branches
|
||||
const { stdout: remoteBranchesOutput } = await execAsync(
|
||||
'git branch -r --format="%(refname:short)"',
|
||||
{ cwd: worktreePath }
|
||||
const remoteBranchesOutput = await execGitCommand(
|
||||
['branch', '-r', '--format=%(refname:short)'],
|
||||
worktreePath
|
||||
);
|
||||
|
||||
const localBranchNames = new Set(branches.map((b) => b.name));
|
||||
@@ -117,9 +114,7 @@ export function createListBranchesHandler() {
|
||||
// Check if any remotes are configured for this repository
|
||||
let hasAnyRemotes = false;
|
||||
try {
|
||||
const { stdout: remotesOutput } = await execAsync('git remote', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
const remotesOutput = await execGitCommand(['remote'], worktreePath);
|
||||
hasAnyRemotes = remotesOutput.trim().length > 0;
|
||||
} catch {
|
||||
// If git remote fails, assume no remotes
|
||||
@@ -131,6 +126,8 @@ export function createListBranchesHandler() {
|
||||
let behindCount = 0;
|
||||
let hasRemoteBranch = false;
|
||||
let trackingRemote: string | undefined;
|
||||
// List of remote names that have a branch matching the current branch name
|
||||
let remotesWithBranch: string[] = [];
|
||||
try {
|
||||
// First check if there's a remote tracking branch
|
||||
const { stdout: upstreamOutput } = await execFileAsync(
|
||||
@@ -172,6 +169,12 @@ export function createListBranchesHandler() {
|
||||
}
|
||||
}
|
||||
|
||||
// Check which remotes have a branch matching the current branch name.
|
||||
// This helps the UI distinguish between "branch exists on tracking remote" vs
|
||||
// "branch was pushed to a different remote" (e.g., pushed to 'upstream' but tracking 'origin').
|
||||
// Use for-each-ref to check cached remote refs (already fetched above if includeRemote was true)
|
||||
remotesWithBranch = await getRemotesWithBranch(worktreePath, currentBranch, hasAnyRemotes);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
@@ -182,6 +185,7 @@ export function createListBranchesHandler() {
|
||||
hasRemoteBranch,
|
||||
hasAnyRemotes,
|
||||
trackingRemote,
|
||||
remotesWithBranch,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -13,7 +13,14 @@ import { promisify } from 'util';
|
||||
import path from 'path';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
import { isGitRepo } from '@automaker/git-utils';
|
||||
import { getErrorMessage, logError, normalizePath, execEnv, isGhCliAvailable } from '../common.js';
|
||||
import {
|
||||
getErrorMessage,
|
||||
logError,
|
||||
normalizePath,
|
||||
execEnv,
|
||||
isGhCliAvailable,
|
||||
execGitCommand,
|
||||
} from '../common.js';
|
||||
import {
|
||||
readAllWorktreeMetadata,
|
||||
updateWorktreePRInfo,
|
||||
@@ -29,6 +36,22 @@ import {
|
||||
const execAsync = promisify(exec);
|
||||
const logger = createLogger('Worktree');
|
||||
|
||||
/** True when git (or shell) could not be spawned (e.g. ENOENT in sandboxed CI). */
|
||||
function isSpawnENOENT(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') return false;
|
||||
const e = error as { code?: string; errno?: number; syscall?: string };
|
||||
// Accept ENOENT with or without syscall so wrapped/reexported errors are handled.
|
||||
// Node may set syscall to 'spawn' or 'spawn git' (or other command name).
|
||||
if (e.code === 'ENOENT' || e.errno === -2) {
|
||||
return (
|
||||
e.syscall === 'spawn' ||
|
||||
(typeof e.syscall === 'string' && e.syscall.startsWith('spawn')) ||
|
||||
e.syscall === undefined
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache for GitHub remote status per project path.
|
||||
* This prevents repeated "no git remotes found" warnings when polling
|
||||
@@ -64,6 +87,8 @@ interface WorktreeInfo {
|
||||
conflictType?: 'merge' | 'rebase' | 'cherry-pick';
|
||||
/** List of files with conflicts */
|
||||
conflictFiles?: string[];
|
||||
/** Source branch involved in merge/rebase/cherry-pick, when resolvable */
|
||||
conflictSourceBranch?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,13 +100,11 @@ async function detectConflictState(worktreePath: string): Promise<{
|
||||
hasConflicts: boolean;
|
||||
conflictType?: 'merge' | 'rebase' | 'cherry-pick';
|
||||
conflictFiles?: string[];
|
||||
conflictSourceBranch?: string;
|
||||
}> {
|
||||
try {
|
||||
// Find the canonical .git directory for this worktree
|
||||
const { stdout: gitDirRaw } = await execAsync('git rev-parse --git-dir', {
|
||||
cwd: worktreePath,
|
||||
timeout: 15000,
|
||||
});
|
||||
// Find the canonical .git directory for this worktree (execGitCommand avoids /bin/sh in CI)
|
||||
const gitDirRaw = await execGitCommand(['rev-parse', '--git-dir'], worktreePath);
|
||||
const gitDir = path.resolve(worktreePath, gitDirRaw.trim());
|
||||
|
||||
// Check for merge, rebase, and cherry-pick state files/directories
|
||||
@@ -121,10 +144,10 @@ async function detectConflictState(worktreePath: string): Promise<{
|
||||
// Get list of conflicted files using machine-readable git status
|
||||
let conflictFiles: string[] = [];
|
||||
try {
|
||||
const { stdout: statusOutput } = await execAsync('git diff --name-only --diff-filter=U', {
|
||||
cwd: worktreePath,
|
||||
timeout: 15000,
|
||||
});
|
||||
const statusOutput = await execGitCommand(
|
||||
['diff', '--name-only', '--diff-filter=U'],
|
||||
worktreePath
|
||||
);
|
||||
conflictFiles = statusOutput
|
||||
.trim()
|
||||
.split('\n')
|
||||
@@ -133,10 +156,84 @@ async function detectConflictState(worktreePath: string): Promise<{
|
||||
// Fall back to empty list if diff fails
|
||||
}
|
||||
|
||||
// Detect the source branch involved in the conflict
|
||||
let conflictSourceBranch: string | undefined;
|
||||
try {
|
||||
if (conflictType === 'merge' && mergeHeadExists) {
|
||||
// For merges, resolve MERGE_HEAD to a branch name
|
||||
const mergeHead = (
|
||||
(await secureFs.readFile(path.join(gitDir, 'MERGE_HEAD'), 'utf-8')) as string
|
||||
).trim();
|
||||
try {
|
||||
const branchName = await execGitCommand(
|
||||
['name-rev', '--name-only', '--refs=refs/heads/*', mergeHead],
|
||||
worktreePath
|
||||
);
|
||||
const cleaned = branchName.trim().replace(/~\d+$/, '');
|
||||
if (cleaned && cleaned !== 'undefined') {
|
||||
conflictSourceBranch = cleaned;
|
||||
}
|
||||
} catch {
|
||||
// Could not resolve to branch name
|
||||
}
|
||||
} else if (conflictType === 'rebase') {
|
||||
// For rebases, read the onto branch from rebase-merge/head-name or rebase-apply/head-name
|
||||
const headNamePath = rebaseMergeExists
|
||||
? path.join(gitDir, 'rebase-merge', 'onto-name')
|
||||
: path.join(gitDir, 'rebase-apply', 'onto-name');
|
||||
try {
|
||||
const ontoName = ((await secureFs.readFile(headNamePath, 'utf-8')) as string).trim();
|
||||
if (ontoName) {
|
||||
conflictSourceBranch = ontoName.replace(/^refs\/heads\//, '');
|
||||
}
|
||||
} catch {
|
||||
// onto-name may not exist; try to resolve the onto commit
|
||||
try {
|
||||
const ontoPath = rebaseMergeExists
|
||||
? path.join(gitDir, 'rebase-merge', 'onto')
|
||||
: path.join(gitDir, 'rebase-apply', 'onto');
|
||||
const ontoCommit = ((await secureFs.readFile(ontoPath, 'utf-8')) as string).trim();
|
||||
if (ontoCommit) {
|
||||
const branchName = await execGitCommand(
|
||||
['name-rev', '--name-only', '--refs=refs/heads/*', ontoCommit],
|
||||
worktreePath
|
||||
);
|
||||
const cleaned = branchName.trim().replace(/~\d+$/, '');
|
||||
if (cleaned && cleaned !== 'undefined') {
|
||||
conflictSourceBranch = cleaned;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Could not resolve onto commit
|
||||
}
|
||||
}
|
||||
} else if (conflictType === 'cherry-pick' && cherryPickHeadExists) {
|
||||
// For cherry-picks, try to resolve CHERRY_PICK_HEAD to a branch name
|
||||
const cherryPickHead = (
|
||||
(await secureFs.readFile(path.join(gitDir, 'CHERRY_PICK_HEAD'), 'utf-8')) as string
|
||||
).trim();
|
||||
try {
|
||||
const branchName = await execGitCommand(
|
||||
['name-rev', '--name-only', '--refs=refs/heads/*', cherryPickHead],
|
||||
worktreePath
|
||||
);
|
||||
const cleaned = branchName.trim().replace(/~\d+$/, '');
|
||||
if (cleaned && cleaned !== 'undefined') {
|
||||
conflictSourceBranch = cleaned;
|
||||
}
|
||||
} catch {
|
||||
// Could not resolve to branch name
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore source branch detection errors
|
||||
}
|
||||
|
||||
return {
|
||||
hasConflicts: conflictFiles.length > 0,
|
||||
conflictType,
|
||||
conflictFiles,
|
||||
conflictSourceBranch,
|
||||
};
|
||||
} catch {
|
||||
// If anything fails, assume no conflicts
|
||||
@@ -146,13 +243,69 @@ async function detectConflictState(worktreePath: string): Promise<{
|
||||
|
||||
async function getCurrentBranch(cwd: string): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await execAsync('git branch --show-current', { cwd });
|
||||
const stdout = await execGitCommand(['branch', '--show-current'], cwd);
|
||||
return stdout.trim();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBranchFromHeadRef(headRef: string): string | null {
|
||||
let normalized = headRef.trim();
|
||||
const prefixes = ['refs/heads/', 'refs/remotes/origin/', 'refs/remotes/', 'refs/'];
|
||||
|
||||
for (const prefix of prefixes) {
|
||||
if (normalized.startsWith(prefix)) {
|
||||
normalized = normalized.slice(prefix.length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Return the full branch name, including any slashes (e.g., "feature/my-branch")
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to recover the branch name for a worktree in detached HEAD state.
|
||||
* This happens during rebase operations where git detaches HEAD from the branch.
|
||||
* We look at git state files (rebase-merge/head-name, rebase-apply/head-name)
|
||||
* to determine which branch the operation is targeting.
|
||||
*
|
||||
* Note: merge conflicts do NOT detach HEAD, so `git worktree list --porcelain`
|
||||
* still includes the `branch` line for merge conflicts. This recovery is
|
||||
* specifically for rebase and cherry-pick operations.
|
||||
*/
|
||||
async function recoverBranchForDetachedWorktree(worktreePath: string): Promise<string | null> {
|
||||
try {
|
||||
const gitDirRaw = await execGitCommand(['rev-parse', '--git-dir'], worktreePath);
|
||||
const gitDir = path.resolve(worktreePath, gitDirRaw.trim());
|
||||
|
||||
// During a rebase, the original branch is stored in rebase-merge/head-name
|
||||
try {
|
||||
const headNamePath = path.join(gitDir, 'rebase-merge', 'head-name');
|
||||
const headName = (await secureFs.readFile(headNamePath, 'utf-8')) as string;
|
||||
const branch = normalizeBranchFromHeadRef(headName);
|
||||
if (branch) return branch;
|
||||
} catch {
|
||||
// Not a rebase-merge
|
||||
}
|
||||
|
||||
// rebase-apply also stores the original branch in head-name
|
||||
try {
|
||||
const headNamePath = path.join(gitDir, 'rebase-apply', 'head-name');
|
||||
const headName = (await secureFs.readFile(headNamePath, 'utf-8')) as string;
|
||||
const branch = normalizeBranchFromHeadRef(headName);
|
||||
if (branch) return branch;
|
||||
} catch {
|
||||
// Not a rebase-apply
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan the .worktrees directory to discover worktrees that may exist on disk
|
||||
* but are not registered with git (e.g., created externally or corrupted state).
|
||||
@@ -204,22 +357,36 @@ async function scanWorktreesDirectory(
|
||||
});
|
||||
} else {
|
||||
// Try to get branch from HEAD if branch --show-current fails (detached HEAD)
|
||||
let headBranch: string | null = null;
|
||||
try {
|
||||
const { stdout: headRef } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
const headBranch = headRef.trim();
|
||||
if (headBranch && headBranch !== 'HEAD') {
|
||||
logger.info(
|
||||
`Discovered worktree in .worktrees/ not in git worktree list: ${entry.name} (branch: ${headBranch})`
|
||||
);
|
||||
discovered.push({
|
||||
path: normalizedPath,
|
||||
branch: headBranch,
|
||||
});
|
||||
const headRef = await execGitCommand(
|
||||
['rev-parse', '--abbrev-ref', 'HEAD'],
|
||||
worktreePath
|
||||
);
|
||||
const ref = headRef.trim();
|
||||
if (ref && ref !== 'HEAD') {
|
||||
headBranch = ref;
|
||||
}
|
||||
} catch {
|
||||
// Can't determine branch, skip this directory
|
||||
} catch (error) {
|
||||
// Can't determine branch from HEAD ref (including timeout) - fall back to detached HEAD recovery
|
||||
logger.debug(
|
||||
`Failed to resolve HEAD ref for ${worktreePath}: ${getErrorMessage(error)}`
|
||||
);
|
||||
}
|
||||
|
||||
// If HEAD is detached (rebase/merge in progress), try recovery from git state files
|
||||
if (!headBranch) {
|
||||
headBranch = await recoverBranchForDetachedWorktree(worktreePath);
|
||||
}
|
||||
|
||||
if (headBranch) {
|
||||
logger.info(
|
||||
`Discovered worktree in .worktrees/ not in git worktree list: ${entry.name} (branch: ${headBranch})`
|
||||
);
|
||||
discovered.push({
|
||||
path: normalizedPath,
|
||||
branch: headBranch,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -378,15 +545,14 @@ export function createListHandler() {
|
||||
// Get current branch in main directory
|
||||
const currentBranch = await getCurrentBranch(projectPath);
|
||||
|
||||
// Get actual worktrees from git
|
||||
const { stdout } = await execAsync('git worktree list --porcelain', {
|
||||
cwd: projectPath,
|
||||
});
|
||||
// Get actual worktrees from git (execGitCommand avoids /bin/sh in sandboxed CI)
|
||||
const stdout = await execGitCommand(['worktree', 'list', '--porcelain'], projectPath);
|
||||
|
||||
const worktrees: WorktreeInfo[] = [];
|
||||
const removedWorktrees: Array<{ path: string; branch: string }> = [];
|
||||
let hasMissingWorktree = false;
|
||||
const lines = stdout.split('\n');
|
||||
let current: { path?: string; branch?: string } = {};
|
||||
let current: { path?: string; branch?: string; isDetached?: boolean } = {};
|
||||
let isFirst = true;
|
||||
|
||||
// First pass: detect removed worktrees
|
||||
@@ -395,8 +561,11 @@ export function createListHandler() {
|
||||
current.path = normalizePath(line.slice(9));
|
||||
} else if (line.startsWith('branch ')) {
|
||||
current.branch = line.slice(7).replace('refs/heads/', '');
|
||||
} else if (line.startsWith('detached')) {
|
||||
// Worktree is in detached HEAD state (e.g., during rebase)
|
||||
current.isDetached = true;
|
||||
} else if (line === '') {
|
||||
if (current.path && current.branch) {
|
||||
if (current.path) {
|
||||
const isMainWorktree = isFirst;
|
||||
// Check if the worktree directory actually exists
|
||||
// Skip checking/pruning the main worktree (projectPath itself)
|
||||
@@ -407,14 +576,19 @@ export function createListHandler() {
|
||||
} catch {
|
||||
worktreeExists = false;
|
||||
}
|
||||
|
||||
if (!isMainWorktree && !worktreeExists) {
|
||||
hasMissingWorktree = true;
|
||||
// Worktree directory doesn't exist - it was manually deleted
|
||||
removedWorktrees.push({
|
||||
path: current.path,
|
||||
branch: current.branch,
|
||||
});
|
||||
} else {
|
||||
// Worktree exists (or is main worktree), add it to the list
|
||||
// Only add to removed list if we know the branch name
|
||||
if (current.branch) {
|
||||
removedWorktrees.push({
|
||||
path: current.path,
|
||||
branch: current.branch,
|
||||
});
|
||||
}
|
||||
} else if (current.branch) {
|
||||
// Normal case: worktree with a known branch
|
||||
worktrees.push({
|
||||
path: current.path,
|
||||
branch: current.branch,
|
||||
@@ -423,16 +597,29 @@ export function createListHandler() {
|
||||
hasWorktree: true,
|
||||
});
|
||||
isFirst = false;
|
||||
} else if (current.isDetached && worktreeExists) {
|
||||
// Detached HEAD (e.g., rebase in progress) - try to recover branch name.
|
||||
// This is critical: without this, worktrees undergoing rebase/merge
|
||||
// operations would silently disappear from the UI.
|
||||
const recoveredBranch = await recoverBranchForDetachedWorktree(current.path);
|
||||
worktrees.push({
|
||||
path: current.path,
|
||||
branch: recoveredBranch || `(detached)`,
|
||||
isMain: isMainWorktree,
|
||||
isCurrent: false,
|
||||
hasWorktree: true,
|
||||
});
|
||||
isFirst = false;
|
||||
}
|
||||
}
|
||||
current = {};
|
||||
}
|
||||
}
|
||||
|
||||
// Prune removed worktrees from git (only if any were detected)
|
||||
if (removedWorktrees.length > 0) {
|
||||
// Prune removed worktrees from git (only if any missing worktrees were detected)
|
||||
if (hasMissingWorktree) {
|
||||
try {
|
||||
await execAsync('git worktree prune', { cwd: projectPath });
|
||||
await execGitCommand(['worktree', 'prune'], projectPath);
|
||||
} catch {
|
||||
// Prune failed, but we'll still report the removed worktrees
|
||||
}
|
||||
@@ -461,9 +648,7 @@ export function createListHandler() {
|
||||
if (includeDetails) {
|
||||
for (const worktree of worktrees) {
|
||||
try {
|
||||
const { stdout: statusOutput } = await execAsync('git status --porcelain', {
|
||||
cwd: worktree.path,
|
||||
});
|
||||
const statusOutput = await execGitCommand(['status', '--porcelain'], worktree.path);
|
||||
const changedFiles = statusOutput
|
||||
.trim()
|
||||
.split('\n')
|
||||
@@ -486,13 +671,14 @@ export function createListHandler() {
|
||||
// hasConflicts is true only when there are actual unresolved files
|
||||
worktree.hasConflicts = conflictState.hasConflicts;
|
||||
worktree.conflictFiles = conflictState.conflictFiles;
|
||||
worktree.conflictSourceBranch = conflictState.conflictSourceBranch;
|
||||
} catch {
|
||||
// Ignore conflict detection errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Assign PR info to each worktree, preferring fresh GitHub data over cached metadata.
|
||||
// Assign PR info to each worktree.
|
||||
// Only fetch GitHub PRs if includeDetails is requested (performance optimization).
|
||||
// Uses --state all to detect merged/closed PRs, limited to 1000 recent PRs.
|
||||
const githubPRs = includeDetails
|
||||
@@ -510,14 +696,27 @@ export function createListHandler() {
|
||||
const metadata = allMetadata.get(worktree.branch);
|
||||
const githubPR = githubPRs.get(worktree.branch);
|
||||
|
||||
if (githubPR) {
|
||||
// Prefer fresh GitHub data (it has the current state)
|
||||
const metadataPR = metadata?.pr;
|
||||
// Preserve explicit user-selected PR tracking from metadata when it differs
|
||||
// from branch-derived GitHub PR lookup. This allows "Change PR Number" to
|
||||
// persist instead of being overwritten by gh pr list for the branch.
|
||||
const hasManualOverride =
|
||||
!!metadataPR && !!githubPR && metadataPR.number !== githubPR.number;
|
||||
|
||||
if (hasManualOverride) {
|
||||
worktree.pr = metadataPR;
|
||||
} else if (githubPR) {
|
||||
// Use fresh GitHub data when there is no explicit override.
|
||||
worktree.pr = githubPR;
|
||||
|
||||
// Sync metadata with GitHub state when:
|
||||
// 1. No metadata exists for this PR (PR created externally)
|
||||
// 2. State has changed (e.g., merged/closed on GitHub)
|
||||
const needsSync = !metadata?.pr || metadata.pr.state !== githubPR.state;
|
||||
// Sync metadata when missing or stale so fallback data stays current.
|
||||
const needsSync =
|
||||
!metadataPR ||
|
||||
metadataPR.number !== githubPR.number ||
|
||||
metadataPR.state !== githubPR.state ||
|
||||
metadataPR.title !== githubPR.title ||
|
||||
metadataPR.url !== githubPR.url ||
|
||||
metadataPR.createdAt !== githubPR.createdAt;
|
||||
if (needsSync) {
|
||||
// Fire and forget - don't block the response
|
||||
updateWorktreePRInfo(projectPath, worktree.branch, githubPR).catch((err) => {
|
||||
@@ -526,9 +725,9 @@ export function createListHandler() {
|
||||
);
|
||||
});
|
||||
}
|
||||
} else if (metadata?.pr && metadata.pr.state === 'OPEN') {
|
||||
} else if (metadataPR && metadataPR.state === 'OPEN') {
|
||||
// Fall back to stored metadata only if the PR is still OPEN
|
||||
worktree.pr = metadata.pr;
|
||||
worktree.pr = metadataPR;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -538,6 +737,26 @@ export function createListHandler() {
|
||||
removedWorktrees: removedWorktrees.length > 0 ? removedWorktrees : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
// When git is unavailable (e.g. sandboxed E2E, PATH without git), return minimal list so UI still loads
|
||||
if (isSpawnENOENT(error)) {
|
||||
const projectPathFromBody = (req.body as { projectPath?: string })?.projectPath;
|
||||
const mainPath = projectPathFromBody ? normalizePath(projectPathFromBody) : undefined;
|
||||
if (mainPath) {
|
||||
res.json({
|
||||
success: true,
|
||||
worktrees: [
|
||||
{
|
||||
path: mainPath,
|
||||
branch: 'main',
|
||||
isMain: true,
|
||||
isCurrent: true,
|
||||
hasWorktree: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
logError(error, 'List worktrees failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
|
||||
@@ -23,9 +23,11 @@ import type { PullResult } from '../../../services/pull-service.js';
|
||||
export function createPullHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { worktreePath, remote, stashIfNeeded } = req.body as {
|
||||
const { worktreePath, remote, remoteBranch, stashIfNeeded } = req.body as {
|
||||
worktreePath: string;
|
||||
remote?: string;
|
||||
/** Specific remote branch to pull (e.g. 'main'). When provided, pulls this branch from the remote regardless of tracking config. */
|
||||
remoteBranch?: string;
|
||||
/** When true, automatically stash local changes before pulling and reapply after */
|
||||
stashIfNeeded?: boolean;
|
||||
};
|
||||
@@ -39,7 +41,7 @@ export function createPullHandler() {
|
||||
}
|
||||
|
||||
// Execute the pull via the service
|
||||
const result = await performPull(worktreePath, { remote, stashIfNeeded });
|
||||
const result = await performPull(worktreePath, { remote, remoteBranch, stashIfNeeded });
|
||||
|
||||
// Map service result to HTTP response
|
||||
mapResultToResponse(res, result);
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
/**
|
||||
* POST /push endpoint - Push a worktree branch to remote
|
||||
*
|
||||
* Git business logic is delegated to push-service.ts.
|
||||
*
|
||||
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||
* the requireValidWorktree middleware in index.ts
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
import { performPush } from '../../../services/push-service.js';
|
||||
|
||||
export function createPushHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { worktreePath, force, remote } = req.body as {
|
||||
const { worktreePath, force, remote, autoResolve } = req.body as {
|
||||
worktreePath: string;
|
||||
force?: boolean;
|
||||
remote?: string;
|
||||
autoResolve?: boolean;
|
||||
};
|
||||
|
||||
if (!worktreePath) {
|
||||
@@ -29,34 +29,28 @@ export function createPushHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get branch name
|
||||
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
const branchName = branchOutput.trim();
|
||||
const result = await performPush(worktreePath, { remote, force, autoResolve });
|
||||
|
||||
// Use specified remote or default to 'origin'
|
||||
const targetRemote = remote || 'origin';
|
||||
|
||||
// Push the branch
|
||||
const forceFlag = force ? '--force' : '';
|
||||
try {
|
||||
await execAsync(`git push -u ${targetRemote} ${branchName} ${forceFlag}`, {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
} catch {
|
||||
// Try setting upstream
|
||||
await execAsync(`git push --set-upstream ${targetRemote} ${branchName} ${forceFlag}`, {
|
||||
cwd: worktreePath,
|
||||
if (!result.success) {
|
||||
const statusCode = isClientError(result.error ?? '') ? 400 : 500;
|
||||
res.status(statusCode).json({
|
||||
success: false,
|
||||
error: result.error,
|
||||
diverged: result.diverged,
|
||||
hasConflicts: result.hasConflicts,
|
||||
conflictFiles: result.conflictFiles,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
branch: branchName,
|
||||
pushed: true,
|
||||
message: `Successfully pushed ${branchName} to ${targetRemote}`,
|
||||
branch: result.branch,
|
||||
pushed: result.pushed,
|
||||
diverged: result.diverged,
|
||||
autoResolved: result.autoResolved,
|
||||
message: result.message,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -65,3 +59,15 @@ export function createPushHandler() {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether an error message represents a client error (400)
|
||||
* vs a server error (500).
|
||||
*/
|
||||
function isClientError(errorMessage: string): boolean {
|
||||
return (
|
||||
errorMessage.includes('detached HEAD') ||
|
||||
errorMessage.includes('rejected') ||
|
||||
errorMessage.includes('diverged')
|
||||
);
|
||||
}
|
||||
|
||||
76
apps/server/src/routes/worktree/routes/set-tracking.ts
Normal file
76
apps/server/src/routes/worktree/routes/set-tracking.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* POST /set-tracking endpoint - Set the upstream tracking branch for a worktree
|
||||
*
|
||||
* Sets `git branch --set-upstream-to=<remote>/<branch>` for the current branch.
|
||||
*
|
||||
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||
* the requireValidWorktree middleware in index.ts
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { execGitCommand } from '@automaker/git-utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { getCurrentBranch } from '../../../lib/git.js';
|
||||
|
||||
export function createSetTrackingHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { worktreePath, remote, branch } = req.body as {
|
||||
worktreePath: string;
|
||||
remote: string;
|
||||
branch?: string;
|
||||
};
|
||||
|
||||
if (!worktreePath) {
|
||||
res.status(400).json({ success: false, error: 'worktreePath required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!remote) {
|
||||
res.status(400).json({ success: false, error: 'remote required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current branch if not provided
|
||||
let targetBranch = branch;
|
||||
if (!targetBranch) {
|
||||
try {
|
||||
targetBranch = await getCurrentBranch(worktreePath);
|
||||
} catch (err) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Failed to get current branch: ${getErrorMessage(err)}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetBranch === 'HEAD') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Cannot set tracking in detached HEAD state.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Set upstream tracking (pass local branch name as final arg to be explicit)
|
||||
await execGitCommand(
|
||||
['branch', '--set-upstream-to', `${remote}/${targetBranch}`, targetBranch],
|
||||
worktreePath
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
branch: targetBranch,
|
||||
remote,
|
||||
upstream: `${remote}/${targetBranch}`,
|
||||
message: `Set tracking branch to ${remote}/${targetBranch}`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Set tracking branch failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
66
apps/server/src/routes/worktree/routes/sync.ts
Normal file
66
apps/server/src/routes/worktree/routes/sync.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* POST /sync endpoint - Pull then push a worktree branch
|
||||
*
|
||||
* Performs a full sync operation: pull latest from remote, then push
|
||||
* local commits. Handles divergence automatically.
|
||||
*
|
||||
* Git business logic is delegated to sync-service.ts.
|
||||
*
|
||||
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||
* the requireValidWorktree middleware in index.ts
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { performSync } from '../../../services/sync-service.js';
|
||||
|
||||
export function createSyncHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { worktreePath, remote } = req.body as {
|
||||
worktreePath: string;
|
||||
remote?: string;
|
||||
};
|
||||
|
||||
if (!worktreePath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'worktreePath required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await performSync(worktreePath, { remote });
|
||||
|
||||
if (!result.success) {
|
||||
const statusCode = result.hasConflicts ? 409 : 500;
|
||||
res.status(statusCode).json({
|
||||
success: false,
|
||||
error: result.error,
|
||||
hasConflicts: result.hasConflicts,
|
||||
conflictFiles: result.conflictFiles,
|
||||
conflictSource: result.conflictSource,
|
||||
pulled: result.pulled,
|
||||
pushed: result.pushed,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
branch: result.branch,
|
||||
pulled: result.pulled,
|
||||
pushed: result.pushed,
|
||||
isFastForward: result.isFastForward,
|
||||
isMerge: result.isMerge,
|
||||
autoResolved: result.autoResolved,
|
||||
message: result.message,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Sync worktree failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
163
apps/server/src/routes/worktree/routes/update-pr-number.ts
Normal file
163
apps/server/src/routes/worktree/routes/update-pr-number.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* POST /update-pr-number endpoint - Update the tracked PR number for a worktree
|
||||
*
|
||||
* Allows users to manually change which PR number is tracked for a worktree branch.
|
||||
* Fetches updated PR info from GitHub when available, or updates metadata with the
|
||||
* provided number only if GitHub CLI is unavailable.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getErrorMessage, logError, execAsync, execEnv, isGhCliAvailable } from '../common.js';
|
||||
import { updateWorktreePRInfo } from '../../../lib/worktree-metadata.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { validatePRState } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('UpdatePRNumber');
|
||||
|
||||
export function createUpdatePRNumberHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { worktreePath, projectPath, prNumber } = req.body as {
|
||||
worktreePath: string;
|
||||
projectPath?: string;
|
||||
prNumber: number;
|
||||
};
|
||||
|
||||
if (!worktreePath) {
|
||||
res.status(400).json({ success: false, error: 'worktreePath required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!prNumber ||
|
||||
typeof prNumber !== 'number' ||
|
||||
prNumber <= 0 ||
|
||||
!Number.isInteger(prNumber)
|
||||
) {
|
||||
res.status(400).json({ success: false, error: 'prNumber must be a positive integer' });
|
||||
return;
|
||||
}
|
||||
|
||||
const effectiveProjectPath = projectPath || worktreePath;
|
||||
|
||||
// Get current branch name
|
||||
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: worktreePath,
|
||||
env: execEnv,
|
||||
});
|
||||
const branchName = branchOutput.trim();
|
||||
|
||||
if (!branchName || branchName === 'HEAD') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Cannot update PR number in detached HEAD state',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to fetch PR info from GitHub for the given PR number
|
||||
const ghCliAvailable = await isGhCliAvailable();
|
||||
|
||||
if (ghCliAvailable) {
|
||||
try {
|
||||
// Detect repository for gh CLI
|
||||
let repoFlag = '';
|
||||
try {
|
||||
const { stdout: remotes } = await execAsync('git remote -v', {
|
||||
cwd: worktreePath,
|
||||
env: execEnv,
|
||||
});
|
||||
const lines = remotes.split(/\r?\n/);
|
||||
let upstreamRepo: string | null = null;
|
||||
let originOwner: string | null = null;
|
||||
let originRepo: string | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const match =
|
||||
line.match(/^(\w+)\s+.*[:/]([^/]+)\/([^/\s]+?)(?:\.git)?\s+\(fetch\)/) ||
|
||||
line.match(/^(\w+)\s+git@[^:]+:([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/) ||
|
||||
line.match(/^(\w+)\s+https?:\/\/[^/]+\/([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/);
|
||||
|
||||
if (match) {
|
||||
const [, remoteName, owner, repo] = match;
|
||||
if (remoteName === 'upstream') {
|
||||
upstreamRepo = `${owner}/${repo}`;
|
||||
} else if (remoteName === 'origin') {
|
||||
originOwner = owner;
|
||||
originRepo = repo;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const targetRepo =
|
||||
upstreamRepo || (originOwner && originRepo ? `${originOwner}/${originRepo}` : null);
|
||||
if (targetRepo) {
|
||||
repoFlag = ` --repo "${targetRepo}"`;
|
||||
}
|
||||
} catch {
|
||||
// Ignore remote parsing errors
|
||||
}
|
||||
|
||||
// Fetch PR info from GitHub using the PR number
|
||||
const viewCmd = `gh pr view ${prNumber}${repoFlag} --json number,title,url,state,createdAt`;
|
||||
const { stdout: prOutput } = await execAsync(viewCmd, {
|
||||
cwd: worktreePath,
|
||||
env: execEnv,
|
||||
});
|
||||
|
||||
const prData = JSON.parse(prOutput);
|
||||
|
||||
const prInfo = {
|
||||
number: prData.number,
|
||||
url: prData.url,
|
||||
title: prData.title,
|
||||
state: validatePRState(prData.state),
|
||||
createdAt: prData.createdAt || new Date().toISOString(),
|
||||
};
|
||||
|
||||
await updateWorktreePRInfo(effectiveProjectPath, branchName, prInfo);
|
||||
|
||||
logger.info(`Updated PR tracking to #${prNumber} for branch ${branchName}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
branch: branchName,
|
||||
prInfo,
|
||||
},
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to fetch PR #${prNumber} from GitHub:`, error);
|
||||
// Fall through to simple update below
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: update with just the number, preserving existing PR info structure
|
||||
// or creating minimal info if no GitHub data available
|
||||
const prInfo = {
|
||||
number: prNumber,
|
||||
url: `https://github.com/pulls/${prNumber}`,
|
||||
title: `PR #${prNumber}`,
|
||||
state: validatePRState('OPEN'),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await updateWorktreePRInfo(effectiveProjectPath, branchName, prInfo);
|
||||
|
||||
logger.info(`Updated PR tracking to #${prNumber} for branch ${branchName} (no GitHub data)`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
branch: branchName,
|
||||
prInfo,
|
||||
ghCliUnavailable: !ghCliAvailable,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Update PR number failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
import type {
|
||||
PlanningMode,
|
||||
ThinkingLevel,
|
||||
ReasoningEffort,
|
||||
ParsedTask,
|
||||
ClaudeCompatibleProvider,
|
||||
Credentials,
|
||||
@@ -24,11 +25,14 @@ export interface AgentExecutionOptions {
|
||||
previousContent?: string;
|
||||
systemPrompt?: string;
|
||||
autoLoadClaudeMd?: boolean;
|
||||
useClaudeCodeSystemPrompt?: boolean;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
reasoningEffort?: ReasoningEffort;
|
||||
branchName?: string | null;
|
||||
credentials?: Credentials;
|
||||
claudeCompatibleProvider?: ClaudeCompatibleProvider;
|
||||
mcpServers?: Record<string, unknown>;
|
||||
sdkSessionId?: string;
|
||||
sdkOptions?: {
|
||||
maxTurns?: number;
|
||||
allowedTools?: string[];
|
||||
@@ -40,6 +44,8 @@ export interface AgentExecutionOptions {
|
||||
specAlreadyDetected?: boolean;
|
||||
existingApprovedPlanContent?: string;
|
||||
persistedTasks?: ParsedTask[];
|
||||
/** Feature status - used to check if pipeline summary extraction is required */
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface AgentExecutionResult {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import path from 'path';
|
||||
import type { ExecuteOptions, ParsedTask } from '@automaker/types';
|
||||
import { isPipelineStatus } from '@automaker/types';
|
||||
import { buildPromptWithImages, createLogger, isAuthenticationError } from '@automaker/utils';
|
||||
import { getFeatureDir } from '@automaker/platform';
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
@@ -38,7 +39,7 @@ export type {
|
||||
|
||||
const logger = createLogger('AgentExecutor');
|
||||
|
||||
const DEFAULT_MAX_TURNS = 1000;
|
||||
const DEFAULT_MAX_TURNS = 10000;
|
||||
|
||||
export class AgentExecutor {
|
||||
private static readonly WRITE_DEBOUNCE_MS = 500;
|
||||
@@ -91,8 +92,10 @@ export class AgentExecutor {
|
||||
existingApprovedPlanContent,
|
||||
persistedTasks,
|
||||
credentials,
|
||||
status, // Feature status for pipeline summary check
|
||||
claudeCompatibleProvider,
|
||||
mcpServers,
|
||||
sdkSessionId,
|
||||
sdkOptions,
|
||||
} = options;
|
||||
const { content: promptContent } = await buildPromptWithImages(
|
||||
@@ -127,8 +130,10 @@ export class AgentExecutor {
|
||||
? (mcpServers as Record<string, { command: string }>)
|
||||
: undefined,
|
||||
thinkingLevel: options.thinkingLevel,
|
||||
reasoningEffort: options.reasoningEffort,
|
||||
credentials,
|
||||
claudeCompatibleProvider,
|
||||
sdkSessionId,
|
||||
};
|
||||
const featureDirForOutput = getFeatureDir(projectPath, featureId);
|
||||
const outputPath = path.join(featureDirForOutput, 'agent-output.md');
|
||||
@@ -204,6 +209,17 @@ export class AgentExecutor {
|
||||
if (writeTimeout) clearTimeout(writeTimeout);
|
||||
if (rawWriteTimeout) clearTimeout(rawWriteTimeout);
|
||||
await writeToFile();
|
||||
|
||||
// Extract and save summary from the new content generated in this session
|
||||
await this.extractAndSaveSessionSummary(
|
||||
projectPath,
|
||||
featureId,
|
||||
result.responseText,
|
||||
previousContent,
|
||||
callbacks,
|
||||
status
|
||||
);
|
||||
|
||||
return {
|
||||
responseText: result.responseText,
|
||||
specDetected: true,
|
||||
@@ -217,6 +233,9 @@ export class AgentExecutor {
|
||||
try {
|
||||
const stream = provider.executeQuery(executeOptions);
|
||||
streamLoop: for await (const msg of stream) {
|
||||
if (msg.session_id && msg.session_id !== options.sdkSessionId) {
|
||||
options.sdkSessionId = msg.session_id;
|
||||
}
|
||||
receivedAnyStreamMessage = true;
|
||||
appendRawEvent(msg);
|
||||
if (abortController.signal.aborted) {
|
||||
@@ -290,8 +309,28 @@ export class AgentExecutor {
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'error') {
|
||||
throw new Error(AgentExecutor.sanitizeProviderError(msg.error));
|
||||
} else if (msg.type === 'result' && msg.subtype === 'success') scheduleWrite();
|
||||
const sanitized = AgentExecutor.sanitizeProviderError(msg.error);
|
||||
logger.error(
|
||||
`[execute] Feature ${featureId} received error from provider. ` +
|
||||
`raw="${msg.error}", sanitized="${sanitized}", session_id=${msg.session_id ?? 'none'}`
|
||||
);
|
||||
throw new Error(sanitized);
|
||||
} else if (msg.type === 'result') {
|
||||
if (msg.subtype === 'success') {
|
||||
scheduleWrite();
|
||||
} else if (msg.subtype?.startsWith('error')) {
|
||||
// Non-success result subtypes from the SDK (error_max_turns, error_during_execution, etc.)
|
||||
logger.error(
|
||||
`[execute] Feature ${featureId} ended with error subtype: ${msg.subtype}. ` +
|
||||
`session_id=${msg.session_id ?? 'none'}`
|
||||
);
|
||||
throw new Error(`Agent execution ended with: ${msg.subtype}`);
|
||||
} else {
|
||||
logger.warn(
|
||||
`[execute] Feature ${featureId} received unhandled result subtype: ${msg.subtype}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearInterval(streamHeartbeat);
|
||||
@@ -314,9 +353,78 @@ export class AgentExecutor {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Capture summary if it hasn't been captured by handleSpecGenerated or executeTasksLoop
|
||||
// or if we're in a simple execution mode (planningMode='skip')
|
||||
await this.extractAndSaveSessionSummary(
|
||||
projectPath,
|
||||
featureId,
|
||||
responseText,
|
||||
previousContent,
|
||||
callbacks,
|
||||
status
|
||||
);
|
||||
|
||||
return { responseText, specDetected, tasksCompleted, aborted };
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the follow-up session scaffold marker from content.
|
||||
* The scaffold is added when resuming a session with previous content:
|
||||
* "\n\n---\n\n## Follow-up Session\n\n"
|
||||
* This ensures fallback summaries don't include the scaffold header.
|
||||
*
|
||||
* The regex pattern handles variations in whitespace while matching the
|
||||
* scaffold structure: dashes followed by "## Follow-up Session" at the
|
||||
* start of the content.
|
||||
*/
|
||||
private static stripFollowUpScaffold(content: string): string {
|
||||
// Pattern matches: ^\s*---\s*##\s*Follow-up Session\s*
|
||||
// - ^ = start of content (scaffold is always at the beginning of sessionContent)
|
||||
// - \s* = any whitespace (handles \n\n before ---, spaces/tabs between markers)
|
||||
// - --- = literal dashes
|
||||
// - \s* = whitespace between dashes and heading
|
||||
// - ## = heading marker
|
||||
// - \s* = whitespace before "Follow-up"
|
||||
// - Follow-up Session = literal heading text
|
||||
// - \s* = trailing whitespace/newlines after heading
|
||||
const scaffoldPattern = /^\s*---\s*##\s*Follow-up Session\s*/;
|
||||
return content.replace(scaffoldPattern, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract summary ONLY from the new content generated in this session
|
||||
* and save it via the provided callback.
|
||||
*/
|
||||
private async extractAndSaveSessionSummary(
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
responseText: string,
|
||||
previousContent: string | undefined,
|
||||
callbacks: AgentExecutorCallbacks,
|
||||
status?: string
|
||||
): Promise<void> {
|
||||
const sessionContent = responseText.substring(previousContent ? previousContent.length : 0);
|
||||
const summary = extractSummary(sessionContent);
|
||||
if (summary) {
|
||||
await callbacks.saveFeatureSummary(projectPath, featureId, summary);
|
||||
return;
|
||||
}
|
||||
|
||||
// If we're in a pipeline step, a summary is expected. Use a fallback if extraction fails.
|
||||
if (isPipelineStatus(status)) {
|
||||
// Strip any follow-up session scaffold before using as fallback
|
||||
const cleanSessionContent = AgentExecutor.stripFollowUpScaffold(sessionContent);
|
||||
const fallback = cleanSessionContent.trim();
|
||||
if (fallback) {
|
||||
await callbacks.saveFeatureSummary(projectPath, featureId, fallback);
|
||||
}
|
||||
logger.warn(
|
||||
`[AgentExecutor] Mandatory summary extraction failed for pipeline feature ${featureId} (status="${status}")`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async executeTasksLoop(
|
||||
options: AgentExecutionOptions,
|
||||
tasks: ParsedTask[],
|
||||
@@ -385,6 +493,9 @@ export class AgentExecutor {
|
||||
taskCompleteDetected = false;
|
||||
|
||||
for await (const msg of taskStream) {
|
||||
if (msg.session_id && msg.session_id !== options.sdkSessionId) {
|
||||
options.sdkSessionId = msg.session_id;
|
||||
}
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
for (const b of msg.message.content) {
|
||||
if (b.type === 'text') {
|
||||
@@ -410,14 +521,15 @@ export class AgentExecutor {
|
||||
}
|
||||
}
|
||||
if (!taskCompleteDetected) {
|
||||
const cid = detectTaskCompleteMarker(taskOutput);
|
||||
if (cid) {
|
||||
const completeMarker = detectTaskCompleteMarker(taskOutput);
|
||||
if (completeMarker) {
|
||||
taskCompleteDetected = true;
|
||||
await this.featureStateManager.updateTaskStatus(
|
||||
projectPath,
|
||||
featureId,
|
||||
cid,
|
||||
'completed'
|
||||
completeMarker.id,
|
||||
'completed',
|
||||
completeMarker.summary
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -438,16 +550,28 @@ export class AgentExecutor {
|
||||
});
|
||||
}
|
||||
} else if (msg.type === 'error') {
|
||||
// Clean the error: strip ANSI codes and redundant "Error: " prefix
|
||||
const cleanedError =
|
||||
(msg.error || `Error during task ${task.id}`)
|
||||
.replace(/\x1b\[[0-9;]*m/g, '')
|
||||
.replace(/^Error:\s*/i, '')
|
||||
.trim() || `Error during task ${task.id}`;
|
||||
throw new Error(cleanedError);
|
||||
} else if (msg.type === 'result' && msg.subtype === 'success') {
|
||||
taskOutput += msg.result || '';
|
||||
responseText += msg.result || '';
|
||||
const fallback = `Error during task ${task.id}`;
|
||||
const sanitized = AgentExecutor.sanitizeProviderError(msg.error || fallback);
|
||||
logger.error(
|
||||
`[executeTasksLoop] Feature ${featureId} task ${task.id} received error from provider. ` +
|
||||
`raw="${msg.error}", sanitized="${sanitized}", session_id=${msg.session_id ?? 'none'}`
|
||||
);
|
||||
throw new Error(sanitized);
|
||||
} else if (msg.type === 'result') {
|
||||
if (msg.subtype === 'success') {
|
||||
taskOutput += msg.result || '';
|
||||
responseText += msg.result || '';
|
||||
} else if (msg.subtype?.startsWith('error')) {
|
||||
logger.error(
|
||||
`[executeTasksLoop] Feature ${featureId} task ${task.id} ended with error subtype: ${msg.subtype}. ` +
|
||||
`session_id=${msg.session_id ?? 'none'}`
|
||||
);
|
||||
throw new Error(`Agent execution ended with: ${msg.subtype}`);
|
||||
} else {
|
||||
logger.warn(
|
||||
`[executeTasksLoop] Feature ${featureId} task ${task.id} received unhandled result subtype: ${msg.subtype}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!taskCompleteDetected)
|
||||
@@ -483,8 +607,6 @@ export class AgentExecutor {
|
||||
}
|
||||
}
|
||||
}
|
||||
const summary = extractSummary(responseText);
|
||||
if (summary) await callbacks.saveFeatureSummary(projectPath, featureId, summary);
|
||||
return { responseText, tasksCompleted, aborted: false };
|
||||
}
|
||||
|
||||
@@ -599,6 +721,9 @@ export class AgentExecutor {
|
||||
for await (const msg of provider.executeQuery(
|
||||
this.buildExecOpts(options, revPrompt, sdkOptions?.maxTurns ?? DEFAULT_MAX_TURNS)
|
||||
)) {
|
||||
if (msg.session_id && msg.session_id !== options.sdkSessionId) {
|
||||
options.sdkSessionId = msg.session_id;
|
||||
}
|
||||
if (msg.type === 'assistant' && msg.message?.content)
|
||||
for (const b of msg.message.content)
|
||||
if (b.type === 'text') {
|
||||
@@ -678,8 +803,6 @@ export class AgentExecutor {
|
||||
);
|
||||
responseText = r.responseText;
|
||||
}
|
||||
const summary = extractSummary(responseText);
|
||||
if (summary) await callbacks.saveFeatureSummary(projectPath, featureId, summary);
|
||||
return { responseText, tasksCompleted };
|
||||
}
|
||||
|
||||
@@ -692,12 +815,14 @@ export class AgentExecutor {
|
||||
allowedTools: o.sdkOptions?.allowedTools as string[] | undefined,
|
||||
abortController: o.abortController,
|
||||
thinkingLevel: o.thinkingLevel,
|
||||
reasoningEffort: o.reasoningEffort,
|
||||
mcpServers:
|
||||
o.mcpServers && Object.keys(o.mcpServers).length > 0
|
||||
? (o.mcpServers as Record<string, { command: string }>)
|
||||
: undefined,
|
||||
credentials: o.credentials,
|
||||
claudeCompatibleProvider: o.claudeCompatibleProvider,
|
||||
sdkSessionId: o.sdkSessionId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -717,6 +842,9 @@ export class AgentExecutor {
|
||||
for await (const msg of provider.executeQuery(
|
||||
this.buildExecOpts(options, contPrompt, options.sdkOptions?.maxTurns ?? DEFAULT_MAX_TURNS)
|
||||
)) {
|
||||
if (msg.session_id && msg.session_id !== options.sdkSessionId) {
|
||||
options.sdkSessionId = msg.session_id;
|
||||
}
|
||||
if (msg.type === 'assistant' && msg.message?.content)
|
||||
for (const b of msg.message.content) {
|
||||
if (b.type === 'text') {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.
|
||||
import type { SettingsService } from './settings-service.js';
|
||||
import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getUseClaudeCodeSystemPromptSetting,
|
||||
filterClaudeMdFromContext,
|
||||
getMCPServersFromSettings,
|
||||
getPromptCustomization,
|
||||
@@ -329,12 +330,6 @@ export class AgentService {
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Build conversation history from existing messages BEFORE adding current message
|
||||
const conversationHistory = session.messages.map((msg) => ({
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
}));
|
||||
|
||||
session.messages.push(userMessage);
|
||||
session.isRunning = true;
|
||||
session.abortController = new AbortController();
|
||||
@@ -363,6 +358,22 @@ export class AgentService {
|
||||
'[AgentService]'
|
||||
);
|
||||
|
||||
// Load useClaudeCodeSystemPrompt setting (project setting takes precedence over global)
|
||||
// Wrap in try/catch so transient settingsService errors don't abort message processing
|
||||
let useClaudeCodeSystemPrompt = true;
|
||||
try {
|
||||
useClaudeCodeSystemPrompt = await getUseClaudeCodeSystemPromptSetting(
|
||||
effectiveWorkDir,
|
||||
this.settingsService,
|
||||
'[AgentService]'
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
'[AgentService] getUseClaudeCodeSystemPromptSetting failed, defaulting to true',
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
// Load MCP servers from settings (global setting only)
|
||||
const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AgentService]');
|
||||
|
||||
@@ -406,6 +417,7 @@ export class AgentService {
|
||||
}
|
||||
}
|
||||
|
||||
let combinedSystemPrompt: string | 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
|
||||
const contextResult = await loadContextFiles({
|
||||
@@ -423,7 +435,7 @@ export class AgentService {
|
||||
|
||||
// Build combined system prompt with base prompt and context files
|
||||
const baseSystemPrompt = await this.getSystemPrompt();
|
||||
const combinedSystemPrompt = contextFilesPrompt
|
||||
combinedSystemPrompt = contextFilesPrompt
|
||||
? `${contextFilesPrompt}\n\n${baseSystemPrompt}`
|
||||
: baseSystemPrompt;
|
||||
|
||||
@@ -448,6 +460,7 @@ export class AgentService {
|
||||
systemPrompt: combinedSystemPrompt,
|
||||
abortController: session.abortController!,
|
||||
autoLoadClaudeMd,
|
||||
useClaudeCodeSystemPrompt,
|
||||
thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models
|
||||
maxTurns: userMaxTurns, // User-configured max turns from settings
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
||||
@@ -474,7 +487,19 @@ export class AgentService {
|
||||
Object.keys(customSubagents).length > 0;
|
||||
|
||||
// Base tools that match the provider's default set
|
||||
const baseTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'];
|
||||
const baseTools = [
|
||||
'Read',
|
||||
'Write',
|
||||
'Edit',
|
||||
'MultiEdit',
|
||||
'Glob',
|
||||
'Grep',
|
||||
'LS',
|
||||
'Bash',
|
||||
'WebSearch',
|
||||
'WebFetch',
|
||||
'TodoWrite',
|
||||
];
|
||||
|
||||
if (allowedTools) {
|
||||
allowedTools = [...allowedTools]; // Create a copy to avoid mutating SDK options
|
||||
@@ -513,6 +538,14 @@ export class AgentService {
|
||||
: stripProviderPrefix(effectiveModel);
|
||||
|
||||
// Build options for provider
|
||||
const conversationHistory = session.messages
|
||||
.slice(0, -1)
|
||||
.map((msg) => ({
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
}))
|
||||
.filter((msg) => msg.content.trim().length > 0);
|
||||
|
||||
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")
|
||||
@@ -522,7 +555,8 @@ export class AgentService {
|
||||
maxTurns: maxTurns,
|
||||
allowedTools: allowedTools,
|
||||
abortController: session.abortController!,
|
||||
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
|
||||
conversationHistory:
|
||||
conversationHistory && conversationHistory.length > 0 ? conversationHistory : undefined,
|
||||
settingSources: settingSources.length > 0 ? settingSources : undefined,
|
||||
sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
|
||||
@@ -550,6 +584,7 @@ export class AgentService {
|
||||
let currentAssistantMessage: Message | null = null;
|
||||
let responseText = '';
|
||||
const toolUses: Array<{ name: string; input: unknown }> = [];
|
||||
const toolNamesById = new Map<string, string>();
|
||||
|
||||
for await (const msg of stream) {
|
||||
// Capture SDK session ID from any message and persist it.
|
||||
@@ -594,11 +629,50 @@ export class AgentService {
|
||||
input: block.input,
|
||||
};
|
||||
toolUses.push(toolUse);
|
||||
if (block.tool_use_id) {
|
||||
toolNamesById.set(block.tool_use_id, toolUse.name);
|
||||
}
|
||||
|
||||
this.emitAgentEvent(sessionId, {
|
||||
type: 'tool_use',
|
||||
tool: toolUse,
|
||||
});
|
||||
} else if (block.type === 'tool_result') {
|
||||
const toolUseId = block.tool_use_id;
|
||||
const toolName = toolUseId ? toolNamesById.get(toolUseId) : undefined;
|
||||
|
||||
// Normalize block.content to a string for the emitted event
|
||||
const rawContent: unknown = block.content;
|
||||
let contentString: string;
|
||||
if (typeof rawContent === 'string') {
|
||||
contentString = rawContent;
|
||||
} else if (Array.isArray(rawContent)) {
|
||||
// Extract text from content blocks (TextBlock, ImageBlock, etc.)
|
||||
contentString = rawContent
|
||||
.map((part: { text?: string; type?: string }) => {
|
||||
if (typeof part === 'string') return part;
|
||||
if (part.text) return part.text;
|
||||
// For non-text blocks (e.g., images), represent as type indicator
|
||||
if (part.type) return `[${part.type}]`;
|
||||
return JSON.stringify(part);
|
||||
})
|
||||
.join('\n');
|
||||
} else if (rawContent !== undefined && rawContent !== null) {
|
||||
contentString = JSON.stringify(rawContent);
|
||||
} else {
|
||||
contentString = '';
|
||||
}
|
||||
|
||||
this.emitAgentEvent(sessionId, {
|
||||
type: 'tool_result',
|
||||
tool: {
|
||||
name: toolName || 'unknown',
|
||||
input: {
|
||||
toolUseId,
|
||||
content: contentString,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,12 @@ const logger = createLogger('AutoLoopCoordinator');
|
||||
const CONSECUTIVE_FAILURE_THRESHOLD = 3;
|
||||
const FAILURE_WINDOW_MS = 60000;
|
||||
|
||||
// Sleep intervals for the auto-loop (in milliseconds)
|
||||
const SLEEP_INTERVAL_CAPACITY_MS = 5000;
|
||||
const SLEEP_INTERVAL_IDLE_MS = 10000;
|
||||
const SLEEP_INTERVAL_NORMAL_MS = 2000;
|
||||
const SLEEP_INTERVAL_ERROR_MS = 5000;
|
||||
|
||||
export interface AutoModeConfig {
|
||||
maxConcurrency: number;
|
||||
useWorktrees: boolean;
|
||||
@@ -169,20 +175,32 @@ export class AutoLoopCoordinator {
|
||||
// presence is accounted for when deciding whether to dispatch new auto-mode tasks.
|
||||
const runningCount = await this.getRunningCountForWorktree(projectPath, branchName);
|
||||
if (runningCount >= projectState.config.maxConcurrency) {
|
||||
await this.sleep(5000, projectState.abortController.signal);
|
||||
await this.sleep(SLEEP_INTERVAL_CAPACITY_MS, projectState.abortController.signal);
|
||||
continue;
|
||||
}
|
||||
const pendingFeatures = await this.loadPendingFeaturesFn(projectPath, branchName);
|
||||
if (pendingFeatures.length === 0) {
|
||||
if (runningCount === 0 && !projectState.hasEmittedIdleEvent) {
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_idle', {
|
||||
message: 'No pending features - auto mode idle',
|
||||
// Double-check that we have no features in 'in_progress' state that might
|
||||
// have been released from the concurrency manager but not yet updated to
|
||||
// their final status. This prevents auto_mode_idle from firing prematurely
|
||||
// when features are transitioning states (e.g., during status update).
|
||||
const hasInProgressFeatures = await this.hasInProgressFeaturesForWorktree(
|
||||
projectPath,
|
||||
branchName,
|
||||
});
|
||||
projectState.hasEmittedIdleEvent = true;
|
||||
branchName
|
||||
);
|
||||
|
||||
// Only emit auto_mode_idle if we're truly done with all features
|
||||
if (!hasInProgressFeatures) {
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_idle', {
|
||||
message: 'No pending features - auto mode idle',
|
||||
projectPath,
|
||||
branchName,
|
||||
});
|
||||
projectState.hasEmittedIdleEvent = true;
|
||||
}
|
||||
}
|
||||
await this.sleep(10000, projectState.abortController.signal);
|
||||
await this.sleep(SLEEP_INTERVAL_IDLE_MS, projectState.abortController.signal);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -228,10 +246,10 @@ export class AutoLoopCoordinator {
|
||||
}
|
||||
});
|
||||
}
|
||||
await this.sleep(2000, projectState.abortController.signal);
|
||||
await this.sleep(SLEEP_INTERVAL_NORMAL_MS, projectState.abortController.signal);
|
||||
} catch {
|
||||
if (projectState.abortController.signal.aborted) break;
|
||||
await this.sleep(5000, projectState.abortController.signal);
|
||||
await this.sleep(SLEEP_INTERVAL_ERROR_MS, projectState.abortController.signal);
|
||||
}
|
||||
}
|
||||
projectState.isRunning = false;
|
||||
@@ -462,4 +480,48 @@ export class AutoLoopCoordinator {
|
||||
signal?.addEventListener('abort', onAbort);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a feature belongs to the current worktree based on branch name.
|
||||
* For main worktree (branchName === null or 'main'): includes features with no branchName or branchName === 'main'.
|
||||
* For feature worktrees (branchName !== null and !== 'main'): only includes features with matching branchName.
|
||||
*/
|
||||
private featureBelongsToWorktree(feature: Feature, branchName: string | null): boolean {
|
||||
const isMainWorktree = branchName === null || branchName === 'main';
|
||||
if (isMainWorktree) {
|
||||
// Main worktree: include features with no branchName or branchName === 'main'
|
||||
return !feature.branchName || feature.branchName === 'main';
|
||||
} else {
|
||||
// Feature worktree: only include exact branch match
|
||||
return feature.branchName === branchName;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are features in 'in_progress' status for the current worktree.
|
||||
* This prevents auto_mode_idle from firing prematurely when features are
|
||||
* transitioning states (e.g., during status update from in_progress to completed).
|
||||
*/
|
||||
private async hasInProgressFeaturesForWorktree(
|
||||
projectPath: string,
|
||||
branchName: string | null
|
||||
): Promise<boolean> {
|
||||
if (!this.loadAllFeaturesFn) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const allFeatures = await this.loadAllFeaturesFn(projectPath);
|
||||
return allFeatures.some(
|
||||
(f) => f.status === 'in_progress' && this.featureBelongsToWorktree(f, branchName)
|
||||
);
|
||||
} catch (error) {
|
||||
const errorInfo = classifyError(error);
|
||||
logger.warn(
|
||||
`Failed to load all features for idle check (projectPath=${projectPath}, branchName=${branchName}): ${errorInfo.message}`,
|
||||
error
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,9 +232,10 @@ export class AutoModeServiceCompat {
|
||||
}
|
||||
|
||||
async detectOrphanedFeatures(
|
||||
projectPath: string
|
||||
projectPath: string,
|
||||
preloadedFeatures?: Feature[]
|
||||
): Promise<Array<{ feature: Feature; missingBranch: string }>> {
|
||||
const facade = this.createFacade(projectPath);
|
||||
return facade.detectOrphanedFeatures();
|
||||
return facade.detectOrphanedFeatures(preloadedFeatures);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,13 @@
|
||||
import path from 'path';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import type { Feature, PlanningMode, ThinkingLevel } from '@automaker/types';
|
||||
import { DEFAULT_MAX_CONCURRENCY, DEFAULT_MODELS, stripProviderPrefix } from '@automaker/types';
|
||||
import type { Feature, PlanningMode, ThinkingLevel, ReasoningEffort } from '@automaker/types';
|
||||
import {
|
||||
DEFAULT_MAX_CONCURRENCY,
|
||||
DEFAULT_MODELS,
|
||||
stripProviderPrefix,
|
||||
isPipelineStatus,
|
||||
} from '@automaker/types';
|
||||
import { resolveModelString } from '@automaker/model-resolver';
|
||||
import { createLogger, loadContextFiles, classifyError } from '@automaker/utils';
|
||||
import { getFeatureDir } from '@automaker/platform';
|
||||
@@ -23,7 +28,7 @@ import * as secureFs from '../../lib/secure-fs.js';
|
||||
import { validateWorkingDirectory, createAutoModeOptions } from '../../lib/sdk-options.js';
|
||||
import {
|
||||
getPromptCustomization,
|
||||
getProviderByModelId,
|
||||
resolveProviderContext,
|
||||
getMCPServersFromSettings,
|
||||
getDefaultMaxTurnsSetting,
|
||||
} from '../../lib/settings-helpers.js';
|
||||
@@ -79,6 +84,37 @@ export class AutoModeServiceFacade {
|
||||
private readonly settingsService: SettingsService | null
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Determine if a feature is eligible to be picked up by the auto-mode loop.
|
||||
*
|
||||
* @param feature - The feature to check
|
||||
* @param branchName - The current worktree branch name (null for main)
|
||||
* @param primaryBranch - The resolved primary branch name for the project
|
||||
* @returns True if the feature is eligible for auto-dispatch
|
||||
*/
|
||||
public static isFeatureEligibleForAutoMode(
|
||||
feature: Feature,
|
||||
branchName: string | null,
|
||||
primaryBranch: string | null
|
||||
): boolean {
|
||||
const isEligibleStatus =
|
||||
feature.status === 'backlog' ||
|
||||
feature.status === 'ready' ||
|
||||
feature.status === 'interrupted' ||
|
||||
isPipelineStatus(feature.status);
|
||||
|
||||
if (!isEligibleStatus) return false;
|
||||
|
||||
// Filter by branch/worktree alignment
|
||||
if (branchName === null) {
|
||||
// For main worktree, include features with no branch or matching primary branch
|
||||
return !feature.branchName || (primaryBranch != null && feature.branchName === primaryBranch);
|
||||
} else {
|
||||
// For named worktrees, only include features matching that branch
|
||||
return feature.branchName === branchName;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify and log an error at the facade boundary.
|
||||
* Emits an error event to the UI so failures are surfaced to the user.
|
||||
@@ -190,8 +226,7 @@ export class AutoModeServiceFacade {
|
||||
/**
|
||||
* Shared agent-run helper used by both PipelineOrchestrator and ExecutionService.
|
||||
*
|
||||
* Resolves the model string, looks up the custom provider/credentials via
|
||||
* getProviderByModelId, then delegates to agentExecutor.execute with the
|
||||
* Resolves provider/model context, then delegates to agentExecutor.execute with the
|
||||
* full payload. The opts parameter uses an index-signature union so it
|
||||
* accepts both the typed ExecutionService opts object and the looser
|
||||
* Record<string, unknown> used by PipelineOrchestrator without requiring
|
||||
@@ -213,8 +248,11 @@ export class AutoModeServiceFacade {
|
||||
previousContent?: string;
|
||||
systemPrompt?: string;
|
||||
autoLoadClaudeMd?: boolean;
|
||||
useClaudeCodeSystemPrompt?: boolean;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
reasoningEffort?: ReasoningEffort;
|
||||
branchName?: string | null;
|
||||
status?: string; // Feature status for pipeline summary check
|
||||
[key: string]: unknown;
|
||||
}
|
||||
): Promise<void> => {
|
||||
@@ -227,16 +265,19 @@ export class AutoModeServiceFacade {
|
||||
| import('@automaker/types').ClaudeCompatibleProvider
|
||||
| undefined;
|
||||
let credentials: import('@automaker/types').Credentials | undefined;
|
||||
let providerResolvedModel: string | undefined;
|
||||
|
||||
if (settingsService) {
|
||||
const providerResult = await getProviderByModelId(
|
||||
resolvedModel,
|
||||
const providerId = opts?.providerId as string | undefined;
|
||||
const result = await resolveProviderContext(
|
||||
settingsService,
|
||||
resolvedModel,
|
||||
providerId,
|
||||
'[AutoModeFacade]'
|
||||
);
|
||||
if (providerResult.provider) {
|
||||
claudeCompatibleProvider = providerResult.provider;
|
||||
credentials = providerResult.credentials;
|
||||
}
|
||||
claudeCompatibleProvider = result.provider;
|
||||
credentials = result.credentials;
|
||||
providerResolvedModel = result.resolvedModel;
|
||||
}
|
||||
|
||||
// Build sdkOptions with proper maxTurns and allowedTools for auto-mode.
|
||||
@@ -244,6 +285,7 @@ export class AutoModeServiceFacade {
|
||||
// internal defaults which may be much lower than intended (e.g., Codex CLI's
|
||||
// default turn limit can cause feature runs to stop prematurely).
|
||||
const autoLoadClaudeMd = opts?.autoLoadClaudeMd ?? false;
|
||||
const useClaudeCodeSystemPrompt = opts?.useClaudeCodeSystemPrompt ?? true;
|
||||
let mcpServers: Record<string, unknown> | undefined;
|
||||
try {
|
||||
if (settingsService) {
|
||||
@@ -261,10 +303,11 @@ export class AutoModeServiceFacade {
|
||||
|
||||
const sdkOpts = createAutoModeOptions({
|
||||
cwd: workDir,
|
||||
model: resolvedModel,
|
||||
model: providerResolvedModel || resolvedModel,
|
||||
systemPrompt: opts?.systemPrompt,
|
||||
abortController,
|
||||
autoLoadClaudeMd,
|
||||
useClaudeCodeSystemPrompt,
|
||||
thinkingLevel: opts?.thinkingLevel,
|
||||
maxTurns: userMaxTurns,
|
||||
mcpServers: mcpServers as
|
||||
@@ -272,8 +315,14 @@ export class AutoModeServiceFacade {
|
||||
| undefined,
|
||||
});
|
||||
|
||||
if (!sdkOpts) {
|
||||
logger.error(
|
||||
`[createRunAgentFn] sdkOpts is UNDEFINED! createAutoModeOptions type: ${typeof createAutoModeOptions}`
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[createRunAgentFn] Feature ${featureId}: model=${resolvedModel}, ` +
|
||||
`[createRunAgentFn] Feature ${featureId}: model=${resolvedModel} (resolved=${providerResolvedModel || resolvedModel}), ` +
|
||||
`maxTurns=${sdkOpts.maxTurns}, allowedTools=${(sdkOpts.allowedTools as string[])?.length ?? 'default'}, ` +
|
||||
`provider=${provider.getName()}`
|
||||
);
|
||||
@@ -292,8 +341,11 @@ export class AutoModeServiceFacade {
|
||||
previousContent: opts?.previousContent as string | undefined,
|
||||
systemPrompt: opts?.systemPrompt as string | undefined,
|
||||
autoLoadClaudeMd: opts?.autoLoadClaudeMd as boolean | undefined,
|
||||
useClaudeCodeSystemPrompt,
|
||||
thinkingLevel: opts?.thinkingLevel as ThinkingLevel | undefined,
|
||||
reasoningEffort: opts?.reasoningEffort as ReasoningEffort | undefined,
|
||||
branchName: opts?.branchName as string | null | undefined,
|
||||
status: opts?.status as string | undefined,
|
||||
provider,
|
||||
effectiveBareModel,
|
||||
credentials,
|
||||
@@ -367,12 +419,8 @@ export class AutoModeServiceFacade {
|
||||
if (branchName === null) {
|
||||
primaryBranch = await worktreeResolver.getCurrentBranch(pPath);
|
||||
}
|
||||
return features.filter(
|
||||
(f) =>
|
||||
(f.status === 'backlog' || f.status === 'ready') &&
|
||||
(branchName === null
|
||||
? !f.branchName || (primaryBranch && f.branchName === primaryBranch)
|
||||
: f.branchName === branchName)
|
||||
return features.filter((f) =>
|
||||
AutoModeServiceFacade.isFeatureEligibleForAutoMode(f, branchName, primaryBranch)
|
||||
);
|
||||
},
|
||||
(pPath, branchName, maxConcurrency) =>
|
||||
@@ -415,9 +463,25 @@ export class AutoModeServiceFacade {
|
||||
(pPath, featureId, status) =>
|
||||
featureStateManager.updateFeatureStatus(pPath, featureId, status),
|
||||
(pPath, featureId) => featureStateManager.loadFeature(pPath, featureId),
|
||||
async (_feature) => {
|
||||
// getPlanningPromptPrefixFn - planning prompts handled by AutoModeService
|
||||
return '';
|
||||
async (feature) => {
|
||||
// getPlanningPromptPrefixFn - select appropriate planning prompt based on feature's planningMode
|
||||
if (!feature.planningMode || feature.planningMode === 'skip') {
|
||||
return '';
|
||||
}
|
||||
const prompts = await getPromptCustomization(settingsService, '[PlanningPromptPrefix]');
|
||||
const autoModePrompts = prompts.autoMode;
|
||||
switch (feature.planningMode) {
|
||||
case 'lite':
|
||||
return feature.requirePlanApproval
|
||||
? autoModePrompts.planningLiteWithApproval + '\n\n'
|
||||
: autoModePrompts.planningLite + '\n\n';
|
||||
case 'spec':
|
||||
return autoModePrompts.planningSpec + '\n\n';
|
||||
case 'full':
|
||||
return autoModePrompts.planningFull + '\n\n';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
},
|
||||
(pPath, featureId, summary) =>
|
||||
featureStateManager.saveFeatureSummary(pPath, featureId, summary),
|
||||
@@ -761,6 +825,7 @@ export class AutoModeServiceFacade {
|
||||
featureId,
|
||||
featureName: feature?.title,
|
||||
branchName: feature?.branchName ?? null,
|
||||
executionMode: 'auto',
|
||||
passes: allPassed,
|
||||
message: allPassed
|
||||
? 'All verification checks passed'
|
||||
@@ -823,6 +888,7 @@ export class AutoModeServiceFacade {
|
||||
featureId,
|
||||
featureName: feature?.title,
|
||||
branchName: feature?.branchName ?? null,
|
||||
executionMode: 'auto',
|
||||
passes: true,
|
||||
message: `Changes committed: ${hash.trim().substring(0, 8)}`,
|
||||
projectPath: this.projectPath,
|
||||
@@ -910,7 +976,7 @@ export class AutoModeServiceFacade {
|
||||
if (feature) {
|
||||
title = feature.title;
|
||||
description = feature.description;
|
||||
branchName = feature.branchName;
|
||||
branchName = feature.branchName ?? undefined;
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore
|
||||
@@ -1067,12 +1133,13 @@ export class AutoModeServiceFacade {
|
||||
|
||||
/**
|
||||
* Detect orphaned features (features with missing branches)
|
||||
* @param preloadedFeatures - Optional pre-loaded features to avoid redundant disk reads
|
||||
*/
|
||||
async detectOrphanedFeatures(): Promise<OrphanedFeatureInfo[]> {
|
||||
async detectOrphanedFeatures(preloadedFeatures?: Feature[]): Promise<OrphanedFeatureInfo[]> {
|
||||
const orphanedFeatures: OrphanedFeatureInfo[] = [];
|
||||
|
||||
try {
|
||||
const allFeatures = await this.featureLoader.getAll(this.projectPath);
|
||||
const allFeatures = preloadedFeatures ?? (await this.featureLoader.getAll(this.projectPath));
|
||||
const featuresWithBranches = allFeatures.filter(
|
||||
(f) => f.branchName && f.branchName.trim() !== ''
|
||||
);
|
||||
@@ -1140,10 +1207,31 @@ export class AutoModeServiceFacade {
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Save execution state for recovery
|
||||
* Save execution state for recovery.
|
||||
*
|
||||
* Uses the active auto-loop config for each worktree so that the persisted
|
||||
* state reflects the real branch and maxConcurrency values rather than the
|
||||
* hard-coded fallbacks (null / DEFAULT_MAX_CONCURRENCY).
|
||||
*/
|
||||
private async saveExecutionState(): Promise<void> {
|
||||
return this.saveExecutionStateForProject(null, DEFAULT_MAX_CONCURRENCY);
|
||||
const projectWorktrees = this.autoLoopCoordinator
|
||||
.getActiveWorktrees()
|
||||
.filter((w) => w.projectPath === this.projectPath);
|
||||
|
||||
if (projectWorktrees.length === 0) {
|
||||
// No active auto loops — save with defaults as a best-effort fallback.
|
||||
return this.saveExecutionStateForProject(null, DEFAULT_MAX_CONCURRENCY);
|
||||
}
|
||||
|
||||
// Save state for every active worktree using its real config values.
|
||||
for (const { branchName } of projectWorktrees) {
|
||||
const config = this.autoLoopCoordinator.getAutoLoopConfigForProject(
|
||||
this.projectPath,
|
||||
branchName
|
||||
);
|
||||
const maxConcurrency = config?.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY;
|
||||
await this.saveExecutionStateForProject(branchName, maxConcurrency);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -159,7 +159,7 @@ export class GlobalAutoModeService {
|
||||
if (feature) {
|
||||
title = feature.title;
|
||||
description = feature.description;
|
||||
branchName = feature.branchName;
|
||||
branchName = feature.branchName ?? undefined;
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore
|
||||
|
||||
@@ -193,7 +193,11 @@ export class CodexModelCacheService {
|
||||
* Infer tier from model ID
|
||||
*/
|
||||
private inferTier(modelId: string): 'premium' | 'standard' | 'basic' {
|
||||
if (modelId.includes('max') || modelId.includes('gpt-5.2-codex')) {
|
||||
if (
|
||||
modelId.includes('max') ||
|
||||
modelId.includes('gpt-5.2-codex') ||
|
||||
modelId.includes('gpt-5.3-codex')
|
||||
) {
|
||||
return 'premium';
|
||||
}
|
||||
if (modelId.includes('mini')) {
|
||||
|
||||
@@ -13,6 +13,8 @@ import path from 'path';
|
||||
import net from 'net';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { EventEmitter } from '../lib/events.js';
|
||||
import fs from 'fs/promises';
|
||||
import { constants } from 'fs';
|
||||
|
||||
const logger = createLogger('DevServerService');
|
||||
|
||||
@@ -86,9 +88,13 @@ const PORT_PATTERNS: Array<{ pattern: RegExp; description: string }> = [
|
||||
},
|
||||
];
|
||||
|
||||
// Throttle output to prevent overwhelming WebSocket under heavy load
|
||||
const OUTPUT_THROTTLE_MS = 4; // ~250fps max update rate for responsive feedback
|
||||
const OUTPUT_BATCH_SIZE = 4096; // Smaller batches for lower latency
|
||||
// Throttle output to prevent overwhelming WebSocket under heavy load.
|
||||
// 100ms (~10fps) is sufficient for readable log streaming while keeping
|
||||
// WebSocket traffic manageable. The previous 4ms rate (~250fps) generated
|
||||
// up to 250 events/sec which caused progressive browser slowdown from
|
||||
// accumulated console logs, JSON serialization overhead, and React re-renders.
|
||||
const OUTPUT_THROTTLE_MS = 100; // ~10fps max update rate
|
||||
const OUTPUT_BATCH_SIZE = 8192; // Larger batches to compensate for lower frequency
|
||||
|
||||
export interface DevServerInfo {
|
||||
worktreePath: string;
|
||||
@@ -110,6 +116,21 @@ export interface DevServerInfo {
|
||||
urlDetected: boolean;
|
||||
// Timer for URL detection timeout fallback
|
||||
urlDetectionTimeout: NodeJS.Timeout | null;
|
||||
// Custom command used to start the server
|
||||
customCommand?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistable subset of DevServerInfo for survival across server restarts
|
||||
*/
|
||||
interface PersistedDevServerInfo {
|
||||
worktreePath: string;
|
||||
allocatedPort: number;
|
||||
port: number;
|
||||
url: string;
|
||||
startedAt: string;
|
||||
urlDetected: boolean;
|
||||
customCommand?: string;
|
||||
}
|
||||
|
||||
// Port allocation starts at 3001 to avoid conflicts with common dev ports
|
||||
@@ -121,8 +142,20 @@ const LIVERELOAD_PORTS = [35729, 35730, 35731] as const;
|
||||
|
||||
class DevServerService {
|
||||
private runningServers: Map<string, DevServerInfo> = new Map();
|
||||
private startingServers: Set<string> = new Set();
|
||||
private allocatedPorts: Set<number> = new Set();
|
||||
private emitter: EventEmitter | null = null;
|
||||
private dataDir: string | null = null;
|
||||
private saveQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
/**
|
||||
* Initialize the service with data directory for persistence
|
||||
*/
|
||||
async initialize(dataDir: string, emitter: EventEmitter): Promise<void> {
|
||||
this.dataDir = dataDir;
|
||||
this.emitter = emitter;
|
||||
await this.loadState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the event emitter for streaming log events
|
||||
@@ -132,6 +165,131 @@ class DevServerService {
|
||||
this.emitter = emitter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the current state of running servers to disk
|
||||
*/
|
||||
private async saveState(): Promise<void> {
|
||||
if (!this.dataDir) return;
|
||||
|
||||
// Queue the save operation to prevent concurrent writes
|
||||
this.saveQueue = this.saveQueue
|
||||
.then(async () => {
|
||||
if (!this.dataDir) return;
|
||||
try {
|
||||
const statePath = path.join(this.dataDir, 'dev-servers.json');
|
||||
const persistedInfo: PersistedDevServerInfo[] = Array.from(
|
||||
this.runningServers.values()
|
||||
).map((s) => ({
|
||||
worktreePath: s.worktreePath,
|
||||
allocatedPort: s.allocatedPort,
|
||||
port: s.port,
|
||||
url: s.url,
|
||||
startedAt: s.startedAt.toISOString(),
|
||||
urlDetected: s.urlDetected,
|
||||
customCommand: s.customCommand,
|
||||
}));
|
||||
|
||||
await fs.writeFile(statePath, JSON.stringify(persistedInfo, null, 2));
|
||||
logger.debug(`Saved dev server state to ${statePath}`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to save dev server state:', error);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Error in save queue:', error);
|
||||
});
|
||||
|
||||
return this.saveQueue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the state of running servers from disk
|
||||
*/
|
||||
private async loadState(): Promise<void> {
|
||||
if (!this.dataDir) return;
|
||||
|
||||
try {
|
||||
const statePath = path.join(this.dataDir, 'dev-servers.json');
|
||||
try {
|
||||
await fs.access(statePath, constants.F_OK);
|
||||
} catch {
|
||||
// File doesn't exist, which is fine
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await fs.readFile(statePath, 'utf-8');
|
||||
const rawParsed: unknown = JSON.parse(content);
|
||||
|
||||
if (!Array.isArray(rawParsed)) {
|
||||
logger.warn('Dev server state file is not an array, skipping load');
|
||||
return;
|
||||
}
|
||||
|
||||
const persistedInfo: PersistedDevServerInfo[] = rawParsed.filter((entry: unknown) => {
|
||||
if (entry === null || typeof entry !== 'object') {
|
||||
logger.warn('Dropping invalid dev server entry (not an object):', entry);
|
||||
return false;
|
||||
}
|
||||
const e = entry as Record<string, unknown>;
|
||||
const valid =
|
||||
typeof e.worktreePath === 'string' &&
|
||||
e.worktreePath.length > 0 &&
|
||||
typeof e.allocatedPort === 'number' &&
|
||||
Number.isInteger(e.allocatedPort) &&
|
||||
e.allocatedPort >= 1 &&
|
||||
e.allocatedPort <= 65535 &&
|
||||
typeof e.port === 'number' &&
|
||||
Number.isInteger(e.port) &&
|
||||
e.port >= 1 &&
|
||||
e.port <= 65535 &&
|
||||
typeof e.url === 'string' &&
|
||||
typeof e.startedAt === 'string' &&
|
||||
typeof e.urlDetected === 'boolean' &&
|
||||
(e.customCommand === undefined || typeof e.customCommand === 'string');
|
||||
if (!valid) {
|
||||
logger.warn('Dropping malformed dev server entry:', e);
|
||||
}
|
||||
return valid;
|
||||
}) as PersistedDevServerInfo[];
|
||||
|
||||
logger.info(`Loading ${persistedInfo.length} dev servers from state`);
|
||||
|
||||
for (const info of persistedInfo) {
|
||||
// Check if the process is still running on the port
|
||||
// Since we can't reliably re-attach to the process for output,
|
||||
// we'll just check if the port is in use.
|
||||
const portInUse = !(await this.isPortAvailable(info.port));
|
||||
|
||||
if (portInUse) {
|
||||
logger.info(`Re-attached to dev server on port ${info.port} for ${info.worktreePath}`);
|
||||
const serverInfo: DevServerInfo = {
|
||||
...info,
|
||||
startedAt: new Date(info.startedAt),
|
||||
process: null, // Process object is lost, but we know it's running
|
||||
scrollbackBuffer: '',
|
||||
outputBuffer: '',
|
||||
flushTimeout: null,
|
||||
stopping: false,
|
||||
urlDetectionTimeout: null,
|
||||
};
|
||||
this.runningServers.set(info.worktreePath, serverInfo);
|
||||
this.allocatedPorts.add(info.allocatedPort);
|
||||
} else {
|
||||
logger.info(
|
||||
`Dev server on port ${info.port} for ${info.worktreePath} is no longer running`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup stale entries from the file if any
|
||||
if (this.runningServers.size !== persistedInfo.length) {
|
||||
await this.saveState();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to load dev server state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune a stale server entry whose process has exited without cleanup.
|
||||
* Clears any pending timers, removes the port from allocatedPorts, deletes
|
||||
@@ -148,6 +306,10 @@ class DevServerService {
|
||||
// been mutated by detectUrlFromOutput to reflect the actual detected port.
|
||||
this.allocatedPorts.delete(server.allocatedPort);
|
||||
this.runningServers.delete(worktreePath);
|
||||
|
||||
// Persist state change
|
||||
this.saveState().catch((err) => logger.error('Failed to save state in pruneStaleServer:', err));
|
||||
|
||||
if (this.emitter) {
|
||||
this.emitter.emit('dev-server:stopped', {
|
||||
worktreePath,
|
||||
@@ -249,7 +411,7 @@ class DevServerService {
|
||||
* - PHP: "Development Server (http://localhost:8000) started"
|
||||
* - Generic: Any localhost URL with a port
|
||||
*/
|
||||
private detectUrlFromOutput(server: DevServerInfo, content: string): void {
|
||||
private async detectUrlFromOutput(server: DevServerInfo, content: string): Promise<void> {
|
||||
// Skip if URL already detected
|
||||
if (server.urlDetected) {
|
||||
return;
|
||||
@@ -304,6 +466,11 @@ class DevServerService {
|
||||
|
||||
logger.info(`Detected server URL via ${description}: ${detectedUrl}`);
|
||||
|
||||
// Persist state change
|
||||
await this.saveState().catch((err) =>
|
||||
logger.error('Failed to save state in detectUrlFromOutput:', err)
|
||||
);
|
||||
|
||||
// Emit URL update event
|
||||
if (this.emitter) {
|
||||
this.emitter.emit('dev-server:url-detected', {
|
||||
@@ -346,6 +513,11 @@ class DevServerService {
|
||||
|
||||
logger.info(`Detected server port via ${description}: ${detectedPort} → ${detectedUrl}`);
|
||||
|
||||
// Persist state change
|
||||
await this.saveState().catch((err) =>
|
||||
logger.error('Failed to save state in detectUrlFromOutput Phase 2:', err)
|
||||
);
|
||||
|
||||
// Emit URL update event
|
||||
if (this.emitter) {
|
||||
this.emitter.emit('dev-server:url-detected', {
|
||||
@@ -365,7 +537,7 @@ class DevServerService {
|
||||
* Handle incoming stdout/stderr data from dev server process
|
||||
* Buffers data for scrollback replay and schedules throttled emission
|
||||
*/
|
||||
private handleProcessOutput(server: DevServerInfo, data: Buffer): void {
|
||||
private async handleProcessOutput(server: DevServerInfo, data: Buffer): Promise<void> {
|
||||
// Skip output if server is stopping
|
||||
if (server.stopping) {
|
||||
return;
|
||||
@@ -374,7 +546,7 @@ class DevServerService {
|
||||
const content = data.toString();
|
||||
|
||||
// Try to detect actual server URL from output
|
||||
this.detectUrlFromOutput(server, content);
|
||||
await this.detectUrlFromOutput(server, content);
|
||||
|
||||
// Append to scrollback buffer for replay on reconnect
|
||||
this.appendToScrollback(server, content);
|
||||
@@ -594,261 +766,305 @@ class DevServerService {
|
||||
};
|
||||
error?: string;
|
||||
}> {
|
||||
// Check if already running
|
||||
if (this.runningServers.has(worktreePath)) {
|
||||
const existing = this.runningServers.get(worktreePath)!;
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
worktreePath: existing.worktreePath,
|
||||
port: existing.port,
|
||||
url: existing.url,
|
||||
message: `Dev server already running on port ${existing.port}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Verify the worktree exists
|
||||
if (!(await this.fileExists(worktreePath))) {
|
||||
// Check if already running or starting
|
||||
if (this.runningServers.has(worktreePath) || this.startingServers.has(worktreePath)) {
|
||||
const existing = this.runningServers.get(worktreePath);
|
||||
if (existing) {
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
worktreePath: existing.worktreePath,
|
||||
port: existing.port,
|
||||
url: existing.url,
|
||||
message: `Dev server already running on port ${existing.port}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: `Worktree path does not exist: ${worktreePath}`,
|
||||
error: 'Dev server is already starting',
|
||||
};
|
||||
}
|
||||
|
||||
// Determine the dev command to use
|
||||
let devCommand: { cmd: string; args: string[] };
|
||||
this.startingServers.add(worktreePath);
|
||||
|
||||
// Normalize custom command: trim whitespace and treat empty strings as undefined
|
||||
const normalizedCustomCommand = customCommand?.trim();
|
||||
|
||||
if (normalizedCustomCommand) {
|
||||
// Use the provided custom command
|
||||
devCommand = this.parseCustomCommand(normalizedCustomCommand);
|
||||
if (!devCommand.cmd) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid custom command: command cannot be empty',
|
||||
};
|
||||
}
|
||||
logger.debug(`Using custom command: ${normalizedCustomCommand}`);
|
||||
} else {
|
||||
// Check for package.json when auto-detecting
|
||||
const packageJsonPath = path.join(worktreePath, 'package.json');
|
||||
if (!(await this.fileExists(packageJsonPath))) {
|
||||
return {
|
||||
success: false,
|
||||
error: `No package.json found in: ${worktreePath}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Get dev command from package manager detection
|
||||
const detectedCommand = await this.getDevCommand(worktreePath);
|
||||
if (!detectedCommand) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Could not determine dev command for: ${worktreePath}`,
|
||||
};
|
||||
}
|
||||
devCommand = detectedCommand;
|
||||
}
|
||||
|
||||
// Find available port
|
||||
let port: number;
|
||||
try {
|
||||
port = await this.findAvailablePort();
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Port allocation failed',
|
||||
};
|
||||
}
|
||||
|
||||
// Reserve the port (port was already force-killed in findAvailablePort)
|
||||
this.allocatedPorts.add(port);
|
||||
|
||||
// Also kill common related ports (livereload, etc.)
|
||||
// Some dev servers use fixed ports for HMR/livereload regardless of main port
|
||||
for (const relatedPort of LIVERELOAD_PORTS) {
|
||||
this.killProcessOnPort(relatedPort);
|
||||
}
|
||||
|
||||
// Small delay to ensure related ports are freed
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
logger.info(`Starting dev server on port ${port}`);
|
||||
logger.debug(`Working directory (cwd): ${worktreePath}`);
|
||||
logger.debug(`Command: ${devCommand.cmd} ${devCommand.args.join(' ')} with PORT=${port}`);
|
||||
|
||||
// Spawn the dev process with PORT environment variable
|
||||
// FORCE_COLOR enables colored output even when not running in a TTY
|
||||
const env = {
|
||||
...process.env,
|
||||
PORT: String(port),
|
||||
FORCE_COLOR: '1',
|
||||
// Some tools use these additional env vars for color detection
|
||||
COLORTERM: 'truecolor',
|
||||
TERM: 'xterm-256color',
|
||||
};
|
||||
|
||||
const devProcess = spawn(devCommand.cmd, devCommand.args, {
|
||||
cwd: worktreePath,
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
// Track if process failed early using object to work around TypeScript narrowing
|
||||
const status = { error: null as string | null, exited: false };
|
||||
|
||||
// Create server info early so we can reference it in handlers
|
||||
// We'll add it to runningServers after verifying the process started successfully
|
||||
const hostname = process.env.HOSTNAME || 'localhost';
|
||||
const serverInfo: DevServerInfo = {
|
||||
worktreePath,
|
||||
allocatedPort: port, // Immutable: records which port we reserved; never changed after this point
|
||||
port,
|
||||
url: `http://${hostname}:${port}`, // Initial URL, may be updated by detectUrlFromOutput
|
||||
process: devProcess,
|
||||
startedAt: new Date(),
|
||||
scrollbackBuffer: '',
|
||||
outputBuffer: '',
|
||||
flushTimeout: null,
|
||||
stopping: false,
|
||||
urlDetected: false, // Will be set to true when actual URL is detected from output
|
||||
urlDetectionTimeout: null, // Will be set after server starts successfully
|
||||
};
|
||||
|
||||
// Capture stdout with buffer management and event emission
|
||||
if (devProcess.stdout) {
|
||||
devProcess.stdout.on('data', (data: Buffer) => {
|
||||
this.handleProcessOutput(serverInfo, data);
|
||||
});
|
||||
}
|
||||
|
||||
// Capture stderr with buffer management and event emission
|
||||
if (devProcess.stderr) {
|
||||
devProcess.stderr.on('data', (data: Buffer) => {
|
||||
this.handleProcessOutput(serverInfo, data);
|
||||
});
|
||||
}
|
||||
|
||||
// Helper to clean up resources and emit stop event
|
||||
const cleanupAndEmitStop = (exitCode: number | null, errorMessage?: string) => {
|
||||
if (serverInfo.flushTimeout) {
|
||||
clearTimeout(serverInfo.flushTimeout);
|
||||
serverInfo.flushTimeout = null;
|
||||
// Verify the worktree exists
|
||||
if (!(await this.fileExists(worktreePath))) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Worktree path does not exist: ${worktreePath}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Clear URL detection timeout to prevent stale fallback emission
|
||||
if (serverInfo.urlDetectionTimeout) {
|
||||
clearTimeout(serverInfo.urlDetectionTimeout);
|
||||
serverInfo.urlDetectionTimeout = null;
|
||||
// Determine the dev command to use
|
||||
let devCommand: { cmd: string; args: string[] };
|
||||
|
||||
// Normalize custom command: trim whitespace and treat empty strings as undefined
|
||||
const normalizedCustomCommand = customCommand?.trim();
|
||||
|
||||
if (normalizedCustomCommand) {
|
||||
// Use the provided custom command
|
||||
devCommand = this.parseCustomCommand(normalizedCustomCommand);
|
||||
if (!devCommand.cmd) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Invalid custom command: command cannot be empty',
|
||||
};
|
||||
}
|
||||
logger.debug(`Using custom command: ${normalizedCustomCommand}`);
|
||||
} else {
|
||||
// Check for package.json when auto-detecting
|
||||
const packageJsonPath = path.join(worktreePath, 'package.json');
|
||||
if (!(await this.fileExists(packageJsonPath))) {
|
||||
return {
|
||||
success: false,
|
||||
error: `No package.json found in: ${worktreePath}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Get dev command from package manager detection
|
||||
const detectedCommand = await this.getDevCommand(worktreePath);
|
||||
if (!detectedCommand) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Could not determine dev command for: ${worktreePath}`,
|
||||
};
|
||||
}
|
||||
devCommand = detectedCommand;
|
||||
}
|
||||
|
||||
// Emit stopped event (only if not already stopping - prevents duplicate events)
|
||||
if (this.emitter && !serverInfo.stopping) {
|
||||
this.emitter.emit('dev-server:stopped', {
|
||||
// Find available port
|
||||
let port: number;
|
||||
try {
|
||||
port = await this.findAvailablePort();
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Port allocation failed',
|
||||
};
|
||||
}
|
||||
|
||||
// Reserve the port (port was already force-killed in findAvailablePort)
|
||||
this.allocatedPorts.add(port);
|
||||
|
||||
// Also kill common related ports (livereload, etc.)
|
||||
// Some dev servers use fixed ports for HMR/livereload regardless of main port
|
||||
for (const relatedPort of LIVERELOAD_PORTS) {
|
||||
this.killProcessOnPort(relatedPort);
|
||||
}
|
||||
|
||||
// Small delay to ensure related ports are freed
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
logger.info(`Starting dev server on port ${port}`);
|
||||
logger.debug(`Working directory (cwd): ${worktreePath}`);
|
||||
logger.debug(`Command: ${devCommand.cmd} ${devCommand.args.join(' ')} with PORT=${port}`);
|
||||
|
||||
// Emit starting only after preflight checks pass to avoid dangling starting state.
|
||||
if (this.emitter) {
|
||||
this.emitter.emit('dev-server:starting', {
|
||||
worktreePath,
|
||||
port: serverInfo.port, // Use the detected port (may differ from allocated port if detectUrlFromOutput updated it)
|
||||
exitCode,
|
||||
error: errorMessage,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
this.allocatedPorts.delete(serverInfo.allocatedPort);
|
||||
this.runningServers.delete(worktreePath);
|
||||
};
|
||||
|
||||
devProcess.on('error', (error) => {
|
||||
logger.error(`Process error:`, error);
|
||||
status.error = error.message;
|
||||
cleanupAndEmitStop(null, error.message);
|
||||
});
|
||||
|
||||
devProcess.on('exit', (code) => {
|
||||
logger.info(`Process for ${worktreePath} exited with code ${code}`);
|
||||
status.exited = true;
|
||||
cleanupAndEmitStop(code);
|
||||
});
|
||||
|
||||
// Wait a moment to see if the process fails immediately
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
if (status.error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to start dev server: ${status.error}`,
|
||||
// Spawn the dev process with PORT environment variable
|
||||
// FORCE_COLOR enables colored output even when not running in a TTY
|
||||
const env = {
|
||||
...process.env,
|
||||
PORT: String(port),
|
||||
FORCE_COLOR: '1',
|
||||
// Some tools use these additional env vars for color detection
|
||||
COLORTERM: 'truecolor',
|
||||
TERM: 'xterm-256color',
|
||||
};
|
||||
}
|
||||
|
||||
if (status.exited) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Dev server process exited immediately. Check server logs for details.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Server started successfully - add to running servers map
|
||||
this.runningServers.set(worktreePath, serverInfo);
|
||||
|
||||
// Emit started event for WebSocket subscribers
|
||||
if (this.emitter) {
|
||||
this.emitter.emit('dev-server:started', {
|
||||
worktreePath,
|
||||
port,
|
||||
url: serverInfo.url,
|
||||
timestamp: new Date().toISOString(),
|
||||
const devProcess = spawn(devCommand.cmd, devCommand.args, {
|
||||
cwd: worktreePath,
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Set up URL detection timeout fallback.
|
||||
// If URL detection hasn't succeeded after URL_DETECTION_TIMEOUT_MS, check if
|
||||
// the allocated port is actually in use (server probably started successfully)
|
||||
// and emit a url-detected event with the allocated port as fallback.
|
||||
// Also re-scan the scrollback buffer in case the URL was printed before
|
||||
// our patterns could match (e.g., it was split across multiple data chunks).
|
||||
serverInfo.urlDetectionTimeout = setTimeout(() => {
|
||||
serverInfo.urlDetectionTimeout = null;
|
||||
// Track if process failed early using object to work around TypeScript narrowing
|
||||
const status = { error: null as string | null, exited: false };
|
||||
|
||||
// Only run fallback if server is still running and URL wasn't detected
|
||||
if (serverInfo.stopping || serverInfo.urlDetected || !this.runningServers.has(worktreePath)) {
|
||||
return;
|
||||
// Create server info early so we can reference it in handlers
|
||||
// We'll add it to runningServers after verifying the process started successfully
|
||||
const fallbackHost = 'localhost';
|
||||
const serverInfo: DevServerInfo = {
|
||||
worktreePath,
|
||||
allocatedPort: port, // Immutable: records which port we reserved; never changed after this point
|
||||
port,
|
||||
url: `http://${fallbackHost}:${port}`, // Initial URL, may be updated by detectUrlFromOutput
|
||||
process: devProcess,
|
||||
startedAt: new Date(),
|
||||
scrollbackBuffer: '',
|
||||
outputBuffer: '',
|
||||
flushTimeout: null,
|
||||
stopping: false,
|
||||
urlDetected: false, // Will be set to true when actual URL is detected from output
|
||||
urlDetectionTimeout: null, // Will be set after server starts successfully
|
||||
customCommand: normalizedCustomCommand,
|
||||
};
|
||||
|
||||
// Capture stdout with buffer management and event emission
|
||||
if (devProcess.stdout) {
|
||||
devProcess.stdout.on('data', (data: Buffer) => {
|
||||
this.handleProcessOutput(serverInfo, data).catch((error: unknown) => {
|
||||
logger.error('Failed to handle dev server stdout output:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Re-scan the entire scrollback buffer for URL patterns
|
||||
// This catches cases where the URL was split across multiple output chunks
|
||||
logger.info(`URL detection timeout for ${worktreePath}, re-scanning scrollback buffer`);
|
||||
this.detectUrlFromOutput(serverInfo, serverInfo.scrollbackBuffer);
|
||||
// Capture stderr with buffer management and event emission
|
||||
if (devProcess.stderr) {
|
||||
devProcess.stderr.on('data', (data: Buffer) => {
|
||||
this.handleProcessOutput(serverInfo, data).catch((error: unknown) => {
|
||||
logger.error('Failed to handle dev server stderr output:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// If still not detected after full rescan, use the allocated port as fallback
|
||||
if (!serverInfo.urlDetected) {
|
||||
logger.info(`URL detection fallback: using allocated port ${port} for ${worktreePath}`);
|
||||
const fallbackUrl = `http://${hostname}:${port}`;
|
||||
serverInfo.url = fallbackUrl;
|
||||
serverInfo.urlDetected = true;
|
||||
// Helper to clean up resources and emit stop event
|
||||
const cleanupAndEmitStop = (exitCode: number | null, errorMessage?: string) => {
|
||||
if (serverInfo.flushTimeout) {
|
||||
clearTimeout(serverInfo.flushTimeout);
|
||||
serverInfo.flushTimeout = null;
|
||||
}
|
||||
|
||||
if (this.emitter) {
|
||||
this.emitter.emit('dev-server:url-detected', {
|
||||
// Clear URL detection timeout to prevent stale fallback emission
|
||||
if (serverInfo.urlDetectionTimeout) {
|
||||
clearTimeout(serverInfo.urlDetectionTimeout);
|
||||
serverInfo.urlDetectionTimeout = null;
|
||||
}
|
||||
|
||||
// Emit stopped event (only if not already stopping - prevents duplicate events)
|
||||
if (this.emitter && !serverInfo.stopping) {
|
||||
this.emitter.emit('dev-server:stopped', {
|
||||
worktreePath,
|
||||
url: fallbackUrl,
|
||||
port,
|
||||
port: serverInfo.port, // Use the detected port (may differ from allocated port if detectUrlFromOutput updated it)
|
||||
exitCode,
|
||||
error: errorMessage,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}, URL_DETECTION_TIMEOUT_MS);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
worktreePath,
|
||||
port,
|
||||
url: `http://${hostname}:${port}`,
|
||||
message: `Dev server started on port ${port}`,
|
||||
},
|
||||
};
|
||||
this.allocatedPorts.delete(serverInfo.allocatedPort);
|
||||
this.runningServers.delete(worktreePath);
|
||||
|
||||
// Persist state change
|
||||
this.saveState().catch((err) => logger.error('Failed to save state in cleanup:', err));
|
||||
};
|
||||
|
||||
devProcess.on('error', (error) => {
|
||||
logger.error(`Process error:`, error);
|
||||
status.error = error.message;
|
||||
cleanupAndEmitStop(null, error.message);
|
||||
});
|
||||
|
||||
devProcess.on('exit', (code) => {
|
||||
logger.info(`Process for ${worktreePath} exited with code ${code}`);
|
||||
status.exited = true;
|
||||
cleanupAndEmitStop(code);
|
||||
});
|
||||
|
||||
// Wait a moment to see if the process fails immediately
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
if (status.error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to start dev server: ${status.error}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (status.exited) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Dev server process exited immediately. Check server logs for details.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Server started successfully - add to running servers map
|
||||
this.runningServers.set(worktreePath, serverInfo);
|
||||
|
||||
// Persist state change
|
||||
await this.saveState().catch((err) =>
|
||||
logger.error('Failed to save state in startDevServer:', err)
|
||||
);
|
||||
|
||||
// Emit started event for WebSocket subscribers
|
||||
if (this.emitter) {
|
||||
this.emitter.emit('dev-server:started', {
|
||||
worktreePath,
|
||||
port,
|
||||
url: serverInfo.url,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Set up URL detection timeout fallback.
|
||||
// If URL detection hasn't succeeded after URL_DETECTION_TIMEOUT_MS, check if
|
||||
// the allocated port is actually in use (server probably started successfully)
|
||||
// and emit a url-detected event with the allocated port as fallback.
|
||||
// Also re-scan the scrollback buffer in case the URL was printed before
|
||||
// our patterns could match (e.g., it was split across multiple data chunks).
|
||||
serverInfo.urlDetectionTimeout = setTimeout(async () => {
|
||||
serverInfo.urlDetectionTimeout = null;
|
||||
|
||||
// Only run fallback if server is still running and URL wasn't detected
|
||||
if (
|
||||
serverInfo.stopping ||
|
||||
serverInfo.urlDetected ||
|
||||
!this.runningServers.has(worktreePath)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-scan the entire scrollback buffer for URL patterns
|
||||
// This catches cases where the URL was split across multiple output chunks
|
||||
logger.info(`URL detection timeout for ${worktreePath}, re-scanning scrollback buffer`);
|
||||
await this.detectUrlFromOutput(serverInfo, serverInfo.scrollbackBuffer).catch((err) =>
|
||||
logger.error('Failed to re-scan scrollback buffer:', err)
|
||||
);
|
||||
|
||||
// If still not detected after full rescan, use the allocated port as fallback
|
||||
if (!serverInfo.urlDetected) {
|
||||
logger.info(`URL detection fallback: using allocated port ${port} for ${worktreePath}`);
|
||||
const fallbackUrl = `http://${fallbackHost}:${port}`;
|
||||
serverInfo.url = fallbackUrl;
|
||||
serverInfo.urlDetected = true;
|
||||
|
||||
// Persist state change
|
||||
await this.saveState().catch((err) =>
|
||||
logger.error('Failed to save state in URL detection fallback:', err)
|
||||
);
|
||||
|
||||
if (this.emitter) {
|
||||
this.emitter.emit('dev-server:url-detected', {
|
||||
worktreePath: serverInfo.worktreePath,
|
||||
url: fallbackUrl,
|
||||
port,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}, URL_DETECTION_TIMEOUT_MS);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
worktreePath: serverInfo.worktreePath,
|
||||
port: serverInfo.port,
|
||||
url: serverInfo.url,
|
||||
message: `Dev server started on port ${port}`,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
this.startingServers.delete(worktreePath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -904,9 +1120,11 @@ class DevServerService {
|
||||
});
|
||||
}
|
||||
|
||||
// Kill the process
|
||||
// Kill the process; persisted/re-attached entries may not have a process handle.
|
||||
if (server.process && !server.process.killed) {
|
||||
server.process.kill('SIGTERM');
|
||||
} else {
|
||||
this.killProcessOnPort(server.port);
|
||||
}
|
||||
|
||||
// Free the originally-reserved port slot (allocatedPort is immutable and always
|
||||
@@ -915,6 +1133,11 @@ class DevServerService {
|
||||
this.allocatedPorts.delete(server.allocatedPort);
|
||||
this.runningServers.delete(worktreePath);
|
||||
|
||||
// Persist state change
|
||||
await this.saveState().catch((err) =>
|
||||
logger.error('Failed to save state in stopDevServer:', err)
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
|
||||
@@ -27,7 +27,11 @@ import type {
|
||||
EventHookTrigger,
|
||||
EventHookShellAction,
|
||||
EventHookHttpAction,
|
||||
EventHookNtfyAction,
|
||||
NtfyEndpointConfig,
|
||||
EventHookContext,
|
||||
} from '@automaker/types';
|
||||
import { ntfyService, type NtfyContext } from './ntfy-service.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const logger = createLogger('EventHooks');
|
||||
@@ -38,19 +42,8 @@ const DEFAULT_SHELL_TIMEOUT = 30000;
|
||||
/** Default timeout for HTTP requests (10 seconds) */
|
||||
const DEFAULT_HTTP_TIMEOUT = 10000;
|
||||
|
||||
/**
|
||||
* Context available for variable substitution in hooks
|
||||
*/
|
||||
interface HookContext {
|
||||
featureId?: string;
|
||||
featureName?: string;
|
||||
projectPath?: string;
|
||||
projectName?: string;
|
||||
error?: string;
|
||||
errorType?: string;
|
||||
timestamp: string;
|
||||
eventType: EventHookTrigger;
|
||||
}
|
||||
// Use the shared EventHookContext type (aliased locally as HookContext for clarity)
|
||||
type HookContext = EventHookContext;
|
||||
|
||||
/**
|
||||
* Auto-mode event payload structure
|
||||
@@ -60,10 +53,13 @@ interface AutoModeEventPayload {
|
||||
featureId?: string;
|
||||
featureName?: string;
|
||||
passes?: boolean;
|
||||
executionMode?: 'auto' | 'manual';
|
||||
message?: string;
|
||||
error?: string;
|
||||
errorType?: string;
|
||||
projectPath?: string;
|
||||
/** Status field present when type === 'feature_status_changed' */
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,6 +71,40 @@ interface FeatureCreatedPayload {
|
||||
projectPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature status changed event payload structure
|
||||
*/
|
||||
interface FeatureStatusChangedPayload {
|
||||
featureId: string;
|
||||
projectPath: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to safely narrow AutoModeEventPayload to FeatureStatusChangedPayload
|
||||
*/
|
||||
function isFeatureStatusChangedPayload(
|
||||
payload: AutoModeEventPayload
|
||||
): payload is AutoModeEventPayload & FeatureStatusChangedPayload {
|
||||
return (
|
||||
typeof payload.featureId === 'string' &&
|
||||
typeof payload.projectPath === 'string' &&
|
||||
typeof payload.status === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature completed event payload structure
|
||||
*/
|
||||
interface FeatureCompletedPayload {
|
||||
featureId: string;
|
||||
featureName?: string;
|
||||
projectPath: string;
|
||||
passes?: boolean;
|
||||
message?: string;
|
||||
executionMode?: 'auto' | 'manual';
|
||||
}
|
||||
|
||||
/**
|
||||
* Event Hook Service
|
||||
*
|
||||
@@ -82,12 +112,30 @@ interface FeatureCreatedPayload {
|
||||
* Also stores events to history for debugging and replay.
|
||||
*/
|
||||
export class EventHookService {
|
||||
/** Feature status that indicates agent work is done and awaiting human review (tests skipped) */
|
||||
private static readonly STATUS_WAITING_APPROVAL = 'waiting_approval';
|
||||
/** Feature status that indicates agent work passed automated verification */
|
||||
private static readonly STATUS_VERIFIED = 'verified';
|
||||
|
||||
private emitter: EventEmitter | null = null;
|
||||
private settingsService: SettingsService | null = null;
|
||||
private eventHistoryService: EventHistoryService | null = null;
|
||||
private featureLoader: FeatureLoader | null = null;
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
|
||||
/**
|
||||
* Track feature IDs that have already had hooks fired via auto_mode_feature_complete
|
||||
* to prevent double-firing when feature_status_changed also fires for the same feature.
|
||||
* Entries are automatically cleaned up after 30 seconds.
|
||||
*/
|
||||
private recentlyHandledFeatures = new Set<string>();
|
||||
|
||||
/**
|
||||
* Timer IDs for pending cleanup of recentlyHandledFeatures entries,
|
||||
* keyed by featureId. Stored so they can be cancelled in destroy().
|
||||
*/
|
||||
private recentlyHandledTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
/**
|
||||
* Initialize the service with event emitter, settings service, event history service, and feature loader
|
||||
*/
|
||||
@@ -108,6 +156,8 @@ export class EventHookService {
|
||||
this.handleAutoModeEvent(payload as AutoModeEventPayload);
|
||||
} else if (type === 'feature:created') {
|
||||
this.handleFeatureCreatedEvent(payload as FeatureCreatedPayload);
|
||||
} else if (type === 'feature:completed') {
|
||||
this.handleFeatureCompletedEvent(payload as FeatureCompletedPayload);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -122,6 +172,12 @@ export class EventHookService {
|
||||
this.unsubscribe();
|
||||
this.unsubscribe = null;
|
||||
}
|
||||
// Cancel all pending cleanup timers to avoid cross-session mutations
|
||||
for (const timerId of this.recentlyHandledTimers.values()) {
|
||||
clearTimeout(timerId);
|
||||
}
|
||||
this.recentlyHandledTimers.clear();
|
||||
this.recentlyHandledFeatures.clear();
|
||||
this.emitter = null;
|
||||
this.settingsService = null;
|
||||
this.eventHistoryService = null;
|
||||
@@ -139,15 +195,31 @@ export class EventHookService {
|
||||
|
||||
switch (payload.type) {
|
||||
case 'auto_mode_feature_complete':
|
||||
// Only map explicit auto-mode completion events.
|
||||
// Manual feature completions are emitted as feature:completed.
|
||||
if (payload.executionMode !== 'auto') return;
|
||||
trigger = payload.passes ? 'feature_success' : 'feature_error';
|
||||
// Track this feature so feature_status_changed doesn't double-fire hooks
|
||||
if (payload.featureId) {
|
||||
this.markFeatureHandled(payload.featureId);
|
||||
}
|
||||
break;
|
||||
case 'auto_mode_error':
|
||||
// Feature-level error (has featureId) vs auto-mode level error
|
||||
trigger = payload.featureId ? 'feature_error' : 'auto_mode_error';
|
||||
// Track this feature so feature_status_changed doesn't double-fire hooks
|
||||
if (payload.featureId) {
|
||||
this.markFeatureHandled(payload.featureId);
|
||||
}
|
||||
break;
|
||||
case 'auto_mode_idle':
|
||||
trigger = 'auto_mode_complete';
|
||||
break;
|
||||
case 'feature_status_changed':
|
||||
if (isFeatureStatusChangedPayload(payload)) {
|
||||
this.handleFeatureStatusChanged(payload);
|
||||
}
|
||||
return;
|
||||
default:
|
||||
// Other event types don't trigger hooks
|
||||
return;
|
||||
@@ -187,6 +259,46 @@ export class EventHookService {
|
||||
await this.executeHooksForTrigger(trigger, context, { passes: payload.passes });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle feature:completed events and trigger matching hooks
|
||||
*/
|
||||
private async handleFeatureCompletedEvent(payload: FeatureCompletedPayload): Promise<void> {
|
||||
if (!payload.featureId || !payload.projectPath) return;
|
||||
|
||||
// Mark as handled to prevent duplicate firing if feature_status_changed also fires
|
||||
this.markFeatureHandled(payload.featureId);
|
||||
|
||||
const passes = payload.passes ?? true;
|
||||
const trigger: EventHookTrigger = passes ? 'feature_success' : 'feature_error';
|
||||
|
||||
// Load feature name if we have featureId but no featureName
|
||||
let featureName: string | undefined = undefined;
|
||||
if (payload.projectPath && this.featureLoader) {
|
||||
try {
|
||||
const feature = await this.featureLoader.get(payload.projectPath, payload.featureId);
|
||||
if (feature?.title) {
|
||||
featureName = feature.title;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to load feature ${payload.featureId} for event hook:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
const isErrorTrigger = trigger === 'feature_error';
|
||||
const context: HookContext = {
|
||||
featureId: payload.featureId,
|
||||
featureName: featureName || payload.featureName,
|
||||
projectPath: payload.projectPath,
|
||||
projectName: this.extractProjectName(payload.projectPath),
|
||||
error: isErrorTrigger ? payload.message : undefined,
|
||||
errorType: undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
eventType: trigger,
|
||||
};
|
||||
|
||||
await this.executeHooksForTrigger(trigger, context, { passes });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle feature:created events and trigger matching hooks
|
||||
*/
|
||||
@@ -203,6 +315,74 @@ export class EventHookService {
|
||||
await this.executeHooksForTrigger('feature_created', context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle feature_status_changed events for non-auto-mode feature completion.
|
||||
*
|
||||
* Auto-mode features already emit auto_mode_feature_complete which triggers hooks.
|
||||
* This handler catches manual (non-auto-mode) feature completions by detecting
|
||||
* status transitions to completion states (verified, waiting_approval).
|
||||
*/
|
||||
private async handleFeatureStatusChanged(payload: FeatureStatusChangedPayload): Promise<void> {
|
||||
// Skip if this feature was already handled via auto_mode_feature_complete
|
||||
if (this.recentlyHandledFeatures.has(payload.featureId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let trigger: EventHookTrigger | null = null;
|
||||
|
||||
if (
|
||||
payload.status === EventHookService.STATUS_VERIFIED ||
|
||||
payload.status === EventHookService.STATUS_WAITING_APPROVAL
|
||||
) {
|
||||
trigger = 'feature_success';
|
||||
} else {
|
||||
// Only completion statuses trigger hooks from status changes
|
||||
return;
|
||||
}
|
||||
|
||||
// Load feature name
|
||||
let featureName: string | undefined = undefined;
|
||||
if (this.featureLoader) {
|
||||
try {
|
||||
const feature = await this.featureLoader.get(payload.projectPath, payload.featureId);
|
||||
if (feature?.title) {
|
||||
featureName = feature.title;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to load feature ${payload.featureId} for status change hook:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
const context: HookContext = {
|
||||
featureId: payload.featureId,
|
||||
featureName,
|
||||
projectPath: payload.projectPath,
|
||||
projectName: this.extractProjectName(payload.projectPath),
|
||||
timestamp: new Date().toISOString(),
|
||||
eventType: trigger,
|
||||
};
|
||||
|
||||
await this.executeHooksForTrigger(trigger, context, { passes: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a feature as recently handled to prevent double-firing hooks.
|
||||
* Entries are cleaned up after 30 seconds.
|
||||
*/
|
||||
private markFeatureHandled(featureId: string): void {
|
||||
// Cancel any existing timer for this feature before setting a new one
|
||||
const existing = this.recentlyHandledTimers.get(featureId);
|
||||
if (existing !== undefined) {
|
||||
clearTimeout(existing);
|
||||
}
|
||||
this.recentlyHandledFeatures.add(featureId);
|
||||
const timerId = setTimeout(() => {
|
||||
this.recentlyHandledFeatures.delete(featureId);
|
||||
this.recentlyHandledTimers.delete(featureId);
|
||||
}, 30000);
|
||||
this.recentlyHandledTimers.set(featureId, timerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute all enabled hooks matching the given trigger and store event to history
|
||||
*/
|
||||
@@ -264,6 +444,8 @@ export class EventHookService {
|
||||
await this.executeShellHook(hook.action, context, hookName);
|
||||
} else if (hook.action.type === 'http') {
|
||||
await this.executeHttpHook(hook.action, context, hookName);
|
||||
} else if (hook.action.type === 'ntfy') {
|
||||
await this.executeNtfyHook(hook.action, context, hookName);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Hook "${hookName}" failed:`, error);
|
||||
@@ -371,6 +553,86 @@ export class EventHookService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an ntfy.sh notification hook
|
||||
*/
|
||||
private async executeNtfyHook(
|
||||
action: EventHookNtfyAction,
|
||||
context: HookContext,
|
||||
hookName: string
|
||||
): Promise<void> {
|
||||
if (!this.settingsService) {
|
||||
logger.warn('Settings service not available for ntfy hook');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the endpoint configuration
|
||||
const settings = await this.settingsService.getGlobalSettings();
|
||||
const endpoints = settings.ntfyEndpoints || [];
|
||||
const endpoint = endpoints.find((e) => e.id === action.endpointId);
|
||||
|
||||
if (!endpoint) {
|
||||
logger.error(`Ntfy hook "${hookName}" references unknown endpoint: ${action.endpointId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert HookContext to NtfyContext
|
||||
const ntfyContext: NtfyContext = {
|
||||
featureId: context.featureId,
|
||||
featureName: context.featureName,
|
||||
projectPath: context.projectPath,
|
||||
projectName: context.projectName,
|
||||
error: context.error,
|
||||
errorType: context.errorType,
|
||||
timestamp: context.timestamp,
|
||||
eventType: context.eventType,
|
||||
};
|
||||
|
||||
// Resolve click URL: action-level overrides endpoint default
|
||||
let clickUrl = action.clickUrl || endpoint.defaultClickUrl;
|
||||
|
||||
// Apply deep-link parameters to the resolved click URL
|
||||
if (clickUrl && context.projectPath) {
|
||||
try {
|
||||
const url = new URL(clickUrl);
|
||||
url.pathname = '/board';
|
||||
// Add projectPath so the UI can switch to the correct project
|
||||
url.searchParams.set('projectPath', context.projectPath);
|
||||
// Add featureId as query param for deep linking to board with feature output modal
|
||||
if (context.featureId) {
|
||||
url.searchParams.set('featureId', context.featureId);
|
||||
}
|
||||
clickUrl = url.toString();
|
||||
} catch (error) {
|
||||
// If URL parsing fails, log warning and use as-is
|
||||
logger.warn(
|
||||
`Failed to parse click URL "${clickUrl}" for deep linking: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Executing ntfy hook "${hookName}" to endpoint "${endpoint.name}"`);
|
||||
|
||||
const result = await ntfyService.sendNotification(
|
||||
endpoint,
|
||||
{
|
||||
title: action.title,
|
||||
body: action.body,
|
||||
tags: action.tags,
|
||||
emoji: action.emoji,
|
||||
clickUrl,
|
||||
priority: action.priority,
|
||||
},
|
||||
ntfyContext
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
logger.warn(`Ntfy hook "${hookName}" failed: ${result.error}`);
|
||||
} else {
|
||||
logger.info(`Ntfy hook "${hookName}" completed successfully`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute {{variable}} placeholders in a string
|
||||
*/
|
||||
|
||||
@@ -12,6 +12,7 @@ import * as secureFs from '../lib/secure-fs.js';
|
||||
import {
|
||||
getPromptCustomization,
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getUseClaudeCodeSystemPromptSetting,
|
||||
filterClaudeMdFromContext,
|
||||
} from '../lib/settings-helpers.js';
|
||||
import { validateWorkingDirectory } from '../lib/sdk-options.js';
|
||||
@@ -59,6 +60,12 @@ import type {
|
||||
|
||||
const logger = createLogger('ExecutionService');
|
||||
|
||||
/** Marker written by agent-executor for each tool invocation. */
|
||||
const TOOL_USE_MARKER = '🔧 Tool:';
|
||||
|
||||
/** Minimum trimmed output length to consider agent work meaningful. */
|
||||
const MIN_MEANINGFUL_OUTPUT_LENGTH = 200;
|
||||
|
||||
export class ExecutionService {
|
||||
constructor(
|
||||
private eventBus: TypedEventBus,
|
||||
@@ -101,16 +108,14 @@ export class ExecutionService {
|
||||
return firstLine.length <= 60 ? firstLine : firstLine.substring(0, 57) + '...';
|
||||
}
|
||||
|
||||
buildFeaturePrompt(
|
||||
feature: Feature,
|
||||
taskExecutionPrompts: {
|
||||
implementationInstructions: string;
|
||||
playwrightVerificationInstructions: string;
|
||||
}
|
||||
): string {
|
||||
/**
|
||||
* Build feature description section (without implementation instructions).
|
||||
* Used when planning mode is active — the planning prompt provides its own instructions.
|
||||
*/
|
||||
buildFeatureDescription(feature: Feature): string {
|
||||
const title = this.extractTitleFromDescription(feature.description);
|
||||
|
||||
let prompt = `## Feature Implementation Task
|
||||
let prompt = `## Feature Task
|
||||
|
||||
**Feature ID:** ${feature.id}
|
||||
**Title:** ${title}
|
||||
@@ -139,6 +144,18 @@ ${feature.spec}
|
||||
prompt += `\n**Context Images Attached:**\n${feature.imagePaths.length} image(s) attached:\n${imagesList}\n`;
|
||||
}
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
buildFeaturePrompt(
|
||||
feature: Feature,
|
||||
taskExecutionPrompts: {
|
||||
implementationInstructions: string;
|
||||
playwrightVerificationInstructions: string;
|
||||
}
|
||||
): string {
|
||||
let prompt = this.buildFeatureDescription(feature);
|
||||
|
||||
prompt += feature.skipTests
|
||||
? `\n${taskExecutionPrompts.implementationInstructions}`
|
||||
: `\n${taskExecutionPrompts.implementationInstructions}\n\n${taskExecutionPrompts.playwrightVerificationInstructions}`;
|
||||
@@ -162,6 +179,7 @@ ${feature.spec}
|
||||
const abortController = tempRunningFeature.abortController;
|
||||
if (isAutoMode) await this.saveExecutionStateFn(projectPath);
|
||||
let feature: Feature | null = null;
|
||||
let pipelineCompleted = false;
|
||||
|
||||
try {
|
||||
validateWorkingDirectory(projectPath);
|
||||
@@ -207,7 +225,12 @@ ${feature.spec}
|
||||
const branchName = feature.branchName;
|
||||
if (!worktreePath && useWorktrees && branchName) {
|
||||
worktreePath = await this.worktreeResolver.findWorktreeForBranch(projectPath, branchName);
|
||||
if (worktreePath) logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`);
|
||||
if (!worktreePath) {
|
||||
throw new Error(
|
||||
`Worktree enabled but no worktree found for feature branch "${branchName}".`
|
||||
);
|
||||
}
|
||||
logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`);
|
||||
}
|
||||
const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath);
|
||||
validateWorkingDirectory(workDir);
|
||||
@@ -241,6 +264,11 @@ ${feature.spec}
|
||||
this.settingsService,
|
||||
'[ExecutionService]'
|
||||
);
|
||||
const useClaudeCodeSystemPrompt = await getUseClaudeCodeSystemPromptSetting(
|
||||
projectPath,
|
||||
this.settingsService,
|
||||
'[ExecutionService]'
|
||||
);
|
||||
const prompts = await getPromptCustomization(this.settingsService, '[ExecutionService]');
|
||||
let prompt: string;
|
||||
const contextResult = await this.loadContextFilesFn({
|
||||
@@ -256,9 +284,15 @@ ${feature.spec}
|
||||
if (options?.continuationPrompt) {
|
||||
prompt = options.continuationPrompt;
|
||||
} else {
|
||||
prompt =
|
||||
(await this.getPlanningPromptPrefixFn(feature)) +
|
||||
this.buildFeaturePrompt(feature, prompts.taskExecution);
|
||||
const planningPrefix = await this.getPlanningPromptPrefixFn(feature);
|
||||
if (planningPrefix) {
|
||||
// Planning mode active: use planning instructions + feature description only.
|
||||
// Do NOT include implementationInstructions — they conflict with the planning
|
||||
// prompt's "DO NOT proceed with implementation until approval" directive.
|
||||
prompt = planningPrefix + '\n\n' + this.buildFeatureDescription(feature);
|
||||
} else {
|
||||
prompt = this.buildFeaturePrompt(feature, prompts.taskExecution);
|
||||
}
|
||||
if (feature.planningMode && feature.planningMode !== 'skip') {
|
||||
this.eventBus.emitAutoModeEvent('planning_started', {
|
||||
featureId: feature.id,
|
||||
@@ -289,7 +323,10 @@ ${feature.spec}
|
||||
requirePlanApproval: feature.requirePlanApproval,
|
||||
systemPrompt: combinedSystemPrompt || undefined,
|
||||
autoLoadClaudeMd,
|
||||
useClaudeCodeSystemPrompt,
|
||||
thinkingLevel: feature.thinkingLevel,
|
||||
reasoningEffort: feature.reasoningEffort,
|
||||
providerId: feature.providerId,
|
||||
branchName: feature.branchName ?? null,
|
||||
}
|
||||
);
|
||||
@@ -353,7 +390,10 @@ Please continue from where you left off and complete all remaining tasks. Use th
|
||||
requirePlanApproval: false,
|
||||
systemPrompt: combinedSystemPrompt || undefined,
|
||||
autoLoadClaudeMd,
|
||||
useClaudeCodeSystemPrompt,
|
||||
thinkingLevel: feature.thinkingLevel,
|
||||
reasoningEffort: feature.reasoningEffort,
|
||||
providerId: feature.providerId,
|
||||
branchName: feature.branchName ?? null,
|
||||
}
|
||||
);
|
||||
@@ -388,9 +428,11 @@ Please continue from where you left off and complete all remaining tasks. Use th
|
||||
branchName: feature.branchName ?? null,
|
||||
abortController,
|
||||
autoLoadClaudeMd,
|
||||
useClaudeCodeSystemPrompt,
|
||||
testAttempts: 0,
|
||||
maxTestAttempts: 5,
|
||||
});
|
||||
pipelineCompleted = true;
|
||||
// Check if pipeline set a terminal status (e.g., merge_conflict) — don't overwrite it
|
||||
const refreshed = await this.loadFeatureFn(projectPath, featureId);
|
||||
if (refreshed?.status === 'merge_conflict') {
|
||||
@@ -398,7 +440,41 @@ Please continue from where you left off and complete all remaining tasks. Use th
|
||||
}
|
||||
}
|
||||
|
||||
const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified';
|
||||
// Read agent output before determining final status.
|
||||
// CLI-based providers (Cursor, Codex, etc.) may exit quickly without doing
|
||||
// meaningful work. Check output to avoid prematurely marking as 'verified'.
|
||||
const outputPath = path.join(getFeatureDir(projectPath, featureId), 'agent-output.md');
|
||||
let agentOutput = '';
|
||||
try {
|
||||
agentOutput = (await secureFs.readFile(outputPath, 'utf-8')) as string;
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
|
||||
// Determine if the agent did meaningful work by checking for tool usage
|
||||
// indicators in the output. The agent executor writes "🔧 Tool:" markers
|
||||
// each time a tool is invoked. No tool usage suggests the CLI exited
|
||||
// without performing implementation work.
|
||||
const hasToolUsage = agentOutput.includes(TOOL_USE_MARKER);
|
||||
const isOutputTooShort = agentOutput.trim().length < MIN_MEANINGFUL_OUTPUT_LENGTH;
|
||||
const agentDidWork = hasToolUsage && !isOutputTooShort;
|
||||
|
||||
let finalStatus: 'verified' | 'waiting_approval';
|
||||
if (feature.skipTests) {
|
||||
finalStatus = 'waiting_approval';
|
||||
} else if (!agentDidWork) {
|
||||
// Agent didn't produce meaningful output (e.g., CLI exited quickly).
|
||||
// Route to waiting_approval so the user can review and re-run.
|
||||
finalStatus = 'waiting_approval';
|
||||
logger.warn(
|
||||
`[executeFeature] Feature ${featureId}: agent produced insufficient output ` +
|
||||
`(${agentOutput.trim().length}/${MIN_MEANINGFUL_OUTPUT_LENGTH} chars, toolUsage=${hasToolUsage}). ` +
|
||||
`Setting status to waiting_approval instead of verified.`
|
||||
);
|
||||
} else {
|
||||
finalStatus = 'verified';
|
||||
}
|
||||
|
||||
await this.updateFeatureStatusFn(projectPath, featureId, finalStatus);
|
||||
this.recordSuccessFn();
|
||||
|
||||
@@ -410,14 +486,10 @@ Please continue from where you left off and complete all remaining tasks. Use th
|
||||
const hasIncompleteTasks = totalTasks > 0 && completedTasks < totalTasks;
|
||||
|
||||
try {
|
||||
const outputPath = path.join(getFeatureDir(projectPath, featureId), 'agent-output.md');
|
||||
let agentOutput = '';
|
||||
try {
|
||||
agentOutput = (await secureFs.readFile(outputPath, 'utf-8')) as string;
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
if (agentOutput) {
|
||||
// Only save summary if feature doesn't already have one (e.g., accumulated from pipeline steps)
|
||||
// This prevents overwriting accumulated summaries with just the last step's output
|
||||
// The agent-executor already extracts and saves summaries during execution
|
||||
if (agentOutput && !completedFeature?.summary) {
|
||||
const summary = extractSummary(agentOutput);
|
||||
if (summary) await this.saveFeatureSummaryFn(projectPath, featureId, summary);
|
||||
}
|
||||
@@ -446,6 +518,7 @@ Please continue from where you left off and complete all remaining tasks. Use th
|
||||
featureId,
|
||||
featureName: feature.title,
|
||||
branchName: feature.branchName ?? null,
|
||||
executionMode: 'auto',
|
||||
passes: true,
|
||||
message: completionMessage,
|
||||
projectPath,
|
||||
@@ -462,6 +535,7 @@ Please continue from where you left off and complete all remaining tasks. Use th
|
||||
featureId,
|
||||
featureName: feature?.title,
|
||||
branchName: feature?.branchName ?? null,
|
||||
executionMode: 'auto',
|
||||
passes: false,
|
||||
message: 'Feature stopped by user',
|
||||
projectPath,
|
||||
@@ -469,7 +543,30 @@ Please continue from where you left off and complete all remaining tasks. Use th
|
||||
}
|
||||
} else {
|
||||
logger.error(`Feature ${featureId} failed:`, error);
|
||||
await this.updateFeatureStatusFn(projectPath, featureId, 'backlog');
|
||||
// If pipeline steps completed successfully, don't send the feature back to backlog.
|
||||
// The pipeline work is done — set to waiting_approval so the user can review.
|
||||
const fallbackStatus = pipelineCompleted ? 'waiting_approval' : 'backlog';
|
||||
if (pipelineCompleted) {
|
||||
logger.info(
|
||||
`[executeFeature] Feature ${featureId} failed after pipeline completed. ` +
|
||||
`Setting status to waiting_approval instead of backlog to preserve pipeline work.`
|
||||
);
|
||||
}
|
||||
// Don't overwrite terminal states like 'merge_conflict' that were set during pipeline execution
|
||||
let currentStatus: string | undefined;
|
||||
try {
|
||||
const currentFeature = await this.loadFeatureFn(projectPath, featureId);
|
||||
currentStatus = currentFeature?.status;
|
||||
} catch (loadErr) {
|
||||
// If loading fails, log it and proceed with the status update anyway
|
||||
logger.warn(
|
||||
`[executeFeature] Failed to reload feature ${featureId} for status check:`,
|
||||
loadErr
|
||||
);
|
||||
}
|
||||
if (currentStatus !== 'merge_conflict') {
|
||||
await this.updateFeatureStatusFn(projectPath, featureId, fallbackStatus);
|
||||
}
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_error', {
|
||||
featureId,
|
||||
featureName: feature?.title,
|
||||
@@ -491,6 +588,22 @@ Please continue from where you left off and complete all remaining tasks. Use th
|
||||
async stopFeature(featureId: string): Promise<boolean> {
|
||||
const running = this.concurrencyManager.getRunningFeature(featureId);
|
||||
if (!running) return false;
|
||||
const { projectPath } = running;
|
||||
|
||||
// Immediately update feature status to 'interrupted' so the UI reflects
|
||||
// the stop right away. CLI-based providers can take seconds to terminate
|
||||
// their subprocess after the abort signal fires, leaving the feature stuck
|
||||
// in 'in_progress' on the Kanban board until the executeFeature catch block
|
||||
// eventually runs. By persisting and emitting the status change here, the
|
||||
// board updates immediately regardless of how long the subprocess takes to stop.
|
||||
try {
|
||||
await this.updateFeatureStatusFn(projectPath, featureId, 'interrupted');
|
||||
} catch (err) {
|
||||
// Non-fatal: the abort still proceeds and executeFeature's catch block
|
||||
// will attempt the same update once the subprocess terminates.
|
||||
logger.warn(`stopFeature: failed to immediately update status for ${featureId}:`, err);
|
||||
}
|
||||
|
||||
running.abortController.abort();
|
||||
this.releaseRunningFeature(featureId, { force: true });
|
||||
return true;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* allowing the service to delegate to other services without circular dependencies.
|
||||
*/
|
||||
|
||||
import type { Feature, PlanningMode, ThinkingLevel } from '@automaker/types';
|
||||
import type { Feature, PlanningMode, ThinkingLevel, ReasoningEffort } from '@automaker/types';
|
||||
import type { loadContextFiles } from '@automaker/utils';
|
||||
import type { PipelineContext } from './pipeline-orchestrator.js';
|
||||
|
||||
@@ -31,7 +31,10 @@ export type RunAgentFn = (
|
||||
previousContent?: string;
|
||||
systemPrompt?: string;
|
||||
autoLoadClaudeMd?: boolean;
|
||||
useClaudeCodeSystemPrompt?: boolean;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
reasoningEffort?: ReasoningEffort;
|
||||
providerId?: string;
|
||||
branchName?: string | null;
|
||||
}
|
||||
) => Promise<void>;
|
||||
|
||||
@@ -378,6 +378,7 @@ export class FeatureLoader {
|
||||
description: featureData.description || '',
|
||||
...featureData,
|
||||
id: featureId,
|
||||
createdAt: featureData.createdAt || new Date().toISOString(),
|
||||
imagePaths: migratedImagePaths,
|
||||
descriptionHistory: initialHistory,
|
||||
};
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import type { Feature, ParsedTask, PlanSpec } from '@automaker/types';
|
||||
import type { Feature, FeatureStatusWithPipeline, ParsedTask, PlanSpec } from '@automaker/types';
|
||||
import { isPipelineStatus } from '@automaker/types';
|
||||
import {
|
||||
atomicWriteJson,
|
||||
readJsonWithRecovery,
|
||||
@@ -28,9 +29,40 @@ import type { EventEmitter } from '../lib/events.js';
|
||||
import type { AutoModeEventType } from './typed-event-bus.js';
|
||||
import { getNotificationService } from './notification-service.js';
|
||||
import { FeatureLoader } from './feature-loader.js';
|
||||
import { pipelineService } from './pipeline-service.js';
|
||||
|
||||
const logger = createLogger('FeatureStateManager');
|
||||
|
||||
// Notification type constants
|
||||
const NOTIFICATION_TYPE_WAITING_APPROVAL = 'feature_waiting_approval';
|
||||
const NOTIFICATION_TYPE_VERIFIED = 'feature_verified';
|
||||
const NOTIFICATION_TYPE_FEATURE_ERROR = 'feature_error';
|
||||
const NOTIFICATION_TYPE_AUTO_MODE_ERROR = 'auto_mode_error';
|
||||
|
||||
// Notification title constants
|
||||
const NOTIFICATION_TITLE_WAITING_APPROVAL = 'Feature Ready for Review';
|
||||
const NOTIFICATION_TITLE_VERIFIED = 'Feature Verified';
|
||||
const NOTIFICATION_TITLE_FEATURE_ERROR = 'Feature Failed';
|
||||
const NOTIFICATION_TITLE_AUTO_MODE_ERROR = 'Auto Mode Error';
|
||||
|
||||
/**
|
||||
* Auto-mode event payload structure
|
||||
* This is the payload that comes with 'auto-mode:event' events
|
||||
*/
|
||||
interface AutoModeEventPayload {
|
||||
type?: string;
|
||||
featureId?: string;
|
||||
featureName?: string;
|
||||
passes?: boolean;
|
||||
executionMode?: 'auto' | 'manual';
|
||||
message?: string;
|
||||
error?: string;
|
||||
errorType?: string;
|
||||
projectPath?: string;
|
||||
/** Status field present when type === 'feature_status_changed' */
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FeatureStateManager handles feature status updates with persistence guarantees.
|
||||
*
|
||||
@@ -43,10 +75,28 @@ const logger = createLogger('FeatureStateManager');
|
||||
export class FeatureStateManager {
|
||||
private events: EventEmitter;
|
||||
private featureLoader: FeatureLoader;
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
|
||||
constructor(events: EventEmitter, featureLoader: FeatureLoader) {
|
||||
this.events = events;
|
||||
this.featureLoader = featureLoader;
|
||||
|
||||
// Subscribe to error events to create notifications
|
||||
this.unsubscribe = events.subscribe((type, payload) => {
|
||||
if (type === 'auto-mode:event') {
|
||||
this.handleAutoModeEventError(payload as AutoModeEventPayload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup subscriptions
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this.unsubscribe) {
|
||||
this.unsubscribe();
|
||||
this.unsubscribe = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,77 +154,18 @@ export class FeatureStateManager {
|
||||
feature.status = status;
|
||||
feature.updatedAt = new Date().toISOString();
|
||||
|
||||
// Set justFinishedAt timestamp when moving to waiting_approval (agent just completed)
|
||||
// Badge will show for 2 minutes after this timestamp
|
||||
if (status === 'waiting_approval') {
|
||||
// Handle justFinishedAt timestamp based on status
|
||||
const shouldSetJustFinishedAt = status === 'waiting_approval';
|
||||
const shouldClearJustFinishedAt = status !== 'waiting_approval';
|
||||
if (shouldSetJustFinishedAt) {
|
||||
feature.justFinishedAt = new Date().toISOString();
|
||||
} else if (shouldClearJustFinishedAt) {
|
||||
feature.justFinishedAt = undefined;
|
||||
}
|
||||
|
||||
// Finalize task statuses when feature is done:
|
||||
// - Mark any in_progress tasks as completed (agent finished but didn't explicitly complete them)
|
||||
// - Do NOT mark pending tasks as completed (they were never started)
|
||||
// - Clear currentTaskId since no task is actively running
|
||||
// This prevents cards in "waiting for review" from appearing to still have running tasks
|
||||
if (feature.planSpec?.tasks) {
|
||||
let tasksFinalized = 0;
|
||||
let tasksPending = 0;
|
||||
for (const task of feature.planSpec.tasks) {
|
||||
if (task.status === 'in_progress') {
|
||||
task.status = 'completed';
|
||||
tasksFinalized++;
|
||||
} else if (task.status === 'pending') {
|
||||
tasksPending++;
|
||||
}
|
||||
}
|
||||
if (tasksFinalized > 0) {
|
||||
logger.info(
|
||||
`[updateFeatureStatus] Finalized ${tasksFinalized} in_progress tasks for feature ${featureId} moving to waiting_approval`
|
||||
);
|
||||
}
|
||||
if (tasksPending > 0) {
|
||||
logger.warn(
|
||||
`[updateFeatureStatus] Feature ${featureId} moving to waiting_approval with ${tasksPending} pending (never started) tasks out of ${feature.planSpec.tasks.length} total`
|
||||
);
|
||||
}
|
||||
// Update tasksCompleted count to reflect actual completed tasks
|
||||
feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter(
|
||||
(t) => t.status === 'completed'
|
||||
).length;
|
||||
feature.planSpec.currentTaskId = undefined;
|
||||
}
|
||||
} else if (status === 'verified') {
|
||||
// Also finalize in_progress tasks when moving directly to verified (skipTests=false)
|
||||
// Do NOT mark pending tasks as completed - they were never started
|
||||
if (feature.planSpec?.tasks) {
|
||||
let tasksFinalized = 0;
|
||||
let tasksPending = 0;
|
||||
for (const task of feature.planSpec.tasks) {
|
||||
if (task.status === 'in_progress') {
|
||||
task.status = 'completed';
|
||||
tasksFinalized++;
|
||||
} else if (task.status === 'pending') {
|
||||
tasksPending++;
|
||||
}
|
||||
}
|
||||
if (tasksFinalized > 0) {
|
||||
logger.info(
|
||||
`[updateFeatureStatus] Finalized ${tasksFinalized} in_progress tasks for feature ${featureId} moving to verified`
|
||||
);
|
||||
}
|
||||
if (tasksPending > 0) {
|
||||
logger.warn(
|
||||
`[updateFeatureStatus] Feature ${featureId} moving to verified with ${tasksPending} pending (never started) tasks out of ${feature.planSpec.tasks.length} total`
|
||||
);
|
||||
}
|
||||
feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter(
|
||||
(t) => t.status === 'completed'
|
||||
).length;
|
||||
feature.planSpec.currentTaskId = undefined;
|
||||
}
|
||||
// Clear the timestamp when moving to other statuses
|
||||
feature.justFinishedAt = undefined;
|
||||
} else {
|
||||
// Clear the timestamp when moving to other statuses
|
||||
feature.justFinishedAt = undefined;
|
||||
// Finalize in-progress tasks when reaching terminal states (waiting_approval or verified)
|
||||
if (status === 'waiting_approval' || status === 'verified') {
|
||||
this.finalizeInProgressTasks(feature, featureId, status);
|
||||
}
|
||||
|
||||
// PERSIST BEFORE EMIT (Pitfall 2)
|
||||
@@ -191,19 +182,21 @@ export class FeatureStateManager {
|
||||
// Wrapped in try-catch so failures don't block syncFeatureToAppSpec below
|
||||
try {
|
||||
const notificationService = getNotificationService();
|
||||
const displayName = this.getFeatureDisplayName(feature, featureId);
|
||||
|
||||
if (status === 'waiting_approval') {
|
||||
await notificationService.createNotification({
|
||||
type: 'feature_waiting_approval',
|
||||
title: 'Feature Ready for Review',
|
||||
message: `"${feature.name || featureId}" is ready for your review and approval.`,
|
||||
type: NOTIFICATION_TYPE_WAITING_APPROVAL,
|
||||
title: displayName,
|
||||
message: NOTIFICATION_TITLE_WAITING_APPROVAL,
|
||||
featureId,
|
||||
projectPath,
|
||||
});
|
||||
} else if (status === 'verified') {
|
||||
await notificationService.createNotification({
|
||||
type: 'feature_verified',
|
||||
title: 'Feature Verified',
|
||||
message: `"${feature.name || featureId}" has been verified and is complete.`,
|
||||
type: NOTIFICATION_TYPE_VERIFIED,
|
||||
title: displayName,
|
||||
message: NOTIFICATION_TITLE_VERIFIED,
|
||||
featureId,
|
||||
projectPath,
|
||||
});
|
||||
@@ -252,7 +245,7 @@ export class FeatureStateManager {
|
||||
const currentStatus = feature?.status;
|
||||
|
||||
// Preserve pipeline_* statuses so resumePipelineFeature can resume from the correct step
|
||||
if (currentStatus && currentStatus.startsWith('pipeline_')) {
|
||||
if (isPipelineStatus(currentStatus)) {
|
||||
logger.info(
|
||||
`Feature ${featureId} was in ${currentStatus}; preserving pipeline status for resume`
|
||||
);
|
||||
@@ -270,7 +263,8 @@ export class FeatureStateManager {
|
||||
|
||||
/**
|
||||
* Shared helper that scans features in a project directory and resets any stuck
|
||||
* in transient states (in_progress, interrupted, pipeline_*) back to resting states.
|
||||
* in transient states (in_progress, interrupted) back to resting states.
|
||||
* Pipeline_* statuses are preserved so they can be resumed.
|
||||
*
|
||||
* Also resets:
|
||||
* - generating planSpec status back to pending
|
||||
@@ -324,10 +318,7 @@ export class FeatureStateManager {
|
||||
|
||||
// Reset features in active execution states back to a resting state
|
||||
// After a server restart, no processes are actually running
|
||||
const isActiveState =
|
||||
originalStatus === 'in_progress' ||
|
||||
originalStatus === 'interrupted' ||
|
||||
(originalStatus != null && originalStatus.startsWith('pipeline_'));
|
||||
const isActiveState = originalStatus === 'in_progress' || originalStatus === 'interrupted';
|
||||
|
||||
if (isActiveState) {
|
||||
const hasApprovedPlan = feature.planSpec?.status === 'approved';
|
||||
@@ -338,6 +329,17 @@ export class FeatureStateManager {
|
||||
);
|
||||
}
|
||||
|
||||
// Handle pipeline_* statuses separately: preserve them so they can be resumed
|
||||
// but still count them as needing attention if they were stuck.
|
||||
if (isPipelineStatus(originalStatus)) {
|
||||
// We don't change the status, but we still want to reset planSpec/task states
|
||||
// if they were stuck in transient generation/execution modes.
|
||||
// No feature.status change here.
|
||||
logger.debug(
|
||||
`[${callerLabel}] Preserving pipeline status for feature ${feature.id}: ${originalStatus}`
|
||||
);
|
||||
}
|
||||
|
||||
// Reset generating planSpec status back to pending (spec generation was interrupted)
|
||||
if (feature.planSpec?.status === 'generating') {
|
||||
feature.planSpec.status = 'pending';
|
||||
@@ -396,10 +398,12 @@ export class FeatureStateManager {
|
||||
* Resets:
|
||||
* - in_progress features back to ready (if has plan) or backlog (if no plan)
|
||||
* - interrupted features back to ready (if has plan) or backlog (if no plan)
|
||||
* - pipeline_* features back to ready (if has plan) or backlog (if no plan)
|
||||
* - generating planSpec status back to pending
|
||||
* - in_progress tasks back to pending
|
||||
*
|
||||
* Preserves:
|
||||
* - pipeline_* statuses (so resumePipelineFeature can resume from correct step)
|
||||
*
|
||||
* @param projectPath - The project path to reset features for
|
||||
*/
|
||||
async resetStuckFeatures(projectPath: string): Promise<void> {
|
||||
@@ -530,6 +534,10 @@ export class FeatureStateManager {
|
||||
* This is called after agent execution completes to save a summary
|
||||
* extracted from the agent's output using <summary> tags.
|
||||
*
|
||||
* For pipeline features (status starts with pipeline_), summaries are accumulated
|
||||
* across steps with a header identifying each step. For non-pipeline features,
|
||||
* the summary is replaced entirely.
|
||||
*
|
||||
* @param projectPath - The project path
|
||||
* @param featureId - The feature ID
|
||||
* @param summary - The summary text to save
|
||||
@@ -537,6 +545,7 @@ export class FeatureStateManager {
|
||||
async saveFeatureSummary(projectPath: string, featureId: string, summary: string): Promise<void> {
|
||||
const featureDir = getFeatureDir(projectPath, featureId);
|
||||
const featurePath = path.join(featureDir, 'feature.json');
|
||||
const normalizedSummary = summary.trim();
|
||||
|
||||
try {
|
||||
const result = await readJsonWithRecovery<Feature | null>(featurePath, null, {
|
||||
@@ -552,7 +561,63 @@ export class FeatureStateManager {
|
||||
return;
|
||||
}
|
||||
|
||||
feature.summary = summary;
|
||||
if (!normalizedSummary) {
|
||||
logger.debug(
|
||||
`[saveFeatureSummary] Skipping empty summary for feature ${featureId} (status="${feature.status}")`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// For pipeline features, accumulate summaries across steps
|
||||
if (isPipelineStatus(feature.status)) {
|
||||
// If we already have a non-phase summary (typically the initial implementation
|
||||
// summary from in_progress), normalize it into a named phase before appending
|
||||
// pipeline step summaries. This keeps the format consistent for UI phase parsing.
|
||||
const implementationHeader = '### Implementation';
|
||||
if (feature.summary && !feature.summary.trimStart().startsWith('### ')) {
|
||||
feature.summary = `${implementationHeader}\n\n${feature.summary.trim()}`;
|
||||
}
|
||||
|
||||
const stepName = await this.getPipelineStepName(projectPath, feature.status);
|
||||
const stepHeader = `### ${stepName}`;
|
||||
const stepSection = `${stepHeader}\n\n${normalizedSummary}`;
|
||||
|
||||
if (feature.summary) {
|
||||
// Check if this step already exists in the summary (e.g., if retried)
|
||||
// Use section splitting to only match real section boundaries, not text in body content
|
||||
const separator = '\n\n---\n\n';
|
||||
const sections = feature.summary.split(separator);
|
||||
let replaced = false;
|
||||
const updatedSections = sections.map((section) => {
|
||||
if (section.startsWith(`${stepHeader}\n\n`)) {
|
||||
replaced = true;
|
||||
return stepSection;
|
||||
}
|
||||
return section;
|
||||
});
|
||||
|
||||
if (replaced) {
|
||||
feature.summary = updatedSections.join(separator);
|
||||
logger.info(
|
||||
`[saveFeatureSummary] Updated existing pipeline step summary for feature ${featureId}: step="${stepName}"`
|
||||
);
|
||||
} else {
|
||||
// Append as a new section
|
||||
feature.summary = `${feature.summary}${separator}${stepSection}`;
|
||||
logger.info(
|
||||
`[saveFeatureSummary] Appended new pipeline step summary for feature ${featureId}: step="${stepName}"`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
feature.summary = stepSection;
|
||||
logger.info(
|
||||
`[saveFeatureSummary] Initialized pipeline summary for feature ${featureId}: step="${stepName}"`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
feature.summary = normalizedSummary;
|
||||
}
|
||||
|
||||
feature.updatedAt = new Date().toISOString();
|
||||
|
||||
// PERSIST BEFORE EMIT
|
||||
@@ -562,13 +627,42 @@ export class FeatureStateManager {
|
||||
this.emitAutoModeEvent('auto_mode_summary', {
|
||||
featureId,
|
||||
projectPath,
|
||||
summary,
|
||||
summary: feature.summary,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to save summary for ${featureId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the pipeline step name from the current pipeline status.
|
||||
*
|
||||
* @param projectPath - The project path
|
||||
* @param status - The current pipeline status (e.g., 'pipeline_abc123')
|
||||
* @returns The step name, or a fallback based on the step ID
|
||||
*/
|
||||
private async getPipelineStepName(projectPath: string, status: string): Promise<string> {
|
||||
try {
|
||||
const stepId = pipelineService.getStepIdFromStatus(status as FeatureStatusWithPipeline);
|
||||
if (stepId) {
|
||||
const step = await pipelineService.getStep(projectPath, stepId);
|
||||
if (step) return step.name;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`[getPipelineStepName] Failed to look up step name for status "${status}", using fallback:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
// Fallback: derive a human-readable name from the status suffix
|
||||
// e.g., 'pipeline_code_review' → 'Code Review'
|
||||
const suffix = status.replace('pipeline_', '');
|
||||
return suffix
|
||||
.split('_')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the status of a specific task within planSpec.tasks
|
||||
*
|
||||
@@ -581,7 +675,8 @@ export class FeatureStateManager {
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
taskId: string,
|
||||
status: ParsedTask['status']
|
||||
status: ParsedTask['status'],
|
||||
summary?: string
|
||||
): Promise<void> {
|
||||
const featureDir = getFeatureDir(projectPath, featureId);
|
||||
const featurePath = path.join(featureDir, 'feature.json');
|
||||
@@ -604,6 +699,9 @@ export class FeatureStateManager {
|
||||
const task = feature.planSpec.tasks.find((t) => t.id === taskId);
|
||||
if (task) {
|
||||
task.status = status;
|
||||
if (summary) {
|
||||
task.summary = summary;
|
||||
}
|
||||
feature.updatedAt = new Date().toISOString();
|
||||
|
||||
// PERSIST BEFORE EMIT
|
||||
@@ -615,6 +713,7 @@ export class FeatureStateManager {
|
||||
projectPath,
|
||||
taskId,
|
||||
status,
|
||||
summary,
|
||||
tasks: feature.planSpec.tasks,
|
||||
});
|
||||
} else {
|
||||
@@ -628,6 +727,137 @@ export class FeatureStateManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the display name for a feature, preferring title over feature ID.
|
||||
* Empty string titles are treated as missing and fallback to featureId.
|
||||
*
|
||||
* @param feature - The feature to get the display name for
|
||||
* @param featureId - The feature ID to use as fallback
|
||||
* @returns The display name (title or feature ID)
|
||||
*/
|
||||
private getFeatureDisplayName(feature: Feature, featureId: string): string {
|
||||
// Use title if it's a non-empty string, otherwise fallback to featureId
|
||||
return feature.title && feature.title.trim() ? feature.title : featureId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle auto-mode events to create error notifications.
|
||||
* This listens for error events and creates notifications to alert users.
|
||||
*/
|
||||
private async handleAutoModeEventError(payload: AutoModeEventPayload): Promise<void> {
|
||||
if (!payload.type) return;
|
||||
|
||||
// Only handle error events
|
||||
if (payload.type !== 'auto_mode_error' && payload.type !== 'auto_mode_feature_complete') {
|
||||
return;
|
||||
}
|
||||
|
||||
// For auto_mode_feature_complete, only notify on failures (passes === false)
|
||||
if (payload.type === 'auto_mode_feature_complete' && payload.passes !== false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get project path - handle different event formats
|
||||
const projectPath = payload.projectPath;
|
||||
if (!projectPath) return;
|
||||
|
||||
try {
|
||||
const notificationService = getNotificationService();
|
||||
|
||||
// Determine notification type and title based on event type
|
||||
// Only auto_mode_feature_complete events should create feature_error notifications
|
||||
const isFeatureError = payload.type === 'auto_mode_feature_complete';
|
||||
const notificationType = isFeatureError
|
||||
? NOTIFICATION_TYPE_FEATURE_ERROR
|
||||
: NOTIFICATION_TYPE_AUTO_MODE_ERROR;
|
||||
const notificationTitle = isFeatureError
|
||||
? NOTIFICATION_TITLE_FEATURE_ERROR
|
||||
: NOTIFICATION_TITLE_AUTO_MODE_ERROR;
|
||||
|
||||
// Build error message
|
||||
let errorMessage = payload.message || 'An error occurred';
|
||||
if (payload.error) {
|
||||
errorMessage = payload.error;
|
||||
}
|
||||
|
||||
// Use feature title as notification title when available, fall back to gesture name
|
||||
let title = notificationTitle;
|
||||
if (payload.featureId) {
|
||||
const displayName = await this.getFeatureDisplayNameById(projectPath, payload.featureId);
|
||||
if (displayName) {
|
||||
title = displayName;
|
||||
errorMessage = `${notificationTitle}: ${errorMessage}`;
|
||||
}
|
||||
}
|
||||
|
||||
await notificationService.createNotification({
|
||||
type: notificationType,
|
||||
title,
|
||||
message: errorMessage,
|
||||
featureId: payload.featureId,
|
||||
projectPath,
|
||||
});
|
||||
} catch (notificationError) {
|
||||
logger.warn(`Failed to create error notification:`, notificationError);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get feature display name by loading the feature directly.
|
||||
*/
|
||||
private async getFeatureDisplayNameById(
|
||||
projectPath: string,
|
||||
featureId: string
|
||||
): Promise<string | null> {
|
||||
const feature = await this.loadFeature(projectPath, featureId);
|
||||
if (!feature) return null;
|
||||
return this.getFeatureDisplayName(feature, featureId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize in-progress tasks when a feature reaches a terminal state.
|
||||
* Marks in_progress tasks as completed but leaves pending tasks untouched.
|
||||
*
|
||||
* @param feature - The feature whose tasks should be finalized
|
||||
* @param featureId - The feature ID for logging
|
||||
* @param targetStatus - The status the feature is transitioning to
|
||||
*/
|
||||
private finalizeInProgressTasks(feature: Feature, featureId: string, targetStatus: string): void {
|
||||
if (!feature.planSpec?.tasks) {
|
||||
return;
|
||||
}
|
||||
|
||||
let tasksFinalized = 0;
|
||||
let tasksPending = 0;
|
||||
|
||||
for (const task of feature.planSpec.tasks) {
|
||||
if (task.status === 'in_progress') {
|
||||
task.status = 'completed';
|
||||
tasksFinalized++;
|
||||
} else if (task.status === 'pending') {
|
||||
tasksPending++;
|
||||
}
|
||||
}
|
||||
|
||||
// Update tasksCompleted count to reflect actual completed tasks
|
||||
feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter(
|
||||
(t) => t.status === 'completed'
|
||||
).length;
|
||||
feature.planSpec.currentTaskId = undefined;
|
||||
|
||||
if (tasksFinalized > 0) {
|
||||
logger.info(
|
||||
`[updateFeatureStatus] Finalized ${tasksFinalized} in_progress tasks for feature ${featureId} moving to ${targetStatus}`
|
||||
);
|
||||
}
|
||||
|
||||
if (tasksPending > 0) {
|
||||
logger.warn(
|
||||
`[updateFeatureStatus] Feature ${featureId} moving to ${targetStatus} with ${tasksPending} pending (never started) tasks out of ${feature.planSpec.tasks.length} total`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an auto-mode event via the event emitter
|
||||
*
|
||||
|
||||
282
apps/server/src/services/ntfy-service.ts
Normal file
282
apps/server/src/services/ntfy-service.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Ntfy Service - Sends push notifications via ntfy.sh
|
||||
*
|
||||
* Provides integration with ntfy.sh for push notifications.
|
||||
* Supports custom servers, authentication, tags, emojis, and click actions.
|
||||
*
|
||||
* @see https://docs.ntfy.sh/publish/
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { NtfyEndpointConfig, EventHookContext } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('Ntfy');
|
||||
|
||||
/** Default timeout for ntfy HTTP requests (10 seconds) */
|
||||
const DEFAULT_NTFY_TIMEOUT = 10000;
|
||||
|
||||
// Re-export EventHookContext as NtfyContext for backward compatibility
|
||||
export type NtfyContext = EventHookContext;
|
||||
|
||||
/**
|
||||
* Ntfy Service
|
||||
*
|
||||
* Handles sending notifications to ntfy.sh endpoints.
|
||||
*/
|
||||
export class NtfyService {
|
||||
/**
|
||||
* Send a notification to a ntfy.sh endpoint
|
||||
*
|
||||
* @param endpoint The ntfy.sh endpoint configuration
|
||||
* @param options Notification options (title, body, tags, etc.)
|
||||
* @param context Context for variable substitution
|
||||
*/
|
||||
async sendNotification(
|
||||
endpoint: NtfyEndpointConfig,
|
||||
options: {
|
||||
title?: string;
|
||||
body?: string;
|
||||
tags?: string;
|
||||
emoji?: string;
|
||||
clickUrl?: string;
|
||||
priority?: 1 | 2 | 3 | 4 | 5;
|
||||
},
|
||||
context: NtfyContext
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
if (!endpoint.enabled) {
|
||||
logger.warn(`Ntfy endpoint "${endpoint.name}" is disabled, skipping notification`);
|
||||
return { success: false, error: 'Endpoint is disabled' };
|
||||
}
|
||||
|
||||
// Validate endpoint configuration
|
||||
const validationError = this.validateEndpoint(endpoint);
|
||||
if (validationError) {
|
||||
logger.error(`Invalid ntfy endpoint configuration: ${validationError}`);
|
||||
return { success: false, error: validationError };
|
||||
}
|
||||
|
||||
// Build URL
|
||||
const serverUrl = endpoint.serverUrl.replace(/\/$/, ''); // Remove trailing slash
|
||||
const url = `${serverUrl}/${encodeURIComponent(endpoint.topic)}`;
|
||||
|
||||
// Build headers
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
};
|
||||
|
||||
// Title (with variable substitution)
|
||||
const title = this.substituteVariables(options.title || this.getDefaultTitle(context), context);
|
||||
if (title) {
|
||||
headers['Title'] = title;
|
||||
}
|
||||
|
||||
// Priority
|
||||
const priority = options.priority || 3;
|
||||
headers['Priority'] = String(priority);
|
||||
|
||||
// Tags and emoji
|
||||
const tags = this.buildTags(
|
||||
options.tags || endpoint.defaultTags,
|
||||
options.emoji || endpoint.defaultEmoji
|
||||
);
|
||||
if (tags) {
|
||||
headers['Tags'] = tags;
|
||||
}
|
||||
|
||||
// Click action URL
|
||||
const clickUrl = this.substituteVariables(
|
||||
options.clickUrl || endpoint.defaultClickUrl || '',
|
||||
context
|
||||
);
|
||||
if (clickUrl) {
|
||||
headers['Click'] = clickUrl;
|
||||
}
|
||||
|
||||
// Authentication
|
||||
this.addAuthHeaders(headers, endpoint);
|
||||
|
||||
// Message body (with variable substitution)
|
||||
const body = this.substituteVariables(options.body || this.getDefaultBody(context), context);
|
||||
|
||||
logger.info(`Sending ntfy notification to ${endpoint.name}: ${title}`);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), DEFAULT_NTFY_TIMEOUT);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
logger.error(`Ntfy notification failed with status ${response.status}: ${errorText}`);
|
||||
return {
|
||||
success: false,
|
||||
error: `HTTP ${response.status}: ${errorText}`,
|
||||
};
|
||||
}
|
||||
|
||||
logger.info(`Ntfy notification sent successfully to ${endpoint.name}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if ((error as Error).name === 'AbortError') {
|
||||
logger.error(`Ntfy notification timed out after ${DEFAULT_NTFY_TIMEOUT}ms`);
|
||||
return { success: false, error: 'Request timed out' };
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`Ntfy notification failed: ${errorMessage}`);
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an ntfy endpoint configuration
|
||||
*/
|
||||
validateEndpoint(endpoint: NtfyEndpointConfig): string | null {
|
||||
// Validate server URL
|
||||
if (!endpoint.serverUrl) {
|
||||
return 'Server URL is required';
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(endpoint.serverUrl);
|
||||
} catch {
|
||||
return 'Invalid server URL format';
|
||||
}
|
||||
|
||||
// Validate topic
|
||||
if (!endpoint.topic) {
|
||||
return 'Topic is required';
|
||||
}
|
||||
|
||||
if (endpoint.topic.includes(' ') || endpoint.topic.includes('\t')) {
|
||||
return 'Topic cannot contain spaces';
|
||||
}
|
||||
|
||||
// Validate authentication
|
||||
if (endpoint.authType === 'basic') {
|
||||
if (!endpoint.username || !endpoint.password) {
|
||||
return 'Username and password are required for basic authentication';
|
||||
}
|
||||
} else if (endpoint.authType === 'token') {
|
||||
if (!endpoint.token) {
|
||||
return 'Access token is required for token authentication';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build tags string from tags and emoji
|
||||
*/
|
||||
private buildTags(tags?: string, emoji?: string): string {
|
||||
const tagList: string[] = [];
|
||||
|
||||
if (tags) {
|
||||
// Split by comma and trim whitespace
|
||||
const parsedTags = tags
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0);
|
||||
tagList.push(...parsedTags);
|
||||
}
|
||||
|
||||
if (emoji) {
|
||||
// Add emoji as first tag if it looks like a shortcode
|
||||
if (emoji.startsWith(':') && emoji.endsWith(':')) {
|
||||
tagList.unshift(emoji.slice(1, -1));
|
||||
} else if (!emoji.includes(' ')) {
|
||||
// If it's a single emoji or shortcode without colons, add as-is
|
||||
tagList.unshift(emoji);
|
||||
}
|
||||
}
|
||||
|
||||
return tagList.join(',');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add authentication headers based on auth type
|
||||
*/
|
||||
private addAuthHeaders(headers: Record<string, string>, endpoint: NtfyEndpointConfig): void {
|
||||
if (endpoint.authType === 'basic' && endpoint.username && endpoint.password) {
|
||||
const credentials = Buffer.from(`${endpoint.username}:${endpoint.password}`).toString(
|
||||
'base64'
|
||||
);
|
||||
headers['Authorization'] = `Basic ${credentials}`;
|
||||
} else if (endpoint.authType === 'token' && endpoint.token) {
|
||||
headers['Authorization'] = `Bearer ${endpoint.token}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default title based on event context
|
||||
*/
|
||||
private getDefaultTitle(context: NtfyContext): string {
|
||||
const eventName = this.formatEventName(context.eventType);
|
||||
if (context.featureName) {
|
||||
return `${eventName}: ${context.featureName}`;
|
||||
}
|
||||
return eventName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default body based on event context
|
||||
*/
|
||||
private getDefaultBody(context: NtfyContext): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (context.featureName) {
|
||||
lines.push(`Feature: ${context.featureName}`);
|
||||
}
|
||||
if (context.featureId) {
|
||||
lines.push(`ID: ${context.featureId}`);
|
||||
}
|
||||
if (context.projectName) {
|
||||
lines.push(`Project: ${context.projectName}`);
|
||||
}
|
||||
if (context.error) {
|
||||
lines.push(`Error: ${context.error}`);
|
||||
}
|
||||
lines.push(`Time: ${context.timestamp}`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format event type to human-readable name
|
||||
*/
|
||||
private formatEventName(eventType: string): string {
|
||||
const eventNames: Record<string, string> = {
|
||||
feature_created: 'Feature Created',
|
||||
feature_success: 'Feature Completed',
|
||||
feature_error: 'Feature Failed',
|
||||
auto_mode_complete: 'Auto Mode Complete',
|
||||
auto_mode_error: 'Auto Mode Error',
|
||||
};
|
||||
return eventNames[eventType] || eventType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute {{variable}} placeholders in a string
|
||||
*/
|
||||
private substituteVariables(template: string, context: NtfyContext): string {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (match, variable) => {
|
||||
const value = context[variable as keyof NtfyContext];
|
||||
if (value === undefined || value === null) {
|
||||
return '';
|
||||
}
|
||||
return String(value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const ntfyService = new NtfyService();
|
||||
@@ -16,6 +16,7 @@ import * as secureFs from '../lib/secure-fs.js';
|
||||
import {
|
||||
getPromptCustomization,
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getUseClaudeCodeSystemPromptSetting,
|
||||
filterClaudeMdFromContext,
|
||||
} from '../lib/settings-helpers.js';
|
||||
import { validateWorkingDirectory } from '../lib/sdk-options.js';
|
||||
@@ -70,8 +71,16 @@ export class PipelineOrchestrator {
|
||||
) {}
|
||||
|
||||
async executePipeline(ctx: PipelineContext): Promise<void> {
|
||||
const { projectPath, featureId, feature, steps, workDir, abortController, autoLoadClaudeMd } =
|
||||
ctx;
|
||||
const {
|
||||
projectPath,
|
||||
featureId,
|
||||
feature,
|
||||
steps,
|
||||
workDir,
|
||||
abortController,
|
||||
autoLoadClaudeMd,
|
||||
useClaudeCodeSystemPrompt,
|
||||
} = ctx;
|
||||
const prompts = await getPromptCustomization(this.settingsService, '[AutoMode]');
|
||||
const contextResult = await this.loadContextFilesFn({
|
||||
projectPath,
|
||||
@@ -106,6 +115,7 @@ export class PipelineOrchestrator {
|
||||
projectPath,
|
||||
});
|
||||
const model = resolveModelString(feature.model, DEFAULT_MODELS.claude);
|
||||
const currentStatus = `pipeline_${step.id}`;
|
||||
await this.runAgentFn(
|
||||
workDir,
|
||||
featureId,
|
||||
@@ -121,7 +131,11 @@ export class PipelineOrchestrator {
|
||||
previousContent: previousContext,
|
||||
systemPrompt: contextFilesPrompt || undefined,
|
||||
autoLoadClaudeMd,
|
||||
useClaudeCodeSystemPrompt,
|
||||
thinkingLevel: feature.thinkingLevel,
|
||||
reasoningEffort: feature.reasoningEffort,
|
||||
status: currentStatus,
|
||||
providerId: feature.providerId,
|
||||
}
|
||||
);
|
||||
try {
|
||||
@@ -154,7 +168,18 @@ export class PipelineOrchestrator {
|
||||
if (previousContext) prompt += `### Previous Work\n${previousContext}\n\n`;
|
||||
return (
|
||||
prompt +
|
||||
`### Pipeline Step Instructions\n${step.instructions}\n\n### Task\nComplete the pipeline step instructions above.`
|
||||
`### Pipeline Step Instructions\n${step.instructions}\n\n### Task\nComplete the pipeline step instructions above.\n\n` +
|
||||
`**CRITICAL: After completing the instructions, you MUST output a summary using this EXACT format:**\n\n` +
|
||||
`<summary>\n` +
|
||||
`## Summary: ${step.name}\n\n` +
|
||||
`### Changes Implemented\n` +
|
||||
`- [List all changes made in this step]\n\n` +
|
||||
`### Files Modified\n` +
|
||||
`- [List all files modified in this step]\n\n` +
|
||||
`### Outcome\n` +
|
||||
`- [Describe the result of this step]\n` +
|
||||
`</summary>\n\n` +
|
||||
`The <summary> and </summary> tags MUST be on their own lines. This is REQUIRED.`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -232,6 +257,7 @@ export class PipelineOrchestrator {
|
||||
featureId,
|
||||
featureName: feature.title,
|
||||
branchName: feature.branchName ?? null,
|
||||
executionMode: 'auto',
|
||||
passes: true,
|
||||
message: 'Pipeline step no longer exists',
|
||||
projectPath,
|
||||
@@ -281,6 +307,7 @@ export class PipelineOrchestrator {
|
||||
featureId,
|
||||
featureName: feature.title,
|
||||
branchName: feature.branchName ?? null,
|
||||
executionMode: 'auto',
|
||||
passes: true,
|
||||
message: 'Pipeline completed (remaining steps excluded)',
|
||||
projectPath,
|
||||
@@ -306,6 +333,7 @@ export class PipelineOrchestrator {
|
||||
featureId,
|
||||
featureName: feature.title,
|
||||
branchName: feature.branchName ?? null,
|
||||
executionMode: 'auto',
|
||||
passes: true,
|
||||
message: 'Pipeline completed (all steps excluded)',
|
||||
projectPath,
|
||||
@@ -322,6 +350,7 @@ export class PipelineOrchestrator {
|
||||
});
|
||||
const abortController = runningEntry.abortController;
|
||||
runningEntry.branchName = feature.branchName ?? null;
|
||||
let pipelineCompleted = false;
|
||||
|
||||
try {
|
||||
validateWorkingDirectory(projectPath);
|
||||
@@ -354,6 +383,11 @@ export class PipelineOrchestrator {
|
||||
this.settingsService,
|
||||
'[AutoMode]'
|
||||
);
|
||||
const useClaudeCodeSystemPrompt = await getUseClaudeCodeSystemPromptSetting(
|
||||
projectPath,
|
||||
this.settingsService,
|
||||
'[AutoMode]'
|
||||
);
|
||||
const context: PipelineContext = {
|
||||
projectPath,
|
||||
featureId,
|
||||
@@ -364,11 +398,13 @@ export class PipelineOrchestrator {
|
||||
branchName: branchName ?? null,
|
||||
abortController,
|
||||
autoLoadClaudeMd,
|
||||
useClaudeCodeSystemPrompt,
|
||||
testAttempts: 0,
|
||||
maxTestAttempts: 5,
|
||||
};
|
||||
|
||||
await this.executePipeline(context);
|
||||
pipelineCompleted = true;
|
||||
|
||||
// Re-fetch feature to check if executePipeline set a terminal status (e.g., merge_conflict)
|
||||
const reloadedFeature = await this.featureStateManager.loadFeature(projectPath, featureId);
|
||||
@@ -384,6 +420,7 @@ export class PipelineOrchestrator {
|
||||
featureId,
|
||||
featureName: feature.title,
|
||||
branchName: feature.branchName ?? null,
|
||||
executionMode: 'auto',
|
||||
passes: true,
|
||||
message: 'Pipeline resumed successfully',
|
||||
projectPath,
|
||||
@@ -397,14 +434,28 @@ export class PipelineOrchestrator {
|
||||
featureId,
|
||||
featureName: feature.title,
|
||||
branchName: feature.branchName ?? null,
|
||||
executionMode: 'auto',
|
||||
passes: false,
|
||||
message: 'Pipeline stopped by user',
|
||||
projectPath,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// If pipeline steps completed successfully, don't send the feature back to backlog.
|
||||
// The pipeline work is done — set to waiting_approval so the user can review.
|
||||
const fallbackStatus = pipelineCompleted ? 'waiting_approval' : 'backlog';
|
||||
if (pipelineCompleted) {
|
||||
logger.info(
|
||||
`[resumeFromStep] Feature ${featureId} failed after pipeline completed. ` +
|
||||
`Setting status to waiting_approval instead of backlog to preserve pipeline work.`
|
||||
);
|
||||
}
|
||||
logger.error(`Pipeline resume failed for ${featureId}:`, error);
|
||||
await this.updateFeatureStatusFn(projectPath, featureId, 'backlog');
|
||||
// Don't overwrite terminal states like 'merge_conflict' that were set during pipeline execution
|
||||
const currentFeature = await this.featureStateManager.loadFeature(projectPath, featureId);
|
||||
if (currentFeature?.status !== 'merge_conflict') {
|
||||
await this.updateFeatureStatusFn(projectPath, featureId, fallbackStatus);
|
||||
}
|
||||
this.eventBus.emitAutoModeEvent('auto_mode_error', {
|
||||
featureId,
|
||||
featureName: feature.title,
|
||||
@@ -462,7 +513,17 @@ export class PipelineOrchestrator {
|
||||
projectPath,
|
||||
undefined,
|
||||
undefined,
|
||||
{ projectPath, planningMode: 'skip', requirePlanApproval: false }
|
||||
{
|
||||
projectPath,
|
||||
planningMode: 'skip',
|
||||
requirePlanApproval: false,
|
||||
useClaudeCodeSystemPrompt: context.useClaudeCodeSystemPrompt,
|
||||
autoLoadClaudeMd: context.autoLoadClaudeMd,
|
||||
thinkingLevel: context.feature.thinkingLevel,
|
||||
reasoningEffort: context.feature.reasoningEffort,
|
||||
status: context.feature.status,
|
||||
providerId: context.feature.providerId,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -556,6 +617,7 @@ export class PipelineOrchestrator {
|
||||
featureId,
|
||||
featureName: feature.title,
|
||||
branchName,
|
||||
executionMode: 'auto',
|
||||
passes: true,
|
||||
message: 'Pipeline completed and merged',
|
||||
projectPath,
|
||||
@@ -596,7 +658,7 @@ export class PipelineOrchestrator {
|
||||
}
|
||||
// Only capture assertion details when they appear in failure context
|
||||
// or match explicit assertion error / expect patterns
|
||||
if (trimmed.includes('AssertionError') || trimmed.includes('AssertionError')) {
|
||||
if (trimmed.includes('AssertionError')) {
|
||||
failedTests.push(trimmed);
|
||||
} else if (
|
||||
inFailureContext &&
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface PipelineContext {
|
||||
branchName: string | null;
|
||||
abortController: AbortController;
|
||||
autoLoadClaudeMd: boolean;
|
||||
useClaudeCodeSystemPrompt?: boolean;
|
||||
testAttempts: number;
|
||||
maxTestAttempts: number;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ const logger = createLogger('PullService');
|
||||
export interface PullOptions {
|
||||
/** Remote name to pull from (defaults to 'origin') */
|
||||
remote?: string;
|
||||
/** Specific remote branch to pull (e.g. 'main'). When provided, overrides the tracking branch and fetches this branch from the remote. */
|
||||
remoteBranch?: string;
|
||||
/** When true, automatically stash local changes before pulling and reapply after */
|
||||
stashIfNeeded?: boolean;
|
||||
}
|
||||
@@ -243,6 +245,7 @@ export async function performPull(
|
||||
): Promise<PullResult> {
|
||||
const targetRemote = options?.remote || 'origin';
|
||||
const stashIfNeeded = options?.stashIfNeeded ?? false;
|
||||
const targetRemoteBranch = options?.remoteBranch;
|
||||
|
||||
// 1. Get current branch name
|
||||
let branchName: string;
|
||||
@@ -313,24 +316,34 @@ export async function performPull(
|
||||
}
|
||||
|
||||
// 7. Verify upstream tracking or remote branch exists
|
||||
const upstreamStatus = await hasUpstreamOrRemoteBranch(worktreePath, branchName, targetRemote);
|
||||
if (upstreamStatus === 'none') {
|
||||
let stashRecoveryFailed = false;
|
||||
if (didStash) {
|
||||
const stashPopped = await tryPopStash(worktreePath);
|
||||
stashRecoveryFailed = !stashPopped;
|
||||
// Skip this check when a specific remote branch is provided - we always use
|
||||
// explicit 'git pull <remote> <branch>' args in that case.
|
||||
let upstreamStatus: UpstreamStatus = 'tracking';
|
||||
if (!targetRemoteBranch) {
|
||||
upstreamStatus = await hasUpstreamOrRemoteBranch(worktreePath, branchName, targetRemote);
|
||||
if (upstreamStatus === 'none') {
|
||||
let stashRecoveryFailed = false;
|
||||
if (didStash) {
|
||||
const stashPopped = await tryPopStash(worktreePath);
|
||||
stashRecoveryFailed = !stashPopped;
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: `Branch '${branchName}' has no upstream branch on remote '${targetRemote}'. Push it first or set upstream with: git branch --set-upstream-to=${targetRemote}/${branchName}${stashRecoveryFailed ? ' Local changes remain stashed and need manual recovery (run: git stash pop).' : ''}`,
|
||||
stashRecoveryFailed: stashRecoveryFailed ? stashRecoveryFailed : undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: `Branch '${branchName}' has no upstream branch on remote '${targetRemote}'. Push it first or set upstream with: git branch --set-upstream-to=${targetRemote}/${branchName}${stashRecoveryFailed ? ' Local changes remain stashed and need manual recovery (run: git stash pop).' : ''}`,
|
||||
stashRecoveryFailed: stashRecoveryFailed ? stashRecoveryFailed : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// 8. Pull latest changes
|
||||
// When a specific remote branch is requested, always use explicit remote + branch args.
|
||||
// When the branch has a configured upstream tracking ref, let Git use it automatically.
|
||||
// When only the remote branch exists (no tracking ref), explicitly specify remote and branch.
|
||||
const pullArgs = upstreamStatus === 'tracking' ? ['pull'] : ['pull', targetRemote, branchName];
|
||||
const pullArgs = targetRemoteBranch
|
||||
? ['pull', targetRemote, targetRemoteBranch]
|
||||
: upstreamStatus === 'tracking'
|
||||
? ['pull']
|
||||
: ['pull', targetRemote, branchName];
|
||||
let pullConflict = false;
|
||||
let pullConflictFiles: string[] = [];
|
||||
|
||||
|
||||
258
apps/server/src/services/push-service.ts
Normal file
258
apps/server/src/services/push-service.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* PushService - Push git operations without HTTP
|
||||
*
|
||||
* Encapsulates the full git push workflow including:
|
||||
* - Branch name and detached HEAD detection
|
||||
* - Safe array-based command execution (no shell interpolation)
|
||||
* - Divergent branch detection and auto-resolution via pull-then-retry
|
||||
* - Structured result reporting
|
||||
*
|
||||
* Mirrors the pull-service.ts pattern for consistency.
|
||||
*/
|
||||
|
||||
import { createLogger, getErrorMessage } from '@automaker/utils';
|
||||
import { execGitCommand } from '@automaker/git-utils';
|
||||
import { getCurrentBranch } from '../lib/git.js';
|
||||
import { performPull } from './pull-service.js';
|
||||
|
||||
const logger = createLogger('PushService');
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface PushOptions {
|
||||
/** Remote name to push to (defaults to 'origin') */
|
||||
remote?: string;
|
||||
/** Force push */
|
||||
force?: boolean;
|
||||
/** When true and push is rejected due to divergence, pull then retry push */
|
||||
autoResolve?: boolean;
|
||||
}
|
||||
|
||||
export interface PushResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
branch?: string;
|
||||
pushed?: boolean;
|
||||
/** Whether the push was initially rejected because the branches diverged */
|
||||
diverged?: boolean;
|
||||
/** Whether divergence was automatically resolved via pull-then-retry */
|
||||
autoResolved?: boolean;
|
||||
/** Whether the auto-resolve pull resulted in merge conflicts */
|
||||
hasConflicts?: boolean;
|
||||
/** Files with merge conflicts (only when hasConflicts is true) */
|
||||
conflictFiles?: string[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Detect whether push error output indicates a diverged/non-fast-forward rejection.
|
||||
*/
|
||||
function isDivergenceError(errorOutput: string): boolean {
|
||||
const lower = errorOutput.toLowerCase();
|
||||
// Require specific divergence indicators rather than just 'rejected' alone,
|
||||
// which could match pre-receive hook rejections or protected branch errors.
|
||||
const hasNonFastForward = lower.includes('non-fast-forward');
|
||||
const hasFetchFirst = lower.includes('fetch first');
|
||||
const hasFailedToPush = lower.includes('failed to push some refs');
|
||||
const hasRejected = lower.includes('rejected');
|
||||
return hasNonFastForward || hasFetchFirst || (hasRejected && hasFailedToPush);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Service Function
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Perform a git push on the given worktree.
|
||||
*
|
||||
* The workflow:
|
||||
* 1. Get current branch name (detect detached HEAD)
|
||||
* 2. Attempt `git push <remote> <branch>` with safe array args
|
||||
* 3. If push fails with divergence and autoResolve is true:
|
||||
* a. Pull from the same remote (with stash support)
|
||||
* b. If pull succeeds without conflicts, retry push
|
||||
* 4. If push fails with "no upstream" error, retry with --set-upstream
|
||||
* 5. Return structured result
|
||||
*
|
||||
* @param worktreePath - Path to the git worktree
|
||||
* @param options - Push options (remote, force, autoResolve)
|
||||
* @returns PushResult with detailed status information
|
||||
*/
|
||||
export async function performPush(
|
||||
worktreePath: string,
|
||||
options?: PushOptions
|
||||
): Promise<PushResult> {
|
||||
const targetRemote = options?.remote || 'origin';
|
||||
const force = options?.force ?? false;
|
||||
const autoResolve = options?.autoResolve ?? false;
|
||||
|
||||
// 1. Get current branch name
|
||||
let branchName: string;
|
||||
try {
|
||||
branchName = await getCurrentBranch(worktreePath);
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to get current branch: ${getErrorMessage(err)}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Check for detached HEAD state
|
||||
if (branchName === 'HEAD') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Cannot push in detached HEAD state. Please checkout a branch first.',
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Build push args (no -u flag; upstream is set in the fallback path only when needed)
|
||||
const pushArgs = ['push', targetRemote, branchName];
|
||||
if (force) {
|
||||
pushArgs.push('--force');
|
||||
}
|
||||
|
||||
// 4. Attempt push
|
||||
try {
|
||||
await execGitCommand(pushArgs, worktreePath);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
branch: branchName,
|
||||
pushed: true,
|
||||
message: `Successfully pushed ${branchName} to ${targetRemote}`,
|
||||
};
|
||||
} catch (pushError: unknown) {
|
||||
const err = pushError as { stderr?: string; stdout?: string; message?: string };
|
||||
const errorOutput = `${err.stderr || ''} ${err.stdout || ''} ${err.message || ''}`;
|
||||
|
||||
// 5. Check if the error is a divergence rejection
|
||||
if (isDivergenceError(errorOutput)) {
|
||||
if (!autoResolve) {
|
||||
return {
|
||||
success: false,
|
||||
branch: branchName,
|
||||
pushed: false,
|
||||
diverged: true,
|
||||
error: `Push rejected: remote has changes not present locally. Use sync or pull first, or enable auto-resolve.`,
|
||||
message: `Push to ${targetRemote} was rejected because the remote branch has diverged.`,
|
||||
};
|
||||
}
|
||||
|
||||
// 6. Auto-resolve: pull then retry push
|
||||
logger.info('Push rejected due to divergence, attempting auto-resolve via pull', {
|
||||
worktreePath,
|
||||
remote: targetRemote,
|
||||
branch: branchName,
|
||||
});
|
||||
|
||||
try {
|
||||
const pullResult = await performPull(worktreePath, {
|
||||
remote: targetRemote,
|
||||
stashIfNeeded: true,
|
||||
});
|
||||
|
||||
if (!pullResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
branch: branchName,
|
||||
pushed: false,
|
||||
diverged: true,
|
||||
autoResolved: false,
|
||||
error: `Auto-resolve failed during pull: ${pullResult.error}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (pullResult.hasConflicts) {
|
||||
return {
|
||||
success: false,
|
||||
branch: branchName,
|
||||
pushed: false,
|
||||
diverged: true,
|
||||
autoResolved: false,
|
||||
hasConflicts: true,
|
||||
conflictFiles: pullResult.conflictFiles,
|
||||
error:
|
||||
'Auto-resolve pull resulted in merge conflicts. Resolve conflicts and push again.',
|
||||
};
|
||||
}
|
||||
|
||||
// 7. Retry push after successful pull
|
||||
try {
|
||||
await execGitCommand(pushArgs, worktreePath);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
branch: branchName,
|
||||
pushed: true,
|
||||
diverged: true,
|
||||
autoResolved: true,
|
||||
message: `Push succeeded after auto-resolving divergence (pulled from ${targetRemote} first).`,
|
||||
};
|
||||
} catch (retryError: unknown) {
|
||||
const retryErr = retryError as { stderr?: string; message?: string };
|
||||
return {
|
||||
success: false,
|
||||
branch: branchName,
|
||||
pushed: false,
|
||||
diverged: true,
|
||||
autoResolved: false,
|
||||
error: `Push failed after auto-resolve pull: ${retryErr.stderr || retryErr.message || 'Unknown error'}`,
|
||||
};
|
||||
}
|
||||
} catch (pullError) {
|
||||
return {
|
||||
success: false,
|
||||
branch: branchName,
|
||||
pushed: false,
|
||||
diverged: true,
|
||||
autoResolved: false,
|
||||
error: `Auto-resolve pull failed: ${getErrorMessage(pullError)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 6b. Non-divergence error (e.g. no upstream configured) - retry with --set-upstream
|
||||
const isNoUpstreamError =
|
||||
errorOutput.toLowerCase().includes('no upstream') ||
|
||||
errorOutput.toLowerCase().includes('has no upstream branch') ||
|
||||
errorOutput.toLowerCase().includes('set-upstream');
|
||||
if (isNoUpstreamError) {
|
||||
try {
|
||||
const setUpstreamArgs = ['push', '--set-upstream', targetRemote, branchName];
|
||||
if (force) {
|
||||
setUpstreamArgs.push('--force');
|
||||
}
|
||||
await execGitCommand(setUpstreamArgs, worktreePath);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
branch: branchName,
|
||||
pushed: true,
|
||||
message: `Successfully pushed ${branchName} to ${targetRemote} (set upstream)`,
|
||||
};
|
||||
} catch (upstreamError: unknown) {
|
||||
const upstreamErr = upstreamError as { stderr?: string; message?: string };
|
||||
return {
|
||||
success: false,
|
||||
branch: branchName,
|
||||
pushed: false,
|
||||
error: upstreamErr.stderr || upstreamErr.message || getErrorMessage(pushError),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 6c. Other push error - return as-is
|
||||
return {
|
||||
success: false,
|
||||
branch: branchName,
|
||||
pushed: false,
|
||||
error: err.stderr || err.message || getErrorMessage(pushError),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import type {
|
||||
WorktreeInfo,
|
||||
PhaseModelConfig,
|
||||
PhaseModelEntry,
|
||||
FeatureTemplate,
|
||||
ClaudeApiProfile,
|
||||
ClaudeCompatibleProvider,
|
||||
ProviderModel,
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
DEFAULT_CREDENTIALS,
|
||||
DEFAULT_PROJECT_SETTINGS,
|
||||
DEFAULT_PHASE_MODELS,
|
||||
DEFAULT_FEATURE_TEMPLATES,
|
||||
SETTINGS_VERSION,
|
||||
CREDENTIALS_VERSION,
|
||||
PROJECT_SETTINGS_VERSION,
|
||||
@@ -139,6 +141,11 @@ export class SettingsService {
|
||||
// Migrate model IDs to canonical format
|
||||
const migratedModelSettings = this.migrateModelSettings(settings);
|
||||
|
||||
// Merge built-in feature templates: ensure all built-in templates exist in user settings.
|
||||
// User customizations (enabled/disabled state, order overrides) are preserved.
|
||||
// New built-in templates added in code updates are injected for existing users.
|
||||
const mergedFeatureTemplates = this.mergeBuiltInTemplates(settings.featureTemplates);
|
||||
|
||||
// Apply any missing defaults (for backwards compatibility)
|
||||
let result: GlobalSettings = {
|
||||
...DEFAULT_GLOBAL_SETTINGS,
|
||||
@@ -149,6 +156,7 @@ export class SettingsService {
|
||||
...settings.keyboardShortcuts,
|
||||
},
|
||||
phaseModels: migratedPhaseModels,
|
||||
featureTemplates: mergedFeatureTemplates,
|
||||
};
|
||||
|
||||
// Version-based migrations
|
||||
@@ -250,6 +258,32 @@ export class SettingsService {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge built-in feature templates with user's stored templates.
|
||||
*
|
||||
* Ensures new built-in templates added in code updates are available to existing users
|
||||
* without overwriting their customizations (e.g., enabled/disabled state, custom order).
|
||||
* Built-in templates missing from stored settings are appended with their defaults.
|
||||
*
|
||||
* @param storedTemplates - Templates from user's settings file (may be undefined for new installs)
|
||||
* @returns Merged template list with all built-in templates present
|
||||
*/
|
||||
private mergeBuiltInTemplates(storedTemplates: FeatureTemplate[] | undefined): FeatureTemplate[] {
|
||||
if (!storedTemplates) {
|
||||
return DEFAULT_FEATURE_TEMPLATES;
|
||||
}
|
||||
|
||||
const storedIds = new Set(storedTemplates.map((t) => t.id));
|
||||
const missingBuiltIns = DEFAULT_FEATURE_TEMPLATES.filter((t) => !storedIds.has(t.id));
|
||||
|
||||
if (missingBuiltIns.length === 0) {
|
||||
return storedTemplates;
|
||||
}
|
||||
|
||||
// Append missing built-in templates after existing ones
|
||||
return [...storedTemplates, ...missingBuiltIns];
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate legacy enhancementModel/validationModel fields to phaseModels structure
|
||||
*
|
||||
@@ -573,6 +607,47 @@ export class SettingsService {
|
||||
ignoreEmptyArrayOverwrite('claudeApiProfiles');
|
||||
// Note: claudeCompatibleProviders intentionally NOT guarded - users should be able to delete all providers
|
||||
|
||||
// Check for explicit permission to clear eventHooks (escape hatch for intentional clearing)
|
||||
const allowEmptyEventHooks =
|
||||
(sanitizedUpdates as Record<string, unknown>).__allowEmptyEventHooks === true;
|
||||
// Remove the flag so it doesn't get persisted
|
||||
delete (sanitizedUpdates as Record<string, unknown>).__allowEmptyEventHooks;
|
||||
|
||||
// Only guard eventHooks if explicit permission wasn't granted
|
||||
if (!allowEmptyEventHooks) {
|
||||
ignoreEmptyArrayOverwrite('eventHooks');
|
||||
}
|
||||
|
||||
// Guard ntfyEndpoints against accidental wipe
|
||||
// (similar to eventHooks, these are user-configured and shouldn't be lost)
|
||||
// Check for explicit permission to clear ntfyEndpoints (escape hatch for intentional clearing)
|
||||
const allowEmptyNtfyEndpoints =
|
||||
(sanitizedUpdates as Record<string, unknown>).__allowEmptyNtfyEndpoints === true;
|
||||
// Remove the flag so it doesn't get persisted
|
||||
delete (sanitizedUpdates as Record<string, unknown>).__allowEmptyNtfyEndpoints;
|
||||
|
||||
if (!allowEmptyNtfyEndpoints) {
|
||||
const currentNtfyLen = Array.isArray(current.ntfyEndpoints)
|
||||
? current.ntfyEndpoints.length
|
||||
: 0;
|
||||
const newNtfyLen = Array.isArray(sanitizedUpdates.ntfyEndpoints)
|
||||
? sanitizedUpdates.ntfyEndpoints.length
|
||||
: currentNtfyLen;
|
||||
|
||||
if (Array.isArray(sanitizedUpdates.ntfyEndpoints) && newNtfyLen === 0 && currentNtfyLen > 0) {
|
||||
logger.warn(
|
||||
'[WIPE_PROTECTION] Attempted to set ntfyEndpoints to empty array! Ignoring update.',
|
||||
{
|
||||
currentNtfyLen,
|
||||
newNtfyLen,
|
||||
}
|
||||
);
|
||||
delete sanitizedUpdates.ntfyEndpoints;
|
||||
}
|
||||
} else {
|
||||
logger.info('[INTENTIONAL_CLEAR] Clearing ntfyEndpoints via escape hatch');
|
||||
}
|
||||
|
||||
// Empty object overwrite guard
|
||||
const ignoreEmptyObjectOverwrite = <K extends keyof GlobalSettings>(key: K): void => {
|
||||
const nextVal = sanitizedUpdates[key] as unknown;
|
||||
@@ -978,6 +1053,8 @@ export class SettingsService {
|
||||
keyboardShortcuts:
|
||||
(appState.keyboardShortcuts as KeyboardShortcuts) ||
|
||||
DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts,
|
||||
eventHooks: (appState.eventHooks as GlobalSettings['eventHooks']) || [],
|
||||
ntfyEndpoints: (appState.ntfyEndpoints as GlobalSettings['ntfyEndpoints']) || [],
|
||||
projects: (appState.projects as ProjectRef[]) || [],
|
||||
trashedProjects: (appState.trashedProjects as TrashedProjectRef[]) || [],
|
||||
projectHistory: (appState.projectHistory as string[]) || [],
|
||||
|
||||
@@ -101,12 +101,32 @@ export function detectTaskStartMarker(text: string): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect [TASK_COMPLETE] marker in text and extract task ID
|
||||
* Detect [TASK_COMPLETE] marker in text and extract task ID and summary
|
||||
* Format: [TASK_COMPLETE] T###: Brief summary
|
||||
*/
|
||||
export function detectTaskCompleteMarker(text: string): string | null {
|
||||
const match = text.match(/\[TASK_COMPLETE\]\s*(T\d{3})/);
|
||||
return match ? match[1] : null;
|
||||
export function detectTaskCompleteMarker(text: string): { id: string; summary?: string } | null {
|
||||
// Use a regex that captures the summary until newline or next task marker
|
||||
// Allow brackets in summary content (e.g., "supports array[index] access")
|
||||
// Pattern breakdown:
|
||||
// - \[TASK_COMPLETE\]\s* - Match the marker
|
||||
// - (T\d{3}) - Capture task ID
|
||||
// - (?::\s*([^\n\[]+))? - Optionally capture summary (stops at newline or bracket)
|
||||
// - But we want to allow brackets in summary, so we use a different approach:
|
||||
// - Match summary until newline, then trim any trailing markers in post-processing
|
||||
const match = text.match(/\[TASK_COMPLETE\]\s*(T\d{3})(?::\s*(.+?))?(?=\n|$)/i);
|
||||
if (!match) return null;
|
||||
|
||||
// Post-process: remove trailing task markers from summary if present
|
||||
let summary = match[2]?.trim();
|
||||
if (summary) {
|
||||
// Remove trailing content that looks like another marker
|
||||
summary = summary.replace(/\s*\[TASK_[A-Z_]+\].*$/i, '').trim();
|
||||
}
|
||||
|
||||
return {
|
||||
id: match[1],
|
||||
summary: summary || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -194,10 +214,14 @@ export function extractSummary(text: string): string | null {
|
||||
}
|
||||
|
||||
// Check for ## Summary section (use last match)
|
||||
const sectionMatches = text.matchAll(/##\s*Summary\s*\n+([\s\S]*?)(?=\n##|\n\*\*|$)/gi);
|
||||
// Stop at \n## [^#] (same-level headers like "## Changes") but preserve ### subsections
|
||||
// (like "### Root Cause", "### Fix Applied") that belong to the summary content.
|
||||
const sectionMatches = text.matchAll(/##\s*Summary\s*\n+([\s\S]*?)(?=\n## [^#]|$)/gi);
|
||||
const sectionMatch = getLastMatch(sectionMatches);
|
||||
if (sectionMatch) {
|
||||
return truncate(sectionMatch[1].trim(), 500);
|
||||
const content = sectionMatch[1].trim();
|
||||
// Keep full content (including ### subsections) up to max length
|
||||
return content.length > 500 ? `${content.substring(0, 500)}...` : content;
|
||||
}
|
||||
|
||||
// Check for **Goal**: section (lite mode, use last match)
|
||||
|
||||
209
apps/server/src/services/sync-service.ts
Normal file
209
apps/server/src/services/sync-service.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* SyncService - Pull then push in a single operation
|
||||
*
|
||||
* Composes performPull() and performPush() to synchronize a branch
|
||||
* with its remote. Always uses stashIfNeeded for the pull step.
|
||||
* If push fails with divergence after pull, retries once.
|
||||
*
|
||||
* Follows the same pattern as pull-service.ts and push-service.ts.
|
||||
*/
|
||||
|
||||
import { createLogger, getErrorMessage } from '@automaker/utils';
|
||||
import { performPull } from './pull-service.js';
|
||||
import { performPush } from './push-service.js';
|
||||
import type { PullResult } from './pull-service.js';
|
||||
import type { PushResult } from './push-service.js';
|
||||
|
||||
const logger = createLogger('SyncService');
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface SyncOptions {
|
||||
/** Remote name (defaults to 'origin') */
|
||||
remote?: string;
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
branch?: string;
|
||||
/** Whether the pull step was performed */
|
||||
pulled?: boolean;
|
||||
/** Whether the push step was performed */
|
||||
pushed?: boolean;
|
||||
/** Pull resulted in conflicts */
|
||||
hasConflicts?: boolean;
|
||||
/** Files with merge conflicts */
|
||||
conflictFiles?: string[];
|
||||
/** Source of conflicts ('pull' | 'stash') */
|
||||
conflictSource?: 'pull' | 'stash';
|
||||
/** Whether the pull was a fast-forward */
|
||||
isFastForward?: boolean;
|
||||
/** Whether the pull resulted in a merge commit */
|
||||
isMerge?: boolean;
|
||||
/** Whether push divergence was auto-resolved */
|
||||
autoResolved?: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Service Function
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Perform a sync operation (pull then push) on the given worktree.
|
||||
*
|
||||
* The workflow:
|
||||
* 1. Pull from remote with stashIfNeeded: true
|
||||
* 2. If pull has conflicts, stop and return conflict info
|
||||
* 3. Push to remote
|
||||
* 4. If push fails with divergence after pull, retry once
|
||||
*
|
||||
* @param worktreePath - Path to the git worktree
|
||||
* @param options - Sync options (remote)
|
||||
* @returns SyncResult with detailed status information
|
||||
*/
|
||||
export async function performSync(
|
||||
worktreePath: string,
|
||||
options?: SyncOptions
|
||||
): Promise<SyncResult> {
|
||||
const targetRemote = options?.remote || 'origin';
|
||||
|
||||
// 1. Pull from remote
|
||||
logger.info('Sync: starting pull', { worktreePath, remote: targetRemote });
|
||||
|
||||
let pullResult: PullResult;
|
||||
try {
|
||||
pullResult = await performPull(worktreePath, {
|
||||
remote: targetRemote,
|
||||
stashIfNeeded: true,
|
||||
});
|
||||
} catch (pullError) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Sync pull failed: ${getErrorMessage(pullError)}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!pullResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
branch: pullResult.branch,
|
||||
pulled: false,
|
||||
pushed: false,
|
||||
error: `Sync pull failed: ${pullResult.error}`,
|
||||
hasConflicts: pullResult.hasConflicts,
|
||||
conflictFiles: pullResult.conflictFiles,
|
||||
conflictSource: pullResult.conflictSource,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. If pull had conflicts, stop and return conflict info
|
||||
if (pullResult.hasConflicts) {
|
||||
return {
|
||||
success: false,
|
||||
branch: pullResult.branch,
|
||||
pulled: true,
|
||||
pushed: false,
|
||||
hasConflicts: true,
|
||||
conflictFiles: pullResult.conflictFiles,
|
||||
conflictSource: pullResult.conflictSource,
|
||||
isFastForward: pullResult.isFastForward,
|
||||
isMerge: pullResult.isMerge,
|
||||
error: 'Sync stopped: pull resulted in merge conflicts. Resolve conflicts and try again.',
|
||||
message: pullResult.message,
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Push to remote
|
||||
logger.info('Sync: pull succeeded, starting push', { worktreePath, remote: targetRemote });
|
||||
|
||||
let pushResult: PushResult;
|
||||
try {
|
||||
pushResult = await performPush(worktreePath, {
|
||||
remote: targetRemote,
|
||||
});
|
||||
} catch (pushError) {
|
||||
return {
|
||||
success: false,
|
||||
branch: pullResult.branch,
|
||||
pulled: true,
|
||||
pushed: false,
|
||||
isFastForward: pullResult.isFastForward,
|
||||
isMerge: pullResult.isMerge,
|
||||
error: `Sync push failed: ${getErrorMessage(pushError)}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!pushResult.success) {
|
||||
// 4. If push diverged after pull, retry once with autoResolve
|
||||
if (pushResult.diverged) {
|
||||
logger.info('Sync: push diverged after pull, retrying with autoResolve', {
|
||||
worktreePath,
|
||||
remote: targetRemote,
|
||||
});
|
||||
|
||||
try {
|
||||
const retryResult = await performPush(worktreePath, {
|
||||
remote: targetRemote,
|
||||
autoResolve: true,
|
||||
});
|
||||
|
||||
if (retryResult.success) {
|
||||
return {
|
||||
success: true,
|
||||
branch: retryResult.branch,
|
||||
pulled: true,
|
||||
pushed: true,
|
||||
autoResolved: true,
|
||||
isFastForward: pullResult.isFastForward,
|
||||
isMerge: pullResult.isMerge,
|
||||
message: 'Sync completed (push required auto-resolve).',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
branch: retryResult.branch,
|
||||
pulled: true,
|
||||
pushed: false,
|
||||
hasConflicts: retryResult.hasConflicts,
|
||||
conflictFiles: retryResult.conflictFiles,
|
||||
error: retryResult.error,
|
||||
};
|
||||
} catch (retryError) {
|
||||
return {
|
||||
success: false,
|
||||
branch: pullResult.branch,
|
||||
pulled: true,
|
||||
pushed: false,
|
||||
error: `Sync push retry failed: ${getErrorMessage(retryError)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
branch: pushResult.branch,
|
||||
pulled: true,
|
||||
pushed: false,
|
||||
isFastForward: pullResult.isFastForward,
|
||||
isMerge: pullResult.isMerge,
|
||||
error: `Sync push failed: ${pushResult.error}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
branch: pushResult.branch,
|
||||
pulled: pullResult.pulled ?? true,
|
||||
pushed: true,
|
||||
isFastForward: pullResult.isFastForward,
|
||||
isMerge: pullResult.isMerge,
|
||||
message: pullResult.pulled
|
||||
? 'Sync completed: pulled latest changes and pushed.'
|
||||
: 'Sync completed: already up to date, pushed local commits.',
|
||||
};
|
||||
}
|
||||
@@ -39,6 +39,18 @@ export interface WorktreeInfo {
|
||||
* 3. Listing all worktrees with normalized paths
|
||||
*/
|
||||
export class WorktreeResolver {
|
||||
private normalizeBranchName(branchName: string | null | undefined): string | null {
|
||||
if (!branchName) return null;
|
||||
let normalized = branchName.trim();
|
||||
if (!normalized) return null;
|
||||
|
||||
normalized = normalized.replace(/^refs\/heads\//, '');
|
||||
normalized = normalized.replace(/^refs\/remotes\/[^/]+\//, '');
|
||||
normalized = normalized.replace(/^(origin|upstream)\//, '');
|
||||
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current branch name for a git repository
|
||||
*
|
||||
@@ -64,6 +76,9 @@ export class WorktreeResolver {
|
||||
*/
|
||||
async findWorktreeForBranch(projectPath: string, branchName: string): Promise<string | null> {
|
||||
try {
|
||||
const normalizedTargetBranch = this.normalizeBranchName(branchName);
|
||||
if (!normalizedTargetBranch) return null;
|
||||
|
||||
const { stdout } = await execAsync('git worktree list --porcelain', {
|
||||
cwd: projectPath,
|
||||
});
|
||||
@@ -76,10 +91,10 @@ export class WorktreeResolver {
|
||||
if (line.startsWith('worktree ')) {
|
||||
currentPath = line.slice(9);
|
||||
} else if (line.startsWith('branch ')) {
|
||||
currentBranch = line.slice(7).replace('refs/heads/', '');
|
||||
currentBranch = this.normalizeBranchName(line.slice(7));
|
||||
} else if (line === '' && currentPath && currentBranch) {
|
||||
// End of a worktree entry
|
||||
if (currentBranch === branchName) {
|
||||
if (currentBranch === normalizedTargetBranch) {
|
||||
// Resolve to absolute path - git may return relative paths
|
||||
// On Windows, this is critical for cwd to work correctly
|
||||
// On all platforms, absolute paths ensure consistent behavior
|
||||
@@ -91,7 +106,7 @@ export class WorktreeResolver {
|
||||
}
|
||||
|
||||
// Check the last entry (if file doesn't end with newline)
|
||||
if (currentPath && currentBranch && currentBranch === branchName) {
|
||||
if (currentPath && currentBranch && currentBranch === normalizedTargetBranch) {
|
||||
return this.resolvePath(projectPath, currentPath);
|
||||
}
|
||||
|
||||
@@ -123,7 +138,7 @@ export class WorktreeResolver {
|
||||
if (line.startsWith('worktree ')) {
|
||||
currentPath = line.slice(9);
|
||||
} else if (line.startsWith('branch ')) {
|
||||
currentBranch = line.slice(7).replace('refs/heads/', '');
|
||||
currentBranch = this.normalizeBranchName(line.slice(7));
|
||||
} else if (line.startsWith('detached')) {
|
||||
// Detached HEAD - branch is null
|
||||
currentBranch = null;
|
||||
|
||||
@@ -8,9 +8,60 @@
|
||||
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { execGitCommand } from '@automaker/git-utils';
|
||||
import type { EventEmitter } from '../lib/events.js';
|
||||
import type { SettingsService } from './settings-service.js';
|
||||
|
||||
/**
|
||||
* Get the list of remote names that have a branch matching the given branch name.
|
||||
*
|
||||
* Uses `git for-each-ref` to check cached remote refs, returning the names of
|
||||
* any remotes that already have a branch with the same name as `currentBranch`.
|
||||
* Returns an empty array when `hasAnyRemotes` is false or when no matching
|
||||
* remote refs are found.
|
||||
*
|
||||
* This helps the UI distinguish between "branch exists on the tracking remote"
|
||||
* vs "branch was pushed to a different remote".
|
||||
*
|
||||
* @param worktreePath - Path to the git worktree
|
||||
* @param currentBranch - Branch name to search for on remotes
|
||||
* @param hasAnyRemotes - Whether the repository has any remotes configured
|
||||
* @returns Array of remote names (e.g. ["origin", "upstream"]) that contain the branch
|
||||
*/
|
||||
export async function getRemotesWithBranch(
|
||||
worktreePath: string,
|
||||
currentBranch: string,
|
||||
hasAnyRemotes: boolean
|
||||
): Promise<string[]> {
|
||||
if (!hasAnyRemotes) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const remoteRefsOutput = await execGitCommand(
|
||||
['for-each-ref', '--format=%(refname:short)', `refs/remotes/*/${currentBranch}`],
|
||||
worktreePath
|
||||
);
|
||||
|
||||
if (!remoteRefsOutput.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return remoteRefsOutput
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((ref) => {
|
||||
// Extract remote name from "remote/branch" format
|
||||
const slashIdx = ref.indexOf('/');
|
||||
return slashIdx !== -1 ? ref.slice(0, slashIdx) : ref;
|
||||
})
|
||||
.filter((name) => name.length > 0);
|
||||
} catch {
|
||||
// Ignore errors - return empty array
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when one or more file copy operations fail during
|
||||
* `copyConfiguredFiles`. The caller can inspect `failures` for details.
|
||||
|
||||
@@ -23,6 +23,7 @@ export type {
|
||||
PhaseModelConfig,
|
||||
PhaseModelKey,
|
||||
PhaseModelEntry,
|
||||
FeatureTemplate,
|
||||
// Claude-compatible provider types
|
||||
ApiKeySource,
|
||||
ClaudeCompatibleProviderType,
|
||||
@@ -41,6 +42,7 @@ export {
|
||||
DEFAULT_CREDENTIALS,
|
||||
DEFAULT_PROJECT_SETTINGS,
|
||||
DEFAULT_PHASE_MODELS,
|
||||
DEFAULT_FEATURE_TEMPLATES,
|
||||
SETTINGS_VERSION,
|
||||
CREDENTIALS_VERSION,
|
||||
PROJECT_SETTINGS_VERSION,
|
||||
|
||||
@@ -168,7 +168,7 @@ describe('enhancement-prompts.ts', () => {
|
||||
const prompt = buildUserPrompt('improve', testText);
|
||||
expect(prompt).toContain('Example 1:');
|
||||
expect(prompt).toContain(testText);
|
||||
expect(prompt).toContain('Now, please enhance the following task description:');
|
||||
expect(prompt).toContain('Please enhance the following task description:');
|
||||
});
|
||||
|
||||
it('should build prompt without examples when includeExamples is false', () => {
|
||||
|
||||
333
apps/server/tests/unit/lib/file-editor-store-logic.test.ts
Normal file
333
apps/server/tests/unit/lib/file-editor-store-logic.test.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
computeIsDirty,
|
||||
updateTabWithContent as updateTabContent,
|
||||
markTabAsSaved as markTabSaved,
|
||||
} from '../../../../ui/src/components/views/file-editor-view/file-editor-dirty-utils.ts';
|
||||
|
||||
/**
|
||||
* Unit tests for the file editor store logic, focusing on the unsaved indicator fix.
|
||||
*
|
||||
* The bug was: File unsaved indicators weren't working reliably - editing a file
|
||||
* and saving it would sometimes leave the dirty indicator (dot) visible.
|
||||
*
|
||||
* Root causes:
|
||||
* 1. Stale closure in handleSave - captured activeTab could have old content
|
||||
* 2. Editor buffer not synced - CodeMirror might have buffered changes not yet in store
|
||||
*
|
||||
* Fix:
|
||||
* - handleSave now gets fresh state from store using getState()
|
||||
* - handleSave gets current content from editor via getValue()
|
||||
* - Content is synced to store before saving if it differs
|
||||
*
|
||||
* Since we can't easily test the React/zustand store in node environment,
|
||||
* we test the pure logic that the store uses for dirty state tracking.
|
||||
*/
|
||||
|
||||
describe('File editor dirty state logic', () => {
|
||||
describe('updateTabContent', () => {
|
||||
it('should set isDirty to true when content differs from originalContent', () => {
|
||||
const tab = {
|
||||
content: 'original content',
|
||||
originalContent: 'original content',
|
||||
isDirty: false,
|
||||
};
|
||||
|
||||
const updated = updateTabContent(tab, 'modified content');
|
||||
|
||||
expect(updated.isDirty).toBe(true);
|
||||
expect(updated.content).toBe('modified content');
|
||||
expect(updated.originalContent).toBe('original content');
|
||||
});
|
||||
|
||||
it('should set isDirty to false when content matches originalContent', () => {
|
||||
const tab = {
|
||||
content: 'original content',
|
||||
originalContent: 'original content',
|
||||
isDirty: false,
|
||||
};
|
||||
|
||||
// First modify it
|
||||
let updated = updateTabContent(tab, 'modified content');
|
||||
expect(updated.isDirty).toBe(true);
|
||||
|
||||
// Now update back to original
|
||||
updated = updateTabContent(updated, 'original content');
|
||||
expect(updated.isDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle empty content correctly', () => {
|
||||
const tab = {
|
||||
content: '',
|
||||
originalContent: '',
|
||||
isDirty: false,
|
||||
};
|
||||
|
||||
const updated = updateTabContent(tab, 'new content');
|
||||
|
||||
expect(updated.isDirty).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markTabSaved', () => {
|
||||
it('should set isDirty to false and update both content and originalContent', () => {
|
||||
const tab = {
|
||||
content: 'original content',
|
||||
originalContent: 'original content',
|
||||
isDirty: false,
|
||||
};
|
||||
|
||||
// First modify
|
||||
let updated = updateTabContent(tab, 'modified content');
|
||||
expect(updated.isDirty).toBe(true);
|
||||
|
||||
// Then save
|
||||
updated = markTabSaved(updated, 'modified content');
|
||||
|
||||
expect(updated.isDirty).toBe(false);
|
||||
expect(updated.content).toBe('modified content');
|
||||
expect(updated.originalContent).toBe('modified content');
|
||||
});
|
||||
|
||||
it('should correctly clear dirty state when save is triggered after edit', () => {
|
||||
// This test simulates the bug scenario:
|
||||
// 1. User edits file -> isDirty = true
|
||||
// 2. User saves -> markTabSaved should set isDirty = false
|
||||
let tab = {
|
||||
content: 'initial',
|
||||
originalContent: 'initial',
|
||||
isDirty: false,
|
||||
};
|
||||
|
||||
// Simulate user editing
|
||||
tab = updateTabContent(tab, 'initial\nnew line');
|
||||
|
||||
// Should be dirty
|
||||
expect(tab.isDirty).toBe(true);
|
||||
|
||||
// Simulate save (with the content that was saved)
|
||||
tab = markTabSaved(tab, 'initial\nnew line');
|
||||
|
||||
// Should NOT be dirty anymore
|
||||
expect(tab.isDirty).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('race condition handling', () => {
|
||||
it('should correctly handle updateTabContent after markTabSaved with same content', () => {
|
||||
// This tests the scenario where:
|
||||
// 1. CodeMirror has a pending onChange with content "B"
|
||||
// 2. User presses save when editor shows "B"
|
||||
// 3. markTabSaved is called with "B"
|
||||
// 4. CodeMirror's pending onChange fires with "B" (same content)
|
||||
// Result: isDirty should remain false
|
||||
let tab = {
|
||||
content: 'A',
|
||||
originalContent: 'A',
|
||||
isDirty: false,
|
||||
};
|
||||
|
||||
// User edits to "B"
|
||||
tab = updateTabContent(tab, 'B');
|
||||
|
||||
// Save with "B"
|
||||
tab = markTabSaved(tab, 'B');
|
||||
|
||||
// Late onChange with same content "B"
|
||||
tab = updateTabContent(tab, 'B');
|
||||
|
||||
expect(tab.isDirty).toBe(false);
|
||||
expect(tab.content).toBe('B');
|
||||
});
|
||||
|
||||
it('should correctly handle updateTabContent after markTabSaved with different content', () => {
|
||||
// This tests the scenario where:
|
||||
// 1. CodeMirror has a pending onChange with content "C"
|
||||
// 2. User presses save when store has "B"
|
||||
// 3. markTabSaved is called with "B"
|
||||
// 4. CodeMirror's pending onChange fires with "C" (different content)
|
||||
// Result: isDirty should be true (file changed after save)
|
||||
let tab = {
|
||||
content: 'A',
|
||||
originalContent: 'A',
|
||||
isDirty: false,
|
||||
};
|
||||
|
||||
// User edits to "B"
|
||||
tab = updateTabContent(tab, 'B');
|
||||
|
||||
// Save with "B"
|
||||
tab = markTabSaved(tab, 'B');
|
||||
|
||||
// Late onChange with different content "C"
|
||||
tab = updateTabContent(tab, 'C');
|
||||
|
||||
// File changed after save, so it should be dirty
|
||||
expect(tab.isDirty).toBe(true);
|
||||
expect(tab.content).toBe('C');
|
||||
expect(tab.originalContent).toBe('B');
|
||||
});
|
||||
|
||||
it('should handle rapid edit-save-edit cycle correctly', () => {
|
||||
// Simulate rapid user actions
|
||||
let tab = {
|
||||
content: 'v1',
|
||||
originalContent: 'v1',
|
||||
isDirty: false,
|
||||
};
|
||||
|
||||
// Edit 1
|
||||
tab = updateTabContent(tab, 'v2');
|
||||
expect(tab.isDirty).toBe(true);
|
||||
|
||||
// Save 1
|
||||
tab = markTabSaved(tab, 'v2');
|
||||
expect(tab.isDirty).toBe(false);
|
||||
|
||||
// Edit 2
|
||||
tab = updateTabContent(tab, 'v3');
|
||||
expect(tab.isDirty).toBe(true);
|
||||
|
||||
// Save 2
|
||||
tab = markTabSaved(tab, 'v3');
|
||||
expect(tab.isDirty).toBe(false);
|
||||
|
||||
// Edit 3 (back to v2)
|
||||
tab = updateTabContent(tab, 'v2');
|
||||
expect(tab.isDirty).toBe(true);
|
||||
|
||||
// Save 3
|
||||
tab = markTabSaved(tab, 'v2');
|
||||
expect(tab.isDirty).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleSave stale closure fix simulation', () => {
|
||||
it('demonstrates the fix: using fresh content instead of closure content', () => {
|
||||
// This test demonstrates why the fix was necessary.
|
||||
// The old handleSave captured activeTab in closure, which could be stale.
|
||||
// The fix gets fresh state from getState() and uses editor.getValue().
|
||||
|
||||
// Simulate store state
|
||||
let storeState = {
|
||||
tabs: [
|
||||
{
|
||||
id: 'tab-1',
|
||||
content: 'A',
|
||||
originalContent: 'A',
|
||||
isDirty: false,
|
||||
},
|
||||
],
|
||||
activeTabId: 'tab-1',
|
||||
};
|
||||
|
||||
// Simulate a "stale closure" capturing the tab state
|
||||
const staleClosureTab = storeState.tabs[0];
|
||||
|
||||
// User edits - store state updates
|
||||
storeState = {
|
||||
...storeState,
|
||||
tabs: [
|
||||
{
|
||||
id: 'tab-1',
|
||||
content: 'B',
|
||||
originalContent: 'A',
|
||||
isDirty: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// OLD BUG: Using stale closure tab would save "A" (old content)
|
||||
const oldBugSavedContent = staleClosureTab!.content;
|
||||
expect(oldBugSavedContent).toBe('A'); // Wrong! Should be "B"
|
||||
|
||||
// FIX: Using fresh state from getState() gets correct content
|
||||
const freshTab = storeState.tabs[0];
|
||||
const fixedSavedContent = freshTab!.content;
|
||||
expect(fixedSavedContent).toBe('B'); // Correct!
|
||||
});
|
||||
|
||||
it('demonstrates syncing editor content before save', () => {
|
||||
// This test demonstrates why we need to get content from editor directly.
|
||||
// The store might have stale content if onChange hasn't fired yet.
|
||||
|
||||
// Simulate store state (has old content because onChange hasn't fired)
|
||||
let storeContent = 'A';
|
||||
|
||||
// Editor has newer content (not yet synced to store)
|
||||
const editorContent = 'B';
|
||||
|
||||
// FIX: Use editor content if available, fall back to store content
|
||||
const contentToSave = editorContent ?? storeContent;
|
||||
|
||||
expect(contentToSave).toBe('B'); // Correctly saves editor content
|
||||
|
||||
// Simulate syncing to store before save
|
||||
if (editorContent !== null && editorContent !== storeContent) {
|
||||
storeContent = editorContent;
|
||||
}
|
||||
|
||||
// Now store is synced
|
||||
expect(storeContent).toBe('B');
|
||||
|
||||
// After save, markTabSaved would set originalContent = savedContent
|
||||
// and isDirty = false (if no more changes come in)
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle whitespace-only changes as dirty', () => {
|
||||
let tab = {
|
||||
content: 'hello',
|
||||
originalContent: 'hello',
|
||||
isDirty: false,
|
||||
};
|
||||
|
||||
tab = updateTabContent(tab, 'hello ');
|
||||
expect(tab.isDirty).toBe(true);
|
||||
});
|
||||
|
||||
it('should treat CRLF and LF line endings as equivalent (not dirty)', () => {
|
||||
let tab = {
|
||||
content: 'line1\nline2',
|
||||
originalContent: 'line1\nline2',
|
||||
isDirty: false,
|
||||
};
|
||||
|
||||
// CodeMirror normalizes \r\n to \n internally, so content that only
|
||||
// differs by line endings should NOT be considered dirty.
|
||||
tab = updateTabContent(tab, 'line1\r\nline2');
|
||||
expect(tab.isDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle unicode content correctly', () => {
|
||||
let tab = {
|
||||
content: '你好世界',
|
||||
originalContent: '你好世界',
|
||||
isDirty: false,
|
||||
};
|
||||
|
||||
tab = updateTabContent(tab, '你好宇宙');
|
||||
expect(tab.isDirty).toBe(true);
|
||||
|
||||
tab = markTabSaved(tab, '你好宇宙');
|
||||
expect(tab.isDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle very large content efficiently', () => {
|
||||
// Generate a large string (1MB)
|
||||
const largeOriginal = 'x'.repeat(1024 * 1024);
|
||||
const largeModified = largeOriginal + 'y';
|
||||
|
||||
let tab = {
|
||||
content: largeOriginal,
|
||||
originalContent: largeOriginal,
|
||||
isDirty: false,
|
||||
};
|
||||
|
||||
tab = updateTabContent(tab, largeModified);
|
||||
|
||||
expect(tab.isDirty).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { getMCPServersFromSettings } from '@/lib/settings-helpers.js';
|
||||
import {
|
||||
getMCPServersFromSettings,
|
||||
getProviderById,
|
||||
getProviderByModelId,
|
||||
resolveProviderContext,
|
||||
getAllProviderModels,
|
||||
} from '@/lib/settings-helpers.js';
|
||||
import type { SettingsService } from '@/services/settings-service.js';
|
||||
|
||||
// Mock the logger
|
||||
@@ -286,4 +292,691 @@ describe('settings-helpers.ts', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProviderById', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return provider when found by ID', async () => {
|
||||
const mockProvider = { id: 'zai-1', name: 'Zai', enabled: true };
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [mockProvider],
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getProviderById('zai-1', mockSettingsService);
|
||||
expect(result.provider).toEqual(mockProvider);
|
||||
});
|
||||
|
||||
it('should return undefined when provider not found', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [],
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getProviderById('unknown', mockSettingsService);
|
||||
expect(result.provider).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return provider even if disabled (caller handles enabled state)', async () => {
|
||||
const mockProvider = { id: 'disabled-1', name: 'Disabled', enabled: false };
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [mockProvider],
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getProviderById('disabled-1', mockSettingsService);
|
||||
expect(result.provider).toEqual(mockProvider);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProviderByModelId', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return provider and modelConfig when found by model ID', async () => {
|
||||
const mockModel = { id: 'custom-model-1', name: 'Custom Model' };
|
||||
const mockProvider = {
|
||||
id: 'provider-1',
|
||||
name: 'Provider 1',
|
||||
enabled: true,
|
||||
models: [mockModel],
|
||||
};
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [mockProvider],
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getProviderByModelId('custom-model-1', mockSettingsService);
|
||||
expect(result.provider).toEqual(mockProvider);
|
||||
expect(result.modelConfig).toEqual(mockModel);
|
||||
});
|
||||
|
||||
it('should resolve mapped Claude model when mapsToClaudeModel is present', async () => {
|
||||
const mockModel = {
|
||||
id: 'custom-model-1',
|
||||
name: 'Custom Model',
|
||||
mapsToClaudeModel: 'sonnet-3-5',
|
||||
};
|
||||
const mockProvider = {
|
||||
id: 'provider-1',
|
||||
name: 'Provider 1',
|
||||
enabled: true,
|
||||
models: [mockModel],
|
||||
};
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [mockProvider],
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getProviderByModelId('custom-model-1', mockSettingsService);
|
||||
expect(result.resolvedModel).toBeDefined();
|
||||
// resolveModelString('sonnet-3-5') usually returns 'claude-3-5-sonnet-20240620' or similar
|
||||
});
|
||||
|
||||
it('should ignore disabled providers', async () => {
|
||||
const mockModel = { id: 'custom-model-1', name: 'Custom Model' };
|
||||
const mockProvider = {
|
||||
id: 'disabled-1',
|
||||
name: 'Disabled Provider',
|
||||
enabled: false,
|
||||
models: [mockModel],
|
||||
};
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [mockProvider],
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getProviderByModelId('custom-model-1', mockSettingsService);
|
||||
expect(result.provider).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveProviderContext', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should resolve provider by explicit providerId', async () => {
|
||||
const mockProvider = {
|
||||
id: 'provider-1',
|
||||
name: 'Provider 1',
|
||||
enabled: true,
|
||||
models: [{ id: 'custom-model-1', name: 'Custom Model' }],
|
||||
};
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [mockProvider],
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({ anthropicApiKey: 'test-key' }),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await resolveProviderContext(
|
||||
mockSettingsService,
|
||||
'custom-model-1',
|
||||
'provider-1'
|
||||
);
|
||||
|
||||
expect(result.provider).toEqual(mockProvider);
|
||||
expect(result.credentials).toEqual({ anthropicApiKey: 'test-key' });
|
||||
});
|
||||
|
||||
it('should return undefined provider when explicit providerId not found', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [],
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await resolveProviderContext(
|
||||
mockSettingsService,
|
||||
'some-model',
|
||||
'unknown-provider'
|
||||
);
|
||||
|
||||
expect(result.provider).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should fallback to model-based lookup when providerId not provided', async () => {
|
||||
const mockProvider = {
|
||||
id: 'provider-1',
|
||||
name: 'Provider 1',
|
||||
enabled: true,
|
||||
models: [{ id: 'custom-model-1', name: 'Custom Model' }],
|
||||
};
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [mockProvider],
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await resolveProviderContext(mockSettingsService, 'custom-model-1');
|
||||
|
||||
expect(result.provider).toEqual(mockProvider);
|
||||
expect(result.modelConfig?.id).toBe('custom-model-1');
|
||||
});
|
||||
|
||||
it('should resolve mapsToClaudeModel to actual Claude model', async () => {
|
||||
const mockProvider = {
|
||||
id: 'provider-1',
|
||||
name: 'Provider 1',
|
||||
enabled: true,
|
||||
models: [
|
||||
{
|
||||
id: 'custom-model-1',
|
||||
name: 'Custom Model',
|
||||
mapsToClaudeModel: 'sonnet',
|
||||
},
|
||||
],
|
||||
};
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [mockProvider],
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await resolveProviderContext(mockSettingsService, 'custom-model-1');
|
||||
|
||||
// resolveModelString('sonnet') should return a valid Claude model ID
|
||||
expect(result.resolvedModel).toBeDefined();
|
||||
expect(result.resolvedModel).toContain('claude');
|
||||
});
|
||||
|
||||
it('should handle empty providers list', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [],
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await resolveProviderContext(mockSettingsService, 'some-model');
|
||||
|
||||
expect(result.provider).toBeUndefined();
|
||||
expect(result.resolvedModel).toBeUndefined();
|
||||
expect(result.modelConfig).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle missing claudeCompatibleProviders field', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||
getCredentials: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await resolveProviderContext(mockSettingsService, 'some-model');
|
||||
|
||||
expect(result.provider).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should skip disabled providers during fallback lookup', async () => {
|
||||
const disabledProvider = {
|
||||
id: 'disabled-1',
|
||||
name: 'Disabled Provider',
|
||||
enabled: false,
|
||||
models: [{ id: 'model-in-disabled', name: 'Model' }],
|
||||
};
|
||||
const enabledProvider = {
|
||||
id: 'enabled-1',
|
||||
name: 'Enabled Provider',
|
||||
enabled: true,
|
||||
models: [{ id: 'model-in-enabled', name: 'Model' }],
|
||||
};
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [disabledProvider, enabledProvider],
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
// Should skip the disabled provider and find the model in the enabled one
|
||||
const result = await resolveProviderContext(mockSettingsService, 'model-in-enabled');
|
||||
expect(result.provider?.id).toBe('enabled-1');
|
||||
|
||||
// Should not find model that only exists in disabled provider
|
||||
const result2 = await resolveProviderContext(mockSettingsService, 'model-in-disabled');
|
||||
expect(result2.provider).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should perform case-insensitive model ID matching', async () => {
|
||||
const mockProvider = {
|
||||
id: 'provider-1',
|
||||
name: 'Provider 1',
|
||||
enabled: true,
|
||||
models: [{ id: 'Custom-Model-1', name: 'Custom Model' }],
|
||||
};
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [mockProvider],
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await resolveProviderContext(mockSettingsService, 'custom-model-1');
|
||||
|
||||
expect(result.provider).toEqual(mockProvider);
|
||||
expect(result.modelConfig?.id).toBe('Custom-Model-1');
|
||||
});
|
||||
|
||||
it('should return error result on exception', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockRejectedValue(new Error('Settings error')),
|
||||
getCredentials: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await resolveProviderContext(mockSettingsService, 'some-model');
|
||||
|
||||
expect(result.provider).toBeUndefined();
|
||||
expect(result.credentials).toBeUndefined();
|
||||
expect(result.resolvedModel).toBeUndefined();
|
||||
expect(result.modelConfig).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should persist and load provider config from server settings', async () => {
|
||||
// This test verifies the main bug fix: providers are loaded from server settings
|
||||
const savedProvider = {
|
||||
id: 'saved-provider-1',
|
||||
name: 'Saved Provider',
|
||||
enabled: true,
|
||||
apiKeySource: 'credentials' as const,
|
||||
models: [
|
||||
{
|
||||
id: 'saved-model-1',
|
||||
name: 'Saved Model',
|
||||
mapsToClaudeModel: 'sonnet',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [savedProvider],
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({
|
||||
anthropicApiKey: 'saved-api-key',
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
// Simulate loading saved provider config
|
||||
const result = await resolveProviderContext(
|
||||
mockSettingsService,
|
||||
'saved-model-1',
|
||||
'saved-provider-1'
|
||||
);
|
||||
|
||||
// Verify the provider is loaded from server settings
|
||||
expect(result.provider).toEqual(savedProvider);
|
||||
expect(result.provider?.id).toBe('saved-provider-1');
|
||||
expect(result.provider?.models).toHaveLength(1);
|
||||
expect(result.credentials?.anthropicApiKey).toBe('saved-api-key');
|
||||
// Verify model mapping is resolved
|
||||
expect(result.resolvedModel).toContain('claude');
|
||||
});
|
||||
|
||||
it('should accept custom logPrefix parameter', async () => {
|
||||
// Verify that the logPrefix parameter is accepted (used by facade.ts)
|
||||
const mockProvider = {
|
||||
id: 'provider-1',
|
||||
name: 'Provider 1',
|
||||
enabled: true,
|
||||
models: [{ id: 'model-1', name: 'Model' }],
|
||||
};
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [mockProvider],
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
// Call with custom logPrefix (as facade.ts does)
|
||||
const result = await resolveProviderContext(
|
||||
mockSettingsService,
|
||||
'model-1',
|
||||
undefined,
|
||||
'[CustomPrefix]'
|
||||
);
|
||||
|
||||
// Function should work the same with custom prefix
|
||||
expect(result.provider).toEqual(mockProvider);
|
||||
});
|
||||
|
||||
// Session restore scenarios - provider.enabled: undefined should be treated as enabled
|
||||
describe('session restore scenarios (enabled: undefined)', () => {
|
||||
it('should treat provider with enabled: undefined as enabled', async () => {
|
||||
// This is the main bug fix: when providers are loaded from settings on session restore,
|
||||
// enabled might be undefined (not explicitly set) and should be treated as enabled
|
||||
const mockProvider = {
|
||||
id: 'provider-1',
|
||||
name: 'Provider 1',
|
||||
enabled: undefined, // Not explicitly set - should be treated as enabled
|
||||
models: [{ id: 'model-1', name: 'Model' }],
|
||||
};
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [mockProvider],
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await resolveProviderContext(mockSettingsService, 'model-1');
|
||||
|
||||
// Provider should be found and used even though enabled is undefined
|
||||
expect(result.provider).toEqual(mockProvider);
|
||||
expect(result.modelConfig?.id).toBe('model-1');
|
||||
});
|
||||
|
||||
it('should use provider by ID when enabled is undefined', async () => {
|
||||
// This tests the explicit providerId lookup with undefined enabled
|
||||
const mockProvider = {
|
||||
id: 'provider-1',
|
||||
name: 'Provider 1',
|
||||
enabled: undefined, // Not explicitly set - should be treated as enabled
|
||||
models: [{ id: 'custom-model', name: 'Custom Model', mapsToClaudeModel: 'sonnet' }],
|
||||
};
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [mockProvider],
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({ anthropicApiKey: 'test-key' }),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await resolveProviderContext(
|
||||
mockSettingsService,
|
||||
'custom-model',
|
||||
'provider-1'
|
||||
);
|
||||
|
||||
// Provider should be found and used even though enabled is undefined
|
||||
expect(result.provider).toEqual(mockProvider);
|
||||
expect(result.credentials?.anthropicApiKey).toBe('test-key');
|
||||
expect(result.resolvedModel).toContain('claude');
|
||||
});
|
||||
|
||||
it('should find model via fallback in provider with enabled: undefined', async () => {
|
||||
// Test fallback model lookup when provider has undefined enabled
|
||||
const providerWithUndefinedEnabled = {
|
||||
id: 'provider-1',
|
||||
name: 'Provider 1',
|
||||
// enabled is not set (undefined)
|
||||
models: [{ id: 'model-1', name: 'Model' }],
|
||||
};
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [providerWithUndefinedEnabled],
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await resolveProviderContext(mockSettingsService, 'model-1');
|
||||
|
||||
expect(result.provider).toEqual(providerWithUndefinedEnabled);
|
||||
expect(result.modelConfig?.id).toBe('model-1');
|
||||
});
|
||||
|
||||
it('should still use provider for connection when model not found in its models array', async () => {
|
||||
// This tests the fix: when providerId is explicitly set and provider is found,
|
||||
// but the model isn't in that provider's models array, we still use that provider
|
||||
// for connection settings (baseUrl, credentials)
|
||||
const mockProvider = {
|
||||
id: 'provider-1',
|
||||
name: 'Provider 1',
|
||||
enabled: true,
|
||||
baseUrl: 'https://custom-api.example.com',
|
||||
models: [{ id: 'other-model', name: 'Other Model' }],
|
||||
};
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [mockProvider],
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({ anthropicApiKey: 'test-key' }),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await resolveProviderContext(
|
||||
mockSettingsService,
|
||||
'unknown-model', // Model not in provider's models array
|
||||
'provider-1'
|
||||
);
|
||||
|
||||
// Provider should still be returned for connection settings
|
||||
expect(result.provider).toEqual(mockProvider);
|
||||
// modelConfig should be undefined since the model wasn't found
|
||||
expect(result.modelConfig).toBeUndefined();
|
||||
// resolvedModel should be undefined since no mapping was found
|
||||
expect(result.resolvedModel).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should fallback to find modelConfig in other providers when not in explicit providerId provider', async () => {
|
||||
// When providerId is set and provider is found, but model isn't there,
|
||||
// we should still search for modelConfig in other providers
|
||||
const provider1 = {
|
||||
id: 'provider-1',
|
||||
name: 'Provider 1',
|
||||
enabled: true,
|
||||
baseUrl: 'https://provider1.example.com',
|
||||
models: [{ id: 'provider1-model', name: 'Provider 1 Model' }],
|
||||
};
|
||||
const provider2 = {
|
||||
id: 'provider-2',
|
||||
name: 'Provider 2',
|
||||
enabled: true,
|
||||
baseUrl: 'https://provider2.example.com',
|
||||
models: [
|
||||
{
|
||||
id: 'shared-model',
|
||||
name: 'Shared Model',
|
||||
mapsToClaudeModel: 'sonnet',
|
||||
},
|
||||
],
|
||||
};
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [provider1, provider2],
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({ anthropicApiKey: 'test-key' }),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await resolveProviderContext(
|
||||
mockSettingsService,
|
||||
'shared-model', // This model is in provider-2, not provider-1
|
||||
'provider-1' // But we explicitly want to use provider-1
|
||||
);
|
||||
|
||||
// Provider should still be provider-1 (for connection settings)
|
||||
expect(result.provider).toEqual(provider1);
|
||||
// But modelConfig should be found from provider-2
|
||||
expect(result.modelConfig?.id).toBe('shared-model');
|
||||
// And the model mapping should be resolved
|
||||
expect(result.resolvedModel).toContain('claude');
|
||||
});
|
||||
|
||||
it('should handle multiple providers with mixed enabled states', async () => {
|
||||
// Test the full session restore scenario with multiple providers
|
||||
const providers = [
|
||||
{
|
||||
id: 'provider-1',
|
||||
name: 'First Provider',
|
||||
enabled: undefined, // Undefined after restore
|
||||
models: [{ id: 'model-a', name: 'Model A' }],
|
||||
},
|
||||
{
|
||||
id: 'provider-2',
|
||||
name: 'Second Provider',
|
||||
// enabled field missing entirely
|
||||
models: [{ id: 'model-b', name: 'Model B', mapsToClaudeModel: 'opus' }],
|
||||
},
|
||||
{
|
||||
id: 'provider-3',
|
||||
name: 'Disabled Provider',
|
||||
enabled: false, // Explicitly disabled
|
||||
models: [{ id: 'model-c', name: 'Model C' }],
|
||||
},
|
||||
];
|
||||
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: providers,
|
||||
}),
|
||||
getCredentials: vi.fn().mockResolvedValue({ anthropicApiKey: 'test-key' }),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
// Provider 1 should work (enabled: undefined)
|
||||
const result1 = await resolveProviderContext(mockSettingsService, 'model-a', 'provider-1');
|
||||
expect(result1.provider?.id).toBe('provider-1');
|
||||
expect(result1.modelConfig?.id).toBe('model-a');
|
||||
|
||||
// Provider 2 should work (enabled field missing)
|
||||
const result2 = await resolveProviderContext(mockSettingsService, 'model-b', 'provider-2');
|
||||
expect(result2.provider?.id).toBe('provider-2');
|
||||
expect(result2.modelConfig?.id).toBe('model-b');
|
||||
expect(result2.resolvedModel).toContain('claude');
|
||||
|
||||
// Provider 3 with explicit providerId IS returned even if disabled
|
||||
// (caller handles enabled state check)
|
||||
const result3 = await resolveProviderContext(mockSettingsService, 'model-c', 'provider-3');
|
||||
// Provider is found but modelConfig won't be found since disabled providers
|
||||
// skip model lookup in their models array
|
||||
expect(result3.provider).toEqual(providers[2]);
|
||||
expect(result3.modelConfig).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllProviderModels', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return all models from enabled providers', async () => {
|
||||
const mockProviders = [
|
||||
{
|
||||
id: 'provider-1',
|
||||
name: 'Provider 1',
|
||||
enabled: true,
|
||||
models: [
|
||||
{ id: 'model-1', name: 'Model 1' },
|
||||
{ id: 'model-2', name: 'Model 2' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'provider-2',
|
||||
name: 'Provider 2',
|
||||
enabled: true,
|
||||
models: [{ id: 'model-3', name: 'Model 3' }],
|
||||
},
|
||||
];
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: mockProviders,
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getAllProviderModels(mockSettingsService);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].providerId).toBe('provider-1');
|
||||
expect(result[0].model.id).toBe('model-1');
|
||||
expect(result[2].providerId).toBe('provider-2');
|
||||
});
|
||||
|
||||
it('should filter out disabled providers', async () => {
|
||||
const mockProviders = [
|
||||
{
|
||||
id: 'enabled-1',
|
||||
name: 'Enabled Provider',
|
||||
enabled: true,
|
||||
models: [{ id: 'model-1', name: 'Model 1' }],
|
||||
},
|
||||
{
|
||||
id: 'disabled-1',
|
||||
name: 'Disabled Provider',
|
||||
enabled: false,
|
||||
models: [{ id: 'model-2', name: 'Model 2' }],
|
||||
},
|
||||
];
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: mockProviders,
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getAllProviderModels(mockSettingsService);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].providerId).toBe('enabled-1');
|
||||
});
|
||||
|
||||
it('should return empty array when no providers configured', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: [],
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getAllProviderModels(mockSettingsService);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle missing claudeCompatibleProviders field', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getAllProviderModels(mockSettingsService);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle provider with no models', async () => {
|
||||
const mockProviders = [
|
||||
{
|
||||
id: 'provider-1',
|
||||
name: 'Provider 1',
|
||||
enabled: true,
|
||||
models: [],
|
||||
},
|
||||
{
|
||||
id: 'provider-2',
|
||||
name: 'Provider 2',
|
||||
enabled: true,
|
||||
// no models field
|
||||
},
|
||||
];
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
claudeCompatibleProviders: mockProviders,
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getAllProviderModels(mockSettingsService);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array on exception', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockRejectedValue(new Error('Settings error')),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getAllProviderModels(mockSettingsService);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { normalizeThinkingLevelForModel } from '@automaker/types';
|
||||
|
||||
describe('normalizeThinkingLevelForModel', () => {
|
||||
it('preserves explicitly selected none for Opus models', () => {
|
||||
expect(normalizeThinkingLevelForModel('claude-opus', 'none')).toBe('none');
|
||||
});
|
||||
|
||||
it('falls back to none when Opus receives an unsupported manual thinking level', () => {
|
||||
expect(normalizeThinkingLevelForModel('claude-opus', 'medium')).toBe('none');
|
||||
});
|
||||
|
||||
it('keeps adaptive for Opus when adaptive is selected', () => {
|
||||
expect(normalizeThinkingLevelForModel('claude-opus', 'adaptive')).toBe('adaptive');
|
||||
});
|
||||
|
||||
it('preserves supported manual levels for non-Opus models', () => {
|
||||
expect(normalizeThinkingLevelForModel('claude-sonnet', 'high')).toBe('high');
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
calculateReasoningTimeout,
|
||||
REASONING_TIMEOUT_MULTIPLIERS,
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
validateBareModelId,
|
||||
} from '@automaker/types';
|
||||
|
||||
const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY';
|
||||
@@ -170,6 +171,30 @@ describe('codex-provider.ts', () => {
|
||||
expect(call.args).toContain('--json');
|
||||
});
|
||||
|
||||
it('uses exec resume when sdkSessionId is provided', async () => {
|
||||
vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})());
|
||||
|
||||
await collectAsyncGenerator(
|
||||
provider.executeQuery({
|
||||
prompt: 'Continue',
|
||||
model: 'gpt-5.2',
|
||||
cwd: '/tmp',
|
||||
sdkSessionId: 'codex-session-123',
|
||||
outputFormat: { type: 'json_schema', schema: { type: 'object', properties: {} } },
|
||||
codexSettings: { additionalDirs: ['/extra/dir'] },
|
||||
})
|
||||
);
|
||||
|
||||
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
|
||||
expect(call.args[0]).toBe('exec');
|
||||
expect(call.args[1]).toBe('resume');
|
||||
expect(call.args).toContain('codex-session-123');
|
||||
expect(call.args).toContain('--json');
|
||||
// Resume queries must not include --output-schema or --add-dir
|
||||
expect(call.args).not.toContain('--output-schema');
|
||||
expect(call.args).not.toContain('--add-dir');
|
||||
});
|
||||
|
||||
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
|
||||
@@ -320,8 +345,10 @@ describe('codex-provider.ts', () => {
|
||||
);
|
||||
|
||||
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
|
||||
// High reasoning effort should have 3x the default timeout (90000ms)
|
||||
expect(call.timeout).toBe(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.high);
|
||||
// High reasoning effort should have 3x the CLI base timeout (120000ms)
|
||||
// CODEX_CLI_TIMEOUT_MS = 120000, multiplier for 'high' = 3.0 → 360000ms
|
||||
const CODEX_CLI_TIMEOUT_MS = 120000;
|
||||
expect(call.timeout).toBe(CODEX_CLI_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.high);
|
||||
});
|
||||
|
||||
it('passes extended timeout for xhigh reasoning effort', async () => {
|
||||
@@ -357,8 +384,10 @@ describe('codex-provider.ts', () => {
|
||||
);
|
||||
|
||||
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
|
||||
// No reasoning effort should use the default timeout
|
||||
expect(call.timeout).toBe(DEFAULT_TIMEOUT_MS);
|
||||
// No reasoning effort should use the CLI base timeout (2 minutes)
|
||||
// CODEX_CLI_TIMEOUT_MS = 120000ms, no multiplier applied
|
||||
const CODEX_CLI_TIMEOUT_MS = 120000;
|
||||
expect(call.timeout).toBe(CODEX_CLI_TIMEOUT_MS);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -427,4 +456,19 @@ describe('codex-provider.ts', () => {
|
||||
expect(calculateReasoningTimeout('xhigh')).toBe(120000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateBareModelId integration', () => {
|
||||
it('should allow codex- prefixed models for Codex provider with expectedProvider="codex"', () => {
|
||||
expect(() => validateBareModelId('codex-gpt-4', 'CodexProvider', 'codex')).not.toThrow();
|
||||
expect(() =>
|
||||
validateBareModelId('codex-gpt-5.1-codex-max', 'CodexProvider', 'codex')
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('should reject other provider prefixes for Codex provider', () => {
|
||||
expect(() => validateBareModelId('cursor-gpt-4', 'CodexProvider', 'codex')).toThrow();
|
||||
expect(() => validateBareModelId('gemini-2.5-flash', 'CodexProvider', 'codex')).toThrow();
|
||||
expect(() => validateBareModelId('copilot-gpt-4', 'CodexProvider', 'codex')).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,35 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { CopilotProvider, CopilotErrorCode } from '@/providers/copilot-provider.js';
|
||||
import { collectAsyncGenerator } from '../../utils/helpers.js';
|
||||
import { CopilotClient } from '@github/copilot-sdk';
|
||||
|
||||
const createSessionMock = vi.fn();
|
||||
const resumeSessionMock = vi.fn();
|
||||
|
||||
function createMockSession(sessionId = 'test-session') {
|
||||
let eventHandler: ((event: any) => void) | null = null;
|
||||
return {
|
||||
sessionId,
|
||||
send: vi.fn().mockImplementation(async () => {
|
||||
if (eventHandler) {
|
||||
eventHandler({ type: 'assistant.message', data: { content: 'hello' } });
|
||||
eventHandler({ type: 'session.idle' });
|
||||
}
|
||||
}),
|
||||
destroy: vi.fn().mockResolvedValue(undefined),
|
||||
on: vi.fn().mockImplementation((handler: (event: any) => void) => {
|
||||
eventHandler = handler;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Mock the Copilot SDK
|
||||
vi.mock('@github/copilot-sdk', () => ({
|
||||
CopilotClient: vi.fn().mockImplementation(() => ({
|
||||
start: vi.fn().mockResolvedValue(undefined),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
createSession: vi.fn().mockResolvedValue({
|
||||
sessionId: 'test-session',
|
||||
send: vi.fn().mockResolvedValue(undefined),
|
||||
destroy: vi.fn().mockResolvedValue(undefined),
|
||||
on: vi.fn(),
|
||||
}),
|
||||
createSession: createSessionMock,
|
||||
resumeSession: resumeSessionMock,
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -49,6 +67,16 @@ describe('copilot-provider.ts', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(CopilotClient).mockImplementation(function () {
|
||||
return {
|
||||
start: vi.fn().mockResolvedValue(undefined),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
createSession: createSessionMock,
|
||||
resumeSession: resumeSessionMock,
|
||||
} as any;
|
||||
});
|
||||
createSessionMock.mockResolvedValue(createMockSession());
|
||||
resumeSessionMock.mockResolvedValue(createMockSession('resumed-session'));
|
||||
|
||||
// Mock fs.existsSync for CLI path validation
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
@@ -303,13 +331,15 @@ describe('copilot-provider.ts', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should normalize tool.execution_end event', () => {
|
||||
it('should normalize tool.execution_complete event', () => {
|
||||
const event = {
|
||||
type: 'tool.execution_end',
|
||||
type: 'tool.execution_complete',
|
||||
data: {
|
||||
toolName: 'read_file',
|
||||
toolCallId: 'call-123',
|
||||
result: 'file content',
|
||||
success: true,
|
||||
result: {
|
||||
content: 'file content',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -329,23 +359,85 @@ describe('copilot-provider.ts', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle tool.execution_end with error', () => {
|
||||
it('should handle tool.execution_complete with error', () => {
|
||||
const event = {
|
||||
type: 'tool.execution_end',
|
||||
type: 'tool.execution_complete',
|
||||
data: {
|
||||
toolName: 'bash',
|
||||
toolCallId: 'call-456',
|
||||
error: 'Command failed',
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Command failed',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result?.message?.content?.[0]).toMatchObject({
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'call-456',
|
||||
content: '[ERROR] Command failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle tool.execution_complete with empty result', () => {
|
||||
const event = {
|
||||
type: 'tool.execution_complete',
|
||||
data: {
|
||||
toolCallId: 'call-789',
|
||||
success: true,
|
||||
result: {
|
||||
content: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result?.message?.content?.[0]).toMatchObject({
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'call-789',
|
||||
content: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle tool.execution_complete with missing result', () => {
|
||||
const event = {
|
||||
type: 'tool.execution_complete',
|
||||
data: {
|
||||
toolCallId: 'call-999',
|
||||
success: true,
|
||||
// No result field
|
||||
},
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result?.message?.content?.[0]).toMatchObject({
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'call-999',
|
||||
content: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle tool.execution_complete with error code', () => {
|
||||
const event = {
|
||||
type: 'tool.execution_complete',
|
||||
data: {
|
||||
toolCallId: 'call-567',
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Permission denied',
|
||||
code: 'EACCES',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result?.message?.content?.[0]).toMatchObject({
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'call-567',
|
||||
content: '[ERROR] Permission denied (EACCES)',
|
||||
});
|
||||
});
|
||||
|
||||
it('should normalize session.idle to success result', () => {
|
||||
const event = { type: 'session.idle' };
|
||||
|
||||
@@ -369,6 +461,45 @@ describe('copilot-provider.ts', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should use error code in fallback when session.error message is empty', () => {
|
||||
const event = {
|
||||
type: 'session.error',
|
||||
data: { message: '', code: 'RATE_LIMIT_EXCEEDED' },
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.type).toBe('error');
|
||||
expect(result!.error).toContain('RATE_LIMIT_EXCEEDED');
|
||||
expect(result!.error).not.toBe('Unknown error');
|
||||
});
|
||||
|
||||
it('should return generic "Copilot agent error" fallback when both message and code are empty', () => {
|
||||
const event = {
|
||||
type: 'session.error',
|
||||
data: { message: '', code: '' },
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.type).toBe('error');
|
||||
expect(result!.error).toBe('Copilot agent error');
|
||||
// Must NOT be the old opaque 'Unknown error'
|
||||
expect(result!.error).not.toBe('Unknown error');
|
||||
});
|
||||
|
||||
it('should return generic "Copilot agent error" fallback when data has no code field', () => {
|
||||
const event = {
|
||||
type: 'session.error',
|
||||
data: { message: '' },
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.type).toBe('error');
|
||||
expect(result!.error).toBe('Copilot agent error');
|
||||
});
|
||||
|
||||
it('should return null for unknown event types', () => {
|
||||
const event = { type: 'unknown.event' };
|
||||
|
||||
@@ -514,4 +645,45 @@ describe('copilot-provider.ts', () => {
|
||||
expect(todoInput.todos[0].status).toBe('completed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeQuery resume behavior', () => {
|
||||
it('uses resumeSession when sdkSessionId is provided', async () => {
|
||||
const results = await collectAsyncGenerator(
|
||||
provider.executeQuery({
|
||||
prompt: 'Hello',
|
||||
model: 'claude-sonnet-4.6',
|
||||
cwd: '/tmp/project',
|
||||
sdkSessionId: 'session-123',
|
||||
})
|
||||
);
|
||||
|
||||
expect(resumeSessionMock).toHaveBeenCalledWith(
|
||||
'session-123',
|
||||
expect.objectContaining({ model: 'claude-sonnet-4.6', streaming: true })
|
||||
);
|
||||
expect(createSessionMock).not.toHaveBeenCalled();
|
||||
expect(results.some((msg) => msg.session_id === 'resumed-session')).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to createSession when resumeSession fails', async () => {
|
||||
resumeSessionMock.mockRejectedValueOnce(new Error('session not found'));
|
||||
createSessionMock.mockResolvedValueOnce(createMockSession('fresh-session'));
|
||||
|
||||
const results = await collectAsyncGenerator(
|
||||
provider.executeQuery({
|
||||
prompt: 'Hello',
|
||||
model: 'claude-sonnet-4.6',
|
||||
cwd: '/tmp/project',
|
||||
sdkSessionId: 'stale-session',
|
||||
})
|
||||
);
|
||||
|
||||
expect(resumeSessionMock).toHaveBeenCalledWith(
|
||||
'stale-session',
|
||||
expect.objectContaining({ model: 'claude-sonnet-4.6', streaming: true })
|
||||
);
|
||||
expect(createSessionMock).toHaveBeenCalledTimes(1);
|
||||
expect(results.some((msg) => msg.session_id === 'fresh-session')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
235
apps/server/tests/unit/providers/cursor-provider.test.ts
Normal file
235
apps/server/tests/unit/providers/cursor-provider.test.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { CursorProvider } from '@/providers/cursor-provider.js';
|
||||
import { validateBareModelId } from '@automaker/types';
|
||||
|
||||
describe('cursor-provider.ts', () => {
|
||||
describe('buildCliArgs', () => {
|
||||
it('adds --resume when sdkSessionId is provided', () => {
|
||||
const provider = Object.create(CursorProvider.prototype) as CursorProvider & {
|
||||
cliPath?: string;
|
||||
};
|
||||
provider.cliPath = '/usr/local/bin/cursor-agent';
|
||||
|
||||
const args = provider.buildCliArgs({
|
||||
prompt: 'Continue the task',
|
||||
model: 'gpt-5',
|
||||
cwd: '/tmp/project',
|
||||
sdkSessionId: 'cursor-session-123',
|
||||
});
|
||||
|
||||
const resumeIndex = args.indexOf('--resume');
|
||||
expect(resumeIndex).toBeGreaterThan(-1);
|
||||
expect(args[resumeIndex + 1]).toBe('cursor-session-123');
|
||||
});
|
||||
|
||||
it('does not add --resume when sdkSessionId is omitted', () => {
|
||||
const provider = Object.create(CursorProvider.prototype) as CursorProvider & {
|
||||
cliPath?: string;
|
||||
};
|
||||
provider.cliPath = '/usr/local/bin/cursor-agent';
|
||||
|
||||
const args = provider.buildCliArgs({
|
||||
prompt: 'Start a new task',
|
||||
model: 'gpt-5',
|
||||
cwd: '/tmp/project',
|
||||
});
|
||||
|
||||
expect(args).not.toContain('--resume');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeEvent - result error handling', () => {
|
||||
let provider: CursorProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
provider = Object.create(CursorProvider.prototype) as CursorProvider;
|
||||
});
|
||||
|
||||
it('returns error message from resultEvent.error when is_error=true', () => {
|
||||
const event = {
|
||||
type: 'result',
|
||||
is_error: true,
|
||||
error: 'Rate limit exceeded',
|
||||
result: '',
|
||||
subtype: 'error',
|
||||
duration_ms: 3000,
|
||||
session_id: 'sess-123',
|
||||
};
|
||||
|
||||
const msg = provider.normalizeEvent(event);
|
||||
|
||||
expect(msg).not.toBeNull();
|
||||
expect(msg!.type).toBe('error');
|
||||
expect(msg!.error).toBe('Rate limit exceeded');
|
||||
});
|
||||
|
||||
it('falls back to resultEvent.result when error field is empty and is_error=true', () => {
|
||||
const event = {
|
||||
type: 'result',
|
||||
is_error: true,
|
||||
error: '',
|
||||
result: 'Process terminated unexpectedly',
|
||||
subtype: 'error',
|
||||
duration_ms: 5000,
|
||||
session_id: 'sess-456',
|
||||
};
|
||||
|
||||
const msg = provider.normalizeEvent(event);
|
||||
|
||||
expect(msg).not.toBeNull();
|
||||
expect(msg!.type).toBe('error');
|
||||
expect(msg!.error).toBe('Process terminated unexpectedly');
|
||||
});
|
||||
|
||||
it('builds diagnostic fallback when both error and result are empty and is_error=true', () => {
|
||||
const event = {
|
||||
type: 'result',
|
||||
is_error: true,
|
||||
error: '',
|
||||
result: '',
|
||||
subtype: 'error',
|
||||
duration_ms: 5000,
|
||||
session_id: 'sess-789',
|
||||
};
|
||||
|
||||
const msg = provider.normalizeEvent(event);
|
||||
|
||||
expect(msg).not.toBeNull();
|
||||
expect(msg!.type).toBe('error');
|
||||
// Should contain diagnostic info rather than 'Unknown error'
|
||||
expect(msg!.error).toContain('5000ms');
|
||||
expect(msg!.error).toContain('sess-789');
|
||||
expect(msg!.error).not.toBe('Unknown error');
|
||||
});
|
||||
|
||||
it('preserves session_id in error message', () => {
|
||||
const event = {
|
||||
type: 'result',
|
||||
is_error: true,
|
||||
error: 'Timeout occurred',
|
||||
result: '',
|
||||
subtype: 'error',
|
||||
duration_ms: 30000,
|
||||
session_id: 'my-session-id',
|
||||
};
|
||||
|
||||
const msg = provider.normalizeEvent(event);
|
||||
|
||||
expect(msg!.session_id).toBe('my-session-id');
|
||||
});
|
||||
|
||||
it('uses "none" when session_id is missing from diagnostic fallback', () => {
|
||||
const event = {
|
||||
type: 'result',
|
||||
is_error: true,
|
||||
error: '',
|
||||
result: '',
|
||||
subtype: 'error',
|
||||
duration_ms: 5000,
|
||||
// session_id intentionally omitted
|
||||
};
|
||||
|
||||
const msg = provider.normalizeEvent(event);
|
||||
|
||||
expect(msg).not.toBeNull();
|
||||
expect(msg!.type).toBe('error');
|
||||
expect(msg!.error).toContain('none');
|
||||
expect(msg!.error).not.toContain('undefined');
|
||||
});
|
||||
|
||||
it('returns success result when is_error=false', () => {
|
||||
const event = {
|
||||
type: 'result',
|
||||
is_error: false,
|
||||
error: '',
|
||||
result: 'Completed successfully',
|
||||
subtype: 'success',
|
||||
duration_ms: 2000,
|
||||
session_id: 'sess-ok',
|
||||
};
|
||||
|
||||
const msg = provider.normalizeEvent(event);
|
||||
|
||||
expect(msg).not.toBeNull();
|
||||
expect(msg!.type).toBe('result');
|
||||
expect(msg!.subtype).toBe('success');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cursor Gemini models support', () => {
|
||||
let provider: CursorProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
provider = Object.create(CursorProvider.prototype) as CursorProvider & {
|
||||
cliPath?: string;
|
||||
};
|
||||
provider.cliPath = '/usr/local/bin/cursor-agent';
|
||||
});
|
||||
|
||||
describe('buildCliArgs with Cursor Gemini models', () => {
|
||||
it('should handle cursor-gemini-3-pro model', () => {
|
||||
const args = provider.buildCliArgs({
|
||||
prompt: 'Write a function',
|
||||
model: 'gemini-3-pro', // Bare model ID after stripping cursor- prefix
|
||||
cwd: '/tmp/project',
|
||||
});
|
||||
|
||||
const modelIndex = args.indexOf('--model');
|
||||
expect(modelIndex).toBeGreaterThan(-1);
|
||||
expect(args[modelIndex + 1]).toBe('gemini-3-pro');
|
||||
});
|
||||
|
||||
it('should handle cursor-gemini-3-flash model', () => {
|
||||
const args = provider.buildCliArgs({
|
||||
prompt: 'Quick task',
|
||||
model: 'gemini-3-flash', // Bare model ID after stripping cursor- prefix
|
||||
cwd: '/tmp/project',
|
||||
});
|
||||
|
||||
const modelIndex = args.indexOf('--model');
|
||||
expect(modelIndex).toBeGreaterThan(-1);
|
||||
expect(args[modelIndex + 1]).toBe('gemini-3-flash');
|
||||
});
|
||||
|
||||
it('should include --resume with Cursor Gemini models when sdkSessionId is provided', () => {
|
||||
const args = provider.buildCliArgs({
|
||||
prompt: 'Continue task',
|
||||
model: 'gemini-3-pro',
|
||||
cwd: '/tmp/project',
|
||||
sdkSessionId: 'cursor-gemini-session-123',
|
||||
});
|
||||
|
||||
const resumeIndex = args.indexOf('--resume');
|
||||
expect(resumeIndex).toBeGreaterThan(-1);
|
||||
expect(args[resumeIndex + 1]).toBe('cursor-gemini-session-123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateBareModelId with Cursor Gemini models', () => {
|
||||
it('should allow gemini- prefixed models for Cursor provider with expectedProvider="cursor"', () => {
|
||||
// This is the key fix - Cursor Gemini models have bare IDs like "gemini-3-pro"
|
||||
expect(() => validateBareModelId('gemini-3-pro', 'CursorProvider', 'cursor')).not.toThrow();
|
||||
expect(() =>
|
||||
validateBareModelId('gemini-3-flash', 'CursorProvider', 'cursor')
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('should still reject other provider prefixes for Cursor provider', () => {
|
||||
expect(() => validateBareModelId('codex-gpt-4', 'CursorProvider', 'cursor')).toThrow();
|
||||
expect(() => validateBareModelId('copilot-gpt-4', 'CursorProvider', 'cursor')).toThrow();
|
||||
expect(() => validateBareModelId('opencode-gpt-4', 'CursorProvider', 'cursor')).toThrow();
|
||||
});
|
||||
|
||||
it('should accept cursor- prefixed models when expectedProvider is "cursor" (for double-prefix validation)', () => {
|
||||
// Note: When expectedProvider="cursor", we skip the cursor- prefix check
|
||||
// This is intentional because the validation happens AFTER prefix stripping
|
||||
// So if cursor-gemini-3-pro reaches validateBareModelId with expectedProvider="cursor",
|
||||
// it means the prefix was NOT properly stripped, but we skip it anyway
|
||||
// since we're checking if the Cursor provider itself can receive cursor- prefixed models
|
||||
expect(() =>
|
||||
validateBareModelId('cursor-gemini-3-pro', 'CursorProvider', 'cursor')
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
272
apps/server/tests/unit/providers/gemini-provider.test.ts
Normal file
272
apps/server/tests/unit/providers/gemini-provider.test.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { GeminiProvider } from '@/providers/gemini-provider.js';
|
||||
import type { ProviderMessage } from '@automaker/types';
|
||||
import { validateBareModelId } from '@automaker/types';
|
||||
|
||||
describe('gemini-provider.ts', () => {
|
||||
let provider: GeminiProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
provider = new GeminiProvider();
|
||||
});
|
||||
|
||||
describe('buildCliArgs', () => {
|
||||
it('should include --prompt with empty string to force headless mode', () => {
|
||||
const args = provider.buildCliArgs({
|
||||
prompt: 'Hello from Gemini',
|
||||
model: '2.5-flash',
|
||||
cwd: '/tmp/project',
|
||||
});
|
||||
|
||||
const promptIndex = args.indexOf('--prompt');
|
||||
expect(promptIndex).toBeGreaterThan(-1);
|
||||
expect(args[promptIndex + 1]).toBe('');
|
||||
});
|
||||
|
||||
it('should include --resume when sdkSessionId is provided', () => {
|
||||
const args = provider.buildCliArgs({
|
||||
prompt: 'Hello',
|
||||
model: '2.5-flash',
|
||||
cwd: '/tmp/project',
|
||||
sdkSessionId: 'gemini-session-123',
|
||||
});
|
||||
|
||||
const resumeIndex = args.indexOf('--resume');
|
||||
expect(resumeIndex).toBeGreaterThan(-1);
|
||||
expect(args[resumeIndex + 1]).toBe('gemini-session-123');
|
||||
});
|
||||
|
||||
it('should not include --resume when sdkSessionId is missing', () => {
|
||||
const args = provider.buildCliArgs({
|
||||
prompt: 'Hello',
|
||||
model: '2.5-flash',
|
||||
cwd: '/tmp/project',
|
||||
});
|
||||
|
||||
expect(args).not.toContain('--resume');
|
||||
});
|
||||
|
||||
it('should include --sandbox false for faster execution', () => {
|
||||
const args = provider.buildCliArgs({
|
||||
prompt: 'Hello',
|
||||
model: '2.5-flash',
|
||||
cwd: '/tmp/project',
|
||||
});
|
||||
|
||||
const sandboxIndex = args.indexOf('--sandbox');
|
||||
expect(sandboxIndex).toBeGreaterThan(-1);
|
||||
expect(args[sandboxIndex + 1]).toBe('false');
|
||||
});
|
||||
|
||||
it('should include --approval-mode yolo for non-interactive use', () => {
|
||||
const args = provider.buildCliArgs({
|
||||
prompt: 'Hello',
|
||||
model: '2.5-flash',
|
||||
cwd: '/tmp/project',
|
||||
});
|
||||
|
||||
const approvalIndex = args.indexOf('--approval-mode');
|
||||
expect(approvalIndex).toBeGreaterThan(-1);
|
||||
expect(args[approvalIndex + 1]).toBe('yolo');
|
||||
});
|
||||
|
||||
it('should include --output-format stream-json', () => {
|
||||
const args = provider.buildCliArgs({
|
||||
prompt: 'Hello',
|
||||
model: '2.5-flash',
|
||||
cwd: '/tmp/project',
|
||||
});
|
||||
|
||||
const formatIndex = args.indexOf('--output-format');
|
||||
expect(formatIndex).toBeGreaterThan(-1);
|
||||
expect(args[formatIndex + 1]).toBe('stream-json');
|
||||
});
|
||||
|
||||
it('should include --include-directories with cwd', () => {
|
||||
const args = provider.buildCliArgs({
|
||||
prompt: 'Hello',
|
||||
model: '2.5-flash',
|
||||
cwd: '/tmp/my-project',
|
||||
});
|
||||
|
||||
const dirIndex = args.indexOf('--include-directories');
|
||||
expect(dirIndex).toBeGreaterThan(-1);
|
||||
expect(args[dirIndex + 1]).toBe('/tmp/my-project');
|
||||
});
|
||||
|
||||
it('should add gemini- prefix to bare model names', () => {
|
||||
const args = provider.buildCliArgs({
|
||||
prompt: 'Hello',
|
||||
model: '2.5-flash',
|
||||
cwd: '/tmp/project',
|
||||
});
|
||||
|
||||
const modelIndex = args.indexOf('--model');
|
||||
expect(modelIndex).toBeGreaterThan(-1);
|
||||
expect(args[modelIndex + 1]).toBe('gemini-2.5-flash');
|
||||
});
|
||||
|
||||
it('should not double-prefix model names that already have gemini-', () => {
|
||||
const args = provider.buildCliArgs({
|
||||
prompt: 'Hello',
|
||||
model: 'gemini-2.5-pro',
|
||||
cwd: '/tmp/project',
|
||||
});
|
||||
|
||||
const modelIndex = args.indexOf('--model');
|
||||
expect(modelIndex).toBeGreaterThan(-1);
|
||||
expect(args[modelIndex + 1]).toBe('gemini-2.5-pro');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeEvent - error handling', () => {
|
||||
it('returns error from result event when status=error and error field is set', () => {
|
||||
const event = {
|
||||
type: 'result',
|
||||
status: 'error',
|
||||
error: 'Model overloaded',
|
||||
session_id: 'sess-gemini-1',
|
||||
stats: { duration_ms: 4000, total_tokens: 0 },
|
||||
};
|
||||
|
||||
const msg = provider.normalizeEvent(event) as ProviderMessage;
|
||||
|
||||
expect(msg).not.toBeNull();
|
||||
expect(msg.type).toBe('error');
|
||||
expect(msg.error).toBe('Model overloaded');
|
||||
expect(msg.session_id).toBe('sess-gemini-1');
|
||||
});
|
||||
|
||||
it('builds diagnostic fallback when result event has status=error but empty error field', () => {
|
||||
const event = {
|
||||
type: 'result',
|
||||
status: 'error',
|
||||
error: '',
|
||||
session_id: 'sess-gemini-2',
|
||||
stats: { duration_ms: 7500, total_tokens: 0 },
|
||||
};
|
||||
|
||||
const msg = provider.normalizeEvent(event) as ProviderMessage;
|
||||
|
||||
expect(msg).not.toBeNull();
|
||||
expect(msg.type).toBe('error');
|
||||
// Diagnostic info should be present instead of 'Unknown error'
|
||||
expect(msg.error).toContain('7500ms');
|
||||
expect(msg.error).toContain('sess-gemini-2');
|
||||
expect(msg.error).not.toBe('Unknown error');
|
||||
});
|
||||
|
||||
it('builds fallback with "unknown" duration when stats are missing', () => {
|
||||
const event = {
|
||||
type: 'result',
|
||||
status: 'error',
|
||||
error: '',
|
||||
session_id: 'sess-gemini-nostats',
|
||||
// no stats field
|
||||
};
|
||||
|
||||
const msg = provider.normalizeEvent(event) as ProviderMessage;
|
||||
|
||||
expect(msg).not.toBeNull();
|
||||
expect(msg.type).toBe('error');
|
||||
expect(msg.error).toContain('unknown');
|
||||
});
|
||||
|
||||
it('returns error from standalone error event with error field set', () => {
|
||||
const event = {
|
||||
type: 'error',
|
||||
error: 'API key invalid',
|
||||
session_id: 'sess-gemini-3',
|
||||
};
|
||||
|
||||
const msg = provider.normalizeEvent(event) as ProviderMessage;
|
||||
|
||||
expect(msg).not.toBeNull();
|
||||
expect(msg.type).toBe('error');
|
||||
expect(msg.error).toBe('API key invalid');
|
||||
});
|
||||
|
||||
it('builds diagnostic fallback when standalone error event has empty error field', () => {
|
||||
const event = {
|
||||
type: 'error',
|
||||
error: '',
|
||||
session_id: 'sess-gemini-empty',
|
||||
};
|
||||
|
||||
const msg = provider.normalizeEvent(event) as ProviderMessage;
|
||||
|
||||
expect(msg).not.toBeNull();
|
||||
expect(msg.type).toBe('error');
|
||||
// Should include session_id, not just 'Unknown error'
|
||||
expect(msg.error).toContain('sess-gemini-empty');
|
||||
expect(msg.error).not.toBe('Unknown error');
|
||||
});
|
||||
|
||||
it('builds fallback mentioning "none" when session_id is missing from error event', () => {
|
||||
const event = {
|
||||
type: 'error',
|
||||
error: '',
|
||||
// no session_id
|
||||
};
|
||||
|
||||
const msg = provider.normalizeEvent(event) as ProviderMessage;
|
||||
|
||||
expect(msg).not.toBeNull();
|
||||
expect(msg.type).toBe('error');
|
||||
expect(msg.error).toContain('none');
|
||||
});
|
||||
|
||||
it('uses consistent "Gemini agent failed" label for both result and error event fallbacks', () => {
|
||||
const resultEvent = {
|
||||
type: 'result',
|
||||
status: 'error',
|
||||
error: '',
|
||||
session_id: 'sess-r',
|
||||
stats: { duration_ms: 1000 },
|
||||
};
|
||||
const errorEvent = {
|
||||
type: 'error',
|
||||
error: '',
|
||||
session_id: 'sess-e',
|
||||
};
|
||||
|
||||
const resultMsg = provider.normalizeEvent(resultEvent) as ProviderMessage;
|
||||
const errorMsg = provider.normalizeEvent(errorEvent) as ProviderMessage;
|
||||
|
||||
// Both fallback messages should use the same "Gemini agent failed" prefix
|
||||
expect(resultMsg.error).toContain('Gemini agent failed');
|
||||
expect(errorMsg.error).toContain('Gemini agent failed');
|
||||
});
|
||||
|
||||
it('returns success result when result event has status=success', () => {
|
||||
const event = {
|
||||
type: 'result',
|
||||
status: 'success',
|
||||
error: '',
|
||||
session_id: 'sess-gemini-ok',
|
||||
stats: { duration_ms: 1200, total_tokens: 500 },
|
||||
};
|
||||
|
||||
const msg = provider.normalizeEvent(event) as ProviderMessage;
|
||||
|
||||
expect(msg).not.toBeNull();
|
||||
expect(msg.type).toBe('result');
|
||||
expect(msg.subtype).toBe('success');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateBareModelId integration', () => {
|
||||
it('should allow gemini- prefixed models for Gemini provider with expectedProvider="gemini"', () => {
|
||||
expect(() =>
|
||||
validateBareModelId('gemini-2.5-flash', 'GeminiProvider', 'gemini')
|
||||
).not.toThrow();
|
||||
expect(() => validateBareModelId('gemini-2.5-pro', 'GeminiProvider', 'gemini')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should reject other provider prefixes for Gemini provider', () => {
|
||||
expect(() => validateBareModelId('cursor-gpt-4', 'GeminiProvider', 'gemini')).toThrow();
|
||||
expect(() => validateBareModelId('codex-gpt-4', 'GeminiProvider', 'gemini')).toThrow();
|
||||
expect(() => validateBareModelId('copilot-gpt-4', 'GeminiProvider', 'gemini')).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Tests for default fields applied to features created by parseAndCreateFeatures
|
||||
*
|
||||
* Verifies that auto-created features include planningMode: 'skip',
|
||||
* requirePlanApproval: false, and dependencies: [].
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import path from 'path';
|
||||
|
||||
// Use vi.hoisted to create mock functions that can be referenced in vi.mock factories
|
||||
const { mockMkdir, mockAtomicWriteJson, mockExtractJsonWithArray, mockCreateNotification } =
|
||||
vi.hoisted(() => ({
|
||||
mockMkdir: vi.fn().mockResolvedValue(undefined),
|
||||
mockAtomicWriteJson: vi.fn().mockResolvedValue(undefined),
|
||||
mockExtractJsonWithArray: vi.fn(),
|
||||
mockCreateNotification: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/secure-fs.js', () => ({
|
||||
mkdir: mockMkdir,
|
||||
}));
|
||||
|
||||
vi.mock('@automaker/utils', () => ({
|
||||
createLogger: vi.fn().mockReturnValue({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
atomicWriteJson: mockAtomicWriteJson,
|
||||
DEFAULT_BACKUP_COUNT: 3,
|
||||
}));
|
||||
|
||||
vi.mock('@automaker/platform', () => ({
|
||||
getFeaturesDir: vi.fn((projectPath: string) => path.join(projectPath, '.automaker', 'features')),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/json-extractor.js', () => ({
|
||||
extractJsonWithArray: mockExtractJsonWithArray,
|
||||
}));
|
||||
|
||||
vi.mock('@/services/notification-service.js', () => ({
|
||||
getNotificationService: vi.fn(() => ({
|
||||
createNotification: mockCreateNotification,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Import after mocks are set up
|
||||
import { parseAndCreateFeatures } from '../../../../src/routes/app-spec/parse-and-create-features.js';
|
||||
|
||||
describe('parseAndCreateFeatures - default fields', () => {
|
||||
const mockEvents = {
|
||||
emit: vi.fn(),
|
||||
} as any;
|
||||
|
||||
const projectPath = '/test/project';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should set planningMode to "skip" on created features', async () => {
|
||||
mockExtractJsonWithArray.mockReturnValue({
|
||||
features: [
|
||||
{
|
||||
id: 'feature-1',
|
||||
title: 'Test Feature',
|
||||
description: 'A test feature',
|
||||
priority: 1,
|
||||
complexity: 'simple',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
|
||||
|
||||
expect(mockAtomicWriteJson).toHaveBeenCalledTimes(1);
|
||||
const writtenData = mockAtomicWriteJson.mock.calls[0][1];
|
||||
expect(writtenData.planningMode).toBe('skip');
|
||||
});
|
||||
|
||||
it('should set requirePlanApproval to false on created features', async () => {
|
||||
mockExtractJsonWithArray.mockReturnValue({
|
||||
features: [
|
||||
{
|
||||
id: 'feature-1',
|
||||
title: 'Test Feature',
|
||||
description: 'A test feature',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
|
||||
|
||||
const writtenData = mockAtomicWriteJson.mock.calls[0][1];
|
||||
expect(writtenData.requirePlanApproval).toBe(false);
|
||||
});
|
||||
|
||||
it('should set dependencies to empty array when not provided', async () => {
|
||||
mockExtractJsonWithArray.mockReturnValue({
|
||||
features: [
|
||||
{
|
||||
id: 'feature-1',
|
||||
title: 'Test Feature',
|
||||
description: 'A test feature',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
|
||||
|
||||
const writtenData = mockAtomicWriteJson.mock.calls[0][1];
|
||||
expect(writtenData.dependencies).toEqual([]);
|
||||
});
|
||||
|
||||
it('should preserve dependencies when provided by the parser', async () => {
|
||||
mockExtractJsonWithArray.mockReturnValue({
|
||||
features: [
|
||||
{
|
||||
id: 'feature-1',
|
||||
title: 'Test Feature',
|
||||
description: 'A test feature',
|
||||
dependencies: ['feature-0'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
|
||||
|
||||
const writtenData = mockAtomicWriteJson.mock.calls[0][1];
|
||||
expect(writtenData.dependencies).toEqual(['feature-0']);
|
||||
});
|
||||
|
||||
it('should apply all default fields consistently across multiple features', async () => {
|
||||
mockExtractJsonWithArray.mockReturnValue({
|
||||
features: [
|
||||
{
|
||||
id: 'feature-1',
|
||||
title: 'Feature 1',
|
||||
description: 'First feature',
|
||||
},
|
||||
{
|
||||
id: 'feature-2',
|
||||
title: 'Feature 2',
|
||||
description: 'Second feature',
|
||||
dependencies: ['feature-1'],
|
||||
},
|
||||
{
|
||||
id: 'feature-3',
|
||||
title: 'Feature 3',
|
||||
description: 'Third feature',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
|
||||
|
||||
expect(mockAtomicWriteJson).toHaveBeenCalledTimes(3);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const writtenData = mockAtomicWriteJson.mock.calls[i][1];
|
||||
expect(writtenData.planningMode, `feature ${i + 1} planningMode`).toBe('skip');
|
||||
expect(writtenData.requirePlanApproval, `feature ${i + 1} requirePlanApproval`).toBe(false);
|
||||
expect(Array.isArray(writtenData.dependencies), `feature ${i + 1} dependencies`).toBe(true);
|
||||
}
|
||||
|
||||
// Feature 2 should have its explicit dependency preserved
|
||||
expect(mockAtomicWriteJson.mock.calls[1][1].dependencies).toEqual(['feature-1']);
|
||||
// Features 1 and 3 should have empty arrays
|
||||
expect(mockAtomicWriteJson.mock.calls[0][1].dependencies).toEqual([]);
|
||||
expect(mockAtomicWriteJson.mock.calls[2][1].dependencies).toEqual([]);
|
||||
});
|
||||
|
||||
it('should set status to "backlog" on all created features', async () => {
|
||||
mockExtractJsonWithArray.mockReturnValue({
|
||||
features: [
|
||||
{
|
||||
id: 'feature-1',
|
||||
title: 'Test Feature',
|
||||
description: 'A test feature',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
|
||||
|
||||
const writtenData = mockAtomicWriteJson.mock.calls[0][1];
|
||||
expect(writtenData.status).toBe('backlog');
|
||||
});
|
||||
|
||||
it('should include createdAt and updatedAt timestamps', async () => {
|
||||
mockExtractJsonWithArray.mockReturnValue({
|
||||
features: [
|
||||
{
|
||||
id: 'feature-1',
|
||||
title: 'Test Feature',
|
||||
description: 'A test feature',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
|
||||
|
||||
const writtenData = mockAtomicWriteJson.mock.calls[0][1];
|
||||
expect(writtenData.createdAt).toBeDefined();
|
||||
expect(writtenData.updatedAt).toBeDefined();
|
||||
// Should be valid ISO date strings
|
||||
expect(new Date(writtenData.createdAt).toISOString()).toBe(writtenData.createdAt);
|
||||
expect(new Date(writtenData.updatedAt).toISOString()).toBe(writtenData.updatedAt);
|
||||
});
|
||||
|
||||
it('should use default values for optional fields not provided', async () => {
|
||||
mockExtractJsonWithArray.mockReturnValue({
|
||||
features: [
|
||||
{
|
||||
id: 'feature-minimal',
|
||||
title: 'Minimal Feature',
|
||||
description: 'Only required fields',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
|
||||
|
||||
const writtenData = mockAtomicWriteJson.mock.calls[0][1];
|
||||
expect(writtenData.category).toBe('Uncategorized');
|
||||
expect(writtenData.priority).toBe(2);
|
||||
expect(writtenData.complexity).toBe('moderate');
|
||||
expect(writtenData.dependencies).toEqual([]);
|
||||
expect(writtenData.planningMode).toBe('skip');
|
||||
expect(writtenData.requirePlanApproval).toBe(false);
|
||||
});
|
||||
|
||||
it('should emit success event after creating features', async () => {
|
||||
mockExtractJsonWithArray.mockReturnValue({
|
||||
features: [
|
||||
{
|
||||
id: 'feature-1',
|
||||
title: 'Feature 1',
|
||||
description: 'First',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await parseAndCreateFeatures(projectPath, 'content', mockEvents);
|
||||
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith(
|
||||
'spec-regeneration:event',
|
||||
expect.objectContaining({
|
||||
type: 'spec_regeneration_complete',
|
||||
projectPath,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should emit error event when no valid JSON is found', async () => {
|
||||
mockExtractJsonWithArray.mockReturnValue(null);
|
||||
|
||||
await parseAndCreateFeatures(projectPath, 'invalid content', mockEvents);
|
||||
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith(
|
||||
'spec-regeneration:event',
|
||||
expect.objectContaining({
|
||||
type: 'spec_regeneration_error',
|
||||
projectPath,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
149
apps/server/tests/unit/routes/backlog-plan/apply.test.ts
Normal file
149
apps/server/tests/unit/routes/backlog-plan/apply.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { mockGetAll, mockCreate, mockUpdate, mockDelete, mockClearBacklogPlan } = vi.hoisted(() => ({
|
||||
mockGetAll: vi.fn(),
|
||||
mockCreate: vi.fn(),
|
||||
mockUpdate: vi.fn(),
|
||||
mockDelete: vi.fn(),
|
||||
mockClearBacklogPlan: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/feature-loader.js', () => ({
|
||||
FeatureLoader: class {
|
||||
getAll = mockGetAll;
|
||||
create = mockCreate;
|
||||
update = mockUpdate;
|
||||
delete = mockDelete;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/routes/backlog-plan/common.js', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
clearBacklogPlan: mockClearBacklogPlan,
|
||||
getErrorMessage: (error: unknown) => (error instanceof Error ? error.message : String(error)),
|
||||
logError: vi.fn(),
|
||||
}));
|
||||
|
||||
import { createApplyHandler } from '@/routes/backlog-plan/routes/apply.js';
|
||||
|
||||
function createMockRes() {
|
||||
const res: {
|
||||
status: ReturnType<typeof vi.fn>;
|
||||
json: ReturnType<typeof vi.fn>;
|
||||
} = {
|
||||
status: vi.fn(),
|
||||
json: vi.fn(),
|
||||
};
|
||||
res.status.mockReturnValue(res);
|
||||
return res;
|
||||
}
|
||||
|
||||
describe('createApplyHandler', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetAll.mockResolvedValue([]);
|
||||
mockCreate.mockResolvedValue({ id: 'feature-created' });
|
||||
mockUpdate.mockResolvedValue({});
|
||||
mockDelete.mockResolvedValue(true);
|
||||
mockClearBacklogPlan.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('applies default feature model and planning settings when backlog plan additions omit them', async () => {
|
||||
const settingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
defaultFeatureModel: { model: 'codex-gpt-5.2-codex', reasoningEffort: 'high' },
|
||||
defaultPlanningMode: 'spec',
|
||||
defaultRequirePlanApproval: true,
|
||||
}),
|
||||
getProjectSettings: vi.fn().mockResolvedValue({}),
|
||||
} as any;
|
||||
|
||||
const req = {
|
||||
body: {
|
||||
projectPath: '/tmp/project',
|
||||
plan: {
|
||||
changes: [
|
||||
{
|
||||
type: 'add',
|
||||
feature: {
|
||||
id: 'feature-from-plan',
|
||||
title: 'Created from plan',
|
||||
description: 'desc',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
const res = createMockRes();
|
||||
|
||||
await createApplyHandler(settingsService)(req, res as any);
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
'/tmp/project',
|
||||
expect.objectContaining({
|
||||
model: 'codex-gpt-5.2-codex',
|
||||
reasoningEffort: 'high',
|
||||
planningMode: 'spec',
|
||||
requirePlanApproval: true,
|
||||
})
|
||||
);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
success: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('uses project default feature model override and enforces no approval for skip mode', async () => {
|
||||
const settingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
defaultFeatureModel: { model: 'claude-opus' },
|
||||
defaultPlanningMode: 'skip',
|
||||
defaultRequirePlanApproval: true,
|
||||
}),
|
||||
getProjectSettings: vi.fn().mockResolvedValue({
|
||||
defaultFeatureModel: {
|
||||
model: 'GLM-4.7',
|
||||
providerId: 'provider-glm',
|
||||
thinkingLevel: 'adaptive',
|
||||
},
|
||||
}),
|
||||
} as any;
|
||||
|
||||
const req = {
|
||||
body: {
|
||||
projectPath: '/tmp/project',
|
||||
plan: {
|
||||
changes: [
|
||||
{
|
||||
type: 'add',
|
||||
feature: {
|
||||
id: 'feature-from-plan',
|
||||
title: 'Created from plan',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
const res = createMockRes();
|
||||
|
||||
await createApplyHandler(settingsService)(req, res as any);
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
'/tmp/project',
|
||||
expect.objectContaining({
|
||||
model: 'GLM-4.7',
|
||||
providerId: 'provider-glm',
|
||||
thinkingLevel: 'adaptive',
|
||||
planningMode: 'skip',
|
||||
requirePlanApproval: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
218
apps/server/tests/unit/routes/backlog-plan/generate-plan.test.ts
Normal file
218
apps/server/tests/unit/routes/backlog-plan/generate-plan.test.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { BacklogPlanResult, ProviderMessage } from '@automaker/types';
|
||||
|
||||
const {
|
||||
mockGetAll,
|
||||
mockExecuteQuery,
|
||||
mockSaveBacklogPlan,
|
||||
mockSetRunningState,
|
||||
mockSetRunningDetails,
|
||||
mockGetPromptCustomization,
|
||||
mockGetAutoLoadClaudeMdSetting,
|
||||
mockGetUseClaudeCodeSystemPromptSetting,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetAll: vi.fn(),
|
||||
mockExecuteQuery: vi.fn(),
|
||||
mockSaveBacklogPlan: vi.fn(),
|
||||
mockSetRunningState: vi.fn(),
|
||||
mockSetRunningDetails: vi.fn(),
|
||||
mockGetPromptCustomization: vi.fn(),
|
||||
mockGetAutoLoadClaudeMdSetting: vi.fn(),
|
||||
mockGetUseClaudeCodeSystemPromptSetting: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/feature-loader.js', () => ({
|
||||
FeatureLoader: class {
|
||||
getAll = mockGetAll;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/providers/provider-factory.js', () => ({
|
||||
ProviderFactory: {
|
||||
getProviderForModel: vi.fn(() => ({
|
||||
executeQuery: mockExecuteQuery,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/routes/backlog-plan/common.js', () => ({
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
setRunningState: mockSetRunningState,
|
||||
setRunningDetails: mockSetRunningDetails,
|
||||
getErrorMessage: (error: unknown) => (error instanceof Error ? error.message : String(error)),
|
||||
saveBacklogPlan: mockSaveBacklogPlan,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/settings-helpers.js', () => ({
|
||||
getPromptCustomization: mockGetPromptCustomization,
|
||||
getAutoLoadClaudeMdSetting: mockGetAutoLoadClaudeMdSetting,
|
||||
getUseClaudeCodeSystemPromptSetting: mockGetUseClaudeCodeSystemPromptSetting,
|
||||
getPhaseModelWithOverrides: vi.fn(),
|
||||
}));
|
||||
|
||||
import { generateBacklogPlan } from '@/routes/backlog-plan/generate-plan.js';
|
||||
|
||||
function createMockEvents() {
|
||||
return {
|
||||
emit: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('generateBacklogPlan', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockGetAll.mockResolvedValue([]);
|
||||
mockGetPromptCustomization.mockResolvedValue({
|
||||
backlogPlan: {
|
||||
systemPrompt: 'System instructions',
|
||||
userPromptTemplate:
|
||||
'Current features:\n{{currentFeatures}}\n\nUser request:\n{{userRequest}}',
|
||||
},
|
||||
});
|
||||
mockGetAutoLoadClaudeMdSetting.mockResolvedValue(false);
|
||||
mockGetUseClaudeCodeSystemPromptSetting.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it('salvages valid streamed JSON when Claude process exits with code 1', async () => {
|
||||
const partialResult: BacklogPlanResult = {
|
||||
changes: [
|
||||
{
|
||||
type: 'add',
|
||||
feature: {
|
||||
title: 'Add signup form',
|
||||
description: 'Create signup UI and validation',
|
||||
category: 'frontend',
|
||||
},
|
||||
reason: 'Required for user onboarding',
|
||||
},
|
||||
],
|
||||
summary: 'Adds signup feature to the backlog',
|
||||
dependencyUpdates: [],
|
||||
};
|
||||
|
||||
const responseJson = JSON.stringify(partialResult);
|
||||
|
||||
async function* streamWithExitError(): AsyncGenerator<ProviderMessage> {
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: responseJson }],
|
||||
},
|
||||
};
|
||||
throw new Error('Claude Code process exited with code 1');
|
||||
}
|
||||
|
||||
mockExecuteQuery.mockReturnValueOnce(streamWithExitError());
|
||||
|
||||
const events = createMockEvents();
|
||||
const abortController = new AbortController();
|
||||
|
||||
const result = await generateBacklogPlan(
|
||||
'/tmp/project',
|
||||
'Please add a signup feature',
|
||||
events as any,
|
||||
abortController,
|
||||
undefined,
|
||||
'claude-opus'
|
||||
);
|
||||
|
||||
expect(mockExecuteQuery).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual(partialResult);
|
||||
expect(mockSaveBacklogPlan).toHaveBeenCalledWith(
|
||||
'/tmp/project',
|
||||
expect.objectContaining({
|
||||
prompt: 'Please add a signup feature',
|
||||
model: 'claude-opus-4-6',
|
||||
result: partialResult,
|
||||
})
|
||||
);
|
||||
expect(events.emit).toHaveBeenCalledWith('backlog-plan:event', {
|
||||
type: 'backlog_plan_complete',
|
||||
result: partialResult,
|
||||
});
|
||||
expect(mockSetRunningState).toHaveBeenCalledWith(false, null);
|
||||
expect(mockSetRunningDetails).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('prefers parseable provider result over longer non-JSON accumulated text on exit', async () => {
|
||||
const recoveredResult: BacklogPlanResult = {
|
||||
changes: [
|
||||
{
|
||||
type: 'add',
|
||||
feature: {
|
||||
title: 'Add reset password flow',
|
||||
description: 'Implement reset password request and token validation UI',
|
||||
category: 'frontend',
|
||||
},
|
||||
reason: 'Supports account recovery',
|
||||
},
|
||||
],
|
||||
summary: 'Adds password reset capability',
|
||||
dependencyUpdates: [],
|
||||
};
|
||||
|
||||
const validProviderResult = JSON.stringify(recoveredResult);
|
||||
const invalidAccumulatedText = `${validProviderResult}\n\nAdditional commentary that breaks raw JSON parsing.`;
|
||||
|
||||
async function* streamWithResultThenExit(): AsyncGenerator<ProviderMessage> {
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: invalidAccumulatedText }],
|
||||
},
|
||||
};
|
||||
yield {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
duration_ms: 10,
|
||||
duration_api_ms: 10,
|
||||
is_error: false,
|
||||
num_turns: 1,
|
||||
result: validProviderResult,
|
||||
session_id: 'session-1',
|
||||
total_cost_usd: 0,
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
output_tokens: 10,
|
||||
server_tool_use: {
|
||||
web_search_requests: 0,
|
||||
},
|
||||
service_tier: 'standard',
|
||||
},
|
||||
};
|
||||
throw new Error('Claude Code process exited with code 1');
|
||||
}
|
||||
|
||||
mockExecuteQuery.mockReturnValueOnce(streamWithResultThenExit());
|
||||
|
||||
const events = createMockEvents();
|
||||
const abortController = new AbortController();
|
||||
|
||||
const result = await generateBacklogPlan(
|
||||
'/tmp/project',
|
||||
'Add password reset support',
|
||||
events as any,
|
||||
abortController,
|
||||
undefined,
|
||||
'claude-opus'
|
||||
);
|
||||
|
||||
expect(result).toEqual(recoveredResult);
|
||||
expect(mockSaveBacklogPlan).toHaveBeenCalledWith(
|
||||
'/tmp/project',
|
||||
expect.objectContaining({
|
||||
result: recoveredResult,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,8 @@ describe('running-agents routes', () => {
|
||||
projectPath: '/home/user/project',
|
||||
projectName: 'project',
|
||||
isAutoMode: true,
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
provider: 'claude',
|
||||
title: 'Implement login feature',
|
||||
description: 'Add user authentication with OAuth',
|
||||
},
|
||||
@@ -55,6 +57,8 @@ describe('running-agents routes', () => {
|
||||
projectPath: '/home/user/other-project',
|
||||
projectName: 'other-project',
|
||||
isAutoMode: false,
|
||||
model: 'codex-gpt-5.1',
|
||||
provider: 'codex',
|
||||
title: 'Fix navigation bug',
|
||||
description: undefined,
|
||||
},
|
||||
@@ -82,6 +86,8 @@ describe('running-agents routes', () => {
|
||||
projectPath: '/project',
|
||||
projectName: 'project',
|
||||
isAutoMode: true,
|
||||
model: undefined,
|
||||
provider: undefined,
|
||||
title: undefined,
|
||||
description: undefined,
|
||||
},
|
||||
@@ -141,6 +147,8 @@ describe('running-agents routes', () => {
|
||||
projectPath: `/project-${i}`,
|
||||
projectName: `project-${i}`,
|
||||
isAutoMode: i % 2 === 0,
|
||||
model: i % 3 === 0 ? 'claude-sonnet-4-20250514' : 'claude-haiku-4-5',
|
||||
provider: 'claude',
|
||||
title: `Feature ${i}`,
|
||||
description: `Description ${i}`,
|
||||
}));
|
||||
@@ -167,6 +175,8 @@ describe('running-agents routes', () => {
|
||||
projectPath: '/workspace/project-alpha',
|
||||
projectName: 'project-alpha',
|
||||
isAutoMode: true,
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
provider: 'claude',
|
||||
title: 'Feature A',
|
||||
description: 'In project alpha',
|
||||
},
|
||||
@@ -175,6 +185,8 @@ describe('running-agents routes', () => {
|
||||
projectPath: '/workspace/project-beta',
|
||||
projectName: 'project-beta',
|
||||
isAutoMode: false,
|
||||
model: 'codex-gpt-5.1',
|
||||
provider: 'codex',
|
||||
title: 'Feature B',
|
||||
description: 'In project beta',
|
||||
},
|
||||
@@ -191,5 +203,56 @@ describe('running-agents routes', () => {
|
||||
expect(response.runningAgents[0].projectPath).toBe('/workspace/project-alpha');
|
||||
expect(response.runningAgents[1].projectPath).toBe('/workspace/project-beta');
|
||||
});
|
||||
|
||||
it('should include model and provider information for running agents', async () => {
|
||||
// Arrange
|
||||
const runningAgents = [
|
||||
{
|
||||
featureId: 'feature-claude',
|
||||
projectPath: '/project',
|
||||
projectName: 'project',
|
||||
isAutoMode: true,
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
provider: 'claude',
|
||||
title: 'Claude Feature',
|
||||
description: 'Using Claude model',
|
||||
},
|
||||
{
|
||||
featureId: 'feature-codex',
|
||||
projectPath: '/project',
|
||||
projectName: 'project',
|
||||
isAutoMode: false,
|
||||
model: 'codex-gpt-5.1',
|
||||
provider: 'codex',
|
||||
title: 'Codex Feature',
|
||||
description: 'Using Codex model',
|
||||
},
|
||||
{
|
||||
featureId: 'feature-cursor',
|
||||
projectPath: '/project',
|
||||
projectName: 'project',
|
||||
isAutoMode: false,
|
||||
model: 'cursor-auto',
|
||||
provider: 'cursor',
|
||||
title: 'Cursor Feature',
|
||||
description: 'Using Cursor model',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(mockAutoModeService.getRunningAgents!).mockResolvedValue(runningAgents);
|
||||
|
||||
// Act
|
||||
const handler = createIndexHandler(mockAutoModeService as AutoModeService);
|
||||
await handler(req, res);
|
||||
|
||||
// Assert
|
||||
const response = vi.mocked(res.json).mock.calls[0][0];
|
||||
expect(response.runningAgents[0].model).toBe('claude-sonnet-4-20250514');
|
||||
expect(response.runningAgents[0].provider).toBe('claude');
|
||||
expect(response.runningAgents[1].model).toBe('codex-gpt-5.1');
|
||||
expect(response.runningAgents[1].provider).toBe('codex');
|
||||
expect(response.runningAgents[2].model).toBe('cursor-auto');
|
||||
expect(response.runningAgents[2].provider).toBe('cursor');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,930 @@
|
||||
/**
|
||||
* Tests for worktree list endpoint handling of detached HEAD state.
|
||||
*
|
||||
* When a worktree is in detached HEAD state (e.g., during a rebase),
|
||||
* `git worktree list --porcelain` outputs "detached" instead of
|
||||
* "branch refs/heads/...". Previously, these worktrees were silently
|
||||
* dropped from the response because the parser required both path AND branch.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import type { Request, Response } from 'express';
|
||||
import { exec } from 'child_process';
|
||||
import { createMockExpressContext } from '../../../utils/mocks.js';
|
||||
|
||||
// Mock all external dependencies before importing the module under test
|
||||
vi.mock('child_process', () => ({
|
||||
exec: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/git.js', () => ({
|
||||
execGitCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@automaker/git-utils', () => ({
|
||||
isGitRepo: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
vi.mock('@automaker/utils', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@automaker/types', () => ({
|
||||
validatePRState: vi.fn((state: string) => state),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/secure-fs.js', () => ({
|
||||
access: vi.fn().mockResolvedValue(undefined),
|
||||
readFile: vi.fn(),
|
||||
readdir: vi.fn().mockResolvedValue([]),
|
||||
stat: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/worktree-metadata.js', () => ({
|
||||
readAllWorktreeMetadata: vi.fn(async () => new Map()),
|
||||
updateWorktreePRInfo: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock('@/routes/worktree/common.js', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
getErrorMessage: vi.fn((e: Error) => e?.message || 'Unknown error'),
|
||||
logError: vi.fn(),
|
||||
normalizePath: vi.fn((p: string) => p),
|
||||
execEnv: {},
|
||||
isGhCliAvailable: vi.fn().mockResolvedValue(false),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/routes/github/routes/check-github-remote.js', () => ({
|
||||
checkGitHubRemote: vi.fn().mockResolvedValue({ hasGitHubRemote: false }),
|
||||
}));
|
||||
|
||||
import { createListHandler } from '@/routes/worktree/routes/list.js';
|
||||
import * as secureFs from '@/lib/secure-fs.js';
|
||||
import { execGitCommand } from '@/lib/git.js';
|
||||
import { readAllWorktreeMetadata, updateWorktreePRInfo } from '@/lib/worktree-metadata.js';
|
||||
import { isGitRepo } from '@automaker/git-utils';
|
||||
import { isGhCliAvailable, normalizePath, getErrorMessage } from '@/routes/worktree/common.js';
|
||||
import { checkGitHubRemote } from '@/routes/github/routes/check-github-remote.js';
|
||||
|
||||
/**
|
||||
* Set up execGitCommand mock (list handler uses this via lib/git.js, not child_process.exec).
|
||||
*/
|
||||
function setupExecGitCommandMock(options: {
|
||||
porcelainOutput: string;
|
||||
projectBranch?: string;
|
||||
gitDirs?: Record<string, string>;
|
||||
worktreeBranches?: Record<string, string>;
|
||||
}) {
|
||||
const { porcelainOutput, projectBranch = 'main', gitDirs = {}, worktreeBranches = {} } = options;
|
||||
|
||||
vi.mocked(execGitCommand).mockImplementation(async (args: string[], cwd: string) => {
|
||||
if (args[0] === 'worktree' && args[1] === 'list' && args[2] === '--porcelain') {
|
||||
return porcelainOutput;
|
||||
}
|
||||
if (args[0] === 'branch' && args[1] === '--show-current') {
|
||||
if (worktreeBranches[cwd] !== undefined) {
|
||||
return worktreeBranches[cwd] + '\n';
|
||||
}
|
||||
return projectBranch + '\n';
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args[1] === '--git-dir') {
|
||||
if (cwd && gitDirs[cwd]) {
|
||||
return gitDirs[cwd] + '\n';
|
||||
}
|
||||
throw new Error('not a git directory');
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref' && args[2] === 'HEAD') {
|
||||
return 'HEAD\n';
|
||||
}
|
||||
if (args[0] === 'worktree' && args[1] === 'prune') {
|
||||
return '';
|
||||
}
|
||||
if (args[0] === 'status' && args[1] === '--porcelain') {
|
||||
return '';
|
||||
}
|
||||
if (args[0] === 'diff' && args[1] === '--name-only' && args[2] === '--diff-filter=U') {
|
||||
return '';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
}
|
||||
|
||||
describe('worktree list - detached HEAD handling', () => {
|
||||
let req: Request;
|
||||
let res: Response;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
const context = createMockExpressContext();
|
||||
req = context.req;
|
||||
res = context.res;
|
||||
|
||||
// Re-establish mock implementations cleared by mockReset/clearAllMocks
|
||||
vi.mocked(isGitRepo).mockResolvedValue(true);
|
||||
vi.mocked(readAllWorktreeMetadata).mockResolvedValue(new Map());
|
||||
vi.mocked(isGhCliAvailable).mockResolvedValue(false);
|
||||
vi.mocked(checkGitHubRemote).mockResolvedValue({ hasGitHubRemote: false });
|
||||
vi.mocked(normalizePath).mockImplementation((p: string) => p);
|
||||
vi.mocked(getErrorMessage).mockImplementation(
|
||||
(e: unknown) => (e as Error)?.message || 'Unknown error'
|
||||
);
|
||||
|
||||
// Default: all paths exist
|
||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||
// Default: .worktrees directory doesn't exist (no scan via readdir)
|
||||
vi.mocked(secureFs.readdir).mockRejectedValue(new Error('ENOENT'));
|
||||
// Default: readFile fails
|
||||
vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
// Default execGitCommand so list handler gets valid porcelain/branch output (vitest clearMocks resets implementations)
|
||||
setupExecGitCommandMock({
|
||||
porcelainOutput: 'worktree /project\nbranch refs/heads/main\n\n',
|
||||
projectBranch: 'main',
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper: set up execGitCommand mock for the list handler.
|
||||
* Worktree-specific behavior can be customized via the options parameter.
|
||||
*/
|
||||
function setupStandardExec(options: {
|
||||
porcelainOutput: string;
|
||||
projectBranch?: string;
|
||||
/** Map of worktree path -> git-dir path */
|
||||
gitDirs?: Record<string, string>;
|
||||
/** Map of worktree cwd -> branch for `git branch --show-current` */
|
||||
worktreeBranches?: Record<string, string>;
|
||||
}) {
|
||||
setupExecGitCommandMock(options);
|
||||
}
|
||||
|
||||
/** Suppress .worktrees dir scan by making access throw for the .worktrees dir. */
|
||||
function disableWorktreesScan() {
|
||||
vi.mocked(secureFs.access).mockImplementation(async (p) => {
|
||||
const pathStr = String(p);
|
||||
// Block only the .worktrees dir access check in scanWorktreesDirectory
|
||||
if (pathStr.endsWith('.worktrees') || pathStr.endsWith('.worktrees/')) {
|
||||
throw new Error('ENOENT');
|
||||
}
|
||||
// All other paths exist
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
describe('porcelain parser', () => {
|
||||
it('should include normal worktrees with branch lines', async () => {
|
||||
req.body = { projectPath: '/project' };
|
||||
|
||||
setupStandardExec({
|
||||
porcelainOutput: [
|
||||
'worktree /project',
|
||||
'branch refs/heads/main',
|
||||
'',
|
||||
'worktree /project/.worktrees/feature-a',
|
||||
'branch refs/heads/feature-a',
|
||||
'',
|
||||
].join('\n'),
|
||||
});
|
||||
disableWorktreesScan();
|
||||
|
||||
const handler = createListHandler();
|
||||
await handler(req, res);
|
||||
|
||||
const response = vi.mocked(res.json).mock.calls[0][0] as {
|
||||
success: boolean;
|
||||
worktrees: Array<{ branch: string; path: string; isMain: boolean; hasWorktree: boolean }>;
|
||||
};
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.worktrees).toHaveLength(2);
|
||||
expect(response.worktrees[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
path: '/project',
|
||||
branch: 'main',
|
||||
isMain: true,
|
||||
hasWorktree: true,
|
||||
})
|
||||
);
|
||||
expect(response.worktrees[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
path: '/project/.worktrees/feature-a',
|
||||
branch: 'feature-a',
|
||||
isMain: false,
|
||||
hasWorktree: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should include worktrees with detached HEAD and recover branch from rebase-merge state', async () => {
|
||||
req.body = { projectPath: '/project' };
|
||||
|
||||
setupStandardExec({
|
||||
porcelainOutput: [
|
||||
'worktree /project',
|
||||
'branch refs/heads/main',
|
||||
'',
|
||||
'worktree /project/.worktrees/rebasing-wt',
|
||||
'detached',
|
||||
'',
|
||||
].join('\n'),
|
||||
gitDirs: {
|
||||
'/project/.worktrees/rebasing-wt': '/project/.worktrees/rebasing-wt/.git',
|
||||
},
|
||||
});
|
||||
disableWorktreesScan();
|
||||
|
||||
// rebase-merge/head-name returns the branch being rebased
|
||||
vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => {
|
||||
const pathStr = String(filePath);
|
||||
if (pathStr.includes('rebase-merge/head-name')) {
|
||||
return 'refs/heads/feature/my-rebasing-branch\n' as any;
|
||||
}
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
|
||||
const handler = createListHandler();
|
||||
await handler(req, res);
|
||||
|
||||
const response = vi.mocked(res.json).mock.calls[0][0] as {
|
||||
worktrees: Array<{ branch: string; path: string; isCurrent: boolean }>;
|
||||
};
|
||||
expect(response.worktrees).toHaveLength(2);
|
||||
expect(response.worktrees[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
path: '/project/.worktrees/rebasing-wt',
|
||||
branch: 'feature/my-rebasing-branch',
|
||||
isMain: false,
|
||||
isCurrent: false,
|
||||
hasWorktree: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should include worktrees with detached HEAD and recover branch from rebase-apply state', async () => {
|
||||
req.body = { projectPath: '/project' };
|
||||
|
||||
setupStandardExec({
|
||||
porcelainOutput: [
|
||||
'worktree /project',
|
||||
'branch refs/heads/main',
|
||||
'',
|
||||
'worktree /project/.worktrees/apply-wt',
|
||||
'detached',
|
||||
'',
|
||||
].join('\n'),
|
||||
gitDirs: {
|
||||
'/project/.worktrees/apply-wt': '/project/.worktrees/apply-wt/.git',
|
||||
},
|
||||
});
|
||||
disableWorktreesScan();
|
||||
|
||||
// rebase-merge doesn't exist, but rebase-apply does
|
||||
vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => {
|
||||
const pathStr = String(filePath);
|
||||
if (pathStr.includes('rebase-apply/head-name')) {
|
||||
return 'refs/heads/feature/apply-branch\n' as any;
|
||||
}
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
|
||||
const handler = createListHandler();
|
||||
await handler(req, res);
|
||||
|
||||
const response = vi.mocked(res.json).mock.calls[0][0] as {
|
||||
worktrees: Array<{ branch: string; path: string }>;
|
||||
};
|
||||
const detachedWt = response.worktrees.find((w) => w.path === '/project/.worktrees/apply-wt');
|
||||
expect(detachedWt).toBeDefined();
|
||||
expect(detachedWt!.branch).toBe('feature/apply-branch');
|
||||
});
|
||||
|
||||
it('should show merge conflict worktrees normally since merge does not detach HEAD', async () => {
|
||||
// During a merge conflict, HEAD stays on the branch, so `git worktree list --porcelain`
|
||||
// still outputs `branch refs/heads/...`. This test verifies merge conflicts don't
|
||||
// trigger the detached HEAD recovery path.
|
||||
req.body = { projectPath: '/project' };
|
||||
|
||||
setupStandardExec({
|
||||
porcelainOutput: [
|
||||
'worktree /project',
|
||||
'branch refs/heads/main',
|
||||
'',
|
||||
'worktree /project/.worktrees/merge-wt',
|
||||
'branch refs/heads/feature/merge-branch',
|
||||
'',
|
||||
].join('\n'),
|
||||
});
|
||||
disableWorktreesScan();
|
||||
|
||||
const handler = createListHandler();
|
||||
await handler(req, res);
|
||||
|
||||
const response = vi.mocked(res.json).mock.calls[0][0] as {
|
||||
worktrees: Array<{ branch: string; path: string }>;
|
||||
};
|
||||
const mergeWt = response.worktrees.find((w) => w.path === '/project/.worktrees/merge-wt');
|
||||
expect(mergeWt).toBeDefined();
|
||||
expect(mergeWt!.branch).toBe('feature/merge-branch');
|
||||
});
|
||||
|
||||
it('should fall back to (detached) when all branch recovery methods fail', async () => {
|
||||
req.body = { projectPath: '/project' };
|
||||
|
||||
setupStandardExec({
|
||||
porcelainOutput: [
|
||||
'worktree /project',
|
||||
'branch refs/heads/main',
|
||||
'',
|
||||
'worktree /project/.worktrees/unknown-wt',
|
||||
'detached',
|
||||
'',
|
||||
].join('\n'),
|
||||
worktreeBranches: {
|
||||
'/project/.worktrees/unknown-wt': '', // empty = no branch
|
||||
},
|
||||
});
|
||||
disableWorktreesScan();
|
||||
|
||||
// All readFile calls fail (no gitDirs so rev-parse --git-dir will throw)
|
||||
vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
const handler = createListHandler();
|
||||
await handler(req, res);
|
||||
|
||||
const response = vi.mocked(res.json).mock.calls[0][0] as {
|
||||
worktrees: Array<{ branch: string; path: string }>;
|
||||
};
|
||||
const detachedWt = response.worktrees.find(
|
||||
(w) => w.path === '/project/.worktrees/unknown-wt'
|
||||
);
|
||||
expect(detachedWt).toBeDefined();
|
||||
expect(detachedWt!.branch).toBe('(detached)');
|
||||
});
|
||||
|
||||
it('should not include detached worktree when directory does not exist on disk', async () => {
|
||||
req.body = { projectPath: '/project' };
|
||||
|
||||
setupStandardExec({
|
||||
porcelainOutput: [
|
||||
'worktree /project',
|
||||
'branch refs/heads/main',
|
||||
'',
|
||||
'worktree /project/.worktrees/deleted-wt',
|
||||
'detached',
|
||||
'',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
// The deleted worktree doesn't exist on disk
|
||||
vi.mocked(secureFs.access).mockImplementation(async (p) => {
|
||||
const pathStr = String(p);
|
||||
if (pathStr.includes('deleted-wt')) {
|
||||
throw new Error('ENOENT');
|
||||
}
|
||||
if (pathStr.endsWith('.worktrees') || pathStr.endsWith('.worktrees/')) {
|
||||
throw new Error('ENOENT');
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const handler = createListHandler();
|
||||
await handler(req, res);
|
||||
|
||||
const response = vi.mocked(res.json).mock.calls[0][0] as {
|
||||
worktrees: Array<{ branch: string; path: string }>;
|
||||
};
|
||||
// Only the main worktree should be present
|
||||
expect(response.worktrees).toHaveLength(1);
|
||||
expect(response.worktrees[0].path).toBe('/project');
|
||||
});
|
||||
|
||||
it('should set isCurrent to false for detached worktrees even if recovered branch matches current branch', async () => {
|
||||
req.body = { projectPath: '/project' };
|
||||
|
||||
setupStandardExec({
|
||||
porcelainOutput: [
|
||||
'worktree /project',
|
||||
'branch refs/heads/main',
|
||||
'',
|
||||
'worktree /project/.worktrees/rebasing-wt',
|
||||
'detached',
|
||||
'',
|
||||
].join('\n'),
|
||||
// currentBranch for project is 'feature/my-branch'
|
||||
projectBranch: 'feature/my-branch',
|
||||
gitDirs: {
|
||||
'/project/.worktrees/rebasing-wt': '/project/.worktrees/rebasing-wt/.git',
|
||||
},
|
||||
});
|
||||
disableWorktreesScan();
|
||||
|
||||
// Recovery returns the same branch as currentBranch
|
||||
vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => {
|
||||
const pathStr = String(filePath);
|
||||
if (pathStr.includes('rebase-merge/head-name')) {
|
||||
return 'refs/heads/feature/my-branch\n' as any;
|
||||
}
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
|
||||
const handler = createListHandler();
|
||||
await handler(req, res);
|
||||
|
||||
const response = vi.mocked(res.json).mock.calls[0][0] as {
|
||||
worktrees: Array<{ branch: string; isCurrent: boolean; path: string }>;
|
||||
};
|
||||
const detachedWt = response.worktrees.find(
|
||||
(w) => w.path === '/project/.worktrees/rebasing-wt'
|
||||
);
|
||||
expect(detachedWt).toBeDefined();
|
||||
// Detached worktrees should always have isCurrent=false
|
||||
expect(detachedWt!.isCurrent).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle mixed normal and detached worktrees', async () => {
|
||||
req.body = { projectPath: '/project' };
|
||||
|
||||
setupStandardExec({
|
||||
porcelainOutput: [
|
||||
'worktree /project',
|
||||
'branch refs/heads/main',
|
||||
'',
|
||||
'worktree /project/.worktrees/normal-wt',
|
||||
'branch refs/heads/feature-normal',
|
||||
'',
|
||||
'worktree /project/.worktrees/rebasing-wt',
|
||||
'detached',
|
||||
'',
|
||||
'worktree /project/.worktrees/another-normal',
|
||||
'branch refs/heads/feature-other',
|
||||
'',
|
||||
].join('\n'),
|
||||
gitDirs: {
|
||||
'/project/.worktrees/rebasing-wt': '/project/.worktrees/rebasing-wt/.git',
|
||||
},
|
||||
});
|
||||
disableWorktreesScan();
|
||||
|
||||
vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => {
|
||||
const pathStr = String(filePath);
|
||||
if (pathStr.includes('rebase-merge/head-name')) {
|
||||
return 'refs/heads/feature/rebasing\n' as any;
|
||||
}
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
|
||||
const handler = createListHandler();
|
||||
await handler(req, res);
|
||||
|
||||
const response = vi.mocked(res.json).mock.calls[0][0] as {
|
||||
worktrees: Array<{ branch: string; path: string; isMain: boolean }>;
|
||||
};
|
||||
expect(response.worktrees).toHaveLength(4);
|
||||
expect(response.worktrees[0]).toEqual(
|
||||
expect.objectContaining({ path: '/project', branch: 'main', isMain: true })
|
||||
);
|
||||
expect(response.worktrees[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
path: '/project/.worktrees/normal-wt',
|
||||
branch: 'feature-normal',
|
||||
isMain: false,
|
||||
})
|
||||
);
|
||||
expect(response.worktrees[2]).toEqual(
|
||||
expect.objectContaining({
|
||||
path: '/project/.worktrees/rebasing-wt',
|
||||
branch: 'feature/rebasing',
|
||||
isMain: false,
|
||||
})
|
||||
);
|
||||
expect(response.worktrees[3]).toEqual(
|
||||
expect.objectContaining({
|
||||
path: '/project/.worktrees/another-normal',
|
||||
branch: 'feature-other',
|
||||
isMain: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should correctly advance isFirst flag past detached worktrees', async () => {
|
||||
req.body = { projectPath: '/project' };
|
||||
|
||||
setupStandardExec({
|
||||
porcelainOutput: [
|
||||
'worktree /project',
|
||||
'branch refs/heads/main',
|
||||
'',
|
||||
'worktree /project/.worktrees/detached-wt',
|
||||
'detached',
|
||||
'',
|
||||
'worktree /project/.worktrees/normal-wt',
|
||||
'branch refs/heads/feature-x',
|
||||
'',
|
||||
].join('\n'),
|
||||
});
|
||||
disableWorktreesScan();
|
||||
vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
const handler = createListHandler();
|
||||
await handler(req, res);
|
||||
|
||||
const response = vi.mocked(res.json).mock.calls[0][0] as {
|
||||
worktrees: Array<{ branch: string; isMain: boolean }>;
|
||||
};
|
||||
expect(response.worktrees).toHaveLength(3);
|
||||
expect(response.worktrees[0].isMain).toBe(true); // main
|
||||
expect(response.worktrees[1].isMain).toBe(false); // detached
|
||||
expect(response.worktrees[2].isMain).toBe(false); // normal
|
||||
});
|
||||
|
||||
it('should not add removed detached worktrees to removedWorktrees list', async () => {
|
||||
req.body = { projectPath: '/project' };
|
||||
|
||||
setupStandardExec({
|
||||
porcelainOutput: [
|
||||
'worktree /project',
|
||||
'branch refs/heads/main',
|
||||
'',
|
||||
'worktree /project/.worktrees/gone-wt',
|
||||
'detached',
|
||||
'',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
// The detached worktree doesn't exist on disk
|
||||
vi.mocked(secureFs.access).mockImplementation(async (p) => {
|
||||
const pathStr = String(p);
|
||||
if (pathStr.includes('gone-wt')) {
|
||||
throw new Error('ENOENT');
|
||||
}
|
||||
if (pathStr.endsWith('.worktrees') || pathStr.endsWith('.worktrees/')) {
|
||||
throw new Error('ENOENT');
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const handler = createListHandler();
|
||||
await handler(req, res);
|
||||
|
||||
const response = vi.mocked(res.json).mock.calls[0][0] as {
|
||||
worktrees: Array<{ branch: string }>;
|
||||
removedWorktrees?: Array<{ path: string; branch: string }>;
|
||||
};
|
||||
// Should not be in removed list since we don't know the branch
|
||||
expect(response.removedWorktrees).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should strip refs/heads/ prefix from recovered branch name', async () => {
|
||||
req.body = { projectPath: '/project' };
|
||||
|
||||
setupStandardExec({
|
||||
porcelainOutput: [
|
||||
'worktree /project',
|
||||
'branch refs/heads/main',
|
||||
'',
|
||||
'worktree /project/.worktrees/wt1',
|
||||
'detached',
|
||||
'',
|
||||
].join('\n'),
|
||||
gitDirs: {
|
||||
'/project/.worktrees/wt1': '/project/.worktrees/wt1/.git',
|
||||
},
|
||||
});
|
||||
disableWorktreesScan();
|
||||
|
||||
vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => {
|
||||
const pathStr = String(filePath);
|
||||
if (pathStr.includes('rebase-merge/head-name')) {
|
||||
return 'refs/heads/my-branch\n' as any;
|
||||
}
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
|
||||
const handler = createListHandler();
|
||||
await handler(req, res);
|
||||
|
||||
const response = vi.mocked(res.json).mock.calls[0][0] as {
|
||||
worktrees: Array<{ branch: string; path: string }>;
|
||||
};
|
||||
const wt = response.worktrees.find((w) => w.path === '/project/.worktrees/wt1');
|
||||
expect(wt).toBeDefined();
|
||||
// Should be 'my-branch', not 'refs/heads/my-branch'
|
||||
expect(wt!.branch).toBe('my-branch');
|
||||
});
|
||||
});
|
||||
|
||||
describe('scanWorktreesDirectory with detached HEAD recovery', () => {
|
||||
it('should recover branch for discovered worktrees with detached HEAD', async () => {
|
||||
req.body = { projectPath: '/project' };
|
||||
|
||||
vi.mocked(execGitCommand).mockImplementation(async (args: string[], cwd: string) => {
|
||||
if (args[0] === 'worktree' && args[1] === 'list') {
|
||||
return 'worktree /project\nbranch refs/heads/main\n\n';
|
||||
}
|
||||
if (args[0] === 'branch' && args[1] === '--show-current') {
|
||||
return cwd === '/project' ? 'main\n' : '\n';
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') {
|
||||
return 'HEAD\n';
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args[1] === '--git-dir') {
|
||||
return '/project/.worktrees/orphan-wt/.git\n';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
// .worktrees directory exists and has an orphan worktree
|
||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.readdir).mockResolvedValue([
|
||||
{ name: 'orphan-wt', isDirectory: () => true, isFile: () => false } as any,
|
||||
]);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
} as any);
|
||||
|
||||
// readFile returns branch from rebase-merge/head-name
|
||||
vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => {
|
||||
const pathStr = String(filePath);
|
||||
if (pathStr.includes('rebase-merge/head-name')) {
|
||||
return 'refs/heads/feature/orphan-branch\n' as any;
|
||||
}
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
|
||||
const handler = createListHandler();
|
||||
await handler(req, res);
|
||||
|
||||
const response = vi.mocked(res.json).mock.calls[0][0] as {
|
||||
worktrees: Array<{ branch: string; path: string }>;
|
||||
};
|
||||
|
||||
const orphanWt = response.worktrees.find((w) => w.path === '/project/.worktrees/orphan-wt');
|
||||
expect(orphanWt).toBeDefined();
|
||||
expect(orphanWt!.branch).toBe('feature/orphan-branch');
|
||||
});
|
||||
|
||||
it('should skip discovered worktrees when all branch detection fails', async () => {
|
||||
req.body = { projectPath: '/project' };
|
||||
|
||||
vi.mocked(execGitCommand).mockImplementation(async (args: string[], cwd: string) => {
|
||||
if (args[0] === 'worktree' && args[1] === 'list') {
|
||||
return 'worktree /project\nbranch refs/heads/main\n\n';
|
||||
}
|
||||
if (args[0] === 'branch' && args[1] === '--show-current') {
|
||||
return cwd === '/project' ? 'main\n' : '\n';
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') {
|
||||
return 'HEAD\n';
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args[1] === '--git-dir') {
|
||||
throw new Error('not a git dir');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.readdir).mockResolvedValue([
|
||||
{ name: 'broken-wt', isDirectory: () => true, isFile: () => false } as any,
|
||||
]);
|
||||
vi.mocked(secureFs.stat).mockResolvedValue({
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
} as any);
|
||||
vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
const handler = createListHandler();
|
||||
await handler(req, res);
|
||||
|
||||
const response = vi.mocked(res.json).mock.calls[0][0] as {
|
||||
worktrees: Array<{ branch: string; path: string }>;
|
||||
};
|
||||
|
||||
// Only main worktree should be present
|
||||
expect(response.worktrees).toHaveLength(1);
|
||||
expect(response.worktrees[0].branch).toBe('main');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PR tracking precedence', () => {
|
||||
it('should keep manually tracked PR from metadata when branch PR differs', async () => {
|
||||
req.body = { projectPath: '/project', includeDetails: true };
|
||||
|
||||
vi.mocked(readAllWorktreeMetadata).mockResolvedValue(
|
||||
new Map([
|
||||
[
|
||||
'feature-a',
|
||||
{
|
||||
branch: 'feature-a',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
pr: {
|
||||
number: 99,
|
||||
url: 'https://github.com/org/repo/pull/99',
|
||||
title: 'Manual override PR',
|
||||
state: 'OPEN',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
vi.mocked(isGhCliAvailable).mockResolvedValue(true);
|
||||
vi.mocked(checkGitHubRemote).mockResolvedValue({
|
||||
hasGitHubRemote: true,
|
||||
owner: 'org',
|
||||
repo: 'repo',
|
||||
});
|
||||
vi.mocked(secureFs.access).mockImplementation(async (p) => {
|
||||
const pathStr = String(p);
|
||||
if (
|
||||
pathStr.includes('MERGE_HEAD') ||
|
||||
pathStr.includes('rebase-merge') ||
|
||||
pathStr.includes('rebase-apply') ||
|
||||
pathStr.includes('CHERRY_PICK_HEAD')
|
||||
) {
|
||||
throw new Error('ENOENT');
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
vi.mocked(execGitCommand).mockImplementation(async (args: string[], cwd: string) => {
|
||||
if (args[0] === 'rev-parse' && args[1] === '--git-dir') {
|
||||
throw new Error('no git dir');
|
||||
}
|
||||
if (args[0] === 'worktree' && args[1] === 'list') {
|
||||
return [
|
||||
'worktree /project',
|
||||
'branch refs/heads/main',
|
||||
'',
|
||||
'worktree /project/.worktrees/feature-a',
|
||||
'branch refs/heads/feature-a',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
if (args[0] === 'branch' && args[1] === '--show-current') {
|
||||
return cwd === '/project' ? 'main\n' : 'feature-a\n';
|
||||
}
|
||||
if (args[0] === 'status' && args[1] === '--porcelain') {
|
||||
return '';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
(exec as unknown as Mock).mockImplementation(
|
||||
(
|
||||
cmd: string,
|
||||
_opts: unknown,
|
||||
callback?: (err: Error | null, out: { stdout: string; stderr: string }) => void
|
||||
) => {
|
||||
const cb = typeof _opts === 'function' ? _opts : callback!;
|
||||
if (cmd.includes('gh pr list')) {
|
||||
cb(null, {
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
number: 42,
|
||||
title: 'Branch PR',
|
||||
url: 'https://github.com/org/repo/pull/42',
|
||||
state: 'OPEN',
|
||||
headRefName: 'feature-a',
|
||||
createdAt: '2026-01-02T00:00:00.000Z',
|
||||
},
|
||||
]),
|
||||
stderr: '',
|
||||
});
|
||||
} else {
|
||||
cb(null, { stdout: '', stderr: '' });
|
||||
}
|
||||
}
|
||||
);
|
||||
disableWorktreesScan();
|
||||
|
||||
const handler = createListHandler();
|
||||
await handler(req, res);
|
||||
|
||||
const response = vi.mocked(res.json).mock.calls[0][0] as {
|
||||
worktrees: Array<{ branch: string; pr?: { number: number; title: string } }>;
|
||||
};
|
||||
const featureWorktree = response.worktrees.find((w) => w.branch === 'feature-a');
|
||||
expect(featureWorktree?.pr?.number).toBe(99);
|
||||
expect(featureWorktree?.pr?.title).toBe('Manual override PR');
|
||||
});
|
||||
|
||||
it('should prefer GitHub PR when it matches metadata number and sync updated fields', async () => {
|
||||
req.body = { projectPath: '/project-2', includeDetails: true };
|
||||
|
||||
vi.mocked(readAllWorktreeMetadata).mockResolvedValue(
|
||||
new Map([
|
||||
[
|
||||
'feature-a',
|
||||
{
|
||||
branch: 'feature-a',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
pr: {
|
||||
number: 42,
|
||||
url: 'https://github.com/org/repo/pull/42',
|
||||
title: 'Old title',
|
||||
state: 'OPEN',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
vi.mocked(isGhCliAvailable).mockResolvedValue(true);
|
||||
vi.mocked(checkGitHubRemote).mockResolvedValue({
|
||||
hasGitHubRemote: true,
|
||||
owner: 'org',
|
||||
repo: 'repo',
|
||||
});
|
||||
vi.mocked(secureFs.access).mockImplementation(async (p) => {
|
||||
const pathStr = String(p);
|
||||
if (
|
||||
pathStr.includes('MERGE_HEAD') ||
|
||||
pathStr.includes('rebase-merge') ||
|
||||
pathStr.includes('rebase-apply') ||
|
||||
pathStr.includes('CHERRY_PICK_HEAD')
|
||||
) {
|
||||
throw new Error('ENOENT');
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
vi.mocked(execGitCommand).mockImplementation(async (args: string[], cwd: string) => {
|
||||
if (args[0] === 'rev-parse' && args[1] === '--git-dir') {
|
||||
throw new Error('no git dir');
|
||||
}
|
||||
if (args[0] === 'worktree' && args[1] === 'list') {
|
||||
return [
|
||||
'worktree /project-2',
|
||||
'branch refs/heads/main',
|
||||
'',
|
||||
'worktree /project-2/.worktrees/feature-a',
|
||||
'branch refs/heads/feature-a',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
if (args[0] === 'branch' && args[1] === '--show-current') {
|
||||
return cwd === '/project-2' ? 'main\n' : 'feature-a\n';
|
||||
}
|
||||
if (args[0] === 'status' && args[1] === '--porcelain') {
|
||||
return '';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
(exec as unknown as Mock).mockImplementation(
|
||||
(
|
||||
cmd: string,
|
||||
_opts: unknown,
|
||||
callback?: (err: Error | null, out: { stdout: string; stderr: string }) => void
|
||||
) => {
|
||||
const cb = typeof _opts === 'function' ? _opts : callback!;
|
||||
if (cmd.includes('gh pr list')) {
|
||||
cb(null, {
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
number: 42,
|
||||
title: 'New title from GitHub',
|
||||
url: 'https://github.com/org/repo/pull/42',
|
||||
state: 'MERGED',
|
||||
headRefName: 'feature-a',
|
||||
createdAt: '2026-01-02T00:00:00.000Z',
|
||||
},
|
||||
]),
|
||||
stderr: '',
|
||||
});
|
||||
} else {
|
||||
cb(null, { stdout: '', stderr: '' });
|
||||
}
|
||||
}
|
||||
);
|
||||
disableWorktreesScan();
|
||||
|
||||
const handler = createListHandler();
|
||||
await handler(req, res);
|
||||
|
||||
const response = vi.mocked(res.json).mock.calls[0][0] as {
|
||||
worktrees: Array<{ branch: string; pr?: { number: number; title: string; state: string } }>;
|
||||
};
|
||||
const featureWorktree = response.worktrees.find((w) => w.branch === 'feature-a');
|
||||
expect(featureWorktree?.pr?.number).toBe(42);
|
||||
expect(featureWorktree?.pr?.title).toBe('New title from GitHub');
|
||||
expect(featureWorktree?.pr?.state).toBe('MERGED');
|
||||
expect(vi.mocked(updateWorktreePRInfo)).toHaveBeenCalledWith(
|
||||
'/project-2',
|
||||
'feature-a',
|
||||
expect.objectContaining({
|
||||
number: 42,
|
||||
title: 'New title from GitHub',
|
||||
state: 'MERGED',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
446
apps/server/tests/unit/services/agent-executor-summary.test.ts
Normal file
446
apps/server/tests/unit/services/agent-executor-summary.test.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { AgentExecutor } from '../../../src/services/agent-executor.js';
|
||||
import type { TypedEventBus } from '../../../src/services/typed-event-bus.js';
|
||||
import type { FeatureStateManager } from '../../../src/services/feature-state-manager.js';
|
||||
import type { PlanApprovalService } from '../../../src/services/plan-approval-service.js';
|
||||
import type { BaseProvider } from '../../../src/providers/base-provider.js';
|
||||
import * as secureFs from '../../../src/lib/secure-fs.js';
|
||||
import { getFeatureDir } from '@automaker/platform';
|
||||
import { buildPromptWithImages } from '@automaker/utils';
|
||||
|
||||
vi.mock('../../../src/lib/secure-fs.js', () => ({
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
writeFile: vi.fn().mockResolvedValue(undefined),
|
||||
appendFile: vi.fn().mockResolvedValue(undefined),
|
||||
readFile: vi.fn().mockResolvedValue(''),
|
||||
}));
|
||||
|
||||
vi.mock('@automaker/platform', () => ({
|
||||
getFeatureDir: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@automaker/utils', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@automaker/utils')>();
|
||||
return {
|
||||
...actual,
|
||||
buildPromptWithImages: vi.fn(),
|
||||
createLogger: vi.fn().mockReturnValue({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('AgentExecutor Summary Extraction', () => {
|
||||
let mockEventBus: TypedEventBus;
|
||||
let mockFeatureStateManager: FeatureStateManager;
|
||||
let mockPlanApprovalService: PlanApprovalService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockEventBus = {
|
||||
emitAutoModeEvent: vi.fn(),
|
||||
} as unknown as TypedEventBus;
|
||||
|
||||
mockFeatureStateManager = {
|
||||
updateTaskStatus: vi.fn().mockResolvedValue(undefined),
|
||||
updateFeaturePlanSpec: vi.fn().mockResolvedValue(undefined),
|
||||
saveFeatureSummary: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as FeatureStateManager;
|
||||
|
||||
mockPlanApprovalService = {
|
||||
waitForApproval: vi.fn(),
|
||||
} as unknown as PlanApprovalService;
|
||||
|
||||
(getFeatureDir as Mock).mockReturnValue('/mock/feature/dir');
|
||||
(buildPromptWithImages as Mock).mockResolvedValue({ content: 'mocked prompt' });
|
||||
});
|
||||
|
||||
it('should extract summary from new session content only', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockPlanApprovalService,
|
||||
null
|
||||
);
|
||||
|
||||
const previousContent = `Some previous work.
|
||||
<summary>Old summary</summary>`;
|
||||
const newWork = `New implementation work.
|
||||
<summary>New summary</summary>`;
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => 'mock',
|
||||
executeQuery: vi.fn().mockImplementation(function* () {
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{ type: 'text', text: newWork }],
|
||||
},
|
||||
};
|
||||
yield { type: 'result', subtype: 'success' };
|
||||
}),
|
||||
} as unknown as BaseProvider;
|
||||
|
||||
const options = {
|
||||
workDir: '/test',
|
||||
featureId: 'test-feature',
|
||||
prompt: 'Test prompt',
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet',
|
||||
planningMode: 'skip' as const,
|
||||
previousContent,
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
waitForApproval: vi.fn(),
|
||||
saveFeatureSummary: vi.fn(),
|
||||
updateFeatureSummary: vi.fn(),
|
||||
buildTaskPrompt: vi.fn(),
|
||||
};
|
||||
|
||||
await executor.execute(options, callbacks);
|
||||
|
||||
// Verify it called saveFeatureSummary with the NEW summary
|
||||
expect(callbacks.saveFeatureSummary).toHaveBeenCalledWith(
|
||||
'/project',
|
||||
'test-feature',
|
||||
'New summary'
|
||||
);
|
||||
|
||||
// Ensure it didn't call it with Old summary
|
||||
expect(callbacks.saveFeatureSummary).not.toHaveBeenCalledWith(
|
||||
'/project',
|
||||
'test-feature',
|
||||
'Old summary'
|
||||
);
|
||||
});
|
||||
|
||||
it('should not save summary if no summary in NEW session content', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockPlanApprovalService,
|
||||
null
|
||||
);
|
||||
|
||||
const previousContent = `Some previous work.
|
||||
<summary>Old summary</summary>`;
|
||||
const newWork = `New implementation work without a summary tag.`;
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => 'mock',
|
||||
executeQuery: vi.fn().mockImplementation(function* () {
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{ type: 'text', text: newWork }],
|
||||
},
|
||||
};
|
||||
yield { type: 'result', subtype: 'success' };
|
||||
}),
|
||||
} as unknown as BaseProvider;
|
||||
|
||||
const options = {
|
||||
workDir: '/test',
|
||||
featureId: 'test-feature',
|
||||
prompt: 'Test prompt',
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet',
|
||||
planningMode: 'skip' as const,
|
||||
previousContent,
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
waitForApproval: vi.fn(),
|
||||
saveFeatureSummary: vi.fn(),
|
||||
updateFeatureSummary: vi.fn(),
|
||||
buildTaskPrompt: vi.fn(),
|
||||
};
|
||||
|
||||
await executor.execute(options, callbacks);
|
||||
|
||||
// Verify it NEVER called saveFeatureSummary because there was no NEW summary
|
||||
expect(callbacks.saveFeatureSummary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should extract task summary and update task status during streaming', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockPlanApprovalService,
|
||||
null
|
||||
);
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => 'mock',
|
||||
executeQuery: vi.fn().mockImplementation(function* () {
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{ type: 'text', text: 'Working... ' }],
|
||||
},
|
||||
};
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{ type: 'text', text: '[TASK_COMPLETE] T001: Task finished successfully' }],
|
||||
},
|
||||
};
|
||||
yield { type: 'result', subtype: 'success' };
|
||||
}),
|
||||
} as unknown as BaseProvider;
|
||||
|
||||
// We trigger executeTasksLoop by providing persistedTasks
|
||||
const options = {
|
||||
workDir: '/test',
|
||||
featureId: 'test-feature',
|
||||
prompt: 'Test prompt',
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet',
|
||||
planningMode: 'skip' as const,
|
||||
existingApprovedPlanContent: 'Some plan',
|
||||
persistedTasks: [{ id: 'T001', description: 'Task 1', status: 'pending' as const }],
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
waitForApproval: vi.fn(),
|
||||
saveFeatureSummary: vi.fn(),
|
||||
updateFeatureSummary: vi.fn(),
|
||||
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
|
||||
};
|
||||
|
||||
await executor.execute(options, callbacks);
|
||||
|
||||
// Verify it updated task status with summary
|
||||
expect(mockFeatureStateManager.updateTaskStatus).toHaveBeenCalledWith(
|
||||
'/project',
|
||||
'test-feature',
|
||||
'T001',
|
||||
'completed',
|
||||
'Task finished successfully'
|
||||
);
|
||||
});
|
||||
|
||||
describe('Pipeline step summary fallback', () => {
|
||||
it('should save fallback summary when extraction fails for pipeline step', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockPlanApprovalService,
|
||||
null
|
||||
);
|
||||
|
||||
// Content without a summary tag (extraction will fail)
|
||||
const newWork = 'Implementation completed without summary tag.';
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => 'mock',
|
||||
executeQuery: vi.fn().mockImplementation(function* () {
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{ type: 'text', text: newWork }],
|
||||
},
|
||||
};
|
||||
yield { type: 'result', subtype: 'success' };
|
||||
}),
|
||||
} as unknown as BaseProvider;
|
||||
|
||||
const options = {
|
||||
workDir: '/test',
|
||||
featureId: 'test-feature',
|
||||
prompt: 'Test prompt',
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet',
|
||||
planningMode: 'skip' as const,
|
||||
status: 'pipeline_step1' as const, // Pipeline status triggers fallback
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
waitForApproval: vi.fn(),
|
||||
saveFeatureSummary: vi.fn(),
|
||||
updateFeatureSummary: vi.fn(),
|
||||
buildTaskPrompt: vi.fn(),
|
||||
};
|
||||
|
||||
await executor.execute(options, callbacks);
|
||||
|
||||
// Verify fallback summary was saved with trimmed content
|
||||
expect(callbacks.saveFeatureSummary).toHaveBeenCalledWith(
|
||||
'/project',
|
||||
'test-feature',
|
||||
'Implementation completed without summary tag.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should not save fallback for non-pipeline status when extraction fails', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockPlanApprovalService,
|
||||
null
|
||||
);
|
||||
|
||||
// Content without a summary tag
|
||||
const newWork = 'Implementation completed without summary tag.';
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => 'mock',
|
||||
executeQuery: vi.fn().mockImplementation(function* () {
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{ type: 'text', text: newWork }],
|
||||
},
|
||||
};
|
||||
yield { type: 'result', subtype: 'success' };
|
||||
}),
|
||||
} as unknown as BaseProvider;
|
||||
|
||||
const options = {
|
||||
workDir: '/test',
|
||||
featureId: 'test-feature',
|
||||
prompt: 'Test prompt',
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet',
|
||||
planningMode: 'skip' as const,
|
||||
status: 'in_progress' as const, // Non-pipeline status
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
waitForApproval: vi.fn(),
|
||||
saveFeatureSummary: vi.fn(),
|
||||
updateFeatureSummary: vi.fn(),
|
||||
buildTaskPrompt: vi.fn(),
|
||||
};
|
||||
|
||||
await executor.execute(options, callbacks);
|
||||
|
||||
// Verify no fallback was saved for non-pipeline status
|
||||
expect(callbacks.saveFeatureSummary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not save empty fallback for pipeline step', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockPlanApprovalService,
|
||||
null
|
||||
);
|
||||
|
||||
// Empty/whitespace-only content
|
||||
const newWork = ' \n\t ';
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => 'mock',
|
||||
executeQuery: vi.fn().mockImplementation(function* () {
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{ type: 'text', text: newWork }],
|
||||
},
|
||||
};
|
||||
yield { type: 'result', subtype: 'success' };
|
||||
}),
|
||||
} as unknown as BaseProvider;
|
||||
|
||||
const options = {
|
||||
workDir: '/test',
|
||||
featureId: 'test-feature',
|
||||
prompt: 'Test prompt',
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet',
|
||||
planningMode: 'skip' as const,
|
||||
status: 'pipeline_step1' as const,
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
waitForApproval: vi.fn(),
|
||||
saveFeatureSummary: vi.fn(),
|
||||
updateFeatureSummary: vi.fn(),
|
||||
buildTaskPrompt: vi.fn(),
|
||||
};
|
||||
|
||||
await executor.execute(options, callbacks);
|
||||
|
||||
// Verify no fallback was saved since content was empty/whitespace
|
||||
expect(callbacks.saveFeatureSummary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should prefer extracted summary over fallback for pipeline step', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockPlanApprovalService,
|
||||
null
|
||||
);
|
||||
|
||||
// Content WITH a summary tag
|
||||
const newWork = `Implementation details here.
|
||||
<summary>Proper summary from extraction</summary>`;
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => 'mock',
|
||||
executeQuery: vi.fn().mockImplementation(function* () {
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{ type: 'text', text: newWork }],
|
||||
},
|
||||
};
|
||||
yield { type: 'result', subtype: 'success' };
|
||||
}),
|
||||
} as unknown as BaseProvider;
|
||||
|
||||
const options = {
|
||||
workDir: '/test',
|
||||
featureId: 'test-feature',
|
||||
prompt: 'Test prompt',
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet',
|
||||
planningMode: 'skip' as const,
|
||||
status: 'pipeline_step1' as const,
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
waitForApproval: vi.fn(),
|
||||
saveFeatureSummary: vi.fn(),
|
||||
updateFeatureSummary: vi.fn(),
|
||||
buildTaskPrompt: vi.fn(),
|
||||
};
|
||||
|
||||
await executor.execute(options, callbacks);
|
||||
|
||||
// Verify extracted summary was saved, not the full content
|
||||
expect(callbacks.saveFeatureSummary).toHaveBeenCalledWith(
|
||||
'/project',
|
||||
'test-feature',
|
||||
'Proper summary from extraction'
|
||||
);
|
||||
// Ensure it didn't save the full content as fallback
|
||||
expect(callbacks.saveFeatureSummary).not.toHaveBeenCalledWith(
|
||||
'/project',
|
||||
'test-feature',
|
||||
expect.stringContaining('Implementation details here')
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -685,6 +685,309 @@ describe('AgentExecutor', () => {
|
||||
await expect(executor.execute(options, callbacks)).rejects.toThrow('API rate limit exceeded');
|
||||
});
|
||||
|
||||
it('should throw "Unknown error" when provider stream yields error with empty message', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockPlanApprovalService,
|
||||
mockSettingsService
|
||||
);
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => 'mock',
|
||||
executeQuery: vi.fn().mockImplementation(function* () {
|
||||
yield {
|
||||
type: 'error',
|
||||
error: '',
|
||||
session_id: 'sess-123',
|
||||
};
|
||||
}),
|
||||
} as unknown as BaseProvider;
|
||||
|
||||
const options: AgentExecutionOptions = {
|
||||
workDir: '/test',
|
||||
featureId: 'test-feature',
|
||||
prompt: 'Test prompt',
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet-4-6',
|
||||
planningMode: 'skip',
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
|
||||
saveFeatureSummary: vi.fn(),
|
||||
updateFeatureSummary: vi.fn(),
|
||||
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
|
||||
};
|
||||
|
||||
await expect(executor.execute(options, callbacks)).rejects.toThrow('Unknown error');
|
||||
});
|
||||
|
||||
it('should throw with sanitized error when provider yields ANSI-decorated error', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockPlanApprovalService,
|
||||
mockSettingsService
|
||||
);
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => 'mock',
|
||||
executeQuery: vi.fn().mockImplementation(function* () {
|
||||
yield {
|
||||
type: 'error',
|
||||
// ANSI color codes + "Error: " prefix that should be stripped
|
||||
error: '\x1b[31mError: Connection refused\x1b[0m',
|
||||
};
|
||||
}),
|
||||
} as unknown as BaseProvider;
|
||||
|
||||
const options: AgentExecutionOptions = {
|
||||
workDir: '/test',
|
||||
featureId: 'test-feature',
|
||||
prompt: 'Test prompt',
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet-4-6',
|
||||
planningMode: 'skip',
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
|
||||
saveFeatureSummary: vi.fn(),
|
||||
updateFeatureSummary: vi.fn(),
|
||||
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
|
||||
};
|
||||
|
||||
// Should strip ANSI codes and "Error: " prefix
|
||||
await expect(executor.execute(options, callbacks)).rejects.toThrow('Connection refused');
|
||||
});
|
||||
|
||||
it('should throw when result subtype is error_max_turns', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockPlanApprovalService,
|
||||
mockSettingsService
|
||||
);
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => 'mock',
|
||||
executeQuery: vi.fn().mockImplementation(function* () {
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{ type: 'text', text: 'Working on it...' }],
|
||||
},
|
||||
};
|
||||
yield {
|
||||
type: 'result',
|
||||
subtype: 'error_max_turns',
|
||||
session_id: 'sess-456',
|
||||
};
|
||||
}),
|
||||
} as unknown as BaseProvider;
|
||||
|
||||
const options: AgentExecutionOptions = {
|
||||
workDir: '/test',
|
||||
featureId: 'test-feature',
|
||||
prompt: 'Test prompt',
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet-4-6',
|
||||
planningMode: 'skip',
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
|
||||
saveFeatureSummary: vi.fn(),
|
||||
updateFeatureSummary: vi.fn(),
|
||||
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
|
||||
};
|
||||
|
||||
await expect(executor.execute(options, callbacks)).rejects.toThrow(
|
||||
'Agent execution ended with: error_max_turns'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when result subtype is error_during_execution', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockPlanApprovalService,
|
||||
mockSettingsService
|
||||
);
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => 'mock',
|
||||
executeQuery: vi.fn().mockImplementation(function* () {
|
||||
yield {
|
||||
type: 'result',
|
||||
subtype: 'error_during_execution',
|
||||
session_id: 'sess-789',
|
||||
};
|
||||
}),
|
||||
} as unknown as BaseProvider;
|
||||
|
||||
const options: AgentExecutionOptions = {
|
||||
workDir: '/test',
|
||||
featureId: 'test-feature',
|
||||
prompt: 'Test prompt',
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet-4-6',
|
||||
planningMode: 'skip',
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
|
||||
saveFeatureSummary: vi.fn(),
|
||||
updateFeatureSummary: vi.fn(),
|
||||
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
|
||||
};
|
||||
|
||||
await expect(executor.execute(options, callbacks)).rejects.toThrow(
|
||||
'Agent execution ended with: error_during_execution'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when result subtype is error_max_structured_output_retries', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockPlanApprovalService,
|
||||
mockSettingsService
|
||||
);
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => 'mock',
|
||||
executeQuery: vi.fn().mockImplementation(function* () {
|
||||
yield {
|
||||
type: 'result',
|
||||
subtype: 'error_max_structured_output_retries',
|
||||
};
|
||||
}),
|
||||
} as unknown as BaseProvider;
|
||||
|
||||
const options: AgentExecutionOptions = {
|
||||
workDir: '/test',
|
||||
featureId: 'test-feature',
|
||||
prompt: 'Test prompt',
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet-4-6',
|
||||
planningMode: 'skip',
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
|
||||
saveFeatureSummary: vi.fn(),
|
||||
updateFeatureSummary: vi.fn(),
|
||||
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
|
||||
};
|
||||
|
||||
await expect(executor.execute(options, callbacks)).rejects.toThrow(
|
||||
'Agent execution ended with: error_max_structured_output_retries'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when result subtype is error_max_budget_usd', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockPlanApprovalService,
|
||||
mockSettingsService
|
||||
);
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => 'mock',
|
||||
executeQuery: vi.fn().mockImplementation(function* () {
|
||||
yield {
|
||||
type: 'result',
|
||||
subtype: 'error_max_budget_usd',
|
||||
session_id: 'sess-budget',
|
||||
};
|
||||
}),
|
||||
} as unknown as BaseProvider;
|
||||
|
||||
const options: AgentExecutionOptions = {
|
||||
workDir: '/test',
|
||||
featureId: 'test-feature',
|
||||
prompt: 'Test prompt',
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet-4-6',
|
||||
planningMode: 'skip',
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
|
||||
saveFeatureSummary: vi.fn(),
|
||||
updateFeatureSummary: vi.fn(),
|
||||
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
|
||||
};
|
||||
|
||||
await expect(executor.execute(options, callbacks)).rejects.toThrow(
|
||||
'Agent execution ended with: error_max_budget_usd'
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT throw when result subtype is success', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockPlanApprovalService,
|
||||
mockSettingsService
|
||||
);
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => 'mock',
|
||||
executeQuery: vi.fn().mockImplementation(function* () {
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{ type: 'text', text: 'Done!' }],
|
||||
},
|
||||
};
|
||||
yield {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
session_id: 'sess-ok',
|
||||
};
|
||||
}),
|
||||
} as unknown as BaseProvider;
|
||||
|
||||
const options: AgentExecutionOptions = {
|
||||
workDir: '/test',
|
||||
featureId: 'test-feature',
|
||||
prompt: 'Test prompt',
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet-4-6',
|
||||
planningMode: 'skip',
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
|
||||
saveFeatureSummary: vi.fn(),
|
||||
updateFeatureSummary: vi.fn(),
|
||||
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
|
||||
};
|
||||
|
||||
// Should resolve without throwing
|
||||
const result = await executor.execute(options, callbacks);
|
||||
expect(result.aborted).toBe(false);
|
||||
expect(result.responseText).toContain('Done!');
|
||||
});
|
||||
|
||||
it('should throw error when authentication fails in response', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
@@ -878,6 +1181,50 @@ describe('AgentExecutor', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass claudeCompatibleProvider to executeQuery options', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockPlanApprovalService,
|
||||
mockSettingsService
|
||||
);
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => 'mock',
|
||||
executeQuery: vi.fn().mockImplementation(function* () {
|
||||
yield { type: 'result', subtype: 'success' };
|
||||
}),
|
||||
} as unknown as BaseProvider;
|
||||
|
||||
const mockClaudeProvider = { id: 'zai-1', name: 'Zai' } as any;
|
||||
|
||||
const options: AgentExecutionOptions = {
|
||||
workDir: '/test',
|
||||
featureId: 'test-feature',
|
||||
prompt: 'Test prompt',
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet-4-6',
|
||||
claudeCompatibleProvider: mockClaudeProvider,
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
|
||||
saveFeatureSummary: vi.fn(),
|
||||
updateFeatureSummary: vi.fn(),
|
||||
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
|
||||
};
|
||||
|
||||
await executor.execute(options, callbacks);
|
||||
|
||||
expect(mockProvider.executeQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
claudeCompatibleProvider: mockClaudeProvider,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should return correct result structure', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
@@ -932,4 +1279,471 @@ describe('AgentExecutor', () => {
|
||||
expect(typeof result.aborted).toBe('boolean');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pipeline summary fallback with scaffold stripping', () => {
|
||||
it('should strip follow-up scaffold from fallback summary when extraction fails', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockPlanApprovalService,
|
||||
mockSettingsService
|
||||
);
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => 'mock',
|
||||
executeQuery: vi.fn().mockImplementation(function* () {
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{ type: 'text', text: 'Some agent output without summary markers' }],
|
||||
},
|
||||
};
|
||||
yield { type: 'result', subtype: 'success' };
|
||||
}),
|
||||
} as unknown as BaseProvider;
|
||||
|
||||
const saveFeatureSummary = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const options: AgentExecutionOptions = {
|
||||
workDir: '/test',
|
||||
featureId: 'test-feature',
|
||||
prompt: 'Test prompt',
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet-4-6',
|
||||
planningMode: 'skip',
|
||||
previousContent: 'Previous session content',
|
||||
status: 'pipeline_step1', // Pipeline status to trigger fallback
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
|
||||
saveFeatureSummary,
|
||||
updateFeatureSummary: vi.fn(),
|
||||
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
|
||||
};
|
||||
|
||||
await executor.execute(options, callbacks);
|
||||
|
||||
// The fallback summary should be called without the scaffold header
|
||||
expect(saveFeatureSummary).toHaveBeenCalled();
|
||||
const savedSummary = saveFeatureSummary.mock.calls[0][2];
|
||||
// Should not contain the scaffold header
|
||||
expect(savedSummary).not.toContain('---');
|
||||
expect(savedSummary).not.toContain('Follow-up Session');
|
||||
// Should contain the actual content
|
||||
expect(savedSummary).toContain('Some agent output without summary markers');
|
||||
});
|
||||
|
||||
it('should not save fallback when scaffold is the only content after stripping', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockPlanApprovalService,
|
||||
mockSettingsService
|
||||
);
|
||||
|
||||
// Provider yields no content - only scaffold will be present
|
||||
const mockProvider = {
|
||||
getName: () => 'mock',
|
||||
executeQuery: vi.fn().mockImplementation(function* () {
|
||||
// Empty stream - no actual content
|
||||
}),
|
||||
} as unknown as BaseProvider;
|
||||
|
||||
const saveFeatureSummary = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const options: AgentExecutionOptions = {
|
||||
workDir: '/test',
|
||||
featureId: 'test-feature',
|
||||
prompt: 'Test prompt',
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet-4-6',
|
||||
planningMode: 'skip',
|
||||
previousContent: 'Previous session content',
|
||||
status: 'pipeline_step1', // Pipeline status
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
|
||||
saveFeatureSummary,
|
||||
updateFeatureSummary: vi.fn(),
|
||||
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
|
||||
};
|
||||
|
||||
await executor.execute(options, callbacks);
|
||||
|
||||
// Should not save an empty fallback (after scaffold is stripped)
|
||||
expect(saveFeatureSummary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should save extracted summary when available, not fallback', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockPlanApprovalService,
|
||||
mockSettingsService
|
||||
);
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => 'mock',
|
||||
executeQuery: vi.fn().mockImplementation(function* () {
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Some content\n\n<summary>Extracted summary here</summary>\n\nMore content',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
yield { type: 'result', subtype: 'success' };
|
||||
}),
|
||||
} as unknown as BaseProvider;
|
||||
|
||||
const saveFeatureSummary = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const options: AgentExecutionOptions = {
|
||||
workDir: '/test',
|
||||
featureId: 'test-feature',
|
||||
prompt: 'Test prompt',
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet-4-6',
|
||||
planningMode: 'skip',
|
||||
previousContent: 'Previous session content',
|
||||
status: 'pipeline_step1', // Pipeline status
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
|
||||
saveFeatureSummary,
|
||||
updateFeatureSummary: vi.fn(),
|
||||
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
|
||||
};
|
||||
|
||||
await executor.execute(options, callbacks);
|
||||
|
||||
// Should save the extracted summary, not the full content
|
||||
expect(saveFeatureSummary).toHaveBeenCalledTimes(1);
|
||||
expect(saveFeatureSummary).toHaveBeenCalledWith(
|
||||
'/project',
|
||||
'test-feature',
|
||||
'Extracted summary here'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle scaffold with various whitespace patterns', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockPlanApprovalService,
|
||||
mockSettingsService
|
||||
);
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => 'mock',
|
||||
executeQuery: vi.fn().mockImplementation(function* () {
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{ type: 'text', text: 'Agent response here' }],
|
||||
},
|
||||
};
|
||||
yield { type: 'result', subtype: 'success' };
|
||||
}),
|
||||
} as unknown as BaseProvider;
|
||||
|
||||
const saveFeatureSummary = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const options: AgentExecutionOptions = {
|
||||
workDir: '/test',
|
||||
featureId: 'test-feature',
|
||||
prompt: 'Test prompt',
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet-4-6',
|
||||
planningMode: 'skip',
|
||||
previousContent: 'Previous session content',
|
||||
status: 'pipeline_step1',
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
|
||||
saveFeatureSummary,
|
||||
updateFeatureSummary: vi.fn(),
|
||||
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
|
||||
};
|
||||
|
||||
await executor.execute(options, callbacks);
|
||||
|
||||
// Should strip scaffold and save actual content
|
||||
expect(saveFeatureSummary).toHaveBeenCalled();
|
||||
const savedSummary = saveFeatureSummary.mock.calls[0][2];
|
||||
expect(savedSummary.trim()).toBe('Agent response here');
|
||||
});
|
||||
|
||||
it('should handle scaffold with extra newlines between markers', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockPlanApprovalService,
|
||||
mockSettingsService
|
||||
);
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => 'mock',
|
||||
executeQuery: vi.fn().mockImplementation(function* () {
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{ type: 'text', text: 'Actual content after scaffold' }],
|
||||
},
|
||||
};
|
||||
yield { type: 'result', subtype: 'success' };
|
||||
}),
|
||||
} as unknown as BaseProvider;
|
||||
|
||||
const saveFeatureSummary = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
// Set up with previous content to trigger scaffold insertion
|
||||
const options: AgentExecutionOptions = {
|
||||
workDir: '/test',
|
||||
featureId: 'test-feature',
|
||||
prompt: 'Test prompt',
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet-4-6',
|
||||
planningMode: 'skip',
|
||||
previousContent: 'Previous session content',
|
||||
status: 'pipeline_step1',
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
|
||||
saveFeatureSummary,
|
||||
updateFeatureSummary: vi.fn(),
|
||||
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
|
||||
};
|
||||
|
||||
await executor.execute(options, callbacks);
|
||||
|
||||
expect(saveFeatureSummary).toHaveBeenCalled();
|
||||
const savedSummary = saveFeatureSummary.mock.calls[0][2];
|
||||
// Verify the scaffold is stripped
|
||||
expect(savedSummary).not.toMatch(/---\s*##\s*Follow-up Session/);
|
||||
});
|
||||
|
||||
it('should handle content without any scaffold (first session)', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockPlanApprovalService,
|
||||
mockSettingsService
|
||||
);
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => 'mock',
|
||||
executeQuery: vi.fn().mockImplementation(function* () {
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{ type: 'text', text: 'First session output without summary' }],
|
||||
},
|
||||
};
|
||||
yield { type: 'result', subtype: 'success' };
|
||||
}),
|
||||
} as unknown as BaseProvider;
|
||||
|
||||
const saveFeatureSummary = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
// No previousContent means no scaffold
|
||||
const options: AgentExecutionOptions = {
|
||||
workDir: '/test',
|
||||
featureId: 'test-feature',
|
||||
prompt: 'Test prompt',
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet-4-6',
|
||||
planningMode: 'skip',
|
||||
previousContent: undefined, // No previous content
|
||||
status: 'pipeline_step1',
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
|
||||
saveFeatureSummary,
|
||||
updateFeatureSummary: vi.fn(),
|
||||
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
|
||||
};
|
||||
|
||||
await executor.execute(options, callbacks);
|
||||
|
||||
expect(saveFeatureSummary).toHaveBeenCalled();
|
||||
const savedSummary = saveFeatureSummary.mock.calls[0][2];
|
||||
expect(savedSummary).toBe('First session output without summary');
|
||||
});
|
||||
|
||||
it('should handle non-pipeline status without saving fallback', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockPlanApprovalService,
|
||||
mockSettingsService
|
||||
);
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => 'mock',
|
||||
executeQuery: vi.fn().mockImplementation(function* () {
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{ type: 'text', text: 'Output without summary' }],
|
||||
},
|
||||
};
|
||||
yield { type: 'result', subtype: 'success' };
|
||||
}),
|
||||
} as unknown as BaseProvider;
|
||||
|
||||
const saveFeatureSummary = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const options: AgentExecutionOptions = {
|
||||
workDir: '/test',
|
||||
featureId: 'test-feature',
|
||||
prompt: 'Test prompt',
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet-4-6',
|
||||
planningMode: 'skip',
|
||||
previousContent: 'Previous content',
|
||||
status: 'implementing', // Non-pipeline status
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
|
||||
saveFeatureSummary,
|
||||
updateFeatureSummary: vi.fn(),
|
||||
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
|
||||
};
|
||||
|
||||
await executor.execute(options, callbacks);
|
||||
|
||||
// Should NOT save fallback for non-pipeline status
|
||||
expect(saveFeatureSummary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should correctly handle content that starts with dashes but is not scaffold', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockPlanApprovalService,
|
||||
mockSettingsService
|
||||
);
|
||||
|
||||
// Content that looks like it might have dashes but is actual content
|
||||
const mockProvider = {
|
||||
getName: () => 'mock',
|
||||
executeQuery: vi.fn().mockImplementation(function* () {
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{ type: 'text', text: '---This is a code comment or separator---' }],
|
||||
},
|
||||
};
|
||||
yield { type: 'result', subtype: 'success' };
|
||||
}),
|
||||
} as unknown as BaseProvider;
|
||||
|
||||
const saveFeatureSummary = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const options: AgentExecutionOptions = {
|
||||
workDir: '/test',
|
||||
featureId: 'test-feature',
|
||||
prompt: 'Test prompt',
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet-4-6',
|
||||
planningMode: 'skip',
|
||||
previousContent: undefined,
|
||||
status: 'pipeline_step1',
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
|
||||
saveFeatureSummary,
|
||||
updateFeatureSummary: vi.fn(),
|
||||
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
|
||||
};
|
||||
|
||||
await executor.execute(options, callbacks);
|
||||
|
||||
expect(saveFeatureSummary).toHaveBeenCalled();
|
||||
const savedSummary = saveFeatureSummary.mock.calls[0][2];
|
||||
// Content should be preserved since it's not the scaffold pattern
|
||||
expect(savedSummary).toContain('---This is a code comment or separator---');
|
||||
});
|
||||
|
||||
it('should handle scaffold at different positions in content', async () => {
|
||||
const executor = new AgentExecutor(
|
||||
mockEventBus,
|
||||
mockFeatureStateManager,
|
||||
mockPlanApprovalService,
|
||||
mockSettingsService
|
||||
);
|
||||
|
||||
const mockProvider = {
|
||||
getName: () => 'mock',
|
||||
executeQuery: vi.fn().mockImplementation(function* () {
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{ type: 'text', text: 'Content after scaffold marker' }],
|
||||
},
|
||||
};
|
||||
yield { type: 'result', subtype: 'success' };
|
||||
}),
|
||||
} as unknown as BaseProvider;
|
||||
|
||||
const saveFeatureSummary = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
// With previousContent, scaffold will be at the start of sessionContent
|
||||
const options: AgentExecutionOptions = {
|
||||
workDir: '/test',
|
||||
featureId: 'test-feature',
|
||||
prompt: 'Test prompt',
|
||||
projectPath: '/project',
|
||||
abortController: new AbortController(),
|
||||
provider: mockProvider,
|
||||
effectiveBareModel: 'claude-sonnet-4-6',
|
||||
planningMode: 'skip',
|
||||
previousContent: 'Previous content',
|
||||
status: 'pipeline_step1',
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
waitForApproval: vi.fn().mockResolvedValue({ approved: true }),
|
||||
saveFeatureSummary,
|
||||
updateFeatureSummary: vi.fn(),
|
||||
buildTaskPrompt: vi.fn().mockReturnValue('task prompt'),
|
||||
};
|
||||
|
||||
await executor.execute(options, callbacks);
|
||||
|
||||
expect(saveFeatureSummary).toHaveBeenCalled();
|
||||
const savedSummary = saveFeatureSummary.mock.calls[0][2];
|
||||
// Scaffold should be stripped, only actual content remains
|
||||
expect(savedSummary).toBe('Content after scaffold marker');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
192
apps/server/tests/unit/services/agent-output-validation.test.ts
Normal file
192
apps/server/tests/unit/services/agent-output-validation.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
/**
|
||||
* Contract tests verifying the tool marker format used by agent-executor
|
||||
* (which writes agent output) and execution-service (which reads it to
|
||||
* determine if the agent did meaningful work).
|
||||
*
|
||||
* The agent-executor writes: `\n🔧 Tool: ${block.name}\n`
|
||||
* The execution-service checks: `agentOutput.includes('🔧 Tool:')`
|
||||
*
|
||||
* These tests ensure the marker format contract stays consistent and
|
||||
* document the exact detection logic used for status determination.
|
||||
*/
|
||||
|
||||
// The exact marker prefix that execution-service searches for
|
||||
const TOOL_MARKER = '🔧 Tool:';
|
||||
|
||||
// Minimum output length threshold for "meaningful work"
|
||||
const MIN_OUTPUT_LENGTH = 200;
|
||||
|
||||
/**
|
||||
* Simulates the agent-executor's tool_use output format.
|
||||
* See: agent-executor.ts line ~293
|
||||
*/
|
||||
function formatToolUseBlock(toolName: string, input?: Record<string, unknown>): string {
|
||||
let output = `\n${TOOL_MARKER} ${toolName}\n`;
|
||||
if (input) output += `Input: ${JSON.stringify(input, null, 2)}\n`;
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates the execution-service's output validation logic.
|
||||
* See: execution-service.ts lines ~427-429
|
||||
*/
|
||||
function validateAgentOutput(
|
||||
agentOutput: string,
|
||||
skipTests: boolean
|
||||
): 'verified' | 'waiting_approval' {
|
||||
const hasToolUsage = agentOutput.includes(TOOL_MARKER);
|
||||
const hasMinimalOutput = agentOutput.trim().length < MIN_OUTPUT_LENGTH;
|
||||
const agentDidWork = hasToolUsage && !hasMinimalOutput;
|
||||
|
||||
if (skipTests) return 'waiting_approval';
|
||||
if (!agentDidWork) return 'waiting_approval';
|
||||
return 'verified';
|
||||
}
|
||||
|
||||
describe('Agent Output Validation - Contract Tests', () => {
|
||||
describe('tool marker format contract', () => {
|
||||
it('agent-executor tool format contains the expected marker', () => {
|
||||
const toolOutput = formatToolUseBlock('Read', { file_path: '/src/index.ts' });
|
||||
expect(toolOutput).toContain(TOOL_MARKER);
|
||||
});
|
||||
|
||||
it('agent-executor tool format includes tool name after marker', () => {
|
||||
const toolOutput = formatToolUseBlock('Edit', {
|
||||
file_path: '/src/app.ts',
|
||||
old_string: 'foo',
|
||||
new_string: 'bar',
|
||||
});
|
||||
expect(toolOutput).toContain('🔧 Tool: Edit');
|
||||
});
|
||||
|
||||
it('agent-executor tool format includes JSON input', () => {
|
||||
const input = { file_path: '/src/index.ts' };
|
||||
const toolOutput = formatToolUseBlock('Read', input);
|
||||
expect(toolOutput).toContain('Input: ');
|
||||
expect(toolOutput).toContain('"file_path": "/src/index.ts"');
|
||||
});
|
||||
|
||||
it('agent-executor tool format works without input', () => {
|
||||
const toolOutput = formatToolUseBlock('Bash');
|
||||
expect(toolOutput).toContain('🔧 Tool: Bash');
|
||||
expect(toolOutput).not.toContain('Input:');
|
||||
});
|
||||
|
||||
it('marker includes colon and space to avoid false positives', () => {
|
||||
// Ensure the marker is specific enough to avoid matching other emoji patterns
|
||||
expect(TOOL_MARKER).toBe('🔧 Tool:');
|
||||
expect(TOOL_MARKER).toContain(':');
|
||||
});
|
||||
});
|
||||
|
||||
describe('output validation logic', () => {
|
||||
it('verified: tool usage + sufficient output', () => {
|
||||
const output =
|
||||
'Starting implementation of the new feature...\n' +
|
||||
formatToolUseBlock('Read', { file_path: '/src/index.ts' }) +
|
||||
'I can see the existing code. Let me make the needed changes.\n' +
|
||||
formatToolUseBlock('Edit', { file_path: '/src/index.ts' }) +
|
||||
'Changes complete. The implementation adds new validation logic and tests.';
|
||||
expect(output.trim().length).toBeGreaterThanOrEqual(MIN_OUTPUT_LENGTH);
|
||||
|
||||
expect(validateAgentOutput(output, false)).toBe('verified');
|
||||
});
|
||||
|
||||
it('waiting_approval: no tool markers regardless of length', () => {
|
||||
const longOutput = 'I analyzed the codebase. '.repeat(50);
|
||||
expect(longOutput.trim().length).toBeGreaterThan(MIN_OUTPUT_LENGTH);
|
||||
|
||||
expect(validateAgentOutput(longOutput, false)).toBe('waiting_approval');
|
||||
});
|
||||
|
||||
it('waiting_approval: tool markers but insufficient length', () => {
|
||||
const shortOutput = formatToolUseBlock('Read', { file_path: '/src/a.ts' });
|
||||
expect(shortOutput.trim().length).toBeLessThan(MIN_OUTPUT_LENGTH);
|
||||
|
||||
expect(validateAgentOutput(shortOutput, false)).toBe('waiting_approval');
|
||||
});
|
||||
|
||||
it('waiting_approval: empty output', () => {
|
||||
expect(validateAgentOutput('', false)).toBe('waiting_approval');
|
||||
});
|
||||
|
||||
it('waiting_approval: skipTests always overrides', () => {
|
||||
const goodOutput =
|
||||
'Starting...\n' +
|
||||
formatToolUseBlock('Read', { file_path: '/src/index.ts' }) +
|
||||
formatToolUseBlock('Edit', { file_path: '/src/index.ts' }) +
|
||||
'Done implementing. '.repeat(15);
|
||||
expect(goodOutput.trim().length).toBeGreaterThanOrEqual(MIN_OUTPUT_LENGTH);
|
||||
|
||||
expect(validateAgentOutput(goodOutput, true)).toBe('waiting_approval');
|
||||
});
|
||||
|
||||
it('boundary: exactly MIN_OUTPUT_LENGTH chars with tool is verified', () => {
|
||||
const tool = formatToolUseBlock('Read');
|
||||
const padding = 'x'.repeat(MIN_OUTPUT_LENGTH - tool.trim().length);
|
||||
const output = tool + padding;
|
||||
expect(output.trim().length).toBeGreaterThanOrEqual(MIN_OUTPUT_LENGTH);
|
||||
|
||||
expect(validateAgentOutput(output, false)).toBe('verified');
|
||||
});
|
||||
|
||||
it('boundary: MIN_OUTPUT_LENGTH - 1 chars with tool is waiting_approval', () => {
|
||||
const marker = `${TOOL_MARKER} Read\n`;
|
||||
const padding = 'x'.repeat(MIN_OUTPUT_LENGTH - 1 - marker.length);
|
||||
const output = marker + padding;
|
||||
expect(output.trim().length).toBe(MIN_OUTPUT_LENGTH - 1);
|
||||
|
||||
expect(validateAgentOutput(output, false)).toBe('waiting_approval');
|
||||
});
|
||||
});
|
||||
|
||||
describe('realistic provider scenarios', () => {
|
||||
it('Claude SDK agent with multiple tools → verified', () => {
|
||||
let output = "I'll implement the feature.\n\n";
|
||||
output += formatToolUseBlock('Read', { file_path: '/src/components/App.tsx' });
|
||||
output += 'I see the component. Let me update it.\n\n';
|
||||
output += formatToolUseBlock('Edit', {
|
||||
file_path: '/src/components/App.tsx',
|
||||
old_string: 'const App = () => {',
|
||||
new_string: 'const App: React.FC = () => {',
|
||||
});
|
||||
output += 'Done. The component is now typed correctly.\n';
|
||||
|
||||
expect(validateAgentOutput(output, false)).toBe('verified');
|
||||
});
|
||||
|
||||
it('Cursor CLI quick exit (no tools) → waiting_approval', () => {
|
||||
const output = 'Task received. Processing...\nResult: completed successfully.';
|
||||
expect(validateAgentOutput(output, false)).toBe('waiting_approval');
|
||||
});
|
||||
|
||||
it('Codex CLI with brief acknowledgment → waiting_approval', () => {
|
||||
const output = 'Understood the task. Starting implementation.\nDone.';
|
||||
expect(validateAgentOutput(output, false)).toBe('waiting_approval');
|
||||
});
|
||||
|
||||
it('Agent that only reads but makes no edits (single Read tool, short output) → waiting_approval', () => {
|
||||
const output = formatToolUseBlock('Read', { file_path: '/src/index.ts' }) + 'File read.';
|
||||
expect(output.trim().length).toBeLessThan(MIN_OUTPUT_LENGTH);
|
||||
expect(validateAgentOutput(output, false)).toBe('waiting_approval');
|
||||
});
|
||||
|
||||
it('Agent with extensive tool usage and explanation → verified', () => {
|
||||
let output = 'Analyzing the codebase for the authentication feature.\n\n';
|
||||
for (let i = 0; i < 5; i++) {
|
||||
output += formatToolUseBlock('Read', { file_path: `/src/auth/handler${i}.ts` });
|
||||
output += `Found handler ${i}. `;
|
||||
}
|
||||
output += formatToolUseBlock('Edit', {
|
||||
file_path: '/src/auth/handler0.ts',
|
||||
old_string: 'function login() {}',
|
||||
new_string: 'async function login(creds: Credentials) { ... }',
|
||||
});
|
||||
output += 'Implementation complete with all authentication changes applied.\n';
|
||||
|
||||
expect(validateAgentOutput(output, false)).toBe('verified');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -188,6 +188,125 @@ describe('agent-service.ts', () => {
|
||||
expect(mockEvents.emit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should emit tool_result events from provider stream', async () => {
|
||||
const mockProvider = {
|
||||
getName: () => 'gemini',
|
||||
executeQuery: async function* () {
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
name: 'Read',
|
||||
tool_use_id: 'tool-1',
|
||||
input: { file_path: 'README.md' },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tool-1',
|
||||
content: 'File contents here',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
yield {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
|
||||
|
||||
vi.mocked(promptBuilder.buildPromptWithImages).mockResolvedValue({
|
||||
content: 'Hello',
|
||||
hasImages: false,
|
||||
});
|
||||
|
||||
await service.sendMessage({
|
||||
sessionId: 'session-1',
|
||||
message: 'Hello',
|
||||
});
|
||||
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith(
|
||||
'agent:stream',
|
||||
expect.objectContaining({
|
||||
sessionId: 'session-1',
|
||||
type: 'tool_result',
|
||||
tool: {
|
||||
name: 'Read',
|
||||
input: {
|
||||
toolUseId: 'tool-1',
|
||||
content: 'File contents here',
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should emit tool_result with unknown tool name for unregistered tool_use_id', async () => {
|
||||
const mockProvider = {
|
||||
getName: () => 'gemini',
|
||||
executeQuery: async function* () {
|
||||
// Yield tool_result WITHOUT a preceding tool_use (unregistered tool_use_id)
|
||||
yield {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'unregistered-id',
|
||||
content: 'Some result content',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
yield {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
|
||||
|
||||
vi.mocked(promptBuilder.buildPromptWithImages).mockResolvedValue({
|
||||
content: 'Hello',
|
||||
hasImages: false,
|
||||
});
|
||||
|
||||
await service.sendMessage({
|
||||
sessionId: 'session-1',
|
||||
message: 'Hello',
|
||||
});
|
||||
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith(
|
||||
'agent:stream',
|
||||
expect.objectContaining({
|
||||
sessionId: 'session-1',
|
||||
type: 'tool_result',
|
||||
tool: {
|
||||
name: 'unknown',
|
||||
input: {
|
||||
toolUseId: 'unregistered-id',
|
||||
content: 'Some result content',
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle images in message', async () => {
|
||||
const mockProvider = {
|
||||
getName: () => 'claude',
|
||||
@@ -303,6 +422,36 @@ describe('agent-service.ts', () => {
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should include context/history preparation for Gemini requests', async () => {
|
||||
let capturedOptions: any;
|
||||
const mockProvider = {
|
||||
getName: () => 'gemini',
|
||||
executeQuery: async function* (options: any) {
|
||||
capturedOptions = options;
|
||||
yield {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(ProviderFactory.getProviderForModelName).mockReturnValue('gemini');
|
||||
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
|
||||
vi.mocked(promptBuilder.buildPromptWithImages).mockResolvedValue({
|
||||
content: 'Hello',
|
||||
hasImages: false,
|
||||
});
|
||||
|
||||
await service.sendMessage({
|
||||
sessionId: 'session-1',
|
||||
message: 'Hello',
|
||||
model: 'gemini-2.5-flash',
|
||||
});
|
||||
|
||||
expect(contextLoader.loadContextFiles).toHaveBeenCalled();
|
||||
expect(capturedOptions).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stopExecution', () => {
|
||||
|
||||
@@ -1050,4 +1050,383 @@ describe('auto-loop-coordinator.ts', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('auto_mode_idle emission timing (idle check fix)', () => {
|
||||
it('emits auto_mode_idle when no features in any state (empty project)', async () => {
|
||||
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
|
||||
vi.mocked(mockLoadAllFeatures).mockResolvedValue([]);
|
||||
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
|
||||
|
||||
await coordinator.startAutoLoopForProject('/test/project', null, 1);
|
||||
|
||||
// Clear the initial event mock calls
|
||||
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
|
||||
|
||||
// Advance time to trigger loop iteration and idle event
|
||||
await vi.advanceTimersByTimeAsync(11000);
|
||||
|
||||
// Stop the loop
|
||||
await coordinator.stopAutoLoopForProject('/test/project', null);
|
||||
|
||||
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', {
|
||||
message: 'No pending features - auto mode idle',
|
||||
projectPath: '/test/project',
|
||||
branchName: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('does NOT emit auto_mode_idle when features are in in_progress status', async () => {
|
||||
// No pending features (backlog/ready)
|
||||
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
|
||||
// But there are features in in_progress status
|
||||
const inProgressFeature: Feature = {
|
||||
...testFeature,
|
||||
id: 'feature-1',
|
||||
status: 'in_progress',
|
||||
title: 'In Progress Feature',
|
||||
};
|
||||
vi.mocked(mockLoadAllFeatures).mockResolvedValue([inProgressFeature]);
|
||||
// No running features in concurrency manager (they were released during status update)
|
||||
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
|
||||
|
||||
await coordinator.startAutoLoopForProject('/test/project', null, 1);
|
||||
|
||||
// Clear the initial event mock calls
|
||||
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
|
||||
|
||||
// Advance time to trigger loop iteration
|
||||
await vi.advanceTimersByTimeAsync(11000);
|
||||
|
||||
// Stop the loop
|
||||
await coordinator.stopAutoLoopForProject('/test/project', null);
|
||||
|
||||
// Should NOT emit auto_mode_idle because there's an in_progress feature
|
||||
expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith('auto_mode_idle', {
|
||||
message: 'No pending features - auto mode idle',
|
||||
projectPath: '/test/project',
|
||||
branchName: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('emits auto_mode_idle after in_progress feature completes', async () => {
|
||||
const completedFeature: Feature = {
|
||||
...testFeature,
|
||||
id: 'feature-1',
|
||||
status: 'completed',
|
||||
title: 'Completed Feature',
|
||||
};
|
||||
|
||||
// Initially has in_progress feature
|
||||
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
|
||||
vi.mocked(mockLoadAllFeatures).mockResolvedValue([completedFeature]);
|
||||
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
|
||||
|
||||
await coordinator.startAutoLoopForProject('/test/project', null, 1);
|
||||
|
||||
// Clear the initial event mock calls
|
||||
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
|
||||
|
||||
// Advance time to trigger loop iteration
|
||||
await vi.advanceTimersByTimeAsync(11000);
|
||||
|
||||
// Stop the loop
|
||||
await coordinator.stopAutoLoopForProject('/test/project', null);
|
||||
|
||||
// Should emit auto_mode_idle because all features are completed
|
||||
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', {
|
||||
message: 'No pending features - auto mode idle',
|
||||
projectPath: '/test/project',
|
||||
branchName: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('does NOT emit auto_mode_idle for in_progress features in main worktree (no branchName)', async () => {
|
||||
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
|
||||
// Feature in main worktree has no branchName
|
||||
const mainWorktreeFeature: Feature = {
|
||||
...testFeature,
|
||||
id: 'feature-main',
|
||||
status: 'in_progress',
|
||||
title: 'Main Worktree Feature',
|
||||
branchName: undefined, // Main worktree feature
|
||||
};
|
||||
// Feature in branch worktree has branchName
|
||||
const branchFeature: Feature = {
|
||||
...testFeature,
|
||||
id: 'feature-branch',
|
||||
status: 'in_progress',
|
||||
title: 'Branch Feature',
|
||||
branchName: 'feature/some-branch',
|
||||
};
|
||||
vi.mocked(mockLoadAllFeatures).mockResolvedValue([mainWorktreeFeature, branchFeature]);
|
||||
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
|
||||
|
||||
// Start auto mode for main worktree
|
||||
await coordinator.startAutoLoopForProject('/test/project', null, 1);
|
||||
|
||||
// Clear the initial event mock calls
|
||||
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
|
||||
|
||||
// Advance time to trigger loop iteration
|
||||
await vi.advanceTimersByTimeAsync(11000);
|
||||
|
||||
// Stop the loop
|
||||
await coordinator.stopAutoLoopForProject('/test/project', null);
|
||||
|
||||
// Should NOT emit auto_mode_idle because there's an in_progress feature in main worktree
|
||||
expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith(
|
||||
'auto_mode_idle',
|
||||
expect.objectContaining({
|
||||
projectPath: '/test/project',
|
||||
branchName: null,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT emit auto_mode_idle for in_progress features with matching branchName', async () => {
|
||||
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
|
||||
// Feature in matching branch
|
||||
const matchingBranchFeature: Feature = {
|
||||
...testFeature,
|
||||
id: 'feature-matching',
|
||||
status: 'in_progress',
|
||||
title: 'Matching Branch Feature',
|
||||
branchName: 'feature/test-branch',
|
||||
};
|
||||
// Feature in different branch
|
||||
const differentBranchFeature: Feature = {
|
||||
...testFeature,
|
||||
id: 'feature-different',
|
||||
status: 'in_progress',
|
||||
title: 'Different Branch Feature',
|
||||
branchName: 'feature/other-branch',
|
||||
};
|
||||
vi.mocked(mockLoadAllFeatures).mockResolvedValue([
|
||||
matchingBranchFeature,
|
||||
differentBranchFeature,
|
||||
]);
|
||||
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
|
||||
|
||||
// Start auto mode for feature/test-branch
|
||||
await coordinator.startAutoLoopForProject('/test/project', 'feature/test-branch', 1);
|
||||
|
||||
// Clear the initial event mock calls
|
||||
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
|
||||
|
||||
// Advance time to trigger loop iteration
|
||||
await vi.advanceTimersByTimeAsync(11000);
|
||||
|
||||
// Stop the loop
|
||||
await coordinator.stopAutoLoopForProject('/test/project', 'feature/test-branch');
|
||||
|
||||
// Should NOT emit auto_mode_idle because there's an in_progress feature with matching branch
|
||||
expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith(
|
||||
'auto_mode_idle',
|
||||
expect.objectContaining({
|
||||
projectPath: '/test/project',
|
||||
branchName: 'feature/test-branch',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('emits auto_mode_idle when in_progress feature has different branchName', async () => {
|
||||
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
|
||||
// Only feature is in a different branch
|
||||
const differentBranchFeature: Feature = {
|
||||
...testFeature,
|
||||
id: 'feature-different',
|
||||
status: 'in_progress',
|
||||
title: 'Different Branch Feature',
|
||||
branchName: 'feature/other-branch',
|
||||
};
|
||||
vi.mocked(mockLoadAllFeatures).mockResolvedValue([differentBranchFeature]);
|
||||
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
|
||||
|
||||
// Start auto mode for feature/test-branch
|
||||
await coordinator.startAutoLoopForProject('/test/project', 'feature/test-branch', 1);
|
||||
|
||||
// Clear the initial event mock calls
|
||||
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
|
||||
|
||||
// Advance time to trigger loop iteration
|
||||
await vi.advanceTimersByTimeAsync(11000);
|
||||
|
||||
// Stop the loop
|
||||
await coordinator.stopAutoLoopForProject('/test/project', 'feature/test-branch');
|
||||
|
||||
// Should emit auto_mode_idle because the in_progress feature is in a different branch
|
||||
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', {
|
||||
message: 'No pending features - auto mode idle',
|
||||
projectPath: '/test/project',
|
||||
branchName: 'feature/test-branch',
|
||||
});
|
||||
});
|
||||
|
||||
it('emits auto_mode_idle when only backlog/ready features exist and no running/in_progress features', async () => {
|
||||
// backlog/ready features should be in loadPendingFeatures, not loadAllFeatures for idle check
|
||||
// But this test verifies the idle check doesn't incorrectly block on backlog/ready
|
||||
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); // No pending (for current iteration check)
|
||||
const backlogFeature: Feature = {
|
||||
...testFeature,
|
||||
id: 'feature-1',
|
||||
status: 'backlog',
|
||||
title: 'Backlog Feature',
|
||||
};
|
||||
const readyFeature: Feature = {
|
||||
...testFeature,
|
||||
id: 'feature-2',
|
||||
status: 'ready',
|
||||
title: 'Ready Feature',
|
||||
};
|
||||
vi.mocked(mockLoadAllFeatures).mockResolvedValue([backlogFeature, readyFeature]);
|
||||
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
|
||||
|
||||
await coordinator.startAutoLoopForProject('/test/project', null, 1);
|
||||
|
||||
// Clear the initial event mock calls
|
||||
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
|
||||
|
||||
// Advance time to trigger loop iteration
|
||||
await vi.advanceTimersByTimeAsync(11000);
|
||||
|
||||
// Stop the loop
|
||||
await coordinator.stopAutoLoopForProject('/test/project', null);
|
||||
|
||||
// Should NOT emit auto_mode_idle because there are backlog/ready features
|
||||
// (even though they're not in_progress, the idle check only looks at in_progress status)
|
||||
// Actually, backlog/ready would be caught by loadPendingFeatures on next iteration,
|
||||
// so this should emit idle since runningCount=0 and no in_progress features
|
||||
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', {
|
||||
message: 'No pending features - auto mode idle',
|
||||
projectPath: '/test/project',
|
||||
branchName: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles loadAllFeaturesFn error gracefully (falls back to emitting idle)', async () => {
|
||||
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
|
||||
vi.mocked(mockLoadAllFeatures).mockRejectedValue(new Error('Failed to load features'));
|
||||
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
|
||||
|
||||
await coordinator.startAutoLoopForProject('/test/project', null, 1);
|
||||
|
||||
// Clear the initial event mock calls
|
||||
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
|
||||
|
||||
// Advance time to trigger loop iteration
|
||||
await vi.advanceTimersByTimeAsync(11000);
|
||||
|
||||
// Stop the loop
|
||||
await coordinator.stopAutoLoopForProject('/test/project', null);
|
||||
|
||||
// Should still emit auto_mode_idle when loadAllFeatures fails (defensive behavior)
|
||||
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', {
|
||||
message: 'No pending features - auto mode idle',
|
||||
projectPath: '/test/project',
|
||||
branchName: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles missing loadAllFeaturesFn gracefully (falls back to emitting idle)', async () => {
|
||||
// Create coordinator without loadAllFeaturesFn
|
||||
const coordWithoutLoadAll = new AutoLoopCoordinator(
|
||||
mockEventBus,
|
||||
mockConcurrencyManager,
|
||||
mockSettingsService,
|
||||
mockExecuteFeature,
|
||||
mockLoadPendingFeatures,
|
||||
mockSaveExecutionState,
|
||||
mockClearExecutionState,
|
||||
mockResetStuckFeatures,
|
||||
mockIsFeatureFinished,
|
||||
mockIsFeatureRunning
|
||||
// loadAllFeaturesFn omitted
|
||||
);
|
||||
|
||||
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
|
||||
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
|
||||
|
||||
await coordWithoutLoadAll.startAutoLoopForProject('/test/project', null, 1);
|
||||
|
||||
// Clear the initial event mock calls
|
||||
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
|
||||
|
||||
// Advance time to trigger loop iteration
|
||||
await vi.advanceTimersByTimeAsync(11000);
|
||||
|
||||
// Stop the loop
|
||||
await coordWithoutLoadAll.stopAutoLoopForProject('/test/project', null);
|
||||
|
||||
// Should emit auto_mode_idle when loadAllFeaturesFn is missing (defensive behavior)
|
||||
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', {
|
||||
message: 'No pending features - auto mode idle',
|
||||
projectPath: '/test/project',
|
||||
branchName: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('only emits auto_mode_idle once per idle period (hasEmittedIdleEvent flag)', async () => {
|
||||
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]);
|
||||
vi.mocked(mockLoadAllFeatures).mockResolvedValue([]);
|
||||
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
|
||||
|
||||
await coordinator.startAutoLoopForProject('/test/project', null, 1);
|
||||
|
||||
// Clear the initial event mock calls
|
||||
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
|
||||
|
||||
// Advance time multiple times to trigger multiple loop iterations
|
||||
await vi.advanceTimersByTimeAsync(11000); // First idle check
|
||||
await vi.advanceTimersByTimeAsync(11000); // Second idle check
|
||||
await vi.advanceTimersByTimeAsync(11000); // Third idle check
|
||||
|
||||
// Stop the loop
|
||||
await coordinator.stopAutoLoopForProject('/test/project', null);
|
||||
|
||||
// Should only emit auto_mode_idle once despite multiple iterations
|
||||
const idleCalls = vi
|
||||
.mocked(mockEventBus.emitAutoModeEvent)
|
||||
.mock.calls.filter((call) => call[0] === 'auto_mode_idle');
|
||||
expect(idleCalls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('premature auto_mode_idle bug scenario: runningCount=0 but feature still in_progress', async () => {
|
||||
// This test reproduces the exact bug scenario described in the feature:
|
||||
// When a feature completes, there's a brief window where:
|
||||
// 1. The feature has been released from runningFeatures (so runningCount = 0)
|
||||
// 2. The feature's status is still 'in_progress' during the status update transition
|
||||
// 3. pendingFeatures returns empty (only checks 'backlog'/'ready' statuses)
|
||||
// The fix ensures auto_mode_idle is NOT emitted in this window
|
||||
|
||||
vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); // No backlog/ready features
|
||||
// Feature is still in in_progress status (during status update transition)
|
||||
const transitioningFeature: Feature = {
|
||||
...testFeature,
|
||||
id: 'feature-1',
|
||||
status: 'in_progress',
|
||||
title: 'Transitioning Feature',
|
||||
};
|
||||
vi.mocked(mockLoadAllFeatures).mockResolvedValue([transitioningFeature]);
|
||||
// Feature has been released from concurrency manager (runningCount = 0)
|
||||
vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0);
|
||||
|
||||
await coordinator.startAutoLoopForProject('/test/project', null, 1);
|
||||
|
||||
// Clear the initial event mock calls
|
||||
vi.mocked(mockEventBus.emitAutoModeEvent).mockClear();
|
||||
|
||||
// Advance time to trigger loop iteration
|
||||
await vi.advanceTimersByTimeAsync(11000);
|
||||
|
||||
// Stop the loop
|
||||
await coordinator.stopAutoLoopForProject('/test/project', null);
|
||||
|
||||
// The fix prevents auto_mode_idle from being emitted in this scenario
|
||||
expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith('auto_mode_idle', {
|
||||
message: 'No pending features - auto mode idle',
|
||||
projectPath: '/test/project',
|
||||
branchName: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
127
apps/server/tests/unit/services/auto-mode-facade.test.ts
Normal file
127
apps/server/tests/unit/services/auto-mode-facade.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AutoModeServiceFacade } from '@/services/auto-mode/facade.js';
|
||||
import type { Feature } from '@automaker/types';
|
||||
|
||||
describe('AutoModeServiceFacade', () => {
|
||||
describe('isFeatureEligibleForAutoMode', () => {
|
||||
it('should include features with pipeline_* status', () => {
|
||||
const features: Partial<Feature>[] = [
|
||||
{ id: '1', status: 'ready', branchName: 'main' },
|
||||
{ id: '2', status: 'pipeline_testing', branchName: 'main' },
|
||||
{ id: '3', status: 'in_progress', branchName: 'main' },
|
||||
{ id: '4', status: 'interrupted', branchName: 'main' },
|
||||
{ id: '5', status: 'backlog', branchName: 'main' },
|
||||
];
|
||||
|
||||
const branchName = 'main';
|
||||
const primaryBranch = 'main';
|
||||
|
||||
const filtered = features.filter((f) =>
|
||||
AutoModeServiceFacade.isFeatureEligibleForAutoMode(f as Feature, branchName, primaryBranch)
|
||||
);
|
||||
|
||||
expect(filtered.map((f) => f.id)).toContain('1'); // ready
|
||||
expect(filtered.map((f) => f.id)).toContain('2'); // pipeline_testing
|
||||
expect(filtered.map((f) => f.id)).toContain('4'); // interrupted
|
||||
expect(filtered.map((f) => f.id)).toContain('5'); // backlog
|
||||
expect(filtered.map((f) => f.id)).not.toContain('3'); // in_progress
|
||||
});
|
||||
|
||||
it('should correctly handle main worktree alignment', () => {
|
||||
const features: Partial<Feature>[] = [
|
||||
{ id: '1', status: 'ready', branchName: undefined },
|
||||
{ id: '2', status: 'ready', branchName: 'main' },
|
||||
{ id: '3', status: 'ready', branchName: 'other' },
|
||||
];
|
||||
|
||||
const branchName = null; // main worktree
|
||||
const primaryBranch = 'main';
|
||||
|
||||
const filtered = features.filter((f) =>
|
||||
AutoModeServiceFacade.isFeatureEligibleForAutoMode(f as Feature, branchName, primaryBranch)
|
||||
);
|
||||
|
||||
expect(filtered.map((f) => f.id)).toContain('1'); // no branch
|
||||
expect(filtered.map((f) => f.id)).toContain('2'); // matching primary branch
|
||||
expect(filtered.map((f) => f.id)).not.toContain('3'); // mismatching branch
|
||||
});
|
||||
|
||||
it('should exclude completed, verified, and waiting_approval statuses', () => {
|
||||
const features: Partial<Feature>[] = [
|
||||
{ id: '1', status: 'completed', branchName: 'main' },
|
||||
{ id: '2', status: 'verified', branchName: 'main' },
|
||||
{ id: '3', status: 'waiting_approval', branchName: 'main' },
|
||||
];
|
||||
|
||||
const filtered = features.filter((f) =>
|
||||
AutoModeServiceFacade.isFeatureEligibleForAutoMode(f as Feature, 'main', 'main')
|
||||
);
|
||||
|
||||
expect(filtered).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should include pipeline_complete as eligible (still a pipeline status)', () => {
|
||||
const feature: Partial<Feature> = {
|
||||
id: '1',
|
||||
status: 'pipeline_complete',
|
||||
branchName: 'main',
|
||||
};
|
||||
|
||||
const result = AutoModeServiceFacade.isFeatureEligibleForAutoMode(
|
||||
feature as Feature,
|
||||
'main',
|
||||
'main'
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should filter pipeline features by branch in named worktrees', () => {
|
||||
const features: Partial<Feature>[] = [
|
||||
{ id: '1', status: 'pipeline_testing', branchName: 'feature-branch' },
|
||||
{ id: '2', status: 'pipeline_review', branchName: 'other-branch' },
|
||||
{ id: '3', status: 'pipeline_deploy', branchName: undefined },
|
||||
];
|
||||
|
||||
const filtered = features.filter((f) =>
|
||||
AutoModeServiceFacade.isFeatureEligibleForAutoMode(f as Feature, 'feature-branch', null)
|
||||
);
|
||||
|
||||
expect(filtered.map((f) => f.id)).toEqual(['1']);
|
||||
});
|
||||
|
||||
it('should handle null primaryBranch for main worktree', () => {
|
||||
const features: Partial<Feature>[] = [
|
||||
{ id: '1', status: 'ready', branchName: undefined },
|
||||
{ id: '2', status: 'ready', branchName: 'main' },
|
||||
];
|
||||
|
||||
const filtered = features.filter((f) =>
|
||||
AutoModeServiceFacade.isFeatureEligibleForAutoMode(f as Feature, null, null)
|
||||
);
|
||||
|
||||
// When primaryBranch is null, only features with no branchName are included
|
||||
expect(filtered.map((f) => f.id)).toEqual(['1']);
|
||||
});
|
||||
|
||||
it('should include various pipeline_* step IDs as eligible', () => {
|
||||
const statuses = [
|
||||
'pipeline_step_abc_123',
|
||||
'pipeline_code_review',
|
||||
'pipeline_step1',
|
||||
'pipeline_testing',
|
||||
'pipeline_deploy',
|
||||
];
|
||||
|
||||
for (const status of statuses) {
|
||||
const feature: Partial<Feature> = { id: '1', status, branchName: 'main' };
|
||||
const result = AutoModeServiceFacade.isFeatureEligibleForAutoMode(
|
||||
feature as Feature,
|
||||
'main',
|
||||
'main'
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock dependencies (hoisted)
|
||||
vi.mock('../../../../src/services/agent-executor.js');
|
||||
vi.mock('../../../../src/lib/settings-helpers.js');
|
||||
vi.mock('../../../../src/providers/provider-factory.js');
|
||||
vi.mock('../../../../src/lib/sdk-options.js');
|
||||
vi.mock('@automaker/model-resolver', () => ({
|
||||
resolveModelString: vi.fn((model, fallback) => model || fallback),
|
||||
DEFAULT_MODELS: { claude: 'claude-3-5-sonnet' },
|
||||
}));
|
||||
|
||||
import { AutoModeServiceFacade } from '../../../../src/services/auto-mode/facade.js';
|
||||
import { AgentExecutor } from '../../../../src/services/agent-executor.js';
|
||||
import * as settingsHelpers from '../../../../src/lib/settings-helpers.js';
|
||||
import { ProviderFactory } from '../../../../src/providers/provider-factory.js';
|
||||
import * as sdkOptions from '../../../../src/lib/sdk-options.js';
|
||||
|
||||
describe('AutoModeServiceFacade Agent Runner', () => {
|
||||
let mockAgentExecutor: MockAgentExecutor;
|
||||
let mockSettingsService: MockSettingsService;
|
||||
let facade: AutoModeServiceFacade;
|
||||
|
||||
// Type definitions for mocks
|
||||
interface MockAgentExecutor {
|
||||
execute: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
interface MockSettingsService {
|
||||
getGlobalSettings: ReturnType<typeof vi.fn>;
|
||||
getCredentials: ReturnType<typeof vi.fn>;
|
||||
getProjectSettings: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Set up the mock for createAutoModeOptions
|
||||
// Note: Using 'as any' because Options type from SDK is complex and we only need
|
||||
// the specific fields that are verified in tests (maxTurns, allowedTools, etc.)
|
||||
vi.mocked(sdkOptions.createAutoModeOptions).mockReturnValue({
|
||||
maxTurns: 123,
|
||||
allowedTools: ['tool1'],
|
||||
systemPrompt: 'system-prompt',
|
||||
} as any);
|
||||
|
||||
mockAgentExecutor = {
|
||||
execute: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
(AgentExecutor as any).mockImplementation(function (this: MockAgentExecutor) {
|
||||
return mockAgentExecutor;
|
||||
});
|
||||
|
||||
mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||
getCredentials: vi.fn().mockResolvedValue({}),
|
||||
getProjectSettings: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
|
||||
// Helper to access the private createRunAgentFn via factory creation
|
||||
facade = AutoModeServiceFacade.create('/project', {
|
||||
events: { on: vi.fn(), emit: vi.fn(), subscribe: vi.fn().mockReturnValue(vi.fn()) } as any,
|
||||
settingsService: mockSettingsService,
|
||||
sharedServices: {
|
||||
eventBus: { emitAutoModeEvent: vi.fn() } as any,
|
||||
worktreeResolver: { getCurrentBranch: vi.fn().mockResolvedValue('main') } as any,
|
||||
concurrencyManager: {
|
||||
isRunning: vi.fn().mockReturnValue(false),
|
||||
getRunningFeature: vi.fn().mockReturnValue(null),
|
||||
} as any,
|
||||
} as any,
|
||||
});
|
||||
});
|
||||
|
||||
it('should resolve provider by providerId and pass to AgentExecutor', async () => {
|
||||
// 1. Setup mocks
|
||||
const mockProvider = { getName: () => 'mock-provider' };
|
||||
(ProviderFactory.getProviderForModel as any).mockReturnValue(mockProvider);
|
||||
|
||||
const mockClaudeProvider = { id: 'zai-1', name: 'Zai' };
|
||||
const mockCredentials = { apiKey: 'test-key' };
|
||||
(settingsHelpers.resolveProviderContext as any).mockResolvedValue({
|
||||
provider: mockClaudeProvider,
|
||||
credentials: mockCredentials,
|
||||
resolvedModel: undefined,
|
||||
});
|
||||
|
||||
const runAgentFn = (facade as any).executionService.runAgentFn;
|
||||
|
||||
// 2. Execute
|
||||
await runAgentFn(
|
||||
'/workdir',
|
||||
'feature-1',
|
||||
'prompt',
|
||||
new AbortController(),
|
||||
'/project',
|
||||
[],
|
||||
'model-1',
|
||||
{
|
||||
providerId: 'zai-1',
|
||||
}
|
||||
);
|
||||
|
||||
// 3. Verify
|
||||
expect(settingsHelpers.resolveProviderContext).toHaveBeenCalledWith(
|
||||
mockSettingsService,
|
||||
'model-1',
|
||||
'zai-1',
|
||||
'[AutoModeFacade]'
|
||||
);
|
||||
|
||||
expect(mockAgentExecutor.execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
claudeCompatibleProvider: mockClaudeProvider,
|
||||
credentials: mockCredentials,
|
||||
model: 'model-1', // Original model ID
|
||||
}),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should fallback to model-based lookup if providerId is not provided', async () => {
|
||||
const mockProvider = { getName: () => 'mock-provider' };
|
||||
(ProviderFactory.getProviderForModel as any).mockReturnValue(mockProvider);
|
||||
|
||||
const mockClaudeProvider = { id: 'zai-model', name: 'Zai Model' };
|
||||
(settingsHelpers.resolveProviderContext as any).mockResolvedValue({
|
||||
provider: mockClaudeProvider,
|
||||
credentials: { apiKey: 'model-key' },
|
||||
resolvedModel: 'resolved-model-1',
|
||||
});
|
||||
|
||||
const runAgentFn = (facade as any).executionService.runAgentFn;
|
||||
|
||||
await runAgentFn(
|
||||
'/workdir',
|
||||
'feature-1',
|
||||
'prompt',
|
||||
new AbortController(),
|
||||
'/project',
|
||||
[],
|
||||
'model-1',
|
||||
{
|
||||
// no providerId
|
||||
}
|
||||
);
|
||||
|
||||
expect(settingsHelpers.resolveProviderContext).toHaveBeenCalledWith(
|
||||
mockSettingsService,
|
||||
'model-1',
|
||||
undefined,
|
||||
'[AutoModeFacade]'
|
||||
);
|
||||
|
||||
expect(mockAgentExecutor.execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
claudeCompatibleProvider: mockClaudeProvider,
|
||||
}),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should use resolvedModel from provider config for createAutoModeOptions if it maps to a Claude model', async () => {
|
||||
const mockProvider = { getName: () => 'mock-provider' };
|
||||
(ProviderFactory.getProviderForModel as any).mockReturnValue(mockProvider);
|
||||
|
||||
const mockClaudeProvider = {
|
||||
id: 'zai-1',
|
||||
name: 'Zai',
|
||||
models: [{ id: 'custom-model-1', mapsToClaudeModel: 'claude-3-opus' }],
|
||||
};
|
||||
(settingsHelpers.resolveProviderContext as any).mockResolvedValue({
|
||||
provider: mockClaudeProvider,
|
||||
credentials: { apiKey: 'test-key' },
|
||||
resolvedModel: 'claude-3-5-opus',
|
||||
});
|
||||
|
||||
const runAgentFn = (facade as any).executionService.runAgentFn;
|
||||
|
||||
await runAgentFn(
|
||||
'/workdir',
|
||||
'feature-1',
|
||||
'prompt',
|
||||
new AbortController(),
|
||||
'/project',
|
||||
[],
|
||||
'custom-model-1',
|
||||
{
|
||||
providerId: 'zai-1',
|
||||
}
|
||||
);
|
||||
|
||||
// Verify createAutoModeOptions was called with the mapped model
|
||||
expect(sdkOptions.createAutoModeOptions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: 'claude-3-5-opus',
|
||||
})
|
||||
);
|
||||
|
||||
// Verify AgentExecutor.execute still gets the original custom model ID
|
||||
expect(mockAgentExecutor.execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: 'custom-model-1',
|
||||
}),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
115
apps/server/tests/unit/services/dev-server-event-types.test.ts
Normal file
115
apps/server/tests/unit/services/dev-server-event-types.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { EventEmitter } from 'events';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import fs from 'fs/promises';
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
// Mock child_process
|
||||
vi.mock('child_process', () => ({
|
||||
spawn: vi.fn(),
|
||||
execSync: vi.fn(),
|
||||
execFile: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock secure-fs
|
||||
vi.mock('@/lib/secure-fs.js', () => ({
|
||||
access: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock net
|
||||
vi.mock('net', () => ({
|
||||
default: {
|
||||
createServer: vi.fn(),
|
||||
},
|
||||
createServer: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as secureFs from '@/lib/secure-fs.js';
|
||||
import net from 'net';
|
||||
|
||||
describe('DevServerService Event Types', () => {
|
||||
let testDataDir: string;
|
||||
let worktreeDir: string;
|
||||
let mockEmitter: EventEmitter;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
|
||||
testDataDir = path.join(os.tmpdir(), `dev-server-events-test-${Date.now()}`);
|
||||
worktreeDir = path.join(os.tmpdir(), `dev-server-worktree-events-test-${Date.now()}`);
|
||||
await fs.mkdir(testDataDir, { recursive: true });
|
||||
await fs.mkdir(worktreeDir, { recursive: true });
|
||||
|
||||
mockEmitter = new EventEmitter();
|
||||
|
||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||
|
||||
const mockServer = new EventEmitter() as any;
|
||||
mockServer.listen = vi.fn().mockImplementation((port: number, host: string) => {
|
||||
process.nextTick(() => mockServer.emit('listening'));
|
||||
});
|
||||
mockServer.close = vi.fn();
|
||||
vi.mocked(net.createServer).mockReturnValue(mockServer);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
await fs.rm(testDataDir, { recursive: true, force: true });
|
||||
await fs.rm(worktreeDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
it('should emit all required event types during dev server lifecycle', async () => {
|
||||
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||
const service = getDevServerService();
|
||||
await service.initialize(testDataDir, mockEmitter as any);
|
||||
|
||||
const mockProcess = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||
|
||||
const emittedEvents: Record<string, any[]> = {
|
||||
'dev-server:starting': [],
|
||||
'dev-server:started': [],
|
||||
'dev-server:url-detected': [],
|
||||
'dev-server:output': [],
|
||||
'dev-server:stopped': [],
|
||||
};
|
||||
|
||||
Object.keys(emittedEvents).forEach((type) => {
|
||||
mockEmitter.on(type, (payload) => emittedEvents[type].push(payload));
|
||||
});
|
||||
|
||||
// 1. Starting & Started
|
||||
await service.startDevServer(worktreeDir, worktreeDir);
|
||||
expect(emittedEvents['dev-server:starting'].length).toBe(1);
|
||||
expect(emittedEvents['dev-server:started'].length).toBe(1);
|
||||
|
||||
// 2. Output & URL Detected
|
||||
mockProcess.stdout.emit('data', Buffer.from('Local: http://localhost:5173/\n'));
|
||||
// Throttled output needs a bit of time (OUTPUT_THROTTLE_MS is 100ms)
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
expect(emittedEvents['dev-server:output'].length).toBeGreaterThanOrEqual(1);
|
||||
expect(emittedEvents['dev-server:url-detected'].length).toBe(1);
|
||||
expect(emittedEvents['dev-server:url-detected'][0].url).toBe('http://localhost:5173/');
|
||||
|
||||
// 3. Stopped
|
||||
await service.stopDevServer(worktreeDir);
|
||||
expect(emittedEvents['dev-server:stopped'].length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// Helper to create a mock child process
|
||||
function createMockProcess() {
|
||||
const mockProcess = new EventEmitter() as any;
|
||||
mockProcess.stdout = new EventEmitter();
|
||||
mockProcess.stderr = new EventEmitter();
|
||||
mockProcess.kill = vi.fn();
|
||||
mockProcess.killed = false;
|
||||
mockProcess.pid = 12345;
|
||||
mockProcess.unref = vi.fn();
|
||||
return mockProcess;
|
||||
}
|
||||
240
apps/server/tests/unit/services/dev-server-persistence.test.ts
Normal file
240
apps/server/tests/unit/services/dev-server-persistence.test.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { EventEmitter } from 'events';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import fs from 'fs/promises';
|
||||
import { spawn, execSync } from 'child_process';
|
||||
|
||||
// Mock child_process
|
||||
vi.mock('child_process', () => ({
|
||||
spawn: vi.fn(),
|
||||
execSync: vi.fn(),
|
||||
execFile: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock secure-fs
|
||||
vi.mock('@/lib/secure-fs.js', () => ({
|
||||
access: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock net
|
||||
vi.mock('net', () => ({
|
||||
default: {
|
||||
createServer: vi.fn(),
|
||||
},
|
||||
createServer: vi.fn(),
|
||||
}));
|
||||
|
||||
import * as secureFs from '@/lib/secure-fs.js';
|
||||
import net from 'net';
|
||||
|
||||
describe('DevServerService Persistence & Sync', () => {
|
||||
let testDataDir: string;
|
||||
let worktreeDir: string;
|
||||
let mockEmitter: EventEmitter;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
|
||||
testDataDir = path.join(os.tmpdir(), `dev-server-persistence-test-${Date.now()}`);
|
||||
worktreeDir = path.join(os.tmpdir(), `dev-server-worktree-test-${Date.now()}`);
|
||||
await fs.mkdir(testDataDir, { recursive: true });
|
||||
await fs.mkdir(worktreeDir, { recursive: true });
|
||||
|
||||
mockEmitter = new EventEmitter();
|
||||
|
||||
// Default mock for secureFs.access - return resolved (file exists)
|
||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||
|
||||
// Default mock for net.createServer - port available
|
||||
const mockServer = new EventEmitter() as any;
|
||||
mockServer.listen = vi.fn().mockImplementation((port: number, host: string) => {
|
||||
process.nextTick(() => mockServer.emit('listening'));
|
||||
});
|
||||
mockServer.close = vi.fn();
|
||||
vi.mocked(net.createServer).mockReturnValue(mockServer);
|
||||
|
||||
// Default mock for execSync - no process on port
|
||||
vi.mocked(execSync).mockImplementation(() => {
|
||||
throw new Error('No process found');
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
await fs.rm(testDataDir, { recursive: true, force: true });
|
||||
await fs.rm(worktreeDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
it('should emit dev-server:starting when startDevServer is called', async () => {
|
||||
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||
const service = getDevServerService();
|
||||
await service.initialize(testDataDir, mockEmitter as any);
|
||||
|
||||
const mockProcess = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||
|
||||
const events: any[] = [];
|
||||
mockEmitter.on('dev-server:starting', (payload) => events.push(payload));
|
||||
|
||||
await service.startDevServer(worktreeDir, worktreeDir);
|
||||
|
||||
expect(events.length).toBe(1);
|
||||
expect(events[0].worktreePath).toBe(worktreeDir);
|
||||
});
|
||||
|
||||
it('should prevent concurrent starts for the same worktree', async () => {
|
||||
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||
const service = getDevServerService();
|
||||
await service.initialize(testDataDir, mockEmitter as any);
|
||||
|
||||
// Delay spawn to simulate long starting time
|
||||
vi.mocked(spawn).mockImplementation(() => {
|
||||
const p = createMockProcess();
|
||||
// Don't return immediately, simulate some work
|
||||
return p as any;
|
||||
});
|
||||
|
||||
// Start first one (don't await yet if we want to test concurrency)
|
||||
const promise1 = service.startDevServer(worktreeDir, worktreeDir);
|
||||
|
||||
// Try to start second one immediately
|
||||
const result2 = await service.startDevServer(worktreeDir, worktreeDir);
|
||||
|
||||
expect(result2.success).toBe(false);
|
||||
expect(result2.error).toContain('already starting');
|
||||
|
||||
await promise1;
|
||||
});
|
||||
|
||||
it('should persist state to dev-servers.json when started', async () => {
|
||||
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||
const service = getDevServerService();
|
||||
await service.initialize(testDataDir, mockEmitter as any);
|
||||
|
||||
const mockProcess = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||
|
||||
await service.startDevServer(worktreeDir, worktreeDir);
|
||||
|
||||
const statePath = path.join(testDataDir, 'dev-servers.json');
|
||||
const exists = await fs
|
||||
.access(statePath)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
expect(exists).toBe(true);
|
||||
|
||||
const content = await fs.readFile(statePath, 'utf-8');
|
||||
const state = JSON.parse(content);
|
||||
expect(state.length).toBe(1);
|
||||
expect(state[0].worktreePath).toBe(worktreeDir);
|
||||
});
|
||||
|
||||
it('should load state from dev-servers.json on initialize', async () => {
|
||||
// 1. Create a fake state file
|
||||
const persistedInfo = [
|
||||
{
|
||||
worktreePath: worktreeDir,
|
||||
allocatedPort: 3005,
|
||||
port: 3005,
|
||||
url: 'http://localhost:3005',
|
||||
startedAt: new Date().toISOString(),
|
||||
urlDetected: true,
|
||||
customCommand: 'npm run dev',
|
||||
},
|
||||
];
|
||||
await fs.writeFile(path.join(testDataDir, 'dev-servers.json'), JSON.stringify(persistedInfo));
|
||||
|
||||
// 2. Mock port as IN USE (so it re-attaches)
|
||||
const mockServer = new EventEmitter() as any;
|
||||
mockServer.listen = vi.fn().mockImplementation((port: number, host: string) => {
|
||||
// Fail to listen = port in use
|
||||
process.nextTick(() => mockServer.emit('error', new Error('EADDRINUSE')));
|
||||
});
|
||||
vi.mocked(net.createServer).mockReturnValue(mockServer);
|
||||
|
||||
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||
const service = getDevServerService();
|
||||
await service.initialize(testDataDir, mockEmitter as any);
|
||||
|
||||
expect(service.isRunning(worktreeDir)).toBe(true);
|
||||
const info = service.getServerInfo(worktreeDir);
|
||||
expect(info?.port).toBe(3005);
|
||||
});
|
||||
|
||||
it('should prune stale servers from state on initialize if port is available', async () => {
|
||||
// 1. Create a fake state file
|
||||
const persistedInfo = [
|
||||
{
|
||||
worktreePath: worktreeDir,
|
||||
allocatedPort: 3005,
|
||||
port: 3005,
|
||||
url: 'http://localhost:3005',
|
||||
startedAt: new Date().toISOString(),
|
||||
urlDetected: true,
|
||||
},
|
||||
];
|
||||
await fs.writeFile(path.join(testDataDir, 'dev-servers.json'), JSON.stringify(persistedInfo));
|
||||
|
||||
// 2. Mock port as AVAILABLE (so it prunes)
|
||||
const mockServer = new EventEmitter() as any;
|
||||
mockServer.listen = vi.fn().mockImplementation((port: number, host: string) => {
|
||||
process.nextTick(() => mockServer.emit('listening'));
|
||||
});
|
||||
mockServer.close = vi.fn();
|
||||
vi.mocked(net.createServer).mockReturnValue(mockServer);
|
||||
|
||||
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||
const service = getDevServerService();
|
||||
await service.initialize(testDataDir, mockEmitter as any);
|
||||
|
||||
expect(service.isRunning(worktreeDir)).toBe(false);
|
||||
|
||||
// Give it a moment to complete the pruning saveState
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Check if file was updated
|
||||
const content = await fs.readFile(path.join(testDataDir, 'dev-servers.json'), 'utf-8');
|
||||
const state = JSON.parse(content);
|
||||
expect(state.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should update persisted state when URL is detected', async () => {
|
||||
const { getDevServerService } = await import('@/services/dev-server-service.js');
|
||||
const service = getDevServerService();
|
||||
await service.initialize(testDataDir, mockEmitter as any);
|
||||
|
||||
const mockProcess = createMockProcess();
|
||||
vi.mocked(spawn).mockReturnValue(mockProcess as any);
|
||||
|
||||
await service.startDevServer(worktreeDir, worktreeDir);
|
||||
|
||||
// Simulate output with URL
|
||||
mockProcess.stdout.emit('data', Buffer.from('Local: http://localhost:5555/\n'));
|
||||
|
||||
// Give it a moment to process and save (needs to wait for saveQueue)
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
const content = await fs.readFile(path.join(testDataDir, 'dev-servers.json'), 'utf-8');
|
||||
const state = JSON.parse(content);
|
||||
expect(state[0].url).toBe('http://localhost:5555/');
|
||||
expect(state[0].port).toBe(5555);
|
||||
expect(state[0].urlDetected).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// Helper to create a mock child process
|
||||
function createMockProcess() {
|
||||
const mockProcess = new EventEmitter() as any;
|
||||
mockProcess.stdout = new EventEmitter();
|
||||
mockProcess.stderr = new EventEmitter();
|
||||
mockProcess.kill = vi.fn();
|
||||
mockProcess.killed = false;
|
||||
mockProcess.pid = 12345;
|
||||
mockProcess.unref = vi.fn();
|
||||
return mockProcess;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,7 @@ import { getFeatureDir } from '@automaker/platform';
|
||||
import {
|
||||
getPromptCustomization,
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getUseClaudeCodeSystemPromptSetting,
|
||||
filterClaudeMdFromContext,
|
||||
} from '../../../src/lib/settings-helpers.js';
|
||||
import { extractSummary } from '../../../src/services/spec-parser.js';
|
||||
@@ -67,6 +68,7 @@ vi.mock('../../../src/lib/settings-helpers.js', () => ({
|
||||
},
|
||||
}),
|
||||
getAutoLoadClaudeMdSetting: vi.fn().mockResolvedValue(true),
|
||||
getUseClaudeCodeSystemPromptSetting: vi.fn().mockResolvedValue(true),
|
||||
filterClaudeMdFromContext: vi.fn().mockReturnValue('context prompt'),
|
||||
}));
|
||||
|
||||
@@ -209,7 +211,14 @@ describe('execution-service.ts', () => {
|
||||
});
|
||||
|
||||
// Default mocks for secureFs
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue('Agent output content');
|
||||
// Include tool usage markers to simulate meaningful agent output.
|
||||
// The execution service checks for '🔧 Tool:' markers and minimum
|
||||
// output length to determine if the agent did real work.
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(
|
||||
'Starting implementation...\n\n🔧 Tool: Read\nInput: {"file_path": "/src/index.ts"}\n\n' +
|
||||
'🔧 Tool: Edit\nInput: {"file_path": "/src/index.ts", "old_string": "foo", "new_string": "bar"}\n\n' +
|
||||
'Implementation complete. Updated the code as requested.'
|
||||
);
|
||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||
|
||||
// Re-setup platform mocks
|
||||
@@ -230,6 +239,7 @@ describe('execution-service.ts', () => {
|
||||
},
|
||||
} as Awaited<ReturnType<typeof getPromptCustomization>>);
|
||||
vi.mocked(getAutoLoadClaudeMdSetting).mockResolvedValue(true);
|
||||
vi.mocked(getUseClaudeCodeSystemPromptSetting).mockResolvedValue(true);
|
||||
vi.mocked(filterClaudeMdFromContext).mockReturnValue('context prompt');
|
||||
|
||||
// Re-setup spec-parser mock
|
||||
@@ -441,13 +451,28 @@ describe('execution-service.ts', () => {
|
||||
const callArgs = mockRunAgentFn.mock.calls[0];
|
||||
expect(callArgs[0]).toMatch(/test.*project/); // workDir contains project
|
||||
expect(callArgs[1]).toBe('feature-1');
|
||||
expect(callArgs[2]).toContain('Feature Implementation Task');
|
||||
expect(callArgs[2]).toContain('Feature Task');
|
||||
expect(callArgs[3]).toBeInstanceOf(AbortController);
|
||||
expect(callArgs[4]).toBe('/test/project');
|
||||
// Model (index 6) should be resolved
|
||||
expect(callArgs[6]).toBe('claude-sonnet-4');
|
||||
});
|
||||
|
||||
it('passes providerId to runAgentFn when present on feature', async () => {
|
||||
const featureWithProvider: Feature = {
|
||||
...testFeature,
|
||||
providerId: 'zai-provider-1',
|
||||
};
|
||||
vi.mocked(mockLoadFeatureFn).mockResolvedValue(featureWithProvider);
|
||||
|
||||
await service.executeFeature('/test/project', 'feature-1');
|
||||
|
||||
expect(mockRunAgentFn).toHaveBeenCalled();
|
||||
const callArgs = mockRunAgentFn.mock.calls[0];
|
||||
const options = callArgs[7];
|
||||
expect(options.providerId).toBe('zai-provider-1');
|
||||
});
|
||||
|
||||
it('executes pipeline after agent completes', async () => {
|
||||
const pipelineSteps = [{ id: 'step-1', name: 'Step 1', order: 1, instructions: 'Do step 1' }];
|
||||
vi.mocked(pipelineService.getPipelineConfig).mockResolvedValue({
|
||||
@@ -1266,6 +1291,34 @@ describe('execution-service.ts', () => {
|
||||
|
||||
expect(mockConcurrencyManager.release).toHaveBeenCalledWith('feature-1', { force: true });
|
||||
});
|
||||
|
||||
it('immediately updates feature status to interrupted before subprocess terminates', async () => {
|
||||
const runningFeature = createRunningFeature('feature-1');
|
||||
vi.mocked(mockConcurrencyManager.getRunningFeature).mockReturnValue(runningFeature);
|
||||
|
||||
await service.stopFeature('feature-1');
|
||||
|
||||
// Should update to 'interrupted' immediately so the UI reflects the stop
|
||||
// without waiting for the CLI subprocess to fully terminate
|
||||
expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith(
|
||||
'/test/project',
|
||||
'feature-1',
|
||||
'interrupted'
|
||||
);
|
||||
});
|
||||
|
||||
it('still aborts and releases even if status update fails', async () => {
|
||||
const runningFeature = createRunningFeature('feature-1');
|
||||
const abortSpy = vi.spyOn(runningFeature.abortController, 'abort');
|
||||
vi.mocked(mockConcurrencyManager.getRunningFeature).mockReturnValue(runningFeature);
|
||||
vi.mocked(mockUpdateFeatureStatusFn).mockRejectedValueOnce(new Error('disk error'));
|
||||
|
||||
const result = await service.stopFeature('feature-1');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(abortSpy).toHaveBeenCalled();
|
||||
expect(mockConcurrencyManager.release).toHaveBeenCalledWith('feature-1', { force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('worktree resolution', () => {
|
||||
@@ -1278,16 +1331,19 @@ describe('execution-service.ts', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to project path when worktree not found', async () => {
|
||||
it('emits error and does not execute agent when worktree is not found in worktree mode', async () => {
|
||||
vi.mocked(mockWorktreeResolver.findWorktreeForBranch).mockResolvedValue(null);
|
||||
|
||||
await service.executeFeature('/test/project', 'feature-1', true);
|
||||
|
||||
// Should still run agent, just with project path
|
||||
expect(mockRunAgentFn).toHaveBeenCalled();
|
||||
const callArgs = mockRunAgentFn.mock.calls[0];
|
||||
// First argument is workDir - should be normalized path to /test/project
|
||||
expect(callArgs[0]).toBe(normalizePath('/test/project'));
|
||||
expect(mockRunAgentFn).not.toHaveBeenCalled();
|
||||
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith(
|
||||
'auto_mode_error',
|
||||
expect.objectContaining({
|
||||
featureId: 'feature-1',
|
||||
error: 'Worktree enabled but no worktree found for feature branch "feature/test-1".',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('skips worktree resolution when useWorktrees is false', async () => {
|
||||
@@ -1401,5 +1457,603 @@ describe('execution-service.ts', () => {
|
||||
expect.objectContaining({ passes: true })
|
||||
);
|
||||
});
|
||||
|
||||
// Helper to create ExecutionService with a custom loadFeatureFn that returns
|
||||
// different features on first load (initial) vs subsequent loads (after completion)
|
||||
const createServiceWithCustomLoad = (completedFeature: Feature): ExecutionService => {
|
||||
let loadCallCount = 0;
|
||||
mockLoadFeatureFn = vi.fn().mockImplementation(() => {
|
||||
loadCallCount++;
|
||||
return loadCallCount === 1 ? testFeature : completedFeature;
|
||||
});
|
||||
|
||||
return new ExecutionService(
|
||||
mockEventBus,
|
||||
mockConcurrencyManager,
|
||||
mockWorktreeResolver,
|
||||
mockSettingsService,
|
||||
mockRunAgentFn,
|
||||
mockExecutePipelineFn,
|
||||
mockUpdateFeatureStatusFn,
|
||||
mockLoadFeatureFn,
|
||||
mockGetPlanningPromptPrefixFn,
|
||||
mockSaveFeatureSummaryFn,
|
||||
mockRecordLearningsFn,
|
||||
mockContextExistsFn,
|
||||
mockResumeFeatureFn,
|
||||
mockTrackFailureFn,
|
||||
mockSignalPauseFn,
|
||||
mockRecordSuccessFn,
|
||||
mockSaveExecutionStateFn,
|
||||
mockLoadContextFilesFn
|
||||
);
|
||||
};
|
||||
|
||||
it('does not overwrite accumulated summary when feature already has one', async () => {
|
||||
const featureWithAccumulatedSummary: Feature = {
|
||||
...testFeature,
|
||||
summary:
|
||||
'### Implementation\n\nFirst step output\n\n---\n\n### Code Review\n\nReview findings',
|
||||
};
|
||||
|
||||
const svc = createServiceWithCustomLoad(featureWithAccumulatedSummary);
|
||||
await svc.executeFeature('/test/project', 'feature-1');
|
||||
|
||||
// saveFeatureSummaryFn should NOT be called because feature already has a summary
|
||||
// This prevents overwriting accumulated pipeline summaries with just the last step's output
|
||||
expect(mockSaveFeatureSummaryFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('saves summary when feature has no existing summary', async () => {
|
||||
const featureWithoutSummary: Feature = {
|
||||
...testFeature,
|
||||
summary: undefined,
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(
|
||||
'🔧 Tool: Edit\nInput: {"file_path": "/src/index.ts"}\n\n<summary>New summary</summary>'
|
||||
);
|
||||
|
||||
const svc = createServiceWithCustomLoad(featureWithoutSummary);
|
||||
await svc.executeFeature('/test/project', 'feature-1');
|
||||
|
||||
// Should save the extracted summary since feature has none
|
||||
expect(mockSaveFeatureSummaryFn).toHaveBeenCalledWith(
|
||||
'/test/project',
|
||||
'feature-1',
|
||||
'Test summary'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not overwrite summary when feature has empty string summary (treats as no summary)', async () => {
|
||||
// Empty string is falsy, so it should be treated as "no summary" and a new one should be saved
|
||||
const featureWithEmptySummary: Feature = {
|
||||
...testFeature,
|
||||
summary: '',
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(
|
||||
'🔧 Tool: Edit\nInput: {"file_path": "/src/index.ts"}\n\n<summary>New summary</summary>'
|
||||
);
|
||||
|
||||
const svc = createServiceWithCustomLoad(featureWithEmptySummary);
|
||||
await svc.executeFeature('/test/project', 'feature-1');
|
||||
|
||||
// Empty string is falsy, so it should save a new summary
|
||||
expect(mockSaveFeatureSummaryFn).toHaveBeenCalledWith(
|
||||
'/test/project',
|
||||
'feature-1',
|
||||
'Test summary'
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves accumulated summary when feature is transitioned from pipeline to verified', async () => {
|
||||
// This is the key scenario: feature went through pipeline steps, accumulated a summary,
|
||||
// then status changed to 'verified' - we should NOT overwrite the accumulated summary
|
||||
const featureWithAccumulatedSummary: Feature = {
|
||||
...testFeature,
|
||||
status: 'verified',
|
||||
summary:
|
||||
'### Implementation\n\nCreated auth module\n\n---\n\n### Code Review\n\nApproved\n\n---\n\n### Testing\n\nAll tests pass',
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue('Agent output with summary');
|
||||
|
||||
const svc = createServiceWithCustomLoad(featureWithAccumulatedSummary);
|
||||
await svc.executeFeature('/test/project', 'feature-1');
|
||||
|
||||
// The accumulated summary should be preserved
|
||||
expect(mockSaveFeatureSummaryFn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeFeature - agent output validation', () => {
|
||||
// Helper to generate realistic agent output with tool markers
|
||||
const makeAgentOutput = (toolCount: number, extraText = ''): string => {
|
||||
let output = 'Starting implementation...\n\n';
|
||||
for (let i = 0; i < toolCount; i++) {
|
||||
output += `🔧 Tool: Edit\nInput: {"file_path": "/src/file${i}.ts", "old_string": "old${i}", "new_string": "new${i}"}\n\n`;
|
||||
}
|
||||
output += `Implementation complete. ${extraText}`;
|
||||
return output;
|
||||
};
|
||||
|
||||
const createServiceWithMocks = () => {
|
||||
return new ExecutionService(
|
||||
mockEventBus,
|
||||
mockConcurrencyManager,
|
||||
mockWorktreeResolver,
|
||||
mockSettingsService,
|
||||
mockRunAgentFn,
|
||||
mockExecutePipelineFn,
|
||||
mockUpdateFeatureStatusFn,
|
||||
mockLoadFeatureFn,
|
||||
mockGetPlanningPromptPrefixFn,
|
||||
mockSaveFeatureSummaryFn,
|
||||
mockRecordLearningsFn,
|
||||
mockContextExistsFn,
|
||||
mockResumeFeatureFn,
|
||||
mockTrackFailureFn,
|
||||
mockSignalPauseFn,
|
||||
mockRecordSuccessFn,
|
||||
mockSaveExecutionStateFn,
|
||||
mockLoadContextFilesFn
|
||||
);
|
||||
};
|
||||
|
||||
it('sets verified when agent output has tool usage and sufficient length', async () => {
|
||||
const output = makeAgentOutput(3, 'Updated authentication module with new login flow.');
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(output);
|
||||
|
||||
await service.executeFeature('/test/project', 'feature-1');
|
||||
|
||||
expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith(
|
||||
'/test/project',
|
||||
'feature-1',
|
||||
'verified'
|
||||
);
|
||||
});
|
||||
|
||||
it('sets waiting_approval when agent output is empty', async () => {
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue('');
|
||||
|
||||
const svc = createServiceWithMocks();
|
||||
await svc.executeFeature('/test/project', 'feature-1');
|
||||
|
||||
expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith(
|
||||
'/test/project',
|
||||
'feature-1',
|
||||
'waiting_approval'
|
||||
);
|
||||
});
|
||||
|
||||
it('sets waiting_approval when agent output has no tool usage markers', async () => {
|
||||
// Long output but no tool markers - agent printed text but didn't use tools
|
||||
const longOutputNoTools = 'I analyzed the codebase and found several issues. '.repeat(20);
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(longOutputNoTools);
|
||||
|
||||
const svc = createServiceWithMocks();
|
||||
await svc.executeFeature('/test/project', 'feature-1');
|
||||
|
||||
expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith(
|
||||
'/test/project',
|
||||
'feature-1',
|
||||
'waiting_approval'
|
||||
);
|
||||
});
|
||||
|
||||
it('sets waiting_approval when agent output has tool markers but is too short', async () => {
|
||||
// Has a tool marker but total output is under 200 chars
|
||||
const shortWithTool = '🔧 Tool: Read\nInput: {"file_path": "/src/index.ts"}\nDone.';
|
||||
expect(shortWithTool.trim().length).toBeLessThan(200);
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(shortWithTool);
|
||||
|
||||
const svc = createServiceWithMocks();
|
||||
await svc.executeFeature('/test/project', 'feature-1');
|
||||
|
||||
expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith(
|
||||
'/test/project',
|
||||
'feature-1',
|
||||
'waiting_approval'
|
||||
);
|
||||
});
|
||||
|
||||
it('sets waiting_approval when agent output file is missing (ENOENT)', async () => {
|
||||
vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
const svc = createServiceWithMocks();
|
||||
await svc.executeFeature('/test/project', 'feature-1');
|
||||
|
||||
expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith(
|
||||
'/test/project',
|
||||
'feature-1',
|
||||
'waiting_approval'
|
||||
);
|
||||
});
|
||||
|
||||
it('sets waiting_approval when agent output is only whitespace', async () => {
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(' \n\n\t \n ');
|
||||
|
||||
const svc = createServiceWithMocks();
|
||||
await svc.executeFeature('/test/project', 'feature-1');
|
||||
|
||||
expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith(
|
||||
'/test/project',
|
||||
'feature-1',
|
||||
'waiting_approval'
|
||||
);
|
||||
});
|
||||
|
||||
it('sets verified when output is exactly at the 200 char threshold with tool usage', async () => {
|
||||
// Create output that's exactly 200 chars trimmed with tool markers
|
||||
const toolMarker = '🔧 Tool: Edit\nInput: {"file_path": "/src/index.ts"}\n';
|
||||
const padding = 'x'.repeat(200 - toolMarker.length);
|
||||
const output = toolMarker + padding;
|
||||
expect(output.trim().length).toBeGreaterThanOrEqual(200);
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(output);
|
||||
|
||||
const svc = createServiceWithMocks();
|
||||
await svc.executeFeature('/test/project', 'feature-1');
|
||||
|
||||
expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith(
|
||||
'/test/project',
|
||||
'feature-1',
|
||||
'verified'
|
||||
);
|
||||
});
|
||||
|
||||
it('sets waiting_approval when output is 199 chars with tool usage (below threshold)', async () => {
|
||||
const toolMarker = '🔧 Tool: Read\n';
|
||||
const padding = 'x'.repeat(199 - toolMarker.length);
|
||||
const output = toolMarker + padding;
|
||||
expect(output.trim().length).toBe(199);
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(output);
|
||||
|
||||
const svc = createServiceWithMocks();
|
||||
await svc.executeFeature('/test/project', 'feature-1');
|
||||
|
||||
expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith(
|
||||
'/test/project',
|
||||
'feature-1',
|
||||
'waiting_approval'
|
||||
);
|
||||
});
|
||||
|
||||
it('skipTests always takes priority over output validation', async () => {
|
||||
// Meaningful output with tool usage - would normally be 'verified'
|
||||
const output = makeAgentOutput(5, 'All changes applied successfully.');
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(output);
|
||||
|
||||
mockLoadFeatureFn = vi.fn().mockResolvedValue({ ...testFeature, skipTests: true });
|
||||
const svc = createServiceWithMocks();
|
||||
|
||||
await svc.executeFeature('/test/project', 'feature-1');
|
||||
|
||||
// skipTests=true always means waiting_approval regardless of output quality
|
||||
expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith(
|
||||
'/test/project',
|
||||
'feature-1',
|
||||
'waiting_approval'
|
||||
);
|
||||
});
|
||||
|
||||
it('skipTests with empty output still results in waiting_approval', async () => {
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue('');
|
||||
|
||||
mockLoadFeatureFn = vi.fn().mockResolvedValue({ ...testFeature, skipTests: true });
|
||||
const svc = createServiceWithMocks();
|
||||
|
||||
await svc.executeFeature('/test/project', 'feature-1');
|
||||
|
||||
expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith(
|
||||
'/test/project',
|
||||
'feature-1',
|
||||
'waiting_approval'
|
||||
);
|
||||
});
|
||||
|
||||
it('still records success even when output validation fails', async () => {
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue('');
|
||||
|
||||
const svc = createServiceWithMocks();
|
||||
await svc.executeFeature('/test/project', 'feature-1');
|
||||
|
||||
// recordSuccess should still be called - the agent ran without errors
|
||||
expect(mockRecordSuccessFn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still extracts summary when output has content but no tool markers', async () => {
|
||||
const outputNoTools = 'A '.repeat(150); // > 200 chars but no tool markers
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(outputNoTools);
|
||||
|
||||
const svc = createServiceWithMocks();
|
||||
await svc.executeFeature('/test/project', 'feature-1');
|
||||
|
||||
// Summary extraction still runs even though status is waiting_approval
|
||||
expect(extractSummary).toHaveBeenCalledWith(outputNoTools);
|
||||
expect(mockSaveFeatureSummaryFn).toHaveBeenCalledWith(
|
||||
'/test/project',
|
||||
'feature-1',
|
||||
'Test summary'
|
||||
);
|
||||
});
|
||||
|
||||
it('emits feature_complete with passes=true even when output validation routes to waiting_approval', async () => {
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue('');
|
||||
|
||||
const svc = createServiceWithMocks();
|
||||
await svc.executeFeature('/test/project', 'feature-1', false, true);
|
||||
|
||||
// The agent ran without error - it's still a "pass" from the execution perspective
|
||||
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith(
|
||||
'auto_mode_feature_complete',
|
||||
expect.objectContaining({ passes: true })
|
||||
);
|
||||
});
|
||||
|
||||
it('handles realistic Cursor CLI output that exits quickly', async () => {
|
||||
// Simulates a Cursor CLI that prints a brief message and exits
|
||||
const cursorQuickExit = 'Task received. Processing...\nResult: completed successfully.';
|
||||
expect(cursorQuickExit.includes('🔧 Tool:')).toBe(false);
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(cursorQuickExit);
|
||||
|
||||
const svc = createServiceWithMocks();
|
||||
await svc.executeFeature('/test/project', 'feature-1');
|
||||
|
||||
// No tool usage = waiting_approval
|
||||
expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith(
|
||||
'/test/project',
|
||||
'feature-1',
|
||||
'waiting_approval'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles realistic Claude SDK output with multiple tool uses', async () => {
|
||||
// Simulates a Claude SDK agent that does real work
|
||||
const claudeOutput =
|
||||
"I'll implement the requested feature.\n\n" +
|
||||
'🔧 Tool: Read\nInput: {"file_path": "/src/components/App.tsx"}\n\n' +
|
||||
'I can see the existing component structure. Let me modify it.\n\n' +
|
||||
'🔧 Tool: Edit\nInput: {"file_path": "/src/components/App.tsx", "old_string": "const App = () => {", "new_string": "const App: React.FC = () => {"}\n\n' +
|
||||
'🔧 Tool: Write\nInput: {"file_path": "/src/components/NewFeature.tsx"}\n\n' +
|
||||
"I've created the new component and updated the existing one. The feature is now implemented with proper TypeScript types.";
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(claudeOutput);
|
||||
|
||||
const svc = createServiceWithMocks();
|
||||
await svc.executeFeature('/test/project', 'feature-1');
|
||||
|
||||
// Real work = verified
|
||||
expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith(
|
||||
'/test/project',
|
||||
'feature-1',
|
||||
'verified'
|
||||
);
|
||||
});
|
||||
|
||||
it('reads agent output from the correct path with utf-8 encoding', async () => {
|
||||
const output = makeAgentOutput(2, 'Done with changes.');
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(output);
|
||||
|
||||
const svc = createServiceWithMocks();
|
||||
await svc.executeFeature('/test/project', 'feature-1');
|
||||
|
||||
// Verify readFile was called with the correct path derived from getFeatureDir
|
||||
expect(secureFs.readFile).toHaveBeenCalledWith(
|
||||
'/test/project/.automaker/features/feature-1/agent-output.md',
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('completion message includes auto-verified when status is verified', async () => {
|
||||
const output = makeAgentOutput(3, 'All changes applied.');
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(output);
|
||||
|
||||
const svc = createServiceWithMocks();
|
||||
await svc.executeFeature('/test/project', 'feature-1', false, true);
|
||||
|
||||
expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith(
|
||||
'auto_mode_feature_complete',
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining('auto-verified'),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('completion message does NOT include auto-verified when status is waiting_approval', async () => {
|
||||
// Empty output → waiting_approval
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue('');
|
||||
|
||||
const svc = createServiceWithMocks();
|
||||
await svc.executeFeature('/test/project', 'feature-1', false, true);
|
||||
|
||||
const completeCall = vi
|
||||
.mocked(mockEventBus.emitAutoModeEvent)
|
||||
.mock.calls.find((call) => call[0] === 'auto_mode_feature_complete');
|
||||
expect(completeCall).toBeDefined();
|
||||
expect((completeCall![1] as { message: string }).message).not.toContain('auto-verified');
|
||||
});
|
||||
|
||||
it('uses same agentOutput for both status determination and summary extraction', async () => {
|
||||
// Specific output that is long enough with tool markers (verified path)
|
||||
// AND has content for summary extraction
|
||||
const specificOutput =
|
||||
'🔧 Tool: Read\nReading file...\n🔧 Tool: Edit\nEditing file...\n' +
|
||||
'The implementation is complete. Here is a detailed description of what was done. '.repeat(
|
||||
3
|
||||
);
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(specificOutput);
|
||||
|
||||
const svc = createServiceWithMocks();
|
||||
await svc.executeFeature('/test/project', 'feature-1');
|
||||
|
||||
// Status should be verified (has tools + long enough)
|
||||
expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith(
|
||||
'/test/project',
|
||||
'feature-1',
|
||||
'verified'
|
||||
);
|
||||
// extractSummary should receive the exact same output
|
||||
expect(extractSummary).toHaveBeenCalledWith(specificOutput);
|
||||
// recordLearnings should also receive the same output
|
||||
expect(mockRecordLearningsFn).toHaveBeenCalledWith(
|
||||
'/test/project',
|
||||
testFeature,
|
||||
specificOutput
|
||||
);
|
||||
});
|
||||
|
||||
it('does not call recordMemoryUsage when output is empty and memoryFiles is empty', async () => {
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue('');
|
||||
const { recordMemoryUsage } = await import('@automaker/utils');
|
||||
|
||||
const svc = createServiceWithMocks();
|
||||
await svc.executeFeature('/test/project', 'feature-1');
|
||||
|
||||
// With empty output and empty memoryFiles, recordMemoryUsage should not be called
|
||||
expect(recordMemoryUsage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles output with special unicode characters correctly', async () => {
|
||||
// Output with various unicode but includes tool markers
|
||||
const unicodeOutput =
|
||||
'🔧 Tool: Read\n' +
|
||||
'🔧 Tool: Edit\n' +
|
||||
'Añadiendo función de búsqueda con caracteres especiales: ñ, ü, ö, é, 日本語テスト. ' +
|
||||
'Die Änderungen wurden erfolgreich implementiert. '.repeat(3);
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(unicodeOutput);
|
||||
|
||||
const svc = createServiceWithMocks();
|
||||
await svc.executeFeature('/test/project', 'feature-1');
|
||||
|
||||
// Should still detect tool markers and sufficient length
|
||||
expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith(
|
||||
'/test/project',
|
||||
'feature-1',
|
||||
'verified'
|
||||
);
|
||||
});
|
||||
|
||||
it('treats output with only newlines and spaces around tool marker as insufficient', async () => {
|
||||
// Has tool marker but surrounded by whitespace, total trimmed < 200
|
||||
const sparseOutput = '\n\n 🔧 Tool: Read \n\n';
|
||||
expect(sparseOutput.trim().length).toBeLessThan(200);
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(sparseOutput);
|
||||
|
||||
const svc = createServiceWithMocks();
|
||||
await svc.executeFeature('/test/project', 'feature-1');
|
||||
|
||||
expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith(
|
||||
'/test/project',
|
||||
'feature-1',
|
||||
'waiting_approval'
|
||||
);
|
||||
});
|
||||
|
||||
it('detects tool marker substring correctly (partial match like "🔧 Tools:" does not count)', async () => {
|
||||
// Output with a similar but not exact marker - "🔧 Tools:" instead of "🔧 Tool:"
|
||||
const wrongMarker = '🔧 Tools: Read\n🔧 Tools: Edit\n' + 'Implementation done. '.repeat(20);
|
||||
expect(wrongMarker.includes('🔧 Tool:')).toBe(false);
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(wrongMarker);
|
||||
|
||||
const svc = createServiceWithMocks();
|
||||
await svc.executeFeature('/test/project', 'feature-1');
|
||||
|
||||
// "🔧 Tools:" is not the same as "🔧 Tool:" - should be waiting_approval
|
||||
expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith(
|
||||
'/test/project',
|
||||
'feature-1',
|
||||
'waiting_approval'
|
||||
);
|
||||
});
|
||||
|
||||
it('pipeline merge_conflict status short-circuits before output validation', async () => {
|
||||
// Set up pipeline that results in merge_conflict
|
||||
vi.mocked(pipelineService.getPipelineConfig).mockResolvedValue({
|
||||
version: 1,
|
||||
steps: [{ id: 'step-1', name: 'Step 1', order: 1, instructions: 'Do step 1' }] as any,
|
||||
});
|
||||
|
||||
// After pipeline, loadFeature returns merge_conflict status
|
||||
let loadCallCount = 0;
|
||||
mockLoadFeatureFn = vi.fn().mockImplementation(() => {
|
||||
loadCallCount++;
|
||||
if (loadCallCount === 1) return testFeature; // initial load
|
||||
// All subsequent loads (task check + pipeline refresh) return merge_conflict
|
||||
return { ...testFeature, status: 'merge_conflict' };
|
||||
});
|
||||
|
||||
const svc = createServiceWithMocks();
|
||||
await svc.executeFeature('/test/project', 'feature-1');
|
||||
|
||||
// Should NOT have called updateFeatureStatusFn with 'verified' or 'waiting_approval'
|
||||
// because pipeline merge_conflict short-circuits the method
|
||||
const statusCalls = vi
|
||||
.mocked(mockUpdateFeatureStatusFn)
|
||||
.mock.calls.filter((call) => call[2] === 'verified' || call[2] === 'waiting_approval');
|
||||
// The only non-in_progress status call should be absent since merge_conflict returns early
|
||||
expect(statusCalls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('sets waiting_approval instead of backlog when error occurs after pipeline completes', async () => {
|
||||
// Set up pipeline with steps
|
||||
vi.mocked(pipelineService.getPipelineConfig).mockResolvedValue({
|
||||
version: 1,
|
||||
steps: [{ id: 'step-1', name: 'Step 1', order: 1, instructions: 'Do step 1' }] as any,
|
||||
});
|
||||
|
||||
// Pipeline succeeds, but reading agent output throws after pipeline completes
|
||||
mockExecutePipelineFn = vi.fn().mockResolvedValue(undefined);
|
||||
// Simulate an error after pipeline completes by making loadFeature throw
|
||||
// on the post-pipeline refresh call
|
||||
let loadCallCount = 0;
|
||||
mockLoadFeatureFn = vi.fn().mockImplementation(() => {
|
||||
loadCallCount++;
|
||||
if (loadCallCount === 1) return testFeature; // initial load
|
||||
// Second call is the task-retry check, third is the pipeline refresh
|
||||
if (loadCallCount <= 2) return testFeature;
|
||||
throw new Error('Unexpected post-pipeline error');
|
||||
});
|
||||
|
||||
const svc = createServiceWithMocks();
|
||||
await svc.executeFeature('/test/project', 'feature-1');
|
||||
|
||||
// Should set to waiting_approval, NOT backlog, since pipeline completed
|
||||
const backlogCalls = vi
|
||||
.mocked(mockUpdateFeatureStatusFn)
|
||||
.mock.calls.filter((call) => call[2] === 'backlog');
|
||||
expect(backlogCalls.length).toBe(0);
|
||||
|
||||
const waitingCalls = vi
|
||||
.mocked(mockUpdateFeatureStatusFn)
|
||||
.mock.calls.filter((call) => call[2] === 'waiting_approval');
|
||||
expect(waitingCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('still sets backlog when error occurs before pipeline completes', async () => {
|
||||
// Set up pipeline with steps
|
||||
vi.mocked(pipelineService.getPipelineConfig).mockResolvedValue({
|
||||
version: 1,
|
||||
steps: [{ id: 'step-1', name: 'Step 1', order: 1, instructions: 'Do step 1' }] as any,
|
||||
});
|
||||
|
||||
// Pipeline itself throws (e.g., agent error during pipeline step)
|
||||
mockExecutePipelineFn = vi.fn().mockRejectedValue(new Error('Agent execution failed'));
|
||||
|
||||
const svc = createServiceWithMocks();
|
||||
await svc.executeFeature('/test/project', 'feature-1');
|
||||
|
||||
// Should still set to backlog since pipeline did NOT complete
|
||||
const backlogCalls = vi
|
||||
.mocked(mockUpdateFeatureStatusFn)
|
||||
.mock.calls.filter((call) => call[2] === 'backlog');
|
||||
expect(backlogCalls.length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,17 @@ import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
|
||||
import path from 'path';
|
||||
import { FeatureStateManager } from '@/services/feature-state-manager.js';
|
||||
import type { Feature } from '@automaker/types';
|
||||
import { isPipelineStatus } from '@automaker/types';
|
||||
|
||||
const PIPELINE_SUMMARY_SEPARATOR = '\n\n---\n\n';
|
||||
const PIPELINE_SUMMARY_HEADER_PREFIX = '### ';
|
||||
import type { EventEmitter } from '@/lib/events.js';
|
||||
import type { FeatureLoader } from '@/services/feature-loader.js';
|
||||
import * as secureFs from '@/lib/secure-fs.js';
|
||||
import { atomicWriteJson, readJsonWithRecovery } from '@automaker/utils';
|
||||
import { getFeatureDir, getFeaturesDir } from '@automaker/platform';
|
||||
import { getNotificationService } from '@/services/notification-service.js';
|
||||
import { pipelineService } from '@/services/pipeline-service.js';
|
||||
|
||||
/**
|
||||
* Helper to normalize paths for cross-platform test compatibility.
|
||||
@@ -42,6 +47,16 @@ vi.mock('@/services/notification-service.js', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/pipeline-service.js', () => ({
|
||||
pipelineService: {
|
||||
getStepIdFromStatus: vi.fn((status: string) => {
|
||||
if (status.startsWith('pipeline_')) return status.replace('pipeline_', '');
|
||||
return null;
|
||||
}),
|
||||
getStep: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('FeatureStateManager', () => {
|
||||
let manager: FeatureStateManager;
|
||||
let mockEvents: EventEmitter;
|
||||
@@ -264,6 +279,81 @@ describe('FeatureStateManager', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should use feature.title as notification title for waiting_approval status', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
const featureWithTitle: Feature = {
|
||||
...mockFeature,
|
||||
title: 'My Awesome Feature Title',
|
||||
name: 'old-name-property', // name property exists but should not be used
|
||||
};
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: featureWithTitle,
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval');
|
||||
|
||||
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'feature_waiting_approval',
|
||||
title: 'My Awesome Feature Title',
|
||||
message: 'Feature Ready for Review',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should fallback to featureId as notification title when feature.title is undefined in waiting_approval notification', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
const featureWithoutTitle: Feature = {
|
||||
...mockFeature,
|
||||
title: undefined,
|
||||
name: 'old-name-property',
|
||||
};
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: featureWithoutTitle,
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval');
|
||||
|
||||
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'feature_waiting_approval',
|
||||
title: 'feature-123',
|
||||
message: 'Feature Ready for Review',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty string title by using featureId as notification title in waiting_approval notification', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
const featureWithEmptyTitle: Feature = {
|
||||
...mockFeature,
|
||||
title: '',
|
||||
name: 'old-name-property',
|
||||
};
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: featureWithEmptyTitle,
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval');
|
||||
|
||||
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'feature_waiting_approval',
|
||||
title: 'feature-123',
|
||||
message: 'Feature Ready for Review',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should create notification for verified status', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
@@ -283,6 +373,81 @@ describe('FeatureStateManager', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should use feature.title as notification title for verified status', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
const featureWithTitle: Feature = {
|
||||
...mockFeature,
|
||||
title: 'My Awesome Feature Title',
|
||||
name: 'old-name-property', // name property exists but should not be used
|
||||
};
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: featureWithTitle,
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.updateFeatureStatus('/project', 'feature-123', 'verified');
|
||||
|
||||
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'feature_verified',
|
||||
title: 'My Awesome Feature Title',
|
||||
message: 'Feature Verified',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should fallback to featureId as notification title when feature.title is undefined in verified notification', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
const featureWithoutTitle: Feature = {
|
||||
...mockFeature,
|
||||
title: undefined,
|
||||
name: 'old-name-property',
|
||||
};
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: featureWithoutTitle,
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.updateFeatureStatus('/project', 'feature-123', 'verified');
|
||||
|
||||
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'feature_verified',
|
||||
title: 'feature-123',
|
||||
message: 'Feature Verified',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty string title by using featureId as notification title in verified notification', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
const featureWithEmptyTitle: Feature = {
|
||||
...mockFeature,
|
||||
title: '',
|
||||
name: 'old-name-property',
|
||||
};
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: featureWithEmptyTitle,
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.updateFeatureStatus('/project', 'feature-123', 'verified');
|
||||
|
||||
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'feature_verified',
|
||||
title: 'feature-123',
|
||||
message: 'Feature Verified',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should sync to app_spec for completed status', async () => {
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature },
|
||||
@@ -341,9 +506,6 @@ describe('FeatureStateManager', () => {
|
||||
|
||||
describe('markFeatureInterrupted', () => {
|
||||
it('should mark feature as interrupted', async () => {
|
||||
(secureFs.readFile as Mock).mockResolvedValue(
|
||||
JSON.stringify({ ...mockFeature, status: 'in_progress' })
|
||||
);
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'in_progress' },
|
||||
recovered: false,
|
||||
@@ -358,20 +520,25 @@ describe('FeatureStateManager', () => {
|
||||
});
|
||||
|
||||
it('should preserve pipeline_* statuses', async () => {
|
||||
(secureFs.readFile as Mock).mockResolvedValue(
|
||||
JSON.stringify({ ...mockFeature, status: 'pipeline_step_1' })
|
||||
);
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_step_1' },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.markFeatureInterrupted('/project', 'feature-123', 'server shutdown');
|
||||
|
||||
// Should NOT call atomicWriteJson because pipeline status is preserved
|
||||
expect(atomicWriteJson).not.toHaveBeenCalled();
|
||||
expect(isPipelineStatus('pipeline_step_1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should preserve pipeline_complete status', async () => {
|
||||
(secureFs.readFile as Mock).mockResolvedValue(
|
||||
JSON.stringify({ ...mockFeature, status: 'pipeline_complete' })
|
||||
);
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_complete' },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.markFeatureInterrupted('/project', 'feature-123');
|
||||
|
||||
@@ -379,7 +546,6 @@ describe('FeatureStateManager', () => {
|
||||
});
|
||||
|
||||
it('should handle feature not found', async () => {
|
||||
(secureFs.readFile as Mock).mockRejectedValue(new Error('ENOENT'));
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: null,
|
||||
recovered: true,
|
||||
@@ -439,6 +605,29 @@ describe('FeatureStateManager', () => {
|
||||
expect(savedFeature.status).toBe('backlog');
|
||||
});
|
||||
|
||||
it('should preserve pipeline_* statuses during reset', async () => {
|
||||
const pipelineFeature: Feature = {
|
||||
...mockFeature,
|
||||
status: 'pipeline_testing',
|
||||
planSpec: { status: 'approved', version: 1, reviewedByUser: true },
|
||||
};
|
||||
|
||||
(secureFs.readdir as Mock).mockResolvedValue([
|
||||
{ name: 'feature-123', isDirectory: () => true },
|
||||
]);
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: pipelineFeature,
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.resetStuckFeatures('/project');
|
||||
|
||||
// Status should NOT be changed, but needsUpdate might be true if other things reset
|
||||
// In this case, nothing else should be reset, so atomicWriteJson shouldn't be called
|
||||
expect(atomicWriteJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reset generating planSpec status to pending', async () => {
|
||||
const stuckFeature: Feature = {
|
||||
...mockFeature,
|
||||
@@ -628,6 +817,379 @@ describe('FeatureStateManager', () => {
|
||||
expect(atomicWriteJson).not.toHaveBeenCalled();
|
||||
expect(mockEvents.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should accumulate summary with step header for pipeline features', async () => {
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code Review', id: 'step1' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_step1', summary: undefined },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'feature-123', 'First step output');
|
||||
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
expect(savedFeature.summary).toBe(
|
||||
`${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nFirst step output`
|
||||
);
|
||||
});
|
||||
|
||||
it('should append subsequent pipeline step summaries with separator', async () => {
|
||||
const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nFirst step output`;
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step2' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_step2', summary: existingSummary },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'feature-123', 'Second step output');
|
||||
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
expect(savedFeature.summary).toBe(
|
||||
`${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nFirst step output${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nSecond step output`
|
||||
);
|
||||
});
|
||||
|
||||
it('should normalize existing non-phase summary before appending pipeline step summary', async () => {
|
||||
const existingSummary = 'Implemented authentication and settings management.';
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code Review', id: 'step1' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_step1', summary: existingSummary },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'feature-123', 'Reviewed and approved changes');
|
||||
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
expect(savedFeature.summary).toBe(
|
||||
`${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nImplemented authentication and settings management.${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nReviewed and approved changes`
|
||||
);
|
||||
});
|
||||
|
||||
it('should use fallback step name when pipeline step not found', async () => {
|
||||
(pipelineService.getStep as Mock).mockResolvedValue(null);
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_unknown_step', summary: undefined },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'feature-123', 'Step output');
|
||||
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
expect(savedFeature.summary).toBe(
|
||||
`${PIPELINE_SUMMARY_HEADER_PREFIX}Unknown Step\n\nStep output`
|
||||
);
|
||||
});
|
||||
|
||||
it('should overwrite summary for non-pipeline features', async () => {
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'in_progress', summary: 'Old summary' },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'feature-123', 'New summary');
|
||||
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
expect(savedFeature.summary).toBe('New summary');
|
||||
});
|
||||
|
||||
it('should emit full accumulated summary for pipeline features', async () => {
|
||||
const existingSummary = '### Code Review\n\nFirst step output';
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Refinement', id: 'step2' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_step2', summary: existingSummary },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'feature-123', 'Refinement output');
|
||||
|
||||
const expectedSummary =
|
||||
'### Code Review\n\nFirst step output\n\n---\n\n### Refinement\n\nRefinement output';
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
|
||||
type: 'auto_mode_summary',
|
||||
featureId: 'feature-123',
|
||||
projectPath: '/project',
|
||||
summary: expectedSummary,
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip accumulation for pipeline features when summary is empty', async () => {
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step1' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_step1', summary: '' },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'feature-123', 'Test output');
|
||||
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
// Empty string is falsy, so should start fresh
|
||||
expect(savedFeature.summary).toBe('### Testing\n\nTest output');
|
||||
});
|
||||
|
||||
it('should skip persistence when incoming summary is only whitespace', async () => {
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step1' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_step1', summary: '### Existing\n\nValue' },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'feature-123', ' \n\t ');
|
||||
|
||||
expect(atomicWriteJson).not.toHaveBeenCalled();
|
||||
expect(mockEvents.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should accumulate three pipeline steps in chronological order', async () => {
|
||||
// Step 1: Code Review
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code Review', id: 'step1' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_step1', summary: undefined },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'feature-123', 'Review findings');
|
||||
const afterStep1 = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
expect(afterStep1.summary).toBe('### Code Review\n\nReview findings');
|
||||
|
||||
// Step 2: Testing (summary from step 1 exists)
|
||||
vi.clearAllMocks();
|
||||
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/feature-123');
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step2' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_step2', summary: afterStep1.summary },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'feature-123', 'All tests pass');
|
||||
const afterStep2 = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
|
||||
// Step 3: Refinement (summaries from steps 1+2 exist)
|
||||
vi.clearAllMocks();
|
||||
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/feature-123');
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Refinement', id: 'step3' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_step3', summary: afterStep2.summary },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'feature-123', 'Code polished');
|
||||
const afterStep3 = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
|
||||
// Verify the full accumulated summary has all three steps in order
|
||||
expect(afterStep3.summary).toBe(
|
||||
`${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nReview findings${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nAll tests pass${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Refinement\n\nCode polished`
|
||||
);
|
||||
});
|
||||
|
||||
it('should replace existing step summary if called again for the same step', async () => {
|
||||
const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nInitial code${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nFirst review attempt`;
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code Review', id: 'step1' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_step1', summary: existingSummary },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary(
|
||||
'/project',
|
||||
'feature-123',
|
||||
'Second review attempt (success)'
|
||||
);
|
||||
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
// Should REPLACE "First review attempt" with "Second review attempt (success)"
|
||||
// and NOT append it as a new section
|
||||
expect(savedFeature.summary).toBe(
|
||||
`${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nInitial code${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nSecond review attempt (success)`
|
||||
);
|
||||
// Ensure it didn't duplicate the separator or header
|
||||
expect(
|
||||
savedFeature.summary.match(new RegExp(PIPELINE_SUMMARY_HEADER_PREFIX + 'Code Review', 'g'))
|
||||
?.length
|
||||
).toBe(1);
|
||||
expect(
|
||||
savedFeature.summary.match(new RegExp(PIPELINE_SUMMARY_SEPARATOR.trim(), 'g'))?.length
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('should replace last step summary without trailing separator', async () => {
|
||||
// Test case: replacing the last step which has no separator after it
|
||||
const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nInitial code${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nFirst test run`;
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step2' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_step2', summary: existingSummary },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'feature-123', 'All tests pass');
|
||||
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
expect(savedFeature.summary).toBe(
|
||||
`${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nInitial code${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nAll tests pass`
|
||||
);
|
||||
});
|
||||
|
||||
it('should replace first step summary with separator after it', async () => {
|
||||
// Test case: replacing the first step which has a separator after it
|
||||
const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nFirst attempt${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nAll tests pass`;
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Implementation', id: 'step1' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_step1', summary: existingSummary },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'feature-123', 'Second attempt');
|
||||
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
expect(savedFeature.summary).toBe(
|
||||
`${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nSecond attempt${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nAll tests pass`
|
||||
);
|
||||
});
|
||||
|
||||
it('should not match step header appearing in body text, only at section boundaries', async () => {
|
||||
// Test case: body text contains "### Testing" which should NOT be matched
|
||||
// Only headers at actual section boundaries should be replaced
|
||||
const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nThis step covers the Testing module.\n\n### Testing\n\nThe above is just markdown in body, not a section header.${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nReal test section`;
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step2' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_step2', summary: existingSummary },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'feature-123', 'Updated test results');
|
||||
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
// The section replacement should only replace the actual Testing section at the boundary
|
||||
// NOT the "### Testing" that appears in the body text
|
||||
expect(savedFeature.summary).toBe(
|
||||
`${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nThis step covers the Testing module.\n\n### Testing\n\nThe above is just markdown in body, not a section header.${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nUpdated test results`
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle step name with special regex characters safely', async () => {
|
||||
// Test case: step name contains characters that would break regex
|
||||
const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Code (Review)\n\nFirst attempt`;
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code (Review)', id: 'step1' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_step1', summary: existingSummary },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'feature-123', 'Second attempt');
|
||||
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
expect(savedFeature.summary).toBe(
|
||||
`${PIPELINE_SUMMARY_HEADER_PREFIX}Code (Review)\n\nSecond attempt`
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle step name with brackets safely', async () => {
|
||||
// Test case: step name contains array-like syntax [0]
|
||||
const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Step [0]\n\nFirst attempt`;
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Step [0]', id: 'step1' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_step1', summary: existingSummary },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'feature-123', 'Second attempt');
|
||||
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
expect(savedFeature.summary).toBe(
|
||||
`${PIPELINE_SUMMARY_HEADER_PREFIX}Step [0]\n\nSecond attempt`
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle pipelineService.getStepIdFromStatus throwing an error gracefully', async () => {
|
||||
(pipelineService.getStepIdFromStatus as Mock).mockImplementation(() => {
|
||||
throw new Error('Config not found');
|
||||
});
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_my_step', summary: undefined },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'feature-123', 'Step output');
|
||||
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
// Should use fallback: capitalize each word in the status suffix
|
||||
expect(savedFeature.summary).toBe(`${PIPELINE_SUMMARY_HEADER_PREFIX}My Step\n\nStep output`);
|
||||
});
|
||||
|
||||
it('should handle pipelineService.getStep throwing an error gracefully', async () => {
|
||||
(pipelineService.getStep as Mock).mockRejectedValue(new Error('Disk read error'));
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_code_review', summary: undefined },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'feature-123', 'Step output');
|
||||
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
// Should use fallback: capitalize each word in the status suffix
|
||||
expect(savedFeature.summary).toBe(
|
||||
`${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nStep output`
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle summary content with markdown formatting', async () => {
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code Review', id: 'step1' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_step1', summary: undefined },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
const markdownSummary =
|
||||
'## Changes Made\n- Fixed **bug** in `parser.ts`\n- Added `validateInput()` function\n\n```typescript\nconst x = 1;\n```';
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'feature-123', markdownSummary);
|
||||
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
expect(savedFeature.summary).toBe(
|
||||
`${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\n${markdownSummary}`
|
||||
);
|
||||
});
|
||||
|
||||
it('should persist before emitting event for pipeline summary accumulation', async () => {
|
||||
const callOrder: string[] = [];
|
||||
const existingSummary = '### Code Review\n\nFirst step output';
|
||||
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step2' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, status: 'pipeline_step2', summary: existingSummary },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
(atomicWriteJson as Mock).mockImplementation(async () => {
|
||||
callOrder.push('persist');
|
||||
});
|
||||
(mockEvents.emit as Mock).mockImplementation(() => {
|
||||
callOrder.push('emit');
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'feature-123', 'Test results');
|
||||
|
||||
expect(callOrder).toEqual(['persist', 'emit']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTaskStatus', () => {
|
||||
@@ -668,6 +1230,48 @@ describe('FeatureStateManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should update task status and summary and emit event', async () => {
|
||||
const featureWithTasks: Feature = {
|
||||
...mockFeature,
|
||||
planSpec: {
|
||||
status: 'approved',
|
||||
version: 1,
|
||||
reviewedByUser: true,
|
||||
tasks: [{ id: 'task-1', title: 'Task 1', status: 'pending', description: '' }],
|
||||
},
|
||||
};
|
||||
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: featureWithTasks,
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.updateTaskStatus(
|
||||
'/project',
|
||||
'feature-123',
|
||||
'task-1',
|
||||
'completed',
|
||||
'Task finished successfully'
|
||||
);
|
||||
|
||||
// Verify persisted
|
||||
const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
expect(savedFeature.planSpec?.tasks?.[0].status).toBe('completed');
|
||||
expect(savedFeature.planSpec?.tasks?.[0].summary).toBe('Task finished successfully');
|
||||
|
||||
// Verify event emitted
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
|
||||
type: 'auto_mode_task_status',
|
||||
featureId: 'feature-123',
|
||||
projectPath: '/project',
|
||||
taskId: 'task-1',
|
||||
status: 'completed',
|
||||
summary: 'Task finished successfully',
|
||||
tasks: expect.any(Array),
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle task not found', async () => {
|
||||
const featureWithTasks: Feature = {
|
||||
...mockFeature,
|
||||
@@ -757,4 +1361,179 @@ describe('FeatureStateManager', () => {
|
||||
expect(callOrder).toEqual(['persist', 'emit']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleAutoModeEventError', () => {
|
||||
let subscribeCallback: (type: string, payload: unknown) => void;
|
||||
|
||||
beforeEach(() => {
|
||||
// Get the subscribe callback from the mock - the callback passed TO subscribe is at index [0]
|
||||
// subscribe is called like: events.subscribe(callback), so callback is at mock.calls[0][0]
|
||||
const mockCalls = (mockEvents.subscribe as Mock).mock.calls;
|
||||
if (mockCalls.length > 0 && mockCalls[0].length > 0) {
|
||||
subscribeCallback = mockCalls[0][0] as typeof subscribeCallback;
|
||||
}
|
||||
});
|
||||
|
||||
it('should ignore events with no type', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
|
||||
await subscribeCallback('auto-mode:event', {});
|
||||
|
||||
expect(mockNotificationService.createNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should ignore non-error events', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
|
||||
await subscribeCallback('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
passes: true,
|
||||
projectPath: '/project',
|
||||
});
|
||||
|
||||
expect(mockNotificationService.createNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create auto_mode_error notification with gesture name as title when no featureId', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
|
||||
await subscribeCallback('auto-mode:event', {
|
||||
type: 'auto_mode_error',
|
||||
message: 'Something went wrong',
|
||||
projectPath: '/project',
|
||||
});
|
||||
|
||||
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'auto_mode_error',
|
||||
title: 'Auto Mode Error',
|
||||
message: 'Something went wrong',
|
||||
projectPath: '/project',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should use error field instead of message when available', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
|
||||
await subscribeCallback('auto-mode:event', {
|
||||
type: 'auto_mode_error',
|
||||
message: 'Some message',
|
||||
error: 'The actual error',
|
||||
projectPath: '/project',
|
||||
});
|
||||
|
||||
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'auto_mode_error',
|
||||
message: 'The actual error',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should use feature title as notification title for feature error with featureId', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...mockFeature, title: 'Login Page Feature' },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
subscribeCallback('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
passes: false,
|
||||
featureId: 'feature-123',
|
||||
error: 'Build failed',
|
||||
projectPath: '/project',
|
||||
});
|
||||
|
||||
// Wait for async handleAutoModeEventError to complete
|
||||
await vi.waitFor(() => {
|
||||
expect(mockNotificationService.createNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'feature_error',
|
||||
title: 'Login Page Feature',
|
||||
message: 'Feature Failed: Build failed',
|
||||
featureId: 'feature-123',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore auto_mode_feature_complete without passes=false', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
|
||||
await subscribeCallback('auto-mode:event', {
|
||||
type: 'auto_mode_feature_complete',
|
||||
passes: true,
|
||||
projectPath: '/project',
|
||||
});
|
||||
|
||||
expect(mockNotificationService.createNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle missing projectPath gracefully', async () => {
|
||||
const mockNotificationService = { createNotification: vi.fn() };
|
||||
(getNotificationService as Mock).mockReturnValue(mockNotificationService);
|
||||
|
||||
await subscribeCallback('auto-mode:event', {
|
||||
type: 'auto_mode_error',
|
||||
message: 'Error occurred',
|
||||
});
|
||||
|
||||
expect(mockNotificationService.createNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle notification service failures gracefully', async () => {
|
||||
(getNotificationService as Mock).mockImplementation(() => {
|
||||
throw new Error('Service unavailable');
|
||||
});
|
||||
|
||||
// Should not throw - the callback returns void so we just call it and wait for async work
|
||||
subscribeCallback('auto-mode:event', {
|
||||
type: 'auto_mode_error',
|
||||
message: 'Error',
|
||||
projectPath: '/project',
|
||||
});
|
||||
|
||||
// Give async handleAutoModeEventError time to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
});
|
||||
|
||||
describe('destroy', () => {
|
||||
it('should unsubscribe from event subscription', () => {
|
||||
const unsubscribeFn = vi.fn();
|
||||
(mockEvents.subscribe as Mock).mockReturnValue(unsubscribeFn);
|
||||
|
||||
// Create a new manager to get a fresh subscription
|
||||
const newManager = new FeatureStateManager(mockEvents, mockFeatureLoader);
|
||||
|
||||
// Call destroy
|
||||
newManager.destroy();
|
||||
|
||||
// Verify unsubscribe was called
|
||||
expect(unsubscribeFn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle destroy being called multiple times', () => {
|
||||
const unsubscribeFn = vi.fn();
|
||||
(mockEvents.subscribe as Mock).mockReturnValue(unsubscribeFn);
|
||||
|
||||
const newManager = new FeatureStateManager(mockEvents, mockFeatureLoader);
|
||||
|
||||
// Call destroy multiple times
|
||||
newManager.destroy();
|
||||
newManager.destroy();
|
||||
|
||||
// Should only unsubscribe once
|
||||
expect(unsubscribeFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
642
apps/server/tests/unit/services/ntfy-service.test.ts
Normal file
642
apps/server/tests/unit/services/ntfy-service.test.ts
Normal file
@@ -0,0 +1,642 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { NtfyService } from '../../../src/services/ntfy-service.js';
|
||||
import type { NtfyEndpointConfig } from '@automaker/types';
|
||||
|
||||
// Mock global fetch
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
describe('NtfyService', () => {
|
||||
let service: NtfyService;
|
||||
let mockFetch: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new NtfyService();
|
||||
mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a valid endpoint config for testing
|
||||
*/
|
||||
function createEndpoint(overrides: Partial<NtfyEndpointConfig> = {}): NtfyEndpointConfig {
|
||||
return {
|
||||
id: 'test-endpoint-id',
|
||||
name: 'Test Endpoint',
|
||||
serverUrl: 'https://ntfy.sh',
|
||||
topic: 'test-topic',
|
||||
authType: 'none',
|
||||
enabled: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a basic context for testing
|
||||
*/
|
||||
function createContext() {
|
||||
return {
|
||||
featureId: 'feat-123',
|
||||
featureName: 'Test Feature',
|
||||
projectPath: '/test/project',
|
||||
projectName: 'test-project',
|
||||
timestamp: '2024-01-15T10:30:00.000Z',
|
||||
eventType: 'feature_success',
|
||||
};
|
||||
}
|
||||
|
||||
describe('validateEndpoint', () => {
|
||||
it('should return null for valid endpoint with no auth', () => {
|
||||
const endpoint = createEndpoint();
|
||||
const result = service.validateEndpoint(endpoint);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for valid endpoint with basic auth', () => {
|
||||
const endpoint = createEndpoint({
|
||||
authType: 'basic',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
});
|
||||
const result = service.validateEndpoint(endpoint);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for valid endpoint with token auth', () => {
|
||||
const endpoint = createEndpoint({
|
||||
authType: 'token',
|
||||
token: 'tk_123456',
|
||||
});
|
||||
const result = service.validateEndpoint(endpoint);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return error when serverUrl is missing', () => {
|
||||
const endpoint = createEndpoint({ serverUrl: '' });
|
||||
const result = service.validateEndpoint(endpoint);
|
||||
expect(result).toBe('Server URL is required');
|
||||
});
|
||||
|
||||
it('should return error when serverUrl is invalid', () => {
|
||||
const endpoint = createEndpoint({ serverUrl: 'not-a-valid-url' });
|
||||
const result = service.validateEndpoint(endpoint);
|
||||
expect(result).toBe('Invalid server URL format');
|
||||
});
|
||||
|
||||
it('should return error when topic is missing', () => {
|
||||
const endpoint = createEndpoint({ topic: '' });
|
||||
const result = service.validateEndpoint(endpoint);
|
||||
expect(result).toBe('Topic is required');
|
||||
});
|
||||
|
||||
it('should return error when topic contains spaces', () => {
|
||||
const endpoint = createEndpoint({ topic: 'invalid topic' });
|
||||
const result = service.validateEndpoint(endpoint);
|
||||
expect(result).toBe('Topic cannot contain spaces');
|
||||
});
|
||||
|
||||
it('should return error when topic contains tabs', () => {
|
||||
const endpoint = createEndpoint({ topic: 'invalid\ttopic' });
|
||||
const result = service.validateEndpoint(endpoint);
|
||||
expect(result).toBe('Topic cannot contain spaces');
|
||||
});
|
||||
|
||||
it('should return error when basic auth is missing username', () => {
|
||||
const endpoint = createEndpoint({
|
||||
authType: 'basic',
|
||||
username: '',
|
||||
password: 'pass',
|
||||
});
|
||||
const result = service.validateEndpoint(endpoint);
|
||||
expect(result).toBe('Username and password are required for basic authentication');
|
||||
});
|
||||
|
||||
it('should return error when basic auth is missing password', () => {
|
||||
const endpoint = createEndpoint({
|
||||
authType: 'basic',
|
||||
username: 'user',
|
||||
password: '',
|
||||
});
|
||||
const result = service.validateEndpoint(endpoint);
|
||||
expect(result).toBe('Username and password are required for basic authentication');
|
||||
});
|
||||
|
||||
it('should return error when token auth is missing token', () => {
|
||||
const endpoint = createEndpoint({
|
||||
authType: 'token',
|
||||
token: '',
|
||||
});
|
||||
const result = service.validateEndpoint(endpoint);
|
||||
expect(result).toBe('Access token is required for token authentication');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendNotification', () => {
|
||||
it('should return error when endpoint is disabled', async () => {
|
||||
const endpoint = createEndpoint({ enabled: false });
|
||||
const result = await service.sendNotification(endpoint, {}, createContext());
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Endpoint is disabled');
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error when endpoint validation fails', async () => {
|
||||
const endpoint = createEndpoint({ serverUrl: '' });
|
||||
const result = await service.sendNotification(endpoint, {}, createContext());
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Server URL is required');
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should send notification with default values', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const result = await service.sendNotification(endpoint, {}, createContext());
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [url, options] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('https://ntfy.sh/test-topic');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(options.headers['Content-Type']).toBe('text/plain; charset=utf-8');
|
||||
expect(options.headers['Title']).toContain('Feature Completed');
|
||||
expect(options.headers['Priority']).toBe('3');
|
||||
});
|
||||
|
||||
it('should send notification with custom title and body', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const result = await service.sendNotification(
|
||||
endpoint,
|
||||
{
|
||||
title: 'Custom Title',
|
||||
body: 'Custom body message',
|
||||
},
|
||||
createContext()
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Title']).toBe('Custom Title');
|
||||
expect(options.body).toBe('Custom body message');
|
||||
});
|
||||
|
||||
it('should send notification with tags and emoji', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const result = await service.sendNotification(
|
||||
endpoint,
|
||||
{
|
||||
tags: 'warning,skull',
|
||||
emoji: 'tada',
|
||||
},
|
||||
createContext()
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Tags']).toBe('tada,warning,skull');
|
||||
});
|
||||
|
||||
it('should send notification with priority', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const result = await service.sendNotification(endpoint, { priority: 5 }, createContext());
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Priority']).toBe('5');
|
||||
});
|
||||
|
||||
it('should send notification with click URL', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const result = await service.sendNotification(
|
||||
endpoint,
|
||||
{ clickUrl: 'https://example.com/feature/123' },
|
||||
createContext()
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Click']).toBe('https://example.com/feature/123');
|
||||
});
|
||||
|
||||
it('should use endpoint default tags and emoji when not specified', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpoint = createEndpoint({
|
||||
defaultTags: 'default-tag',
|
||||
defaultEmoji: 'rocket',
|
||||
});
|
||||
const result = await service.sendNotification(endpoint, {}, createContext());
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Tags']).toBe('rocket,default-tag');
|
||||
});
|
||||
|
||||
it('should use endpoint default click URL when not specified', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpoint = createEndpoint({
|
||||
defaultClickUrl: 'https://default.example.com',
|
||||
});
|
||||
const result = await service.sendNotification(endpoint, {}, createContext());
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Click']).toBe('https://default.example.com');
|
||||
});
|
||||
|
||||
it('should send notification with basic authentication', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpoint = createEndpoint({
|
||||
authType: 'basic',
|
||||
username: 'testuser',
|
||||
password: 'testpass',
|
||||
});
|
||||
const result = await service.sendNotification(endpoint, {}, createContext());
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
// Basic auth should be base64 encoded
|
||||
const expectedAuth = Buffer.from('testuser:testpass').toString('base64');
|
||||
expect(options.headers['Authorization']).toBe(`Basic ${expectedAuth}`);
|
||||
});
|
||||
|
||||
it('should send notification with token authentication', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpoint = createEndpoint({
|
||||
authType: 'token',
|
||||
token: 'tk_test_token_123',
|
||||
});
|
||||
const result = await service.sendNotification(endpoint, {}, createContext());
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Authorization']).toBe('Bearer tk_test_token_123');
|
||||
});
|
||||
|
||||
it('should return error on HTTP error response', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 403,
|
||||
text: () => Promise.resolve('Forbidden - invalid token'),
|
||||
});
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const result = await service.sendNotification(endpoint, {}, createContext());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('403');
|
||||
expect(result.error).toContain('Forbidden');
|
||||
});
|
||||
|
||||
it('should return error on timeout', async () => {
|
||||
mockFetch.mockImplementationOnce(() => {
|
||||
const error = new Error('Aborted');
|
||||
error.name = 'AbortError';
|
||||
throw error;
|
||||
});
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const result = await service.sendNotification(endpoint, {}, createContext());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Request timed out');
|
||||
});
|
||||
|
||||
it('should return error on network error', async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const result = await service.sendNotification(endpoint, {}, createContext());
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Network error');
|
||||
});
|
||||
|
||||
it('should handle server URL with trailing slash', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpoint = createEndpoint({ serverUrl: 'https://ntfy.sh/' });
|
||||
await service.sendNotification(endpoint, {}, createContext());
|
||||
|
||||
const url = mockFetch.mock.calls[0][0];
|
||||
expect(url).toBe('https://ntfy.sh/test-topic');
|
||||
});
|
||||
|
||||
it('should URL encode the topic', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const endpoint = createEndpoint({ topic: 'test/topic#special' });
|
||||
await service.sendNotification(endpoint, {}, createContext());
|
||||
|
||||
const url = mockFetch.mock.calls[0][0];
|
||||
expect(url).toContain('test%2Ftopic%23special');
|
||||
});
|
||||
});
|
||||
|
||||
describe('variable substitution', () => {
|
||||
it('should substitute {{featureId}} in title', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
await service.sendNotification(
|
||||
endpoint,
|
||||
{ title: 'Feature {{featureId}} completed' },
|
||||
createContext()
|
||||
);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Title']).toBe('Feature feat-123 completed');
|
||||
});
|
||||
|
||||
it('should substitute {{featureName}} in body', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
await service.sendNotification(
|
||||
endpoint,
|
||||
{ body: 'The feature "{{featureName}}" is done!' },
|
||||
createContext()
|
||||
);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.body).toBe('The feature "Test Feature" is done!');
|
||||
});
|
||||
|
||||
it('should substitute {{projectName}} in title', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
await service.sendNotification(
|
||||
endpoint,
|
||||
{ title: '[{{projectName}}] Event: {{eventType}}' },
|
||||
createContext()
|
||||
);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Title']).toBe('[test-project] Event: feature_success');
|
||||
});
|
||||
|
||||
it('should substitute {{timestamp}} in body', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
await service.sendNotification(
|
||||
endpoint,
|
||||
{ body: 'Completed at: {{timestamp}}' },
|
||||
createContext()
|
||||
);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.body).toBe('Completed at: 2024-01-15T10:30:00.000Z');
|
||||
});
|
||||
|
||||
it('should substitute {{error}} in body for error events', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const context = {
|
||||
...createContext(),
|
||||
eventType: 'feature_error',
|
||||
error: 'Something went wrong',
|
||||
};
|
||||
await service.sendNotification(endpoint, { title: 'Error: {{error}}' }, context);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Title']).toBe('Error: Something went wrong');
|
||||
});
|
||||
|
||||
it('should substitute multiple variables', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
await service.sendNotification(
|
||||
endpoint,
|
||||
{
|
||||
title: '[{{projectName}}] {{featureName}}',
|
||||
body: 'Feature {{featureId}} completed at {{timestamp}}',
|
||||
},
|
||||
createContext()
|
||||
);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Title']).toBe('[test-project] Test Feature');
|
||||
expect(options.body).toBe('Feature feat-123 completed at 2024-01-15T10:30:00.000Z');
|
||||
});
|
||||
|
||||
it('should replace unknown variables with empty string', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
await service.sendNotification(
|
||||
endpoint,
|
||||
{ title: 'Value: {{unknownVariable}}' },
|
||||
createContext()
|
||||
);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Title']).toBe('Value: ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('default title generation', () => {
|
||||
it('should generate title with feature name for feature_success', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
await service.sendNotification(endpoint, {}, createContext());
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Title']).toBe('Feature Completed: Test Feature');
|
||||
});
|
||||
|
||||
it('should generate title without feature name when missing', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const context = { ...createContext(), featureName: undefined };
|
||||
await service.sendNotification(endpoint, {}, context);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Title']).toBe('Feature Completed');
|
||||
});
|
||||
|
||||
it('should generate correct title for feature_created', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const context = { ...createContext(), eventType: 'feature_created' };
|
||||
await service.sendNotification(endpoint, {}, context);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Title']).toBe('Feature Created: Test Feature');
|
||||
});
|
||||
|
||||
it('should generate correct title for feature_error', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const context = { ...createContext(), eventType: 'feature_error' };
|
||||
await service.sendNotification(endpoint, {}, context);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Title']).toBe('Feature Failed: Test Feature');
|
||||
});
|
||||
|
||||
it('should generate correct title for auto_mode_complete', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const context = {
|
||||
...createContext(),
|
||||
eventType: 'auto_mode_complete',
|
||||
featureName: undefined,
|
||||
};
|
||||
await service.sendNotification(endpoint, {}, context);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Title']).toBe('Auto Mode Complete');
|
||||
});
|
||||
|
||||
it('should generate correct title for auto_mode_error', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const context = { ...createContext(), eventType: 'auto_mode_error', featureName: undefined };
|
||||
await service.sendNotification(endpoint, {}, context);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Title']).toBe('Auto Mode Error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('default body generation', () => {
|
||||
it('should generate body with feature info', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
await service.sendNotification(endpoint, {}, createContext());
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.body).toContain('Feature: Test Feature');
|
||||
expect(options.body).toContain('ID: feat-123');
|
||||
expect(options.body).toContain('Project: test-project');
|
||||
expect(options.body).toContain('Time: 2024-01-15T10:30:00.000Z');
|
||||
});
|
||||
|
||||
it('should include error in body for error events', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
const context = {
|
||||
...createContext(),
|
||||
eventType: 'feature_error',
|
||||
error: 'Build failed',
|
||||
};
|
||||
await service.sendNotification(endpoint, {}, context);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.body).toContain('Error: Build failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('emoji and tags handling', () => {
|
||||
it('should handle emoji shortcode with colons', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
await service.sendNotification(endpoint, { emoji: ':tada:' }, createContext());
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Tags']).toBe('tada');
|
||||
});
|
||||
|
||||
it('should handle emoji without colons', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
await service.sendNotification(endpoint, { emoji: 'warning' }, createContext());
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Tags']).toBe('warning');
|
||||
});
|
||||
|
||||
it('should combine emoji and tags correctly', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
await service.sendNotification(
|
||||
endpoint,
|
||||
{ emoji: 'rotating_light', tags: 'urgent,alert' },
|
||||
createContext()
|
||||
);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
// Emoji comes first, then tags
|
||||
expect(options.headers['Tags']).toBe('rotating_light,urgent,alert');
|
||||
});
|
||||
|
||||
it('should ignore emoji with spaces', async () => {
|
||||
mockFetch.mockResolvedValueOnce({ ok: true, status: 200 });
|
||||
|
||||
const endpoint = createEndpoint();
|
||||
await service.sendNotification(
|
||||
endpoint,
|
||||
{ emoji: 'multi word emoji', tags: 'test' },
|
||||
createContext()
|
||||
);
|
||||
|
||||
const options = mockFetch.mock.calls[0][1];
|
||||
expect(options.headers['Tags']).toBe('test');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { PipelineOrchestrator } from '../../../src/services/pipeline-orchestrator.js';
|
||||
import type { Feature } from '@automaker/types';
|
||||
|
||||
describe('PipelineOrchestrator Prompts', () => {
|
||||
const mockFeature: Feature = {
|
||||
id: 'feature-123',
|
||||
title: 'Test Feature',
|
||||
description: 'A test feature',
|
||||
status: 'in_progress',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
tasks: [],
|
||||
};
|
||||
|
||||
const mockBuildFeaturePrompt = (feature: Feature) => `Feature: ${feature.title}`;
|
||||
|
||||
it('should include mandatory summary requirement in pipeline step prompt', () => {
|
||||
const orchestrator = new PipelineOrchestrator(
|
||||
null as any, // eventBus
|
||||
null as any, // featureStateManager
|
||||
null as any, // agentExecutor
|
||||
null as any, // testRunnerService
|
||||
null as any, // worktreeResolver
|
||||
null as any, // concurrencyManager
|
||||
null as any, // settingsService
|
||||
null as any, // updateFeatureStatusFn
|
||||
null as any, // loadContextFilesFn
|
||||
mockBuildFeaturePrompt,
|
||||
null as any, // executeFeatureFn
|
||||
null as any // runAgentFn
|
||||
);
|
||||
|
||||
const step = {
|
||||
id: 'step1',
|
||||
name: 'Code Review',
|
||||
instructions: 'Review the code for quality.',
|
||||
};
|
||||
|
||||
const prompt = orchestrator.buildPipelineStepPrompt(
|
||||
step as any,
|
||||
mockFeature,
|
||||
'Previous work context',
|
||||
{ implementationInstructions: '', playwrightVerificationInstructions: '' }
|
||||
);
|
||||
|
||||
expect(prompt).toContain('## Pipeline Step: Code Review');
|
||||
expect(prompt).toContain('Review the code for quality.');
|
||||
expect(prompt).toContain(
|
||||
'**CRITICAL: After completing the instructions, you MUST output a summary using this EXACT format:**'
|
||||
);
|
||||
expect(prompt).toContain('<summary>');
|
||||
expect(prompt).toContain('## Summary: Code Review');
|
||||
expect(prompt).toContain('</summary>');
|
||||
expect(prompt).toContain('The <summary> and </summary> tags MUST be on their own lines.');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user