mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
Compare commits
245 Commits
feature/cl
...
cf35ca8650
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf35ca8650 | ||
|
|
4f1555f196 | ||
|
|
5aace0ce0f | ||
|
|
e439d8a632 | ||
|
|
b7c6b8bfc6 | ||
|
|
a60904bd51 | ||
|
|
d7c3337330 | ||
|
|
c848306e4c | ||
|
|
f0042312d0 | ||
|
|
e876d177b8 | ||
|
|
8caec15199 | ||
|
|
7fe9aacb09 | ||
|
|
f55c985634 | ||
|
|
38e8a4c4ea | ||
|
|
f3ce5ce8ab | ||
|
|
99de7813c9 | ||
|
|
2de3ae69d4 | ||
|
|
0b4e9573ed | ||
|
|
d7ad87bd1b | ||
|
|
615823652c | ||
|
|
2f883bad20 | ||
|
|
45706990df | ||
|
|
c9c406dd21 | ||
|
|
014736bc1d | ||
|
|
c05359c787 | ||
|
|
a32cb08d1e | ||
|
|
08d1497cbe | ||
|
|
5c335641fa | ||
|
|
0fb471ca15 | ||
|
|
b65037d995 | ||
|
|
5eda2c9b2b | ||
|
|
006152554b | ||
|
|
3b56d553c9 | ||
|
|
375f9ea9d4 | ||
|
|
bf25a7a4e5 | ||
|
|
5171abc37f | ||
|
|
9c8265c4e5 | ||
|
|
ef779daedf | ||
|
|
011ac404bb | ||
|
|
9587f13de5 | ||
|
|
08dc90b378 | ||
|
|
80ef21c8d0 | ||
|
|
98d98cc056 | ||
|
|
2a24377870 | ||
|
|
895e4c28ba | ||
|
|
ebf2fcadd6 | ||
|
|
019da6b77a | ||
|
|
605d9658d9 | ||
|
|
906f471521 | ||
|
|
a10ddadbde | ||
|
|
3399d48823 | ||
|
|
7f5c5e864d | ||
|
|
35d2d41821 | ||
|
|
6a3993385e | ||
|
|
df7024f4ea | ||
|
|
4485c49c9b | ||
|
|
7a5cb38a37 | ||
|
|
c9833b67a0 | ||
|
|
0f11ee2212 | ||
|
|
74b301c2d1 | ||
|
|
81ee2d1399 | ||
|
|
f025ced035 | ||
|
|
4f07948712 | ||
|
|
07f95ae13b | ||
|
|
8dd6ab2161 | ||
|
|
b5143f4b00 | ||
|
|
f5efa857ca | ||
|
|
c401bf4e63 | ||
|
|
43d5ec9aed | ||
|
|
f8108b1a6c | ||
|
|
076ab14a5e | ||
|
|
a4c43b99a5 | ||
|
|
0f00180c50 | ||
|
|
22853c988a | ||
|
|
e52837cbe7 | ||
|
|
d12e0705f0 | ||
|
|
a3e536b8e6 | ||
|
|
43661e5a6e | ||
|
|
1b2bf0df3f | ||
|
|
b1060c6a11 | ||
|
|
db87e83aed | ||
|
|
92b1fb3725 | ||
|
|
d7f86d142a | ||
|
|
bbe669cdf2 | ||
|
|
8e13245aab | ||
|
|
cec5f91a86 | ||
|
|
ed92d4fd80 | ||
|
|
a6190f71b3 | ||
|
|
d04934359a | ||
|
|
7246debb69 | ||
|
|
066ffe5639 | ||
|
|
7bf02b64fa | ||
|
|
a3c62e8358 | ||
|
|
1ecb97b71c | ||
|
|
1e87b73dfd | ||
|
|
5a3dac1533 | ||
|
|
f3b16ad8ce | ||
|
|
140c444e6f | ||
|
|
907c1d65b3 | ||
|
|
92f2702f3b | ||
|
|
735786701f | ||
|
|
900bbb5e80 | ||
|
|
bc3e3dad1c | ||
|
|
d8fa5c4cd1 | ||
|
|
f005c30017 | ||
|
|
4012a2964a | ||
|
|
0b92349890 | ||
|
|
51a75ae589 | ||
|
|
650edd69ca | ||
|
|
46abd34444 | ||
|
|
5cf817e9de | ||
|
|
42ee4f211d | ||
|
|
372cfe6982 | ||
|
|
1430fb6926 | ||
|
|
9e15f3609a | ||
|
|
b34ffd9565 | ||
|
|
ac9f33bd2b | ||
|
|
269b1c9478 | ||
|
|
7bc7918cc6 | ||
|
|
860d6836b9 | ||
|
|
5281b81ddf | ||
|
|
7a33940816 | ||
|
|
ee4464bdad | ||
|
|
7e1095b773 | ||
|
|
9d297c650a | ||
|
|
68d78f2f5b | ||
|
|
fb6d6bbf2f | ||
|
|
c8ed3fafce | ||
|
|
5939c5d20b | ||
|
|
ad6fc01045 | ||
|
|
ea34f304cb | ||
|
|
53ad78dfc8 | ||
|
|
26b819291f | ||
|
|
01859f3a9a | ||
|
|
a4214276d7 | ||
|
|
d09da4af20 | ||
|
|
afb6e14811 | ||
|
|
c65f931326 | ||
|
|
f480386905 | ||
|
|
7773db559d | ||
|
|
655f254538 | ||
|
|
b4be3c11e2 | ||
|
|
433e6016c3 | ||
|
|
02dfda108e | ||
|
|
57ce198ae9 | ||
|
|
733ca15e15 | ||
|
|
e110c058a2 | ||
|
|
0fdda11b09 | ||
|
|
0155da0be5 | ||
|
|
41b127ebf3 | ||
|
|
e7e83a30d9 | ||
|
|
40950b5fce | ||
|
|
3f05735be1 | ||
|
|
05f0ceceb6 | ||
|
|
28d50aa017 | ||
|
|
103c6bc8a0 | ||
|
|
6c47068f71 | ||
|
|
a9616ff309 | ||
|
|
4fa0923ff8 | ||
|
|
c3cecc18f2 | ||
|
|
3fcda8abfc | ||
|
|
a45ee59b7d | ||
|
|
662f854203 | ||
|
|
f2860d9366 | ||
|
|
6eb7acb6d4 | ||
|
|
4ab927a5fb | ||
|
|
02de3df3df | ||
|
|
b73885e04a | ||
|
|
afa93dde0d | ||
|
|
aac59c2b3a | ||
|
|
c3e7e57968 | ||
|
|
c55654b737 | ||
|
|
7bb97953a7 | ||
|
|
2214c2700b | ||
|
|
7bee54717c | ||
|
|
5ab53afd7f | ||
|
|
3ebd67f35f | ||
|
|
641bbde877 | ||
|
|
7c80249bbf | ||
|
|
a73a57b9a4 | ||
|
|
db71dc9aa5 | ||
|
|
a8ddd07442 | ||
|
|
2165223b49 | ||
|
|
3bde3d2732 | ||
|
|
900a312c92 | ||
|
|
69ff8df7c1 | ||
|
|
4f584f9a89 | ||
|
|
47a6033b43 | ||
|
|
a1f234c7e2 | ||
|
|
8facdc66a9 | ||
|
|
2ab78dd590 | ||
|
|
c14a40f7f8 | ||
|
|
8dd5858299 | ||
|
|
76eb3a2ac2 | ||
|
|
179c5ae9c2 | ||
|
|
8c356d7c36 | ||
|
|
a863dcc11d | ||
|
|
cf60f84f89 | ||
|
|
47e6ed6a17 | ||
|
|
d266c98e48 | ||
|
|
628e464b74 | ||
|
|
17d42e7931 | ||
|
|
5119ee4222 | ||
|
|
b039b745be | ||
|
|
02a7a54736 | ||
|
|
43481c2bab | ||
|
|
d7f6e72a9e | ||
|
|
82e22b4362 | ||
|
|
0d9259473e | ||
|
|
ea3930cf3d | ||
|
|
d97c4b7b57 | ||
|
|
2fac2ca4bb | ||
|
|
9bb52f1ded | ||
|
|
f987fc1f10 | ||
|
|
cf3ee6aec6 | ||
|
|
9ad58e1a74 | ||
|
|
55b17a7a11 | ||
|
|
2854e24e84 | ||
|
|
b91d84ee84 | ||
|
|
30a2c3d740 | ||
|
|
e3213b1426 | ||
|
|
bfc23cdfa1 | ||
|
|
8b5da3195b | ||
|
|
0c452a3ebc | ||
|
|
cfc5530d1c | ||
|
|
d96f369b73 | ||
|
|
f0e655f49a | ||
|
|
d22deabe79 | ||
|
|
518c81815e | ||
|
|
01652d0d11 | ||
|
|
361cb06bf0 | ||
|
|
3170e22383 | ||
|
|
9dbec7281a | ||
|
|
c2fed78733 | ||
|
|
5fe7bcd378 | ||
|
|
20caa424fc | ||
|
|
c4e0a7cc96 | ||
|
|
d1219a225c | ||
|
|
3411256366 | ||
|
|
d08ef472a3 | ||
|
|
d81997d24b | ||
|
|
845674128e | ||
|
|
2bc931a8b0 | ||
|
|
e57549c06e | ||
|
|
927ce9121d |
25
.github/workflows/release.yml
vendored
25
.github/workflows/release.yml
vendored
@@ -4,6 +4,9 @@ on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
@@ -62,7 +65,10 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-builds
|
||||
path: apps/ui/release/*.{dmg,zip}
|
||||
path: |
|
||||
apps/ui/release/*.dmg
|
||||
apps/ui/release/*.zip
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Windows artifacts
|
||||
@@ -71,6 +77,7 @@ jobs:
|
||||
with:
|
||||
name: windows-builds
|
||||
path: apps/ui/release/*.exe
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload Linux artifacts
|
||||
@@ -78,7 +85,11 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-builds
|
||||
path: apps/ui/release/*.{AppImage,deb,rpm}
|
||||
path: |
|
||||
apps/ui/release/*.AppImage
|
||||
apps/ui/release/*.deb
|
||||
apps/ui/release/*.rpm
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
upload:
|
||||
@@ -108,9 +119,13 @@ jobs:
|
||||
- name: Upload to GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
fail_on_unmatched_files: true
|
||||
files: |
|
||||
artifacts/macos-builds/*.{dmg,zip,blockmap}
|
||||
artifacts/windows-builds/*.{exe,blockmap}
|
||||
artifacts/linux-builds/*.{AppImage,deb,rpm,blockmap}
|
||||
artifacts/macos-builds/*.dmg
|
||||
artifacts/macos-builds/*.zip
|
||||
artifacts/windows-builds/*.exe
|
||||
artifacts/linux-builds/*.AppImage
|
||||
artifacts/linux-builds/*.deb
|
||||
artifacts/linux-builds/*.rpm
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -25,9 +25,11 @@ COPY libs/types/package*.json ./libs/types/
|
||||
COPY libs/utils/package*.json ./libs/utils/
|
||||
COPY libs/prompts/package*.json ./libs/prompts/
|
||||
COPY libs/platform/package*.json ./libs/platform/
|
||||
COPY libs/spec-parser/package*.json ./libs/spec-parser/
|
||||
COPY libs/model-resolver/package*.json ./libs/model-resolver/
|
||||
COPY libs/dependency-resolver/package*.json ./libs/dependency-resolver/
|
||||
COPY libs/git-utils/package*.json ./libs/git-utils/
|
||||
COPY libs/spec-parser/package*.json ./libs/spec-parser/
|
||||
|
||||
# Copy scripts (needed by npm workspace)
|
||||
COPY scripts ./scripts
|
||||
|
||||
17
TODO.md
17
TODO.md
@@ -1,17 +0,0 @@
|
||||
# Bugs
|
||||
|
||||
- Setting the default model does not seem like it works.
|
||||
|
||||
# UX
|
||||
|
||||
- Consolidate all models to a single place in the settings instead of having AI profiles and all this other stuff
|
||||
- Simplify the create feature modal. It should just be one page. I don't need nessa tabs and all these nested buttons. It's too complex.
|
||||
- added to do's list checkbox directly into the card so as it's going through if there's any to do items we can see those update live
|
||||
- When the feature is done, I want to see a summary of the LLM. That's the first thing I should see when I double click the card.
|
||||
- I went away to mass edit all my features. For example, when I created a new project, it added auto testing on every single feature card. Now I have to manually go through one by one and change those. Have a way to mass edit those, the configuration of all them.
|
||||
- Double check and debug if there's memory leaks. It seems like the memory of automaker grows like 3 gigabytes. It's 5gb right now and I'm running three different cursor cli features implementing at the same time.
|
||||
- Typing in the text area of the plan mode was super laggy.
|
||||
- When I have a bunch of features running at the same time, it seems like I cannot edit the features in the backlog. Like they don't persist their file changes and I think this is because of the secure FS file has an internal queue to prevent hitting that file open write limit. We may have to reconsider refactoring away from file system and do Postgres or SQLite or something.
|
||||
- modals are not scrollable if height of the screen is small enough
|
||||
- and the Agent Runner add an archival button for the new sessions.
|
||||
- investigate a potential issue with the feature cards not refreshing. I see a lock icon on the feature card But it doesn't go away until I open the card and edit it and I turn the testing mode off. I think there's like a refresh sync issue.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@automaker/server",
|
||||
"version": "0.12.0",
|
||||
"version": "0.13.0",
|
||||
"description": "Backend server for Automaker - provides API for both web and Electron modes",
|
||||
"author": "AutoMaker Team",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
@@ -32,6 +32,7 @@
|
||||
"@automaker/prompts": "1.0.0",
|
||||
"@automaker/types": "1.0.0",
|
||||
"@automaker/utils": "1.0.0",
|
||||
"@github/copilot-sdk": "^0.1.16",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
"@openai/codex-sdk": "^0.77.0",
|
||||
"cookie-parser": "1.4.7",
|
||||
@@ -40,7 +41,8 @@
|
||||
"express": "5.2.1",
|
||||
"morgan": "1.10.1",
|
||||
"node-pty": "1.1.0-beta41",
|
||||
"ws": "8.18.3"
|
||||
"ws": "8.18.3",
|
||||
"yaml": "2.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cookie": "0.6.0",
|
||||
|
||||
@@ -16,7 +16,7 @@ import { createServer } from 'http';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
import { createEventEmitter, type EventEmitter } from './lib/events.js';
|
||||
import { initAllowedPaths } from '@automaker/platform';
|
||||
import { initAllowedPaths, getClaudeAuthIndicators } from '@automaker/platform';
|
||||
import { createLogger, setLogLevel, LogLevel } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('Server');
|
||||
@@ -43,7 +43,6 @@ import { createEnhancePromptRoutes } from './routes/enhance-prompt/index.js';
|
||||
import { createWorktreeRoutes } from './routes/worktree/index.js';
|
||||
import { createGitRoutes } from './routes/git/index.js';
|
||||
import { createSetupRoutes } from './routes/setup/index.js';
|
||||
import { createSuggestionsRoutes } from './routes/suggestions/index.js';
|
||||
import { createModelsRoutes } from './routes/models/index.js';
|
||||
import { createRunningAgentsRoutes } from './routes/running-agents/index.js';
|
||||
import { createWorkspaceRoutes } from './routes/workspace/index.js';
|
||||
@@ -83,6 +82,8 @@ import { createNotificationsRoutes } from './routes/notifications/index.js';
|
||||
import { getNotificationService } from './services/notification-service.js';
|
||||
import { createEventHistoryRoutes } from './routes/event-history/index.js';
|
||||
import { getEventHistoryService } from './services/event-history-service.js';
|
||||
import { getTestRunnerService } from './services/test-runner-service.js';
|
||||
import { createProjectsRoutes } from './routes/projects/index.js';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
@@ -116,15 +117,44 @@ export function isRequestLoggingEnabled(): boolean {
|
||||
// Width for log box content (excluding borders)
|
||||
const BOX_CONTENT_WIDTH = 67;
|
||||
|
||||
// Check for required environment variables
|
||||
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
|
||||
// Check for Claude authentication (async - runs in background)
|
||||
// The Claude Agent SDK can use either ANTHROPIC_API_KEY or Claude Code CLI authentication
|
||||
(async () => {
|
||||
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
|
||||
|
||||
if (!hasAnthropicKey) {
|
||||
if (hasAnthropicKey) {
|
||||
logger.info('✓ ANTHROPIC_API_KEY detected');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for Claude Code CLI authentication
|
||||
try {
|
||||
const indicators = await getClaudeAuthIndicators();
|
||||
const hasCliAuth =
|
||||
indicators.hasStatsCacheWithActivity ||
|
||||
(indicators.hasSettingsFile && indicators.hasProjectsSessions) ||
|
||||
(indicators.hasCredentialsFile &&
|
||||
(indicators.credentials?.hasOAuthToken || indicators.credentials?.hasApiKey));
|
||||
|
||||
if (hasCliAuth) {
|
||||
logger.info('✓ Claude Code CLI authentication detected');
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore errors checking CLI auth - will fall through to warning
|
||||
logger.warn('Error checking for Claude Code CLI authentication:', error);
|
||||
}
|
||||
|
||||
// No authentication found - show warning
|
||||
const wHeader = '⚠️ WARNING: No Claude authentication configured'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const w1 = 'The Claude Agent SDK requires authentication to function.'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const w2 = 'Set your Anthropic API key:'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const w3 = ' export ANTHROPIC_API_KEY="sk-ant-..."'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const w4 = 'Or use the setup wizard in Settings to configure authentication.'.padEnd(
|
||||
const w2 = 'Options:'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const w3 = '1. Install Claude Code CLI and authenticate with subscription'.padEnd(
|
||||
BOX_CONTENT_WIDTH
|
||||
);
|
||||
const w4 = '2. Set your Anthropic API key:'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const w5 = ' export ANTHROPIC_API_KEY="sk-ant-..."'.padEnd(BOX_CONTENT_WIDTH);
|
||||
const w6 = '3. Use the setup wizard in Settings to configure authentication.'.padEnd(
|
||||
BOX_CONTENT_WIDTH
|
||||
);
|
||||
|
||||
@@ -137,14 +167,13 @@ if (!hasAnthropicKey) {
|
||||
║ ║
|
||||
║ ${w2}║
|
||||
║ ${w3}║
|
||||
║ ║
|
||||
║ ${w4}║
|
||||
║ ${w5}║
|
||||
║ ${w6}║
|
||||
║ ║
|
||||
╚═════════════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
} else {
|
||||
logger.info('✓ ANTHROPIC_API_KEY detected');
|
||||
}
|
||||
})();
|
||||
|
||||
// Initialize security
|
||||
initAllowedPaths();
|
||||
@@ -248,8 +277,12 @@ notificationService.setEventEmitter(events);
|
||||
// Initialize Event History Service
|
||||
const eventHistoryService = getEventHistoryService();
|
||||
|
||||
// Initialize Test Runner Service with event emitter for real-time test output streaming
|
||||
const testRunnerService = getTestRunnerService();
|
||||
testRunnerService.setEventEmitter(events);
|
||||
|
||||
// Initialize Event Hook Service for custom event triggers (with history storage)
|
||||
eventHookService.initialize(events, settingsService, eventHistoryService);
|
||||
eventHookService.initialize(events, settingsService, eventHistoryService, featureLoader);
|
||||
|
||||
// Initialize services
|
||||
(async () => {
|
||||
@@ -321,12 +354,14 @@ app.get('/api/health/detailed', createDetailedHandler());
|
||||
app.use('/api/fs', createFsRoutes(events));
|
||||
app.use('/api/agent', createAgentRoutes(agentService, events));
|
||||
app.use('/api/sessions', createSessionsRoutes(agentService));
|
||||
app.use('/api/features', createFeaturesRoutes(featureLoader, settingsService, events));
|
||||
app.use(
|
||||
'/api/features',
|
||||
createFeaturesRoutes(featureLoader, settingsService, events, autoModeService)
|
||||
);
|
||||
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
|
||||
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
|
||||
app.use('/api/worktree', createWorktreeRoutes(events, settingsService));
|
||||
app.use('/api/git', createGitRoutes());
|
||||
app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService));
|
||||
app.use('/api/models', createModelsRoutes());
|
||||
app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events, settingsService));
|
||||
app.use('/api/running-agents', createRunningAgentsRoutes(autoModeService));
|
||||
@@ -344,6 +379,10 @@ app.use('/api/pipeline', createPipelineRoutes(pipelineService));
|
||||
app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader));
|
||||
app.use('/api/notifications', createNotificationsRoutes(notificationService));
|
||||
app.use('/api/event-history', createEventHistoryRoutes(eventHistoryService, settingsService));
|
||||
app.use(
|
||||
'/api/projects',
|
||||
createProjectsRoutes(featureLoader, autoModeService, settingsService, notificationService)
|
||||
);
|
||||
|
||||
// Create HTTP server
|
||||
const server = createServer(app);
|
||||
@@ -761,21 +800,36 @@ process.on('uncaughtException', (error: Error) => {
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', () => {
|
||||
logger.info('SIGTERM received, shutting down...');
|
||||
// Graceful shutdown timeout (30 seconds)
|
||||
const SHUTDOWN_TIMEOUT_MS = 30000;
|
||||
|
||||
// Graceful shutdown helper
|
||||
const gracefulShutdown = async (signal: string) => {
|
||||
logger.info(`${signal} received, shutting down...`);
|
||||
|
||||
// Set up a force-exit timeout to prevent hanging
|
||||
const forceExitTimeout = setTimeout(() => {
|
||||
logger.error(`Shutdown timed out after ${SHUTDOWN_TIMEOUT_MS}ms, forcing exit`);
|
||||
process.exit(1);
|
||||
}, SHUTDOWN_TIMEOUT_MS);
|
||||
|
||||
// Mark all running features as interrupted before shutdown
|
||||
// This ensures they can be resumed when the server restarts
|
||||
// Note: markAllRunningFeaturesInterrupted handles errors internally and never rejects
|
||||
await autoModeService.markAllRunningFeaturesInterrupted(`${signal} signal received`);
|
||||
|
||||
terminalService.cleanup();
|
||||
server.close(() => {
|
||||
clearTimeout(forceExitTimeout);
|
||||
logger.info('Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
};
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
gracefulShutdown('SIGTERM');
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
logger.info('SIGINT received, shutting down...');
|
||||
terminalService.cleanup();
|
||||
server.close(() => {
|
||||
logger.info('Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
gracefulShutdown('SIGINT');
|
||||
});
|
||||
|
||||
@@ -23,6 +23,13 @@ const SESSION_COOKIE_NAME = 'automaker_session';
|
||||
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
const WS_TOKEN_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes for WebSocket connection tokens
|
||||
|
||||
/**
|
||||
* Check if an environment variable is set to 'true'
|
||||
*/
|
||||
function isEnvTrue(envVar: string | undefined): boolean {
|
||||
return envVar === 'true';
|
||||
}
|
||||
|
||||
// Session store - persisted to file for survival across server restarts
|
||||
const validSessions = new Map<string, { createdAt: number; expiresAt: number }>();
|
||||
|
||||
@@ -134,8 +141,8 @@ const API_KEY = ensureApiKey();
|
||||
const BOX_CONTENT_WIDTH = 67;
|
||||
|
||||
// Print API key to console for web mode users (unless suppressed for production logging)
|
||||
if (process.env.AUTOMAKER_HIDE_API_KEY !== 'true') {
|
||||
const autoLoginEnabled = process.env.AUTOMAKER_AUTO_LOGIN === 'true';
|
||||
if (!isEnvTrue(process.env.AUTOMAKER_HIDE_API_KEY)) {
|
||||
const autoLoginEnabled = isEnvTrue(process.env.AUTOMAKER_AUTO_LOGIN);
|
||||
const autoLoginStatus = autoLoginEnabled ? 'enabled (auto-login active)' : 'disabled';
|
||||
|
||||
// Build box lines with exact padding
|
||||
@@ -375,6 +382,12 @@ function checkAuthentication(
|
||||
* 5. Session cookie (for web mode)
|
||||
*/
|
||||
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
|
||||
// Allow disabling auth for local/trusted networks
|
||||
if (isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = checkAuthentication(
|
||||
req.headers as Record<string, string | string[] | undefined>,
|
||||
req.query as Record<string, string | undefined>,
|
||||
@@ -420,9 +433,10 @@ export function isAuthEnabled(): boolean {
|
||||
* Get authentication status for health endpoint
|
||||
*/
|
||||
export function getAuthStatus(): { enabled: boolean; method: string } {
|
||||
const disabled = isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH);
|
||||
return {
|
||||
enabled: true,
|
||||
method: 'api_key_or_session',
|
||||
enabled: !disabled,
|
||||
method: disabled ? 'disabled' : 'api_key_or_session',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -430,6 +444,7 @@ export function getAuthStatus(): { enabled: boolean; method: string } {
|
||||
* Check if a request is authenticated (for status endpoint)
|
||||
*/
|
||||
export function isRequestAuthenticated(req: Request): boolean {
|
||||
if (isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH)) return true;
|
||||
const result = checkAuthentication(
|
||||
req.headers as Record<string, string | string[] | undefined>,
|
||||
req.query as Record<string, string | undefined>,
|
||||
@@ -447,5 +462,6 @@ export function checkRawAuthentication(
|
||||
query: Record<string, string | undefined>,
|
||||
cookies: Record<string, string | undefined>
|
||||
): boolean {
|
||||
if (isEnvTrue(process.env.AUTOMAKER_DISABLE_AUTH)) return true;
|
||||
return checkAuthentication(headers, query, cookies).authenticated;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,17 @@
|
||||
import type { SettingsService } from '../services/settings-service.js';
|
||||
import type { ContextFilesResult, ContextFileInfo } from '@automaker/utils';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { MCPServerConfig, McpServerConfig, PromptCustomization } from '@automaker/types';
|
||||
import type {
|
||||
MCPServerConfig,
|
||||
McpServerConfig,
|
||||
PromptCustomization,
|
||||
ClaudeApiProfile,
|
||||
ClaudeCompatibleProvider,
|
||||
PhaseModelKey,
|
||||
PhaseModelEntry,
|
||||
Credentials,
|
||||
} from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
|
||||
import {
|
||||
mergeAutoModePrompts,
|
||||
mergeAgentPrompts,
|
||||
@@ -345,3 +355,376 @@ export async function getCustomSubagents(
|
||||
|
||||
return Object.keys(merged).length > 0 ? merged : undefined;
|
||||
}
|
||||
|
||||
/** Result from getActiveClaudeApiProfile */
|
||||
export interface ActiveClaudeApiProfileResult {
|
||||
/** The active profile, or undefined if using direct Anthropic API */
|
||||
profile: ClaudeApiProfile | undefined;
|
||||
/** Credentials for resolving 'credentials' apiKeySource */
|
||||
credentials: import('@automaker/types').Credentials | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active Claude API profile and credentials from settings.
|
||||
* Checks project settings first for per-project overrides, then falls back to global settings.
|
||||
* Returns both the profile and credentials for resolving 'credentials' apiKeySource.
|
||||
*
|
||||
* @deprecated Use getProviderById and getPhaseModelWithOverrides instead for the new provider system.
|
||||
* This function is kept for backward compatibility during migration.
|
||||
*
|
||||
* @param settingsService - Optional settings service instance
|
||||
* @param logPrefix - Prefix for log messages (e.g., '[AgentService]')
|
||||
* @param projectPath - Optional project path for per-project override
|
||||
* @returns Promise resolving to object with profile and credentials
|
||||
*/
|
||||
export async function getActiveClaudeApiProfile(
|
||||
settingsService?: SettingsService | null,
|
||||
logPrefix = '[SettingsHelper]',
|
||||
projectPath?: string
|
||||
): Promise<ActiveClaudeApiProfileResult> {
|
||||
if (!settingsService) {
|
||||
return { profile: undefined, credentials: undefined };
|
||||
}
|
||||
|
||||
try {
|
||||
const globalSettings = await settingsService.getGlobalSettings();
|
||||
const credentials = await settingsService.getCredentials();
|
||||
const profiles = globalSettings.claudeApiProfiles || [];
|
||||
|
||||
// Check for project-level override first
|
||||
let activeProfileId: string | null | undefined;
|
||||
let isProjectOverride = false;
|
||||
|
||||
if (projectPath) {
|
||||
const projectSettings = await settingsService.getProjectSettings(projectPath);
|
||||
// undefined = use global, null = explicit no profile, string = specific profile
|
||||
if (projectSettings.activeClaudeApiProfileId !== undefined) {
|
||||
activeProfileId = projectSettings.activeClaudeApiProfileId;
|
||||
isProjectOverride = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to global if project doesn't specify
|
||||
if (activeProfileId === undefined && !isProjectOverride) {
|
||||
activeProfileId = globalSettings.activeClaudeApiProfileId;
|
||||
}
|
||||
|
||||
// No active profile selected - use direct Anthropic API
|
||||
if (!activeProfileId) {
|
||||
if (isProjectOverride && activeProfileId === null) {
|
||||
logger.info(`${logPrefix} Project explicitly using Direct Anthropic API`);
|
||||
}
|
||||
return { profile: undefined, credentials };
|
||||
}
|
||||
|
||||
// Find the active profile by ID
|
||||
const activeProfile = profiles.find((p) => p.id === activeProfileId);
|
||||
|
||||
if (activeProfile) {
|
||||
const overrideSuffix = isProjectOverride ? ' (project override)' : '';
|
||||
logger.info(`${logPrefix} Using Claude API profile: ${activeProfile.name}${overrideSuffix}`);
|
||||
return { profile: activeProfile, credentials };
|
||||
} else {
|
||||
logger.warn(
|
||||
`${logPrefix} Active profile ID "${activeProfileId}" not found, falling back to direct Anthropic API`
|
||||
);
|
||||
return { profile: undefined, credentials };
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Failed to load Claude API profile:`, error);
|
||||
return { profile: undefined, credentials: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// New Provider System Helpers
|
||||
// ============================================================================
|
||||
|
||||
/** Result from getProviderById */
|
||||
export interface ProviderByIdResult {
|
||||
/** The provider, or undefined if not found */
|
||||
provider: ClaudeCompatibleProvider | undefined;
|
||||
/** Credentials for resolving 'credentials' apiKeySource */
|
||||
credentials: Credentials | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a ClaudeCompatibleProvider by its ID.
|
||||
* Returns the provider configuration and credentials for API key resolution.
|
||||
*
|
||||
* @param providerId - The provider ID to look up
|
||||
* @param settingsService - Settings service instance
|
||||
* @param logPrefix - Prefix for log messages
|
||||
* @returns Promise resolving to object with provider and credentials
|
||||
*/
|
||||
export async function getProviderById(
|
||||
providerId: string,
|
||||
settingsService: SettingsService,
|
||||
logPrefix = '[SettingsHelper]'
|
||||
): Promise<ProviderByIdResult> {
|
||||
try {
|
||||
const globalSettings = await settingsService.getGlobalSettings();
|
||||
const credentials = await settingsService.getCredentials();
|
||||
const providers = globalSettings.claudeCompatibleProviders || [];
|
||||
|
||||
const provider = providers.find((p) => p.id === providerId);
|
||||
|
||||
if (provider) {
|
||||
if (provider.enabled === false) {
|
||||
logger.warn(`${logPrefix} Provider "${provider.name}" (${providerId}) is disabled`);
|
||||
} else {
|
||||
logger.debug(`${logPrefix} Found provider: ${provider.name}`);
|
||||
}
|
||||
return { provider, credentials };
|
||||
} else {
|
||||
logger.warn(`${logPrefix} Provider not found: ${providerId}`);
|
||||
return { provider: undefined, credentials };
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Failed to load provider by ID:`, error);
|
||||
return { provider: undefined, credentials: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
/** Result from getPhaseModelWithOverrides */
|
||||
export interface PhaseModelWithOverridesResult {
|
||||
/** The resolved phase model entry */
|
||||
phaseModel: PhaseModelEntry;
|
||||
/** Whether a project override was applied */
|
||||
isProjectOverride: boolean;
|
||||
/** The provider if providerId is set and found */
|
||||
provider: ClaudeCompatibleProvider | undefined;
|
||||
/** Credentials for API key resolution */
|
||||
credentials: Credentials | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the phase model configuration for a specific phase, applying project overrides if available.
|
||||
* Also resolves the provider if the phase model has a providerId.
|
||||
*
|
||||
* @param phase - The phase key (e.g., 'enhancementModel', 'specGenerationModel')
|
||||
* @param settingsService - Optional settings service instance (returns defaults if undefined)
|
||||
* @param projectPath - Optional project path for checking overrides
|
||||
* @param logPrefix - Prefix for log messages
|
||||
* @returns Promise resolving to phase model with provider info
|
||||
*/
|
||||
export async function getPhaseModelWithOverrides(
|
||||
phase: PhaseModelKey,
|
||||
settingsService?: SettingsService | null,
|
||||
projectPath?: string,
|
||||
logPrefix = '[SettingsHelper]'
|
||||
): Promise<PhaseModelWithOverridesResult> {
|
||||
// Handle undefined settingsService gracefully
|
||||
if (!settingsService) {
|
||||
logger.info(`${logPrefix} SettingsService not available, using default for ${phase}`);
|
||||
return {
|
||||
phaseModel: DEFAULT_PHASE_MODELS[phase] || { model: 'sonnet' },
|
||||
isProjectOverride: false,
|
||||
provider: undefined,
|
||||
credentials: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const globalSettings = await settingsService.getGlobalSettings();
|
||||
const credentials = await settingsService.getCredentials();
|
||||
const globalPhaseModels = globalSettings.phaseModels || {};
|
||||
|
||||
// Start with global phase model
|
||||
let phaseModel = globalPhaseModels[phase];
|
||||
let isProjectOverride = false;
|
||||
|
||||
// Check for project override
|
||||
if (projectPath) {
|
||||
const projectSettings = await settingsService.getProjectSettings(projectPath);
|
||||
const projectOverrides = projectSettings.phaseModelOverrides || {};
|
||||
|
||||
if (projectOverrides[phase]) {
|
||||
phaseModel = projectOverrides[phase];
|
||||
isProjectOverride = true;
|
||||
logger.debug(`${logPrefix} Using project override for ${phase}`);
|
||||
}
|
||||
}
|
||||
|
||||
// If no phase model found, use per-phase default
|
||||
if (!phaseModel) {
|
||||
phaseModel = DEFAULT_PHASE_MODELS[phase] || { model: 'sonnet' };
|
||||
logger.debug(`${logPrefix} No ${phase} configured, using default: ${phaseModel.model}`);
|
||||
}
|
||||
|
||||
// Resolve provider if providerId is set
|
||||
let provider: ClaudeCompatibleProvider | undefined;
|
||||
if (phaseModel.providerId) {
|
||||
const providers = globalSettings.claudeCompatibleProviders || [];
|
||||
provider = providers.find((p) => p.id === phaseModel.providerId);
|
||||
|
||||
if (provider) {
|
||||
if (provider.enabled === false) {
|
||||
logger.warn(
|
||||
`${logPrefix} Provider "${provider.name}" for ${phase} is disabled, falling back to direct API`
|
||||
);
|
||||
provider = undefined;
|
||||
} else {
|
||||
logger.debug(`${logPrefix} Using provider "${provider.name}" for ${phase}`);
|
||||
}
|
||||
} else {
|
||||
logger.warn(
|
||||
`${logPrefix} Provider ${phaseModel.providerId} not found for ${phase}, falling back to direct API`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
phaseModel,
|
||||
isProjectOverride,
|
||||
provider,
|
||||
credentials,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Failed to get phase model with overrides:`, error);
|
||||
// Return a safe default
|
||||
return {
|
||||
phaseModel: { model: 'sonnet' },
|
||||
isProjectOverride: false,
|
||||
provider: undefined,
|
||||
credentials: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Result from getProviderByModelId */
|
||||
export interface ProviderByModelIdResult {
|
||||
/** The provider that contains this model, or undefined if not found */
|
||||
provider: ClaudeCompatibleProvider | undefined;
|
||||
/** The model configuration if found */
|
||||
modelConfig: import('@automaker/types').ProviderModel | undefined;
|
||||
/** Credentials for API key resolution */
|
||||
credentials: Credentials | undefined;
|
||||
/** The resolved Claude model ID to use for API calls (from mapsToClaudeModel) */
|
||||
resolvedModel: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a ClaudeCompatibleProvider by one of its model IDs.
|
||||
* Searches through all enabled providers to find one that contains the specified model.
|
||||
* This is useful when you have a model string from the UI but need the provider config.
|
||||
*
|
||||
* Also resolves the `mapsToClaudeModel` field to get the actual Claude model ID to use
|
||||
* when calling the API (e.g., "GLM-4.5-Air" -> "claude-haiku-4-5").
|
||||
*
|
||||
* @param modelId - The model ID to search for (e.g., "GLM-4.7", "MiniMax-M2.1")
|
||||
* @param settingsService - Settings service instance
|
||||
* @param logPrefix - Prefix for log messages
|
||||
* @returns Promise resolving to object with provider, model config, credentials, and resolved model
|
||||
*/
|
||||
export async function getProviderByModelId(
|
||||
modelId: string,
|
||||
settingsService: SettingsService,
|
||||
logPrefix = '[SettingsHelper]'
|
||||
): Promise<ProviderByModelIdResult> {
|
||||
try {
|
||||
const globalSettings = await settingsService.getGlobalSettings();
|
||||
const credentials = await settingsService.getCredentials();
|
||||
const providers = globalSettings.claudeCompatibleProviders || [];
|
||||
|
||||
// Search through all enabled providers for this model
|
||||
for (const provider of providers) {
|
||||
// Skip disabled providers
|
||||
if (provider.enabled === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this provider has the model
|
||||
const modelConfig = provider.models?.find(
|
||||
(m) => m.id === modelId || m.id.toLowerCase() === modelId.toLowerCase()
|
||||
);
|
||||
|
||||
if (modelConfig) {
|
||||
logger.info(`${logPrefix} Found model "${modelId}" in provider "${provider.name}"`);
|
||||
|
||||
// Resolve the mapped Claude model if specified
|
||||
let resolvedModel: string | undefined;
|
||||
if (modelConfig.mapsToClaudeModel) {
|
||||
// Import resolveModelString to convert alias to full model ID
|
||||
const { resolveModelString } = await import('@automaker/model-resolver');
|
||||
resolvedModel = resolveModelString(modelConfig.mapsToClaudeModel);
|
||||
logger.info(
|
||||
`${logPrefix} Model "${modelId}" maps to Claude model "${modelConfig.mapsToClaudeModel}" -> "${resolvedModel}"`
|
||||
);
|
||||
}
|
||||
|
||||
return { provider, modelConfig, credentials, resolvedModel };
|
||||
}
|
||||
}
|
||||
|
||||
// Model not found in any provider
|
||||
logger.debug(`${logPrefix} Model "${modelId}" not found in any provider`);
|
||||
return {
|
||||
provider: undefined,
|
||||
modelConfig: undefined,
|
||||
credentials: undefined,
|
||||
resolvedModel: undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Failed to find provider by model ID:`, error);
|
||||
return {
|
||||
provider: undefined,
|
||||
modelConfig: undefined,
|
||||
credentials: undefined,
|
||||
resolvedModel: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all enabled provider models for use in model dropdowns.
|
||||
* Returns models from all enabled ClaudeCompatibleProviders.
|
||||
*
|
||||
* @param settingsService - Settings service instance
|
||||
* @param logPrefix - Prefix for log messages
|
||||
* @returns Promise resolving to array of provider models with their provider info
|
||||
*/
|
||||
export async function getAllProviderModels(
|
||||
settingsService: SettingsService,
|
||||
logPrefix = '[SettingsHelper]'
|
||||
): Promise<
|
||||
Array<{
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
model: import('@automaker/types').ProviderModel;
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
const globalSettings = await settingsService.getGlobalSettings();
|
||||
const providers = globalSettings.claudeCompatibleProviders || [];
|
||||
|
||||
const allModels: Array<{
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
model: import('@automaker/types').ProviderModel;
|
||||
}> = [];
|
||||
|
||||
for (const provider of providers) {
|
||||
// Skip disabled providers
|
||||
if (provider.enabled === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const model of provider.models || []) {
|
||||
allModels.push({
|
||||
providerId: provider.id,
|
||||
providerName: provider.name,
|
||||
model,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`${logPrefix} Found ${allModels.length} models from ${providers.length} providers`
|
||||
);
|
||||
return allModels;
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Failed to get all provider models:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,21 @@ import { BaseProvider } from './base-provider.js';
|
||||
import { classifyError, getUserFriendlyErrorMessage, createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('ClaudeProvider');
|
||||
import { getThinkingTokenBudget, validateBareModelId } from '@automaker/types';
|
||||
import {
|
||||
getThinkingTokenBudget,
|
||||
validateBareModelId,
|
||||
type ClaudeApiProfile,
|
||||
type ClaudeCompatibleProvider,
|
||||
type Credentials,
|
||||
} from '@automaker/types';
|
||||
|
||||
/**
|
||||
* ProviderConfig - Union type for provider configuration
|
||||
*
|
||||
* Accepts either the legacy ClaudeApiProfile or new ClaudeCompatibleProvider.
|
||||
* Both share the same connection settings structure.
|
||||
*/
|
||||
type ProviderConfig = ClaudeApiProfile | ClaudeCompatibleProvider;
|
||||
import type {
|
||||
ExecuteOptions,
|
||||
ProviderMessage,
|
||||
@@ -21,9 +35,19 @@ import type {
|
||||
// Explicit allowlist of environment variables to pass to the SDK.
|
||||
// Only these vars are passed - nothing else from process.env leaks through.
|
||||
const ALLOWED_ENV_VARS = [
|
||||
// Authentication
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
// Endpoint configuration
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'API_TIMEOUT_MS',
|
||||
// Model mappings
|
||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||
// Traffic control
|
||||
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
|
||||
// System vars (always from process.env)
|
||||
'PATH',
|
||||
'HOME',
|
||||
'SHELL',
|
||||
@@ -33,16 +57,132 @@ const ALLOWED_ENV_VARS = [
|
||||
'LC_ALL',
|
||||
];
|
||||
|
||||
// System vars are always passed from process.env regardless of profile
|
||||
const SYSTEM_ENV_VARS = ['PATH', 'HOME', 'SHELL', 'TERM', 'USER', 'LANG', 'LC_ALL'];
|
||||
|
||||
/**
|
||||
* Build environment for the SDK with only explicitly allowed variables
|
||||
* Check if the config is a ClaudeCompatibleProvider (new system)
|
||||
* by checking for the 'models' array property
|
||||
*/
|
||||
function buildEnv(): Record<string, string | undefined> {
|
||||
function isClaudeCompatibleProvider(config: ProviderConfig): config is ClaudeCompatibleProvider {
|
||||
return 'models' in config && Array.isArray(config.models);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build environment for the SDK with only explicitly allowed variables.
|
||||
* When a provider/profile is provided, uses its configuration (clean switch - don't inherit from process.env).
|
||||
* When no provider is provided, uses direct Anthropic API settings from process.env.
|
||||
*
|
||||
* Supports both:
|
||||
* - ClaudeCompatibleProvider (new system with models[] array)
|
||||
* - ClaudeApiProfile (legacy system with modelMappings)
|
||||
*
|
||||
* @param providerConfig - Optional provider configuration for alternative endpoint
|
||||
* @param credentials - Optional credentials object for resolving 'credentials' apiKeySource
|
||||
*/
|
||||
function buildEnv(
|
||||
providerConfig?: ProviderConfig,
|
||||
credentials?: Credentials
|
||||
): Record<string, string | undefined> {
|
||||
const env: Record<string, string | undefined> = {};
|
||||
for (const key of ALLOWED_ENV_VARS) {
|
||||
|
||||
if (providerConfig) {
|
||||
// Use provider configuration (clean switch - don't inherit non-system vars from process.env)
|
||||
logger.debug('[buildEnv] Using provider configuration:', {
|
||||
name: providerConfig.name,
|
||||
baseUrl: providerConfig.baseUrl,
|
||||
apiKeySource: providerConfig.apiKeySource ?? 'inline',
|
||||
isNewProvider: isClaudeCompatibleProvider(providerConfig),
|
||||
});
|
||||
|
||||
// Resolve API key based on source strategy
|
||||
let apiKey: string | undefined;
|
||||
const source = providerConfig.apiKeySource ?? 'inline'; // Default to inline for backwards compat
|
||||
|
||||
switch (source) {
|
||||
case 'inline':
|
||||
apiKey = providerConfig.apiKey;
|
||||
break;
|
||||
case 'env':
|
||||
apiKey = process.env.ANTHROPIC_API_KEY;
|
||||
break;
|
||||
case 'credentials':
|
||||
apiKey = credentials?.apiKeys?.anthropic;
|
||||
break;
|
||||
}
|
||||
|
||||
// Warn if no API key found
|
||||
if (!apiKey) {
|
||||
logger.warn(`No API key found for provider "${providerConfig.name}" with source "${source}"`);
|
||||
}
|
||||
|
||||
// Authentication
|
||||
if (providerConfig.useAuthToken) {
|
||||
env['ANTHROPIC_AUTH_TOKEN'] = apiKey;
|
||||
} else {
|
||||
env['ANTHROPIC_API_KEY'] = apiKey;
|
||||
}
|
||||
|
||||
// Endpoint configuration
|
||||
env['ANTHROPIC_BASE_URL'] = providerConfig.baseUrl;
|
||||
logger.debug(`[buildEnv] Set ANTHROPIC_BASE_URL to: ${providerConfig.baseUrl}`);
|
||||
|
||||
if (providerConfig.timeoutMs) {
|
||||
env['API_TIMEOUT_MS'] = String(providerConfig.timeoutMs);
|
||||
}
|
||||
|
||||
// Model mappings - only for legacy ClaudeApiProfile
|
||||
// For ClaudeCompatibleProvider, the model is passed directly (no mapping needed)
|
||||
if (!isClaudeCompatibleProvider(providerConfig) && providerConfig.modelMappings) {
|
||||
if (providerConfig.modelMappings.haiku) {
|
||||
env['ANTHROPIC_DEFAULT_HAIKU_MODEL'] = providerConfig.modelMappings.haiku;
|
||||
}
|
||||
if (providerConfig.modelMappings.sonnet) {
|
||||
env['ANTHROPIC_DEFAULT_SONNET_MODEL'] = providerConfig.modelMappings.sonnet;
|
||||
}
|
||||
if (providerConfig.modelMappings.opus) {
|
||||
env['ANTHROPIC_DEFAULT_OPUS_MODEL'] = providerConfig.modelMappings.opus;
|
||||
}
|
||||
}
|
||||
|
||||
// Traffic control
|
||||
if (providerConfig.disableNonessentialTraffic) {
|
||||
env['CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC'] = '1';
|
||||
}
|
||||
} else {
|
||||
// Use direct Anthropic API - pass through credentials or environment variables
|
||||
// This supports:
|
||||
// 1. API Key mode: ANTHROPIC_API_KEY from credentials (UI settings) or env
|
||||
// 2. Claude Max plan: Uses CLI OAuth auth (SDK handles this automatically)
|
||||
// 3. Custom endpoints via ANTHROPIC_BASE_URL env var (backward compatibility)
|
||||
//
|
||||
// Priority: credentials file (UI settings) -> environment variable
|
||||
// Note: Only auth and endpoint vars are passed. Model mappings and traffic
|
||||
// control are NOT passed (those require a profile for explicit configuration).
|
||||
if (credentials?.apiKeys?.anthropic) {
|
||||
env['ANTHROPIC_API_KEY'] = credentials.apiKeys.anthropic;
|
||||
} else if (process.env.ANTHROPIC_API_KEY) {
|
||||
env['ANTHROPIC_API_KEY'] = process.env.ANTHROPIC_API_KEY;
|
||||
}
|
||||
// If using Claude Max plan via CLI auth, the SDK handles auth automatically
|
||||
// when no API key is provided. We don't set ANTHROPIC_AUTH_TOKEN here
|
||||
// unless it was explicitly set in process.env (rare edge case).
|
||||
if (process.env.ANTHROPIC_AUTH_TOKEN) {
|
||||
env['ANTHROPIC_AUTH_TOKEN'] = process.env.ANTHROPIC_AUTH_TOKEN;
|
||||
}
|
||||
// Pass through ANTHROPIC_BASE_URL if set in environment (backward compatibility)
|
||||
if (process.env.ANTHROPIC_BASE_URL) {
|
||||
env['ANTHROPIC_BASE_URL'] = process.env.ANTHROPIC_BASE_URL;
|
||||
}
|
||||
}
|
||||
|
||||
// Always add system vars from process.env
|
||||
for (const key of SYSTEM_ENV_VARS) {
|
||||
if (process.env[key]) {
|
||||
env[key] = process.env[key];
|
||||
}
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
@@ -70,8 +210,15 @@ export class ClaudeProvider extends BaseProvider {
|
||||
conversationHistory,
|
||||
sdkSessionId,
|
||||
thinkingLevel,
|
||||
claudeApiProfile,
|
||||
claudeCompatibleProvider,
|
||||
credentials,
|
||||
} = options;
|
||||
|
||||
// Determine which provider config to use
|
||||
// claudeCompatibleProvider takes precedence over claudeApiProfile
|
||||
const providerConfig = claudeCompatibleProvider || claudeApiProfile;
|
||||
|
||||
// Convert thinking level to token budget
|
||||
const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel);
|
||||
|
||||
@@ -82,7 +229,9 @@ export class ClaudeProvider extends BaseProvider {
|
||||
maxTurns,
|
||||
cwd,
|
||||
// Pass only explicitly allowed environment variables to SDK
|
||||
env: buildEnv(),
|
||||
// When a provider is active, uses provider settings (clean switch)
|
||||
// When no provider, uses direct Anthropic API (from process.env or CLI OAuth)
|
||||
env: buildEnv(providerConfig, credentials),
|
||||
// Pass through allowedTools if provided by caller (decided by sdk-options.ts)
|
||||
...(allowedTools && { allowedTools }),
|
||||
// AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation
|
||||
@@ -127,6 +276,18 @@ export class ClaudeProvider extends BaseProvider {
|
||||
promptPayload = prompt;
|
||||
}
|
||||
|
||||
// Log the environment being passed to the SDK for debugging
|
||||
const envForSdk = sdkOptions.env as Record<string, string | undefined>;
|
||||
logger.debug('[ClaudeProvider] SDK Configuration:', {
|
||||
model: sdkOptions.model,
|
||||
baseUrl: envForSdk?.['ANTHROPIC_BASE_URL'] || '(default Anthropic API)',
|
||||
hasApiKey: !!envForSdk?.['ANTHROPIC_API_KEY'],
|
||||
hasAuthToken: !!envForSdk?.['ANTHROPIC_AUTH_TOKEN'],
|
||||
providerName: providerConfig?.name || '(direct Anthropic)',
|
||||
maxTurns: sdkOptions.maxTurns,
|
||||
maxThinkingTokens: sdkOptions.maxThinkingTokens,
|
||||
});
|
||||
|
||||
// Execute via Claude Agent SDK
|
||||
try {
|
||||
const stream = query({ prompt: promptPayload, options: sdkOptions });
|
||||
|
||||
@@ -98,9 +98,14 @@ const TEXT_ENCODING = 'utf-8';
|
||||
* This is the "no output" timeout - if the CLI doesn't produce any JSONL output
|
||||
* for this duration, the process is killed. For reasoning models with high
|
||||
* reasoning effort, this timeout is dynamically extended via calculateReasoningTimeout().
|
||||
*
|
||||
* For feature generation (which can generate 50+ features), we use a much longer
|
||||
* base timeout (5 minutes) since Codex models are slower at generating large JSON responses.
|
||||
*
|
||||
* @see calculateReasoningTimeout from @automaker/types
|
||||
*/
|
||||
const CODEX_CLI_TIMEOUT_MS = DEFAULT_TIMEOUT_MS;
|
||||
const CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS = 300000; // 5 minutes for feature generation
|
||||
const CONTEXT_WINDOW_256K = 256000;
|
||||
const MAX_OUTPUT_32K = 32000;
|
||||
const MAX_OUTPUT_16K = 16000;
|
||||
@@ -827,7 +832,14 @@ export class CodexProvider extends BaseProvider {
|
||||
// Higher reasoning effort (e.g., 'xhigh' for "xtra thinking" mode) requires more time
|
||||
// for the model to generate reasoning tokens before producing output.
|
||||
// This fixes GitHub issue #530 where features would get stuck with reasoning models.
|
||||
const timeout = calculateReasoningTimeout(options.reasoningEffort, CODEX_CLI_TIMEOUT_MS);
|
||||
//
|
||||
// For feature generation with 'xhigh', use the extended 5-minute base timeout
|
||||
// since generating 50+ features takes significantly longer than normal operations.
|
||||
const baseTimeout =
|
||||
options.reasoningEffort === 'xhigh'
|
||||
? CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS
|
||||
: CODEX_CLI_TIMEOUT_MS;
|
||||
const timeout = calculateReasoningTimeout(options.reasoningEffort, baseTimeout);
|
||||
|
||||
const stream = spawnJSONLProcess({
|
||||
command: commandPath,
|
||||
|
||||
942
apps/server/src/providers/copilot-provider.ts
Normal file
942
apps/server/src/providers/copilot-provider.ts
Normal file
@@ -0,0 +1,942 @@
|
||||
/**
|
||||
* Copilot Provider - Executes queries using the GitHub Copilot SDK
|
||||
*
|
||||
* Uses the official @github/copilot-sdk for:
|
||||
* - Session management and streaming responses
|
||||
* - GitHub OAuth authentication (via gh CLI)
|
||||
* - Tool call handling and permission management
|
||||
* - Runtime model discovery
|
||||
*
|
||||
* Based on https://github.com/github/copilot-sdk
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { CliProvider, type CliSpawnConfig, type CliErrorInfo } from './cli-provider.js';
|
||||
import type {
|
||||
ProviderConfig,
|
||||
ExecuteOptions,
|
||||
ProviderMessage,
|
||||
InstallationStatus,
|
||||
ModelDefinition,
|
||||
} from './types.js';
|
||||
// Note: validateBareModelId is not used because Copilot's bare model IDs
|
||||
// legitimately contain prefixes like claude-, gemini-, gpt-
|
||||
import {
|
||||
COPILOT_MODEL_MAP,
|
||||
type CopilotAuthStatus,
|
||||
type CopilotRuntimeModel,
|
||||
} from '@automaker/types';
|
||||
import { createLogger, isAbortError } from '@automaker/utils';
|
||||
import { CopilotClient, type PermissionRequest } from '@github/copilot-sdk';
|
||||
import {
|
||||
normalizeTodos,
|
||||
normalizeFilePathInput,
|
||||
normalizeCommandInput,
|
||||
normalizePatternInput,
|
||||
} from './tool-normalization.js';
|
||||
|
||||
// Create logger for this module
|
||||
const logger = createLogger('CopilotProvider');
|
||||
|
||||
// Default bare model (without copilot- prefix) for SDK calls
|
||||
const DEFAULT_BARE_MODEL = 'claude-sonnet-4.5';
|
||||
|
||||
// =============================================================================
|
||||
// SDK Event Types (from @github/copilot-sdk)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* SDK session event data types
|
||||
*/
|
||||
interface SdkEvent {
|
||||
type: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
interface SdkMessageEvent extends SdkEvent {
|
||||
type: 'assistant.message';
|
||||
data: {
|
||||
content: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Note: SdkMessageDeltaEvent is not used - we skip delta events to reduce noise
|
||||
// The final assistant.message event contains the complete content
|
||||
|
||||
interface SdkToolExecutionStartEvent extends SdkEvent {
|
||||
type: 'tool.execution_start';
|
||||
data: {
|
||||
toolName: string;
|
||||
toolCallId: string;
|
||||
input?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
interface SdkToolExecutionEndEvent extends SdkEvent {
|
||||
type: 'tool.execution_end';
|
||||
data: {
|
||||
toolName: string;
|
||||
toolCallId: string;
|
||||
result?: string;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface SdkSessionIdleEvent extends SdkEvent {
|
||||
type: 'session.idle';
|
||||
}
|
||||
|
||||
interface SdkSessionErrorEvent extends SdkEvent {
|
||||
type: 'session.error';
|
||||
data: {
|
||||
message: string;
|
||||
code?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Error Codes
|
||||
// =============================================================================
|
||||
|
||||
export enum CopilotErrorCode {
|
||||
NOT_INSTALLED = 'COPILOT_NOT_INSTALLED',
|
||||
NOT_AUTHENTICATED = 'COPILOT_NOT_AUTHENTICATED',
|
||||
RATE_LIMITED = 'COPILOT_RATE_LIMITED',
|
||||
MODEL_UNAVAILABLE = 'COPILOT_MODEL_UNAVAILABLE',
|
||||
NETWORK_ERROR = 'COPILOT_NETWORK_ERROR',
|
||||
PROCESS_CRASHED = 'COPILOT_PROCESS_CRASHED',
|
||||
TIMEOUT = 'COPILOT_TIMEOUT',
|
||||
CLI_ERROR = 'COPILOT_CLI_ERROR',
|
||||
SDK_ERROR = 'COPILOT_SDK_ERROR',
|
||||
UNKNOWN = 'COPILOT_UNKNOWN_ERROR',
|
||||
}
|
||||
|
||||
export interface CopilotError extends Error {
|
||||
code: CopilotErrorCode;
|
||||
recoverable: boolean;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tool Name Normalization
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Copilot SDK tool name to standard tool name mapping
|
||||
*
|
||||
* Maps Copilot CLI tool names to our standard tool names for consistent UI display.
|
||||
* Tool names are case-insensitive (normalized to lowercase before lookup).
|
||||
*/
|
||||
const COPILOT_TOOL_NAME_MAP: Record<string, string> = {
|
||||
// File operations
|
||||
read_file: 'Read',
|
||||
read: 'Read',
|
||||
view: 'Read', // Copilot uses 'view' for reading files
|
||||
read_many_files: 'Read',
|
||||
write_file: 'Write',
|
||||
write: 'Write',
|
||||
create_file: 'Write',
|
||||
edit_file: 'Edit',
|
||||
edit: 'Edit',
|
||||
replace: 'Edit',
|
||||
patch: 'Edit',
|
||||
// Shell operations
|
||||
run_shell: 'Bash',
|
||||
run_shell_command: 'Bash',
|
||||
shell: 'Bash',
|
||||
bash: 'Bash',
|
||||
execute: 'Bash',
|
||||
terminal: 'Bash',
|
||||
// Search operations
|
||||
search: 'Grep',
|
||||
grep: 'Grep',
|
||||
search_file_content: 'Grep',
|
||||
find_files: 'Glob',
|
||||
glob: 'Glob',
|
||||
list_dir: 'Ls',
|
||||
list_directory: 'Ls',
|
||||
ls: 'Ls',
|
||||
// Web operations
|
||||
web_fetch: 'WebFetch',
|
||||
fetch: 'WebFetch',
|
||||
web_search: 'WebSearch',
|
||||
search_web: 'WebSearch',
|
||||
google_web_search: 'WebSearch',
|
||||
// Todo operations
|
||||
todo_write: 'TodoWrite',
|
||||
write_todos: 'TodoWrite',
|
||||
update_todos: 'TodoWrite',
|
||||
// Planning/intent operations (Copilot-specific)
|
||||
report_intent: 'ReportIntent', // Keep as-is, it's a planning tool
|
||||
think: 'Think',
|
||||
plan: 'Plan',
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize Copilot tool names to standard tool names
|
||||
*/
|
||||
function normalizeCopilotToolName(copilotToolName: string): string {
|
||||
const lowerName = copilotToolName.toLowerCase();
|
||||
return COPILOT_TOOL_NAME_MAP[lowerName] || copilotToolName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize Copilot tool input parameters to standard format
|
||||
*
|
||||
* Maps Copilot's parameter names to our standard parameter names.
|
||||
* Uses shared utilities from tool-normalization.ts for common normalizations.
|
||||
*/
|
||||
function normalizeCopilotToolInput(
|
||||
toolName: string,
|
||||
input: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const normalizedName = normalizeCopilotToolName(toolName);
|
||||
|
||||
// Normalize todo_write / write_todos: ensure proper format
|
||||
if (normalizedName === 'TodoWrite' && Array.isArray(input.todos)) {
|
||||
return { todos: normalizeTodos(input.todos) };
|
||||
}
|
||||
|
||||
// Normalize file path parameters for Read/Write/Edit tools
|
||||
if (normalizedName === 'Read' || normalizedName === 'Write' || normalizedName === 'Edit') {
|
||||
return normalizeFilePathInput(input);
|
||||
}
|
||||
|
||||
// Normalize shell command parameters for Bash tool
|
||||
if (normalizedName === 'Bash') {
|
||||
return normalizeCommandInput(input);
|
||||
}
|
||||
|
||||
// Normalize search parameters for Grep tool
|
||||
if (normalizedName === 'Grep') {
|
||||
return normalizePatternInput(input);
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
/**
|
||||
* CopilotProvider - Integrates GitHub Copilot SDK as an AI provider
|
||||
*
|
||||
* Features:
|
||||
* - GitHub OAuth authentication
|
||||
* - SDK-based session management
|
||||
* - Runtime model discovery
|
||||
* - Tool call normalization
|
||||
* - Per-execution working directory support
|
||||
*/
|
||||
export class CopilotProvider extends CliProvider {
|
||||
private runtimeModels: CopilotRuntimeModel[] | null = null;
|
||||
|
||||
constructor(config: ProviderConfig = {}) {
|
||||
super(config);
|
||||
// Trigger CLI detection on construction
|
||||
this.ensureCliDetected();
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// CliProvider Abstract Method Implementations
|
||||
// ==========================================================================
|
||||
|
||||
getName(): string {
|
||||
return 'copilot';
|
||||
}
|
||||
|
||||
getCliName(): string {
|
||||
return 'copilot';
|
||||
}
|
||||
|
||||
getSpawnConfig(): CliSpawnConfig {
|
||||
return {
|
||||
windowsStrategy: 'npx', // Copilot CLI can be run via npx
|
||||
npxPackage: '@github/copilot', // Official GitHub Copilot CLI package
|
||||
commonPaths: {
|
||||
linux: [
|
||||
path.join(os.homedir(), '.local/bin/copilot'),
|
||||
'/usr/local/bin/copilot',
|
||||
path.join(os.homedir(), '.npm-global/bin/copilot'),
|
||||
],
|
||||
darwin: [
|
||||
path.join(os.homedir(), '.local/bin/copilot'),
|
||||
'/usr/local/bin/copilot',
|
||||
'/opt/homebrew/bin/copilot',
|
||||
path.join(os.homedir(), '.npm-global/bin/copilot'),
|
||||
],
|
||||
win32: [
|
||||
path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'copilot.cmd'),
|
||||
path.join(os.homedir(), '.npm-global', 'copilot.cmd'),
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract prompt text from ExecuteOptions
|
||||
*
|
||||
* Note: CopilotProvider does not yet support vision/image inputs.
|
||||
* If non-text content is provided, an error is thrown.
|
||||
*/
|
||||
private extractPromptText(options: ExecuteOptions): string {
|
||||
if (typeof options.prompt === 'string') {
|
||||
return options.prompt;
|
||||
} else if (Array.isArray(options.prompt)) {
|
||||
// Check for non-text content (images, etc.) which we don't support yet
|
||||
const hasNonText = options.prompt.some((p) => p.type !== 'text');
|
||||
if (hasNonText) {
|
||||
throw new Error(
|
||||
'CopilotProvider does not yet support non-text prompt parts (e.g., images). ' +
|
||||
'Please use text-only prompts or switch to a provider that supports vision.'
|
||||
);
|
||||
}
|
||||
return options.prompt
|
||||
.filter((p) => p.type === 'text' && p.text)
|
||||
.map((p) => p.text)
|
||||
.join('\n');
|
||||
} else {
|
||||
throw new Error('Invalid prompt format');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Not used with SDK approach - kept for interface compatibility
|
||||
*/
|
||||
buildCliArgs(_options: ExecuteOptions): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert SDK event to AutoMaker ProviderMessage format
|
||||
*/
|
||||
normalizeEvent(event: unknown): ProviderMessage | null {
|
||||
const sdkEvent = event as SdkEvent;
|
||||
|
||||
switch (sdkEvent.type) {
|
||||
case 'assistant.message': {
|
||||
const messageEvent = sdkEvent as SdkMessageEvent;
|
||||
return {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: messageEvent.data.content }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'assistant.message_delta': {
|
||||
// Skip delta events - they create too much noise
|
||||
// The final assistant.message event has the complete content
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'tool.execution_start': {
|
||||
const toolEvent = sdkEvent as SdkToolExecutionStartEvent;
|
||||
const normalizedName = normalizeCopilotToolName(toolEvent.data.toolName);
|
||||
const normalizedInput = toolEvent.data.input
|
||||
? normalizeCopilotToolInput(toolEvent.data.toolName, toolEvent.data.input)
|
||||
: {};
|
||||
|
||||
return {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
name: normalizedName,
|
||||
tool_use_id: toolEvent.data.toolCallId,
|
||||
input: normalizedInput,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'tool.execution_end': {
|
||||
const toolResultEvent = sdkEvent as SdkToolExecutionEndEvent;
|
||||
const isError = !!toolResultEvent.data.error;
|
||||
const content = isError
|
||||
? `[ERROR] ${toolResultEvent.data.error}`
|
||||
: toolResultEvent.data.result || '';
|
||||
|
||||
return {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: toolResultEvent.data.toolCallId,
|
||||
content,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'session.idle': {
|
||||
logger.debug('Copilot session idle');
|
||||
return {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
};
|
||||
}
|
||||
|
||||
case 'session.error': {
|
||||
const errorEvent = sdkEvent as SdkSessionErrorEvent;
|
||||
return {
|
||||
type: 'error',
|
||||
error: errorEvent.data.message || 'Unknown error',
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
logger.debug(`Unknown Copilot SDK event type: ${sdkEvent.type}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// CliProvider Overrides
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Override error mapping for Copilot-specific error codes
|
||||
*/
|
||||
protected mapError(stderr: string, exitCode: number | null): CliErrorInfo {
|
||||
const lower = stderr.toLowerCase();
|
||||
|
||||
if (
|
||||
lower.includes('not authenticated') ||
|
||||
lower.includes('please log in') ||
|
||||
lower.includes('unauthorized') ||
|
||||
lower.includes('login required') ||
|
||||
lower.includes('authentication required') ||
|
||||
lower.includes('github login')
|
||||
) {
|
||||
return {
|
||||
code: CopilotErrorCode.NOT_AUTHENTICATED,
|
||||
message: 'GitHub Copilot is not authenticated',
|
||||
recoverable: true,
|
||||
suggestion: 'Run "gh auth login" or "copilot auth login" to authenticate with GitHub',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes('rate limit') ||
|
||||
lower.includes('too many requests') ||
|
||||
lower.includes('429') ||
|
||||
lower.includes('quota exceeded')
|
||||
) {
|
||||
return {
|
||||
code: CopilotErrorCode.RATE_LIMITED,
|
||||
message: 'Copilot API rate limit exceeded',
|
||||
recoverable: true,
|
||||
suggestion: 'Wait a few minutes and try again',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes('model not available') ||
|
||||
lower.includes('invalid model') ||
|
||||
lower.includes('unknown model') ||
|
||||
lower.includes('model not found') ||
|
||||
(lower.includes('not found') && lower.includes('404'))
|
||||
) {
|
||||
return {
|
||||
code: CopilotErrorCode.MODEL_UNAVAILABLE,
|
||||
message: 'Requested model is not available',
|
||||
recoverable: true,
|
||||
suggestion: `Try using "${DEFAULT_BARE_MODEL}" or select a different model`,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes('network') ||
|
||||
lower.includes('connection') ||
|
||||
lower.includes('econnrefused') ||
|
||||
lower.includes('timeout')
|
||||
) {
|
||||
return {
|
||||
code: CopilotErrorCode.NETWORK_ERROR,
|
||||
message: 'Network connection error',
|
||||
recoverable: true,
|
||||
suggestion: 'Check your internet connection and try again',
|
||||
};
|
||||
}
|
||||
|
||||
if (exitCode === 137 || lower.includes('killed') || lower.includes('sigterm')) {
|
||||
return {
|
||||
code: CopilotErrorCode.PROCESS_CRASHED,
|
||||
message: 'Copilot CLI process was terminated',
|
||||
recoverable: true,
|
||||
suggestion: 'The process may have run out of memory. Try a simpler task.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: CopilotErrorCode.UNKNOWN,
|
||||
message: stderr || `Copilot CLI exited with code ${exitCode}`,
|
||||
recoverable: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Override install instructions for Copilot-specific guidance
|
||||
*/
|
||||
protected getInstallInstructions(): string {
|
||||
return 'Install with: npm install -g @github/copilot (or visit https://github.com/github/copilot)';
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a prompt using Copilot SDK with real-time streaming
|
||||
*
|
||||
* Creates a new CopilotClient for each execution with the correct working directory.
|
||||
* Streams tool execution events in real-time for UI display.
|
||||
*/
|
||||
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
||||
this.ensureCliDetected();
|
||||
|
||||
// Note: We don't use validateBareModelId here because Copilot's model IDs
|
||||
// legitimately contain prefixes like claude-, gemini-, gpt- which are the
|
||||
// actual model names from the Copilot CLI. We only need to ensure the
|
||||
// copilot- prefix has been stripped by the ProviderFactory.
|
||||
if (options.model?.startsWith('copilot-')) {
|
||||
throw new Error(
|
||||
`[CopilotProvider] Model ID should not have 'copilot-' prefix. Got: '${options.model}'. ` +
|
||||
`The ProviderFactory should strip this prefix before passing to the provider.`
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.cliPath) {
|
||||
throw this.createError(
|
||||
CopilotErrorCode.NOT_INSTALLED,
|
||||
'Copilot CLI is not installed',
|
||||
true,
|
||||
this.getInstallInstructions()
|
||||
);
|
||||
}
|
||||
|
||||
const promptText = this.extractPromptText(options);
|
||||
const bareModel = options.model || DEFAULT_BARE_MODEL;
|
||||
const workingDirectory = options.cwd || process.cwd();
|
||||
|
||||
logger.debug(
|
||||
`CopilotProvider.executeQuery called with model: "${bareModel}", cwd: "${workingDirectory}"`
|
||||
);
|
||||
logger.debug(`Prompt length: ${promptText.length} characters`);
|
||||
|
||||
// Create a client for this execution with the correct working directory
|
||||
const client = new CopilotClient({
|
||||
logLevel: 'warning',
|
||||
autoRestart: false,
|
||||
cwd: workingDirectory,
|
||||
});
|
||||
|
||||
// Use an async queue to bridge callback-based SDK events to async generator
|
||||
const eventQueue: SdkEvent[] = [];
|
||||
let resolveWaiting: (() => void) | null = null;
|
||||
let sessionComplete = false;
|
||||
let sessionError: Error | null = null;
|
||||
|
||||
const pushEvent = (event: SdkEvent) => {
|
||||
eventQueue.push(event);
|
||||
if (resolveWaiting) {
|
||||
resolveWaiting();
|
||||
resolveWaiting = null;
|
||||
}
|
||||
};
|
||||
|
||||
const waitForEvent = (): Promise<void> => {
|
||||
if (eventQueue.length > 0 || sessionComplete) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
resolveWaiting = resolve;
|
||||
});
|
||||
};
|
||||
|
||||
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({
|
||||
model: bareModel,
|
||||
streaming: true,
|
||||
// AUTONOMOUS MODE: Auto-approve all permission requests.
|
||||
// AutoMaker is designed for fully autonomous AI agent operation.
|
||||
// Security boundary is provided by Docker containerization (see CLAUDE.md).
|
||||
// User is warned about this at app startup.
|
||||
onPermissionRequest: async (
|
||||
request: PermissionRequest
|
||||
): Promise<{ kind: 'approved' } | { kind: 'denied-interactively-by-user' }> => {
|
||||
logger.debug(`Permission request: ${request.kind}`);
|
||||
return { kind: 'approved' };
|
||||
},
|
||||
});
|
||||
|
||||
const sessionId = session.sessionId;
|
||||
logger.debug(`Session created: ${sessionId}`);
|
||||
|
||||
// Set up event handler to push events to queue
|
||||
session.on((event: SdkEvent) => {
|
||||
logger.debug(`SDK event: ${event.type}`);
|
||||
|
||||
if (event.type === 'session.idle') {
|
||||
sessionComplete = true;
|
||||
pushEvent(event);
|
||||
} else if (event.type === 'session.error') {
|
||||
const errorEvent = event as SdkSessionErrorEvent;
|
||||
sessionError = new Error(errorEvent.data.message);
|
||||
sessionComplete = true;
|
||||
pushEvent(event);
|
||||
} else {
|
||||
// Push all other events (tool.execution_start, tool.execution_end, assistant.message, etc.)
|
||||
pushEvent(event);
|
||||
}
|
||||
});
|
||||
|
||||
// Send the prompt (non-blocking)
|
||||
await session.send({ prompt: promptText });
|
||||
|
||||
// Process events as they arrive
|
||||
while (!sessionComplete || eventQueue.length > 0) {
|
||||
await waitForEvent();
|
||||
|
||||
// Check for errors first (before processing events to avoid race condition)
|
||||
if (sessionError) {
|
||||
await session.destroy();
|
||||
await client.stop();
|
||||
throw sessionError;
|
||||
}
|
||||
|
||||
// Process all queued events
|
||||
while (eventQueue.length > 0) {
|
||||
const event = eventQueue.shift()!;
|
||||
const normalized = this.normalizeEvent(event);
|
||||
if (normalized) {
|
||||
// Add session_id if not present
|
||||
if (!normalized.session_id) {
|
||||
normalized.session_id = sessionId;
|
||||
}
|
||||
yield normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
await session.destroy();
|
||||
await client.stop();
|
||||
logger.debug('CopilotClient stopped successfully');
|
||||
} catch (error) {
|
||||
// Ensure client is stopped on error
|
||||
try {
|
||||
await client.stop();
|
||||
} catch (cleanupError) {
|
||||
// Log but don't throw cleanup errors - the original error is more important
|
||||
logger.debug(`Failed to stop client during cleanup: ${cleanupError}`);
|
||||
}
|
||||
|
||||
if (isAbortError(error)) {
|
||||
logger.debug('Query aborted');
|
||||
return;
|
||||
}
|
||||
|
||||
// Map errors to CopilotError
|
||||
if (error instanceof Error) {
|
||||
logger.error(`Copilot SDK error: ${error.message}`);
|
||||
const errorInfo = this.mapError(error.message, null);
|
||||
throw this.createError(
|
||||
errorInfo.code as CopilotErrorCode,
|
||||
errorInfo.message,
|
||||
errorInfo.recoverable,
|
||||
errorInfo.suggestion
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Copilot-Specific Methods
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Create a CopilotError with details
|
||||
*/
|
||||
private createError(
|
||||
code: CopilotErrorCode,
|
||||
message: string,
|
||||
recoverable: boolean = false,
|
||||
suggestion?: string
|
||||
): CopilotError {
|
||||
const error = new Error(message) as CopilotError;
|
||||
error.code = code;
|
||||
error.recoverable = recoverable;
|
||||
error.suggestion = suggestion;
|
||||
error.name = 'CopilotError';
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Copilot CLI version
|
||||
*/
|
||||
async getVersion(): Promise<string | null> {
|
||||
this.ensureCliDetected();
|
||||
if (!this.cliPath) return null;
|
||||
|
||||
try {
|
||||
const result = execSync(`"${this.cliPath}" --version`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
stdio: 'pipe',
|
||||
}).trim();
|
||||
return result;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check authentication status
|
||||
*
|
||||
* Uses GitHub CLI (gh) to check Copilot authentication status.
|
||||
* The Copilot CLI relies on gh auth for authentication.
|
||||
*/
|
||||
async checkAuth(): Promise<CopilotAuthStatus> {
|
||||
this.ensureCliDetected();
|
||||
if (!this.cliPath) {
|
||||
logger.debug('checkAuth: CLI not found');
|
||||
return { authenticated: false, method: 'none' };
|
||||
}
|
||||
|
||||
logger.debug('checkAuth: Starting credential check');
|
||||
|
||||
// Try to check GitHub CLI authentication status first
|
||||
// The Copilot CLI uses gh auth for authentication
|
||||
try {
|
||||
const ghStatus = execSync('gh auth status --hostname github.com', {
|
||||
encoding: 'utf8',
|
||||
timeout: 10000,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
logger.debug(`checkAuth: gh auth status output: ${ghStatus.substring(0, 200)}`);
|
||||
|
||||
// Parse gh auth status output
|
||||
const loggedInMatch = ghStatus.match(/Logged in to github\.com account (\S+)/);
|
||||
if (loggedInMatch) {
|
||||
return {
|
||||
authenticated: true,
|
||||
method: 'oauth',
|
||||
login: loggedInMatch[1],
|
||||
host: 'github.com',
|
||||
};
|
||||
}
|
||||
|
||||
// Check for token auth
|
||||
if (ghStatus.includes('Logged in') || ghStatus.includes('Token:')) {
|
||||
return {
|
||||
authenticated: true,
|
||||
method: 'oauth',
|
||||
host: 'github.com',
|
||||
};
|
||||
}
|
||||
} catch (ghError) {
|
||||
logger.debug(`checkAuth: gh auth status failed: ${ghError}`);
|
||||
}
|
||||
|
||||
// Try Copilot-specific auth check if gh is not available
|
||||
try {
|
||||
const result = execSync(`"${this.cliPath}" auth status`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 10000,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
logger.debug(`checkAuth: copilot auth status output: ${result.substring(0, 200)}`);
|
||||
|
||||
if (result.includes('authenticated') || result.includes('logged in')) {
|
||||
return {
|
||||
authenticated: true,
|
||||
method: 'cli',
|
||||
};
|
||||
}
|
||||
} catch (copilotError) {
|
||||
logger.debug(`checkAuth: copilot auth status failed: ${copilotError}`);
|
||||
}
|
||||
|
||||
// Check for GITHUB_TOKEN environment variable
|
||||
if (process.env.GITHUB_TOKEN) {
|
||||
logger.debug('checkAuth: Found GITHUB_TOKEN environment variable');
|
||||
return {
|
||||
authenticated: true,
|
||||
method: 'oauth',
|
||||
statusMessage: 'Using GITHUB_TOKEN environment variable',
|
||||
};
|
||||
}
|
||||
|
||||
// Check for gh config file
|
||||
const ghConfigPath = path.join(os.homedir(), '.config', 'gh', 'hosts.yml');
|
||||
try {
|
||||
await fs.access(ghConfigPath);
|
||||
const content = await fs.readFile(ghConfigPath, 'utf8');
|
||||
if (content.includes('github.com') && content.includes('oauth_token')) {
|
||||
logger.debug('checkAuth: Found gh config with oauth_token');
|
||||
return {
|
||||
authenticated: true,
|
||||
method: 'oauth',
|
||||
host: 'github.com',
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
logger.debug('checkAuth: No gh config found');
|
||||
}
|
||||
|
||||
// No credentials found
|
||||
logger.debug('checkAuth: No valid credentials found');
|
||||
return {
|
||||
authenticated: false,
|
||||
method: 'none',
|
||||
error:
|
||||
'No authentication configured. Run "gh auth login" or install GitHub Copilot extension.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch available models from the CLI at runtime
|
||||
*/
|
||||
async fetchRuntimeModels(): Promise<CopilotRuntimeModel[]> {
|
||||
this.ensureCliDetected();
|
||||
if (!this.cliPath) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to list models using the CLI
|
||||
const result = execSync(`"${this.cliPath}" models list --format json`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 15000,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
const models = JSON.parse(result) as CopilotRuntimeModel[];
|
||||
this.runtimeModels = models;
|
||||
logger.debug(`Fetched ${models.length} runtime models from Copilot CLI`);
|
||||
return models;
|
||||
} catch (error) {
|
||||
// Clear cache on failure to avoid returning stale data
|
||||
this.runtimeModels = null;
|
||||
logger.debug(`Failed to fetch runtime models: ${error}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect installation status (required by BaseProvider)
|
||||
*/
|
||||
async detectInstallation(): Promise<InstallationStatus> {
|
||||
const installed = await this.isInstalled();
|
||||
const version = installed ? await this.getVersion() : undefined;
|
||||
const auth = await this.checkAuth();
|
||||
|
||||
return {
|
||||
installed,
|
||||
version: version || undefined,
|
||||
path: this.cliPath || undefined,
|
||||
method: 'cli',
|
||||
authenticated: auth.authenticated,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the detected CLI path (public accessor for status endpoints)
|
||||
*/
|
||||
getCliPath(): string | null {
|
||||
this.ensureCliDetected();
|
||||
return this.cliPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available Copilot models
|
||||
*
|
||||
* Returns both static model definitions and runtime-discovered models
|
||||
*/
|
||||
getAvailableModels(): ModelDefinition[] {
|
||||
// Start with static model definitions - explicitly typed to allow runtime models
|
||||
const staticModels: ModelDefinition[] = Object.entries(COPILOT_MODEL_MAP).map(
|
||||
([id, config]) => ({
|
||||
id, // Full model ID with copilot- prefix
|
||||
name: config.label,
|
||||
modelString: id.replace('copilot-', ''), // Bare model for CLI
|
||||
provider: 'copilot',
|
||||
description: config.description,
|
||||
supportsTools: config.supportsTools,
|
||||
supportsVision: config.supportsVision,
|
||||
contextWindow: config.contextWindow,
|
||||
})
|
||||
);
|
||||
|
||||
// Add runtime models if available (discovered via CLI)
|
||||
if (this.runtimeModels) {
|
||||
for (const runtimeModel of this.runtimeModels) {
|
||||
// Skip if already in static list
|
||||
const staticId = `copilot-${runtimeModel.id}`;
|
||||
if (staticModels.some((m) => m.id === staticId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
staticModels.push({
|
||||
id: staticId,
|
||||
name: runtimeModel.name || runtimeModel.id,
|
||||
modelString: runtimeModel.id,
|
||||
provider: 'copilot',
|
||||
description: `Dynamic model: ${runtimeModel.name || runtimeModel.id}`,
|
||||
supportsTools: true,
|
||||
supportsVision: runtimeModel.capabilities?.supportsVision ?? false,
|
||||
contextWindow: runtimeModel.capabilities?.maxInputTokens,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return staticModels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a feature is supported
|
||||
*
|
||||
* Note: Vision is NOT currently supported - the SDK doesn't handle image inputs yet.
|
||||
* This may change in future versions of the Copilot SDK.
|
||||
*/
|
||||
supportsFeature(feature: string): boolean {
|
||||
const supported = ['tools', 'text', 'streaming'];
|
||||
return supported.includes(feature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if runtime models have been cached
|
||||
*/
|
||||
hasCachedModels(): boolean {
|
||||
return this.runtimeModels !== null && this.runtimeModels.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the runtime model cache
|
||||
*/
|
||||
clearModelCache(): void {
|
||||
this.runtimeModels = null;
|
||||
logger.debug('Cleared Copilot model cache');
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh models from CLI and return all available models
|
||||
*/
|
||||
async refreshModels(): Promise<ModelDefinition[]> {
|
||||
logger.debug('Refreshing Copilot models from CLI');
|
||||
await this.fetchRuntimeModels();
|
||||
return this.getAvailableModels();
|
||||
}
|
||||
}
|
||||
@@ -337,10 +337,11 @@ export class CursorProvider extends CliProvider {
|
||||
'--stream-partial-output' // Real-time streaming
|
||||
);
|
||||
|
||||
// Only add --force if NOT in read-only mode
|
||||
// Without --force, Cursor CLI suggests changes but doesn't apply them
|
||||
// With --force, Cursor CLI can actually edit files
|
||||
if (!options.readOnly) {
|
||||
// In read-only mode, use --mode ask for Q&A style (no tools)
|
||||
// Otherwise, add --force to allow file edits
|
||||
if (options.readOnly) {
|
||||
cliArgs.push('--mode', 'ask');
|
||||
} else {
|
||||
cliArgs.push('--force');
|
||||
}
|
||||
|
||||
@@ -672,10 +673,13 @@ export class CursorProvider extends CliProvider {
|
||||
);
|
||||
}
|
||||
|
||||
// Extract prompt text to pass via stdin (avoids shell escaping issues)
|
||||
const promptText = this.extractPromptText(options);
|
||||
// Embed system prompt into user prompt (Cursor CLI doesn't support separate system messages)
|
||||
const effectiveOptions = this.embedSystemPromptIntoPrompt(options);
|
||||
|
||||
const cliArgs = this.buildCliArgs(options);
|
||||
// Extract prompt text to pass via stdin (avoids shell escaping issues)
|
||||
const promptText = this.extractPromptText(effectiveOptions);
|
||||
|
||||
const cliArgs = this.buildCliArgs(effectiveOptions);
|
||||
const subprocessOptions = this.buildSubprocessOptions(options, cliArgs);
|
||||
|
||||
// Pass prompt via stdin to avoid shell interpretation of special characters
|
||||
|
||||
810
apps/server/src/providers/gemini-provider.ts
Normal file
810
apps/server/src/providers/gemini-provider.ts
Normal file
@@ -0,0 +1,810 @@
|
||||
/**
|
||||
* Gemini Provider - Executes queries using the Gemini CLI
|
||||
*
|
||||
* Extends CliProvider with Gemini-specific:
|
||||
* - Event normalization for Gemini's JSONL streaming format
|
||||
* - Google account and API key authentication support
|
||||
* - Thinking level configuration
|
||||
*
|
||||
* Based on https://github.com/google-gemini/gemini-cli
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { CliProvider, type CliSpawnConfig, type CliErrorInfo } from './cli-provider.js';
|
||||
import type {
|
||||
ProviderConfig,
|
||||
ExecuteOptions,
|
||||
ProviderMessage,
|
||||
InstallationStatus,
|
||||
ModelDefinition,
|
||||
ContentBlock,
|
||||
} from './types.js';
|
||||
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 { normalizeTodos } from './tool-normalization.js';
|
||||
|
||||
// Create logger for this module
|
||||
const logger = createLogger('GeminiProvider');
|
||||
|
||||
// =============================================================================
|
||||
// Gemini Stream Event Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Base event structure from Gemini CLI --output-format stream-json
|
||||
*
|
||||
* Actual CLI output format:
|
||||
* {"type":"init","timestamp":"...","session_id":"...","model":"..."}
|
||||
* {"type":"message","timestamp":"...","role":"user","content":"..."}
|
||||
* {"type":"message","timestamp":"...","role":"assistant","content":"...","delta":true}
|
||||
* {"type":"tool_use","timestamp":"...","tool_name":"...","tool_id":"...","parameters":{...}}
|
||||
* {"type":"tool_result","timestamp":"...","tool_id":"...","status":"success","output":"..."}
|
||||
* {"type":"result","timestamp":"...","status":"success","stats":{...}}
|
||||
*/
|
||||
interface GeminiStreamEvent {
|
||||
type: 'init' | 'message' | 'tool_use' | 'tool_result' | 'result' | 'error';
|
||||
timestamp?: string;
|
||||
session_id?: string;
|
||||
}
|
||||
|
||||
interface GeminiInitEvent extends GeminiStreamEvent {
|
||||
type: 'init';
|
||||
session_id: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
interface GeminiMessageEvent extends GeminiStreamEvent {
|
||||
type: 'message';
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
delta?: boolean;
|
||||
session_id?: string;
|
||||
}
|
||||
|
||||
interface GeminiToolUseEvent extends GeminiStreamEvent {
|
||||
type: 'tool_use';
|
||||
tool_id: string;
|
||||
tool_name: string;
|
||||
parameters: Record<string, unknown>;
|
||||
session_id?: string;
|
||||
}
|
||||
|
||||
interface GeminiToolResultEvent extends GeminiStreamEvent {
|
||||
type: 'tool_result';
|
||||
tool_id: string;
|
||||
status: 'success' | 'error';
|
||||
output: string;
|
||||
session_id?: string;
|
||||
}
|
||||
|
||||
interface GeminiResultEvent extends GeminiStreamEvent {
|
||||
type: 'result';
|
||||
status: 'success' | 'error';
|
||||
stats?: {
|
||||
total_tokens?: number;
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
cached?: number;
|
||||
input?: number;
|
||||
duration_ms?: number;
|
||||
tool_calls?: number;
|
||||
};
|
||||
error?: string;
|
||||
session_id?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Error Codes
|
||||
// =============================================================================
|
||||
|
||||
export enum GeminiErrorCode {
|
||||
NOT_INSTALLED = 'GEMINI_NOT_INSTALLED',
|
||||
NOT_AUTHENTICATED = 'GEMINI_NOT_AUTHENTICATED',
|
||||
RATE_LIMITED = 'GEMINI_RATE_LIMITED',
|
||||
MODEL_UNAVAILABLE = 'GEMINI_MODEL_UNAVAILABLE',
|
||||
NETWORK_ERROR = 'GEMINI_NETWORK_ERROR',
|
||||
PROCESS_CRASHED = 'GEMINI_PROCESS_CRASHED',
|
||||
TIMEOUT = 'GEMINI_TIMEOUT',
|
||||
UNKNOWN = 'GEMINI_UNKNOWN_ERROR',
|
||||
}
|
||||
|
||||
export interface GeminiError extends Error {
|
||||
code: GeminiErrorCode;
|
||||
recoverable: boolean;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tool Name Normalization
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Gemini CLI tool name to standard tool name mapping
|
||||
* This allows the UI to properly categorize and display Gemini tool calls
|
||||
*/
|
||||
const GEMINI_TOOL_NAME_MAP: Record<string, string> = {
|
||||
write_todos: 'TodoWrite',
|
||||
read_file: 'Read',
|
||||
read_many_files: 'Read',
|
||||
replace: 'Edit',
|
||||
write_file: 'Write',
|
||||
run_shell_command: 'Bash',
|
||||
search_file_content: 'Grep',
|
||||
glob: 'Glob',
|
||||
list_directory: 'Ls',
|
||||
web_fetch: 'WebFetch',
|
||||
google_web_search: 'WebSearch',
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize Gemini tool names to standard tool names
|
||||
*/
|
||||
function normalizeGeminiToolName(geminiToolName: string): string {
|
||||
return GEMINI_TOOL_NAME_MAP[geminiToolName] || geminiToolName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize Gemini tool input parameters to standard format
|
||||
*
|
||||
* Uses shared normalizeTodos utility for consistent todo normalization.
|
||||
*
|
||||
* Gemini `write_todos` format:
|
||||
* {"todos": [{"description": "Task text", "status": "pending|in_progress|completed|cancelled"}]}
|
||||
*
|
||||
* Claude `TodoWrite` format:
|
||||
* {"todos": [{"content": "Task text", "status": "pending|in_progress|completed", "activeForm": "..."}]}
|
||||
*/
|
||||
function normalizeGeminiToolInput(
|
||||
toolName: string,
|
||||
input: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
// Normalize write_todos using shared utility
|
||||
if (toolName === 'write_todos' && Array.isArray(input.todos)) {
|
||||
return { todos: normalizeTodos(input.todos) };
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
/**
|
||||
* GeminiProvider - Integrates Gemini CLI as an AI provider
|
||||
*
|
||||
* Features:
|
||||
* - Google account OAuth login support
|
||||
* - API key authentication (GEMINI_API_KEY)
|
||||
* - Vertex AI support
|
||||
* - Thinking level configuration
|
||||
* - Streaming JSON output
|
||||
*/
|
||||
export class GeminiProvider extends CliProvider {
|
||||
constructor(config: ProviderConfig = {}) {
|
||||
super(config);
|
||||
// Trigger CLI detection on construction
|
||||
this.ensureCliDetected();
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// CliProvider Abstract Method Implementations
|
||||
// ==========================================================================
|
||||
|
||||
getName(): string {
|
||||
return 'gemini';
|
||||
}
|
||||
|
||||
getCliName(): string {
|
||||
return 'gemini';
|
||||
}
|
||||
|
||||
getSpawnConfig(): CliSpawnConfig {
|
||||
return {
|
||||
windowsStrategy: 'npx', // Gemini CLI can be run via npx
|
||||
npxPackage: '@google/gemini-cli', // Official Google Gemini CLI package
|
||||
commonPaths: {
|
||||
linux: [
|
||||
path.join(os.homedir(), '.local/bin/gemini'),
|
||||
'/usr/local/bin/gemini',
|
||||
path.join(os.homedir(), '.npm-global/bin/gemini'),
|
||||
],
|
||||
darwin: [
|
||||
path.join(os.homedir(), '.local/bin/gemini'),
|
||||
'/usr/local/bin/gemini',
|
||||
'/opt/homebrew/bin/gemini',
|
||||
path.join(os.homedir(), '.npm-global/bin/gemini'),
|
||||
],
|
||||
win32: [
|
||||
path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'gemini.cmd'),
|
||||
path.join(os.homedir(), '.npm-global', 'gemini.cmd'),
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract prompt text from ExecuteOptions
|
||||
*/
|
||||
private extractPromptText(options: ExecuteOptions): string {
|
||||
if (typeof options.prompt === 'string') {
|
||||
return options.prompt;
|
||||
} else if (Array.isArray(options.prompt)) {
|
||||
return options.prompt
|
||||
.filter((p) => p.type === 'text' && p.text)
|
||||
.map((p) => p.text)
|
||||
.join('\n');
|
||||
} else {
|
||||
throw new Error('Invalid prompt format');
|
||||
}
|
||||
}
|
||||
|
||||
buildCliArgs(options: ExecuteOptions): string[] {
|
||||
// Model comes in stripped of provider prefix (e.g., '2.5-flash' from 'gemini-2.5-flash')
|
||||
// We need to add 'gemini-' back since it's part of the actual CLI model name
|
||||
const bareModel = options.model || '2.5-flash';
|
||||
const cliArgs: string[] = [];
|
||||
|
||||
// Streaming JSON output format for real-time updates
|
||||
cliArgs.push('--output-format', 'stream-json');
|
||||
|
||||
// Model selection - Gemini CLI expects full model names like "gemini-2.5-flash"
|
||||
// Unlike Cursor CLI where 'cursor-' is just a routing prefix, for Gemini CLI
|
||||
// the 'gemini-' is part of the actual model name Google expects
|
||||
if (bareModel && bareModel !== 'auto') {
|
||||
// Add gemini- prefix if not already present (handles edge cases)
|
||||
const cliModel = bareModel.startsWith('gemini-') ? bareModel : `gemini-${bareModel}`;
|
||||
cliArgs.push('--model', cliModel);
|
||||
}
|
||||
|
||||
// Disable sandbox mode for faster execution (sandbox adds overhead)
|
||||
cliArgs.push('--sandbox', 'false');
|
||||
|
||||
// YOLO mode for automatic approval (required for non-interactive use)
|
||||
// Use explicit approval-mode for clearer semantics
|
||||
cliArgs.push('--approval-mode', 'yolo');
|
||||
|
||||
// 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
|
||||
if (options.cwd) {
|
||||
cliArgs.push('--include-directories', options.cwd);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Gemini event to AutoMaker ProviderMessage format
|
||||
*/
|
||||
normalizeEvent(event: unknown): ProviderMessage | null {
|
||||
const geminiEvent = event as GeminiStreamEvent;
|
||||
|
||||
switch (geminiEvent.type) {
|
||||
case 'init': {
|
||||
// Init event - capture session but don't yield a message
|
||||
const initEvent = geminiEvent as GeminiInitEvent;
|
||||
logger.debug(
|
||||
`Gemini init event: session=${initEvent.session_id}, model=${initEvent.model}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'message': {
|
||||
const messageEvent = geminiEvent as GeminiMessageEvent;
|
||||
|
||||
// Skip user messages - already handled by caller
|
||||
if (messageEvent.role === 'user') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle assistant messages
|
||||
if (messageEvent.role === 'assistant') {
|
||||
return {
|
||||
type: 'assistant',
|
||||
session_id: messageEvent.session_id,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: messageEvent.content }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'tool_use': {
|
||||
const toolEvent = geminiEvent as GeminiToolUseEvent;
|
||||
const normalizedName = normalizeGeminiToolName(toolEvent.tool_name);
|
||||
const normalizedInput = normalizeGeminiToolInput(
|
||||
toolEvent.tool_name,
|
||||
toolEvent.parameters as Record<string, unknown>
|
||||
);
|
||||
|
||||
return {
|
||||
type: 'assistant',
|
||||
session_id: toolEvent.session_id,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
name: normalizedName,
|
||||
tool_use_id: toolEvent.tool_id,
|
||||
input: normalizedInput,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'tool_result': {
|
||||
const toolResultEvent = geminiEvent as GeminiToolResultEvent;
|
||||
// If tool result is an error, prefix with error indicator
|
||||
const content =
|
||||
toolResultEvent.status === 'error'
|
||||
? `[ERROR] ${toolResultEvent.output}`
|
||||
: toolResultEvent.output;
|
||||
return {
|
||||
type: 'assistant',
|
||||
session_id: toolResultEvent.session_id,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: toolResultEvent.tool_id,
|
||||
content,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
case 'result': {
|
||||
const resultEvent = geminiEvent as GeminiResultEvent;
|
||||
|
||||
if (resultEvent.status === 'error') {
|
||||
return {
|
||||
type: 'error',
|
||||
session_id: resultEvent.session_id,
|
||||
error: resultEvent.error || 'Unknown error',
|
||||
};
|
||||
}
|
||||
|
||||
// Success result - include stats for logging
|
||||
logger.debug(
|
||||
`Gemini result: status=${resultEvent.status}, tokens=${resultEvent.stats?.total_tokens}`
|
||||
);
|
||||
return {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
session_id: resultEvent.session_id,
|
||||
};
|
||||
}
|
||||
|
||||
case 'error': {
|
||||
const errorEvent = geminiEvent as GeminiResultEvent;
|
||||
return {
|
||||
type: 'error',
|
||||
session_id: errorEvent.session_id,
|
||||
error: errorEvent.error || 'Unknown error',
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
logger.debug(`Unknown Gemini event type: ${geminiEvent.type}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// CliProvider Overrides
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Override error mapping for Gemini-specific error codes
|
||||
*/
|
||||
protected mapError(stderr: string, exitCode: number | null): CliErrorInfo {
|
||||
const lower = stderr.toLowerCase();
|
||||
|
||||
if (
|
||||
lower.includes('not authenticated') ||
|
||||
lower.includes('please log in') ||
|
||||
lower.includes('unauthorized') ||
|
||||
lower.includes('login required') ||
|
||||
lower.includes('error authenticating') ||
|
||||
lower.includes('loadcodeassist') ||
|
||||
(lower.includes('econnrefused') && lower.includes('8888'))
|
||||
) {
|
||||
return {
|
||||
code: GeminiErrorCode.NOT_AUTHENTICATED,
|
||||
message: 'Gemini CLI is not authenticated',
|
||||
recoverable: true,
|
||||
suggestion:
|
||||
'Run "gemini" interactively to log in, or set GEMINI_API_KEY environment variable',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes('rate limit') ||
|
||||
lower.includes('too many requests') ||
|
||||
lower.includes('429') ||
|
||||
lower.includes('quota exceeded')
|
||||
) {
|
||||
return {
|
||||
code: GeminiErrorCode.RATE_LIMITED,
|
||||
message: 'Gemini API rate limit exceeded',
|
||||
recoverable: true,
|
||||
suggestion: 'Wait a few minutes and try again. Free tier: 60 req/min, 1000 req/day',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes('model not available') ||
|
||||
lower.includes('invalid model') ||
|
||||
lower.includes('unknown model') ||
|
||||
lower.includes('modelnotfounderror') ||
|
||||
lower.includes('model not found') ||
|
||||
(lower.includes('not found') && lower.includes('404'))
|
||||
) {
|
||||
return {
|
||||
code: GeminiErrorCode.MODEL_UNAVAILABLE,
|
||||
message: 'Requested model is not available',
|
||||
recoverable: true,
|
||||
suggestion: 'Try using "gemini-2.5-flash" or select a different model',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes('network') ||
|
||||
lower.includes('connection') ||
|
||||
lower.includes('econnrefused') ||
|
||||
lower.includes('timeout')
|
||||
) {
|
||||
return {
|
||||
code: GeminiErrorCode.NETWORK_ERROR,
|
||||
message: 'Network connection error',
|
||||
recoverable: true,
|
||||
suggestion: 'Check your internet connection and try again',
|
||||
};
|
||||
}
|
||||
|
||||
if (exitCode === 137 || lower.includes('killed') || lower.includes('sigterm')) {
|
||||
return {
|
||||
code: GeminiErrorCode.PROCESS_CRASHED,
|
||||
message: 'Gemini CLI process was terminated',
|
||||
recoverable: true,
|
||||
suggestion: 'The process may have run out of memory. Try a simpler task.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: GeminiErrorCode.UNKNOWN,
|
||||
message: stderr || `Gemini CLI exited with code ${exitCode}`,
|
||||
recoverable: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Override install instructions for Gemini-specific guidance
|
||||
*/
|
||||
protected getInstallInstructions(): string {
|
||||
return 'Install with: npm install -g @google/gemini-cli (or visit https://github.com/google-gemini/gemini-cli)';
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a prompt using Gemini CLI with streaming
|
||||
*/
|
||||
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
||||
this.ensureCliDetected();
|
||||
|
||||
// Validate that model doesn't have a provider prefix
|
||||
validateBareModelId(options.model, 'GeminiProvider');
|
||||
|
||||
if (!this.cliPath) {
|
||||
throw this.createError(
|
||||
GeminiErrorCode.NOT_INSTALLED,
|
||||
'Gemini CLI is not installed',
|
||||
true,
|
||||
this.getInstallInstructions()
|
||||
);
|
||||
}
|
||||
|
||||
// Extract prompt text to pass as positional argument
|
||||
const promptText = this.extractPromptText(options);
|
||||
|
||||
// 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
|
||||
|
||||
const subprocessOptions = this.buildSubprocessOptions(options, cliArgs);
|
||||
|
||||
let sessionId: string | undefined;
|
||||
|
||||
logger.debug(`GeminiProvider.executeQuery called with model: "${options.model}"`);
|
||||
|
||||
try {
|
||||
for await (const rawEvent of spawnJSONLProcess(subprocessOptions)) {
|
||||
const event = rawEvent as GeminiStreamEvent;
|
||||
|
||||
// Capture session ID from init event
|
||||
if (event.type === 'init') {
|
||||
const initEvent = event as GeminiInitEvent;
|
||||
sessionId = initEvent.session_id;
|
||||
logger.debug(`Session started: ${sessionId}, model: ${initEvent.model}`);
|
||||
}
|
||||
|
||||
// Normalize and yield the event
|
||||
const normalized = this.normalizeEvent(event);
|
||||
if (normalized) {
|
||||
if (!normalized.session_id && sessionId) {
|
||||
normalized.session_id = sessionId;
|
||||
}
|
||||
yield normalized;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
logger.debug('Query aborted');
|
||||
return;
|
||||
}
|
||||
|
||||
// Map CLI errors to GeminiError
|
||||
if (error instanceof Error && 'stderr' in error) {
|
||||
const errorInfo = this.mapError(
|
||||
(error as { stderr?: string }).stderr || error.message,
|
||||
(error as { exitCode?: number | null }).exitCode ?? null
|
||||
);
|
||||
throw this.createError(
|
||||
errorInfo.code as GeminiErrorCode,
|
||||
errorInfo.message,
|
||||
errorInfo.recoverable,
|
||||
errorInfo.suggestion
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Gemini-Specific Methods
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Create a GeminiError with details
|
||||
*/
|
||||
private createError(
|
||||
code: GeminiErrorCode,
|
||||
message: string,
|
||||
recoverable: boolean = false,
|
||||
suggestion?: string
|
||||
): GeminiError {
|
||||
const error = new Error(message) as GeminiError;
|
||||
error.code = code;
|
||||
error.recoverable = recoverable;
|
||||
error.suggestion = suggestion;
|
||||
error.name = 'GeminiError';
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Gemini CLI version
|
||||
*/
|
||||
async getVersion(): Promise<string | null> {
|
||||
this.ensureCliDetected();
|
||||
if (!this.cliPath) return null;
|
||||
|
||||
try {
|
||||
const result = execSync(`"${this.cliPath}" --version`, {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
stdio: 'pipe',
|
||||
}).trim();
|
||||
return result;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check authentication status
|
||||
*
|
||||
* Uses a fast credential check approach:
|
||||
* 1. Check for GEMINI_API_KEY environment variable
|
||||
* 2. Check for Google Cloud credentials
|
||||
* 3. Check for Gemini settings file with stored credentials
|
||||
* 4. Quick CLI auth test with --help (fast, doesn't make API calls)
|
||||
*/
|
||||
async checkAuth(): Promise<GeminiAuthStatus> {
|
||||
this.ensureCliDetected();
|
||||
if (!this.cliPath) {
|
||||
logger.debug('checkAuth: CLI not found');
|
||||
return { authenticated: false, method: 'none' };
|
||||
}
|
||||
|
||||
logger.debug('checkAuth: Starting credential check');
|
||||
|
||||
// Determine the likely auth method based on environment
|
||||
const hasApiKey = !!process.env.GEMINI_API_KEY;
|
||||
const hasEnvApiKey = hasApiKey;
|
||||
const hasVertexAi = !!(
|
||||
process.env.GOOGLE_APPLICATION_CREDENTIALS || process.env.GOOGLE_CLOUD_PROJECT
|
||||
);
|
||||
|
||||
logger.debug(`checkAuth: hasApiKey=${hasApiKey}, hasVertexAi=${hasVertexAi}`);
|
||||
|
||||
// Check for Gemini credentials file (~/.gemini/settings.json)
|
||||
const geminiConfigDir = path.join(os.homedir(), '.gemini');
|
||||
const settingsPath = path.join(geminiConfigDir, 'settings.json');
|
||||
let hasCredentialsFile = false;
|
||||
let authType: string | null = null;
|
||||
|
||||
try {
|
||||
await fs.access(settingsPath);
|
||||
logger.debug(`checkAuth: Found settings file at ${settingsPath}`);
|
||||
try {
|
||||
const content = await fs.readFile(settingsPath, 'utf8');
|
||||
const settings = JSON.parse(content);
|
||||
|
||||
// Auth config is at security.auth.selectedType (e.g., "oauth-personal", "oauth-adc", "api-key")
|
||||
const selectedType = settings?.security?.auth?.selectedType;
|
||||
if (selectedType) {
|
||||
hasCredentialsFile = true;
|
||||
authType = selectedType;
|
||||
logger.debug(`checkAuth: Settings file has auth config, selectedType=${selectedType}`);
|
||||
} else {
|
||||
logger.debug(`checkAuth: Settings file found but no auth type configured`);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.debug(`checkAuth: Failed to parse settings file: ${e}`);
|
||||
}
|
||||
} catch {
|
||||
logger.debug('checkAuth: No settings file found');
|
||||
}
|
||||
|
||||
// If we have an API key, we're authenticated
|
||||
if (hasApiKey) {
|
||||
logger.debug('checkAuth: Using API key authentication');
|
||||
return {
|
||||
authenticated: true,
|
||||
method: 'api_key',
|
||||
hasApiKey,
|
||||
hasEnvApiKey,
|
||||
hasCredentialsFile,
|
||||
};
|
||||
}
|
||||
|
||||
// If we have Vertex AI credentials, we're authenticated
|
||||
if (hasVertexAi) {
|
||||
logger.debug('checkAuth: Using Vertex AI authentication');
|
||||
return {
|
||||
authenticated: true,
|
||||
method: 'vertex_ai',
|
||||
hasApiKey,
|
||||
hasEnvApiKey,
|
||||
hasCredentialsFile,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if settings file indicates configured authentication
|
||||
if (hasCredentialsFile && authType) {
|
||||
// OAuth types: "oauth-personal", "oauth-adc"
|
||||
// API key type: "api-key"
|
||||
// Code assist: "code-assist" (requires IDE integration)
|
||||
if (authType.startsWith('oauth')) {
|
||||
logger.debug(`checkAuth: OAuth authentication configured (${authType})`);
|
||||
return {
|
||||
authenticated: true,
|
||||
method: 'google_login',
|
||||
hasApiKey,
|
||||
hasEnvApiKey,
|
||||
hasCredentialsFile,
|
||||
};
|
||||
}
|
||||
|
||||
if (authType === 'api-key') {
|
||||
logger.debug('checkAuth: API key authentication configured in settings');
|
||||
return {
|
||||
authenticated: true,
|
||||
method: 'api_key',
|
||||
hasApiKey,
|
||||
hasEnvApiKey,
|
||||
hasCredentialsFile,
|
||||
};
|
||||
}
|
||||
|
||||
if (authType === 'code-assist' || authType === 'codeassist') {
|
||||
logger.debug('checkAuth: Code Assist auth configured but requires local server');
|
||||
return {
|
||||
authenticated: false,
|
||||
method: 'google_login',
|
||||
hasApiKey,
|
||||
hasEnvApiKey,
|
||||
hasCredentialsFile,
|
||||
error:
|
||||
'Code Assist authentication requires IDE integration. Please use "gemini" CLI to log in with a different method, or set GEMINI_API_KEY.',
|
||||
};
|
||||
}
|
||||
|
||||
// Unknown auth type but something is configured
|
||||
logger.debug(`checkAuth: Unknown auth type configured: ${authType}`);
|
||||
return {
|
||||
authenticated: true,
|
||||
method: 'google_login',
|
||||
hasApiKey,
|
||||
hasEnvApiKey,
|
||||
hasCredentialsFile,
|
||||
};
|
||||
}
|
||||
|
||||
// No credentials found
|
||||
logger.debug('checkAuth: No valid credentials found');
|
||||
return {
|
||||
authenticated: false,
|
||||
method: 'none',
|
||||
hasApiKey,
|
||||
hasEnvApiKey,
|
||||
hasCredentialsFile,
|
||||
error:
|
||||
'No authentication configured. Run "gemini" interactively to log in, or set GEMINI_API_KEY.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect installation status (required by BaseProvider)
|
||||
*/
|
||||
async detectInstallation(): Promise<InstallationStatus> {
|
||||
const installed = await this.isInstalled();
|
||||
const version = installed ? await this.getVersion() : undefined;
|
||||
const auth = await this.checkAuth();
|
||||
|
||||
return {
|
||||
installed,
|
||||
version: version || undefined,
|
||||
path: this.cliPath || undefined,
|
||||
method: 'cli',
|
||||
hasApiKey: !!process.env.GEMINI_API_KEY,
|
||||
authenticated: auth.authenticated,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the detected CLI path (public accessor for status endpoints)
|
||||
*/
|
||||
getCliPath(): string | null {
|
||||
this.ensureCliDetected();
|
||||
return this.cliPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available Gemini models
|
||||
*/
|
||||
getAvailableModels(): ModelDefinition[] {
|
||||
return Object.entries(GEMINI_MODEL_MAP).map(([id, config]) => ({
|
||||
id, // Full model ID with gemini- prefix (e.g., 'gemini-2.5-flash')
|
||||
name: config.label,
|
||||
modelString: id, // Same as id - CLI uses the full model name
|
||||
provider: 'gemini',
|
||||
description: config.description,
|
||||
supportsTools: true,
|
||||
supportsVision: config.supportsVision,
|
||||
contextWindow: config.contextWindow,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a feature is supported
|
||||
*/
|
||||
supportsFeature(feature: string): boolean {
|
||||
const supported = ['tools', 'text', 'streaming', 'vision', 'thinking'];
|
||||
return supported.includes(feature);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,16 @@ export type {
|
||||
ProviderMessage,
|
||||
InstallationStatus,
|
||||
ModelDefinition,
|
||||
AgentDefinition,
|
||||
ReasoningEffort,
|
||||
SystemPromptPreset,
|
||||
ConversationMessage,
|
||||
ContentBlock,
|
||||
ValidationResult,
|
||||
McpServerConfig,
|
||||
McpStdioServerConfig,
|
||||
McpSSEServerConfig,
|
||||
McpHttpServerConfig,
|
||||
} from './types.js';
|
||||
|
||||
// Claude provider
|
||||
@@ -28,6 +38,12 @@ export { CursorConfigManager } from './cursor-config-manager.js';
|
||||
// OpenCode provider
|
||||
export { OpencodeProvider } from './opencode-provider.js';
|
||||
|
||||
// Gemini provider
|
||||
export { GeminiProvider, GeminiErrorCode } from './gemini-provider.js';
|
||||
|
||||
// Copilot provider (GitHub Copilot SDK)
|
||||
export { CopilotProvider, CopilotErrorCode } from './copilot-provider.js';
|
||||
|
||||
// Provider factory
|
||||
export { ProviderFactory } from './provider-factory.js';
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ import type {
|
||||
InstallationStatus,
|
||||
ContentBlock,
|
||||
} from '@automaker/types';
|
||||
import { stripProviderPrefix } from '@automaker/types';
|
||||
import { type SubprocessOptions, getOpenCodeAuthIndicators } from '@automaker/platform';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
@@ -328,10 +327,18 @@ export class OpencodeProvider extends CliProvider {
|
||||
args.push('--format', 'json');
|
||||
|
||||
// Handle model selection
|
||||
// Strip 'opencode-' prefix if present, OpenCode uses format like 'anthropic/claude-sonnet-4-5'
|
||||
// Convert canonical prefix format (opencode-xxx) to CLI slash format (opencode/xxx)
|
||||
// OpenCode CLI expects provider/model format (e.g., 'opencode/big-model')
|
||||
if (options.model) {
|
||||
const model = stripProviderPrefix(options.model);
|
||||
args.push('--model', model);
|
||||
// Strip opencode- prefix if present, then ensure slash format
|
||||
const model = options.model.startsWith('opencode-')
|
||||
? options.model.slice('opencode-'.length)
|
||||
: options.model;
|
||||
|
||||
// If model has slash, it's already provider/model format; otherwise prepend opencode/
|
||||
const cliModel = model.includes('/') ? model : `opencode/${model}`;
|
||||
|
||||
args.push('--model', cliModel);
|
||||
}
|
||||
|
||||
// Note: OpenCode reads from stdin automatically when input is piped
|
||||
@@ -1035,7 +1042,7 @@ export class OpencodeProvider extends CliProvider {
|
||||
'lm studio': 'lmstudio',
|
||||
lmstudio: 'lmstudio',
|
||||
opencode: 'opencode',
|
||||
'z.ai coding plan': 'z-ai',
|
||||
'z.ai coding plan': 'zai-coding-plan',
|
||||
'z.ai': 'z-ai',
|
||||
};
|
||||
|
||||
|
||||
@@ -7,7 +7,14 @@
|
||||
|
||||
import { BaseProvider } from './base-provider.js';
|
||||
import type { InstallationStatus, ModelDefinition } from './types.js';
|
||||
import { isCursorModel, isCodexModel, isOpencodeModel, type ModelProvider } from '@automaker/types';
|
||||
import {
|
||||
isCursorModel,
|
||||
isCodexModel,
|
||||
isOpencodeModel,
|
||||
isGeminiModel,
|
||||
isCopilotModel,
|
||||
type ModelProvider,
|
||||
} from '@automaker/types';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
@@ -16,6 +23,8 @@ const DISCONNECTED_MARKERS: Record<string, string> = {
|
||||
codex: '.codex-disconnected',
|
||||
cursor: '.cursor-disconnected',
|
||||
opencode: '.opencode-disconnected',
|
||||
gemini: '.gemini-disconnected',
|
||||
copilot: '.copilot-disconnected',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -239,8 +248,8 @@ export class ProviderFactory {
|
||||
model.modelString === modelId ||
|
||||
model.id.endsWith(`-${modelId}`) ||
|
||||
model.modelString.endsWith(`-${modelId}`) ||
|
||||
model.modelString === modelId.replace(/^(claude|cursor|codex)-/, '') ||
|
||||
model.modelString === modelId.replace(/-(claude|cursor|codex)$/, '')
|
||||
model.modelString === modelId.replace(/^(claude|cursor|codex|gemini)-/, '') ||
|
||||
model.modelString === modelId.replace(/-(claude|cursor|codex|gemini)$/, '')
|
||||
) {
|
||||
return model.supportsVision ?? true;
|
||||
}
|
||||
@@ -267,6 +276,8 @@ import { ClaudeProvider } from './claude-provider.js';
|
||||
import { CursorProvider } from './cursor-provider.js';
|
||||
import { CodexProvider } from './codex-provider.js';
|
||||
import { OpencodeProvider } from './opencode-provider.js';
|
||||
import { GeminiProvider } from './gemini-provider.js';
|
||||
import { CopilotProvider } from './copilot-provider.js';
|
||||
|
||||
// Register Claude provider
|
||||
registerProvider('claude', {
|
||||
@@ -301,3 +312,19 @@ registerProvider('opencode', {
|
||||
canHandleModel: (model: string) => isOpencodeModel(model),
|
||||
priority: 3, // Between codex (5) and claude (0)
|
||||
});
|
||||
|
||||
// Register Gemini provider
|
||||
registerProvider('gemini', {
|
||||
factory: () => new GeminiProvider(),
|
||||
aliases: ['google'],
|
||||
canHandleModel: (model: string) => isGeminiModel(model),
|
||||
priority: 4, // Between opencode (3) and codex (5)
|
||||
});
|
||||
|
||||
// Register Copilot provider (GitHub Copilot SDK)
|
||||
registerProvider('copilot', {
|
||||
factory: () => new CopilotProvider(),
|
||||
aliases: ['github-copilot', 'github'],
|
||||
canHandleModel: (model: string) => isCopilotModel(model),
|
||||
priority: 6, // High priority - check before Codex since both can handle GPT models
|
||||
});
|
||||
|
||||
@@ -20,6 +20,9 @@ import type {
|
||||
ContentBlock,
|
||||
ThinkingLevel,
|
||||
ReasoningEffort,
|
||||
ClaudeApiProfile,
|
||||
ClaudeCompatibleProvider,
|
||||
Credentials,
|
||||
} from '@automaker/types';
|
||||
import { stripProviderPrefix } from '@automaker/types';
|
||||
|
||||
@@ -54,6 +57,18 @@ export interface SimpleQueryOptions {
|
||||
readOnly?: boolean;
|
||||
/** Setting sources for CLAUDE.md loading */
|
||||
settingSources?: Array<'user' | 'project' | 'local'>;
|
||||
/**
|
||||
* Active Claude API profile for alternative endpoint configuration
|
||||
* @deprecated Use claudeCompatibleProvider instead
|
||||
*/
|
||||
claudeApiProfile?: ClaudeApiProfile;
|
||||
/**
|
||||
* Claude-compatible provider for alternative endpoint configuration.
|
||||
* Takes precedence over claudeApiProfile if both are set.
|
||||
*/
|
||||
claudeCompatibleProvider?: ClaudeCompatibleProvider;
|
||||
/** Credentials for resolving 'credentials' apiKeySource in Claude API profiles/providers */
|
||||
credentials?: Credentials;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,6 +140,9 @@ export async function simpleQuery(options: SimpleQueryOptions): Promise<SimpleQu
|
||||
reasoningEffort: options.reasoningEffort,
|
||||
readOnly: options.readOnly,
|
||||
settingSources: options.settingSources,
|
||||
claudeApiProfile: options.claudeApiProfile, // Legacy: Pass active Claude API profile for alternative endpoint configuration
|
||||
claudeCompatibleProvider: options.claudeCompatibleProvider, // New: Pass Claude-compatible provider (takes precedence)
|
||||
credentials: options.credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
};
|
||||
|
||||
for await (const msg of provider.executeQuery(providerOptions)) {
|
||||
@@ -207,6 +225,9 @@ export async function streamingQuery(options: StreamingQueryOptions): Promise<Si
|
||||
reasoningEffort: options.reasoningEffort,
|
||||
readOnly: options.readOnly,
|
||||
settingSources: options.settingSources,
|
||||
claudeApiProfile: options.claudeApiProfile, // Legacy: Pass active Claude API profile for alternative endpoint configuration
|
||||
claudeCompatibleProvider: options.claudeCompatibleProvider, // New: Pass Claude-compatible provider (takes precedence)
|
||||
credentials: options.credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
};
|
||||
|
||||
for await (const msg of provider.executeQuery(providerOptions)) {
|
||||
|
||||
112
apps/server/src/providers/tool-normalization.ts
Normal file
112
apps/server/src/providers/tool-normalization.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Shared tool normalization utilities for AI providers
|
||||
*
|
||||
* These utilities help normalize tool inputs from various AI providers
|
||||
* to the standard format expected by the application.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Valid todo status values in the standard format
|
||||
*/
|
||||
type TodoStatus = 'pending' | 'in_progress' | 'completed';
|
||||
|
||||
/**
|
||||
* Set of valid status values for validation
|
||||
*/
|
||||
const VALID_STATUSES = new Set<TodoStatus>(['pending', 'in_progress', 'completed']);
|
||||
|
||||
/**
|
||||
* Todo item from various AI providers (Gemini, Copilot, etc.)
|
||||
*/
|
||||
interface ProviderTodo {
|
||||
description?: string;
|
||||
content?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard todo format used by the application
|
||||
*/
|
||||
interface NormalizedTodo {
|
||||
content: string;
|
||||
status: TodoStatus;
|
||||
activeForm: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a provider status value to a valid TodoStatus
|
||||
*/
|
||||
function normalizeStatus(status: string | undefined): TodoStatus {
|
||||
if (!status) return 'pending';
|
||||
if (status === 'cancelled' || status === 'canceled') return 'completed';
|
||||
if (VALID_STATUSES.has(status as TodoStatus)) return status as TodoStatus;
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize todos array from provider format to standard format
|
||||
*
|
||||
* Handles different formats from providers:
|
||||
* - Gemini: { description, status } with 'cancelled' as possible status
|
||||
* - Copilot: { content/description, status } with 'cancelled' as possible status
|
||||
*
|
||||
* Output format (Claude/Standard):
|
||||
* - { content, status, activeForm } where status is 'pending'|'in_progress'|'completed'
|
||||
*/
|
||||
export function normalizeTodos(todos: ProviderTodo[] | null | undefined): NormalizedTodo[] {
|
||||
if (!todos) return [];
|
||||
return todos.map((todo) => ({
|
||||
content: todo.content || todo.description || '',
|
||||
status: normalizeStatus(todo.status),
|
||||
// Use content/description as activeForm since providers may not have it
|
||||
activeForm: todo.content || todo.description || '',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize file path parameters from various provider formats
|
||||
*
|
||||
* Different providers use different parameter names for file paths:
|
||||
* - path, file, filename, filePath -> file_path
|
||||
*/
|
||||
export function normalizeFilePathInput(input: Record<string, unknown>): Record<string, unknown> {
|
||||
const normalized = { ...input };
|
||||
if (!normalized.file_path) {
|
||||
if (input.path) normalized.file_path = input.path;
|
||||
else if (input.file) normalized.file_path = input.file;
|
||||
else if (input.filename) normalized.file_path = input.filename;
|
||||
else if (input.filePath) normalized.file_path = input.filePath;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize shell command parameters from various provider formats
|
||||
*
|
||||
* Different providers use different parameter names for commands:
|
||||
* - cmd, script -> command
|
||||
*/
|
||||
export function normalizeCommandInput(input: Record<string, unknown>): Record<string, unknown> {
|
||||
const normalized = { ...input };
|
||||
if (!normalized.command) {
|
||||
if (input.cmd) normalized.command = input.cmd;
|
||||
else if (input.script) normalized.command = input.script;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize search pattern parameters from various provider formats
|
||||
*
|
||||
* Different providers use different parameter names for search patterns:
|
||||
* - query, search, regex -> pattern
|
||||
*/
|
||||
export function normalizePatternInput(input: Record<string, unknown>): Record<string, unknown> {
|
||||
const normalized = { ...input };
|
||||
if (!normalized.pattern) {
|
||||
if (input.query) normalized.pattern = input.query;
|
||||
else if (input.search) normalized.pattern = input.search;
|
||||
else if (input.regex) normalized.pattern = input.regex;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
@@ -19,4 +19,7 @@ export type {
|
||||
InstallationStatus,
|
||||
ValidationResult,
|
||||
ModelDefinition,
|
||||
AgentDefinition,
|
||||
ReasoningEffort,
|
||||
SystemPromptPreset,
|
||||
} from '@automaker/types';
|
||||
|
||||
@@ -8,19 +8,82 @@
|
||||
import * as secureFs from '../../lib/secure-fs.js';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS, supportsStructuredOutput, isCodexModel } from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { streamingQuery } from '../../providers/simple-query-service.js';
|
||||
import { parseAndCreateFeatures } from './parse-and-create-features.js';
|
||||
import { extractJsonWithArray } from '../../lib/json-extractor.js';
|
||||
import { getAppSpecPath } from '@automaker/platform';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
|
||||
import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getPromptCustomization,
|
||||
getPhaseModelWithOverrides,
|
||||
} from '../../lib/settings-helpers.js';
|
||||
import { FeatureLoader } from '../../services/feature-loader.js';
|
||||
|
||||
const logger = createLogger('SpecRegeneration');
|
||||
|
||||
const DEFAULT_MAX_FEATURES = 50;
|
||||
|
||||
/**
|
||||
* Timeout for Codex models when generating features (5 minutes).
|
||||
* Codex models are slower and need more time to generate 50+ features.
|
||||
*/
|
||||
const CODEX_FEATURE_GENERATION_TIMEOUT_MS = 300000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Type for extracted features JSON response
|
||||
*/
|
||||
interface FeaturesExtractionResult {
|
||||
features: Array<{
|
||||
id: string;
|
||||
category?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
priority?: number;
|
||||
complexity?: 'simple' | 'moderate' | 'complex';
|
||||
dependencies?: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON schema for features output format (Claude/Codex structured output)
|
||||
*/
|
||||
const featuresOutputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
features: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Unique feature identifier (kebab-case)' },
|
||||
category: { type: 'string', description: 'Feature category' },
|
||||
title: { type: 'string', description: 'Short, descriptive title' },
|
||||
description: { type: 'string', description: 'Detailed feature description' },
|
||||
priority: {
|
||||
type: 'number',
|
||||
description: 'Priority level: 1 (highest) to 5 (lowest)',
|
||||
},
|
||||
complexity: {
|
||||
type: 'string',
|
||||
enum: ['simple', 'moderate', 'complex'],
|
||||
description: 'Implementation complexity',
|
||||
},
|
||||
dependencies: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'IDs of features this depends on',
|
||||
},
|
||||
},
|
||||
required: ['id', 'title', 'description'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['features'],
|
||||
} as const;
|
||||
|
||||
export async function generateFeaturesFromSpec(
|
||||
projectPath: string,
|
||||
events: EventEmitter,
|
||||
@@ -115,25 +178,97 @@ Generate ${featureCount} NEW features that build on each other logically. Rememb
|
||||
'[FeatureGeneration]'
|
||||
);
|
||||
|
||||
// Get model from phase settings
|
||||
const settings = await settingsService?.getGlobalSettings();
|
||||
const phaseModelEntry =
|
||||
settings?.phaseModels?.featureGenerationModel || DEFAULT_PHASE_MODELS.featureGenerationModel;
|
||||
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
|
||||
// Get model from phase settings with provider info
|
||||
const {
|
||||
phaseModel: phaseModelEntry,
|
||||
provider,
|
||||
credentials,
|
||||
} = settingsService
|
||||
? await getPhaseModelWithOverrides(
|
||||
'featureGenerationModel',
|
||||
settingsService,
|
||||
projectPath,
|
||||
'[FeatureGeneration]'
|
||||
)
|
||||
: {
|
||||
phaseModel: DEFAULT_PHASE_MODELS.featureGenerationModel,
|
||||
provider: undefined,
|
||||
credentials: undefined,
|
||||
};
|
||||
const { model, thinkingLevel, reasoningEffort } = resolvePhaseModel(phaseModelEntry);
|
||||
|
||||
logger.info('Using model:', model);
|
||||
logger.info('Using model:', model, provider ? `via provider: ${provider.name}` : 'direct API');
|
||||
|
||||
// Codex models need extended timeout for generating many features.
|
||||
// Use 'xhigh' reasoning effort to get 5-minute timeout (300s base * 1.0x = 300s).
|
||||
// The Codex provider has a special 5-minute base timeout for feature generation.
|
||||
const isCodex = isCodexModel(model);
|
||||
const effectiveReasoningEffort = isCodex ? 'xhigh' : reasoningEffort;
|
||||
|
||||
if (isCodex) {
|
||||
logger.info('Codex model detected - using extended timeout (5 minutes for feature generation)');
|
||||
}
|
||||
if (effectiveReasoningEffort) {
|
||||
logger.info('Reasoning effort:', effectiveReasoningEffort);
|
||||
}
|
||||
|
||||
// Determine if we should use structured output based on model type
|
||||
const useStructuredOutput = supportsStructuredOutput(model);
|
||||
logger.info(
|
||||
`Structured output mode: ${useStructuredOutput ? 'enabled (Claude/Codex)' : 'disabled (using JSON instructions)'}`
|
||||
);
|
||||
|
||||
// Build the final prompt - for non-Claude/Codex models, include explicit JSON instructions
|
||||
let finalPrompt = prompt;
|
||||
if (!useStructuredOutput) {
|
||||
finalPrompt = `${prompt}
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
1. DO NOT write any files. Return the JSON in your response only.
|
||||
2. After analyzing the spec, respond with ONLY a JSON object - no explanations, no markdown, just raw JSON.
|
||||
3. The JSON must have this exact structure:
|
||||
{
|
||||
"features": [
|
||||
{
|
||||
"id": "unique-feature-id",
|
||||
"category": "Category Name",
|
||||
"title": "Short Feature Title",
|
||||
"description": "Detailed description of the feature",
|
||||
"priority": 1,
|
||||
"complexity": "simple|moderate|complex",
|
||||
"dependencies": ["other-feature-id"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
4. Feature IDs must be unique, lowercase, kebab-case (e.g., "user-authentication", "data-export")
|
||||
5. Priority ranges from 1 (highest) to 5 (lowest)
|
||||
6. Complexity must be one of: "simple", "moderate", "complex"
|
||||
7. Dependencies is an array of feature IDs that must be completed first (can be empty)
|
||||
|
||||
Your entire response should be valid JSON starting with { and ending with }. No text before or after.`;
|
||||
}
|
||||
|
||||
// Use streamingQuery with event callbacks
|
||||
const result = await streamingQuery({
|
||||
prompt,
|
||||
prompt: finalPrompt,
|
||||
model,
|
||||
cwd: projectPath,
|
||||
maxTurns: 250,
|
||||
allowedTools: ['Read', 'Glob', 'Grep'],
|
||||
abortController,
|
||||
thinkingLevel,
|
||||
reasoningEffort: effectiveReasoningEffort, // Extended timeout for Codex models
|
||||
readOnly: true, // Feature generation only reads code, doesn't write
|
||||
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
||||
claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
outputFormat: useStructuredOutput
|
||||
? {
|
||||
type: 'json_schema',
|
||||
schema: featuresOutputSchema,
|
||||
}
|
||||
: undefined,
|
||||
onText: (text) => {
|
||||
logger.debug(`Feature text block received (${text.length} chars)`);
|
||||
events.emit('spec-regeneration:event', {
|
||||
@@ -144,15 +279,51 @@ Generate ${featureCount} NEW features that build on each other logically. Rememb
|
||||
},
|
||||
});
|
||||
|
||||
const responseText = result.text;
|
||||
// Get response content - prefer structured output if available
|
||||
let contentForParsing: string;
|
||||
|
||||
logger.info(`Feature stream complete.`);
|
||||
logger.info(`Feature response length: ${responseText.length} chars`);
|
||||
logger.info('========== FULL RESPONSE TEXT ==========');
|
||||
logger.info(responseText);
|
||||
logger.info('========== END RESPONSE TEXT ==========');
|
||||
if (result.structured_output) {
|
||||
// Use structured output from Claude/Codex models
|
||||
logger.info('✅ Received structured output from model');
|
||||
contentForParsing = JSON.stringify(result.structured_output);
|
||||
logger.debug('Structured output:', contentForParsing);
|
||||
} else {
|
||||
// Use text response (for non-Claude/Codex models or fallback)
|
||||
// Pre-extract JSON to handle conversational text that may surround the JSON response
|
||||
// This follows the same pattern used in generate-spec.ts and validate-issue.ts
|
||||
const rawText = result.text;
|
||||
logger.info(`Feature stream complete.`);
|
||||
logger.info(`Feature response length: ${rawText.length} chars`);
|
||||
logger.info('========== FULL RESPONSE TEXT ==========');
|
||||
logger.info(rawText);
|
||||
logger.info('========== END RESPONSE TEXT ==========');
|
||||
|
||||
await parseAndCreateFeatures(projectPath, responseText, events);
|
||||
// Pre-extract JSON from response - handles conversational text around the JSON
|
||||
const extracted = extractJsonWithArray<FeaturesExtractionResult>(rawText, 'features', {
|
||||
logger,
|
||||
});
|
||||
if (extracted) {
|
||||
contentForParsing = JSON.stringify(extracted);
|
||||
logger.info('✅ Pre-extracted JSON from text response');
|
||||
} else {
|
||||
// If pre-extraction fails, we know the next step will also fail.
|
||||
// Throw an error here to avoid redundant parsing and make the failure point clearer.
|
||||
logger.error(
|
||||
'❌ Could not extract features JSON from model response. Full response text was:\n' +
|
||||
rawText
|
||||
);
|
||||
const errorMessage =
|
||||
'Failed to parse features from model response: No valid JSON with a "features" array found.';
|
||||
events.emit('spec-regeneration:event', {
|
||||
type: 'spec_regeneration_error',
|
||||
error: errorMessage,
|
||||
projectPath: projectPath,
|
||||
});
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
await parseAndCreateFeatures(projectPath, contentForParsing, events);
|
||||
|
||||
logger.debug('========== generateFeaturesFromSpec() completed ==========');
|
||||
}
|
||||
|
||||
@@ -9,14 +9,18 @@ import * as secureFs from '../../lib/secure-fs.js';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { specOutputSchema, specToXml, type SpecOutput } from '../../lib/app-spec-format.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS, supportsStructuredOutput } from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { extractJson } from '../../lib/json-extractor.js';
|
||||
import { streamingQuery } from '../../providers/simple-query-service.js';
|
||||
import { generateFeaturesFromSpec } from './generate-features-from-spec.js';
|
||||
import { ensureAutomakerDir, getAppSpecPath } from '@automaker/platform';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
|
||||
import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getPromptCustomization,
|
||||
getPhaseModelWithOverrides,
|
||||
} from '../../lib/settings-helpers.js';
|
||||
|
||||
const logger = createLogger('SpecRegeneration');
|
||||
|
||||
@@ -92,21 +96,37 @@ ${prompts.appSpec.structuredSpecInstructions}`;
|
||||
'[SpecRegeneration]'
|
||||
);
|
||||
|
||||
// Get model from phase settings
|
||||
const settings = await settingsService?.getGlobalSettings();
|
||||
const phaseModelEntry =
|
||||
settings?.phaseModels?.specGenerationModel || DEFAULT_PHASE_MODELS.specGenerationModel;
|
||||
// Get model from phase settings with provider info
|
||||
const {
|
||||
phaseModel: phaseModelEntry,
|
||||
provider,
|
||||
credentials,
|
||||
} = settingsService
|
||||
? await getPhaseModelWithOverrides(
|
||||
'specGenerationModel',
|
||||
settingsService,
|
||||
projectPath,
|
||||
'[SpecRegeneration]'
|
||||
)
|
||||
: {
|
||||
phaseModel: DEFAULT_PHASE_MODELS.specGenerationModel,
|
||||
provider: undefined,
|
||||
credentials: undefined,
|
||||
};
|
||||
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
|
||||
|
||||
logger.info('Using model:', model);
|
||||
logger.info('Using model:', model, provider ? `via provider: ${provider.name}` : 'direct API');
|
||||
|
||||
let responseText = '';
|
||||
let structuredOutput: SpecOutput | null = null;
|
||||
|
||||
// Determine if we should use structured output (Claude supports it, Cursor doesn't)
|
||||
const useStructuredOutput = !isCursorModel(model);
|
||||
// Determine if we should use structured output based on model type
|
||||
const useStructuredOutput = supportsStructuredOutput(model);
|
||||
logger.info(
|
||||
`Structured output mode: ${useStructuredOutput ? 'enabled (Claude/Codex)' : 'disabled (using JSON instructions)'}`
|
||||
);
|
||||
|
||||
// Build the final prompt - for Cursor, include JSON schema instructions
|
||||
// Build the final prompt - for non-Claude/Codex models, include JSON schema instructions
|
||||
let finalPrompt = prompt;
|
||||
if (!useStructuredOutput) {
|
||||
finalPrompt = `${prompt}
|
||||
@@ -132,6 +152,8 @@ Your entire response should be valid JSON starting with { and ending with }. No
|
||||
thinkingLevel,
|
||||
readOnly: true, // Spec generation only reads code, we write the spec ourselves
|
||||
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
||||
claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
outputFormat: useStructuredOutput
|
||||
? {
|
||||
type: 'json_schema',
|
||||
|
||||
@@ -10,12 +10,16 @@
|
||||
import * as secureFs from '../../lib/secure-fs.js';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS, supportsStructuredOutput } from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { streamingQuery } from '../../providers/simple-query-service.js';
|
||||
import { extractJson } from '../../lib/json-extractor.js';
|
||||
import { getAppSpecPath } from '@automaker/platform';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import { getAutoLoadClaudeMdSetting } from '../../lib/settings-helpers.js';
|
||||
import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getPhaseModelWithOverrides,
|
||||
} from '../../lib/settings-helpers.js';
|
||||
import { FeatureLoader } from '../../services/feature-loader.js';
|
||||
import {
|
||||
extractImplementedFeatures,
|
||||
@@ -31,6 +35,28 @@ import { getNotificationService } from '../../services/notification-service.js';
|
||||
|
||||
const logger = createLogger('SpecSync');
|
||||
|
||||
/**
|
||||
* Type for extracted tech stack JSON response
|
||||
*/
|
||||
interface TechStackExtractionResult {
|
||||
technologies: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON schema for tech stack analysis output (Claude/Codex structured output)
|
||||
*/
|
||||
const techStackOutputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
technologies: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'List of technologies detected in the project',
|
||||
},
|
||||
},
|
||||
required: ['technologies'],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Result of a sync operation
|
||||
*/
|
||||
@@ -152,13 +178,35 @@ export async function syncSpec(
|
||||
'[SpecSync]'
|
||||
);
|
||||
|
||||
const settings = await settingsService?.getGlobalSettings();
|
||||
const phaseModelEntry =
|
||||
settings?.phaseModels?.specGenerationModel || DEFAULT_PHASE_MODELS.specGenerationModel;
|
||||
// Get model from phase settings with provider info
|
||||
const {
|
||||
phaseModel: phaseModelEntry,
|
||||
provider,
|
||||
credentials,
|
||||
} = settingsService
|
||||
? await getPhaseModelWithOverrides(
|
||||
'specGenerationModel',
|
||||
settingsService,
|
||||
projectPath,
|
||||
'[SpecSync]'
|
||||
)
|
||||
: {
|
||||
phaseModel: DEFAULT_PHASE_MODELS.specGenerationModel,
|
||||
provider: undefined,
|
||||
credentials: undefined,
|
||||
};
|
||||
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
|
||||
|
||||
logger.info('Using model:', model, provider ? `via provider: ${provider.name}` : 'direct API');
|
||||
|
||||
// Determine if we should use structured output based on model type
|
||||
const useStructuredOutput = supportsStructuredOutput(model);
|
||||
logger.info(
|
||||
`Structured output mode: ${useStructuredOutput ? 'enabled (Claude/Codex)' : 'disabled (using JSON instructions)'}`
|
||||
);
|
||||
|
||||
// Use AI to analyze tech stack
|
||||
const techAnalysisPrompt = `Analyze this project and return ONLY a JSON object with the current technology stack.
|
||||
let techAnalysisPrompt = `Analyze this project and return ONLY a JSON object with the current technology stack.
|
||||
|
||||
Current known technologies: ${currentTechStack.join(', ')}
|
||||
|
||||
@@ -174,6 +222,16 @@ Return ONLY this JSON format, no other text:
|
||||
"technologies": ["Technology 1", "Technology 2", ...]
|
||||
}`;
|
||||
|
||||
// Add explicit JSON instructions for non-Claude/Codex models
|
||||
if (!useStructuredOutput) {
|
||||
techAnalysisPrompt = `${techAnalysisPrompt}
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
1. DO NOT write any files. Return the JSON in your response only.
|
||||
2. Your entire response should be valid JSON starting with { and ending with }.
|
||||
3. No explanations, no markdown, no text before or after the JSON.`;
|
||||
}
|
||||
|
||||
try {
|
||||
const techResult = await streamingQuery({
|
||||
prompt: techAnalysisPrompt,
|
||||
@@ -185,44 +243,69 @@ Return ONLY this JSON format, no other text:
|
||||
thinkingLevel,
|
||||
readOnly: true,
|
||||
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
||||
claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
outputFormat: useStructuredOutput
|
||||
? {
|
||||
type: 'json_schema',
|
||||
schema: techStackOutputSchema,
|
||||
}
|
||||
: undefined,
|
||||
onText: (text) => {
|
||||
logger.debug(`Tech analysis text: ${text.substring(0, 100)}`);
|
||||
},
|
||||
});
|
||||
|
||||
// Parse tech stack from response
|
||||
const jsonMatch = techResult.text.match(/\{[\s\S]*"technologies"[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
if (Array.isArray(parsed.technologies)) {
|
||||
const newTechStack = parsed.technologies as string[];
|
||||
// Parse tech stack from response - prefer structured output if available
|
||||
let parsedTechnologies: string[] | null = null;
|
||||
|
||||
// Calculate differences
|
||||
const currentSet = new Set(currentTechStack.map((t) => t.toLowerCase()));
|
||||
const newSet = new Set(newTechStack.map((t) => t.toLowerCase()));
|
||||
if (techResult.structured_output) {
|
||||
// Use structured output from Claude/Codex models
|
||||
const structured = techResult.structured_output as unknown as TechStackExtractionResult;
|
||||
if (Array.isArray(structured.technologies)) {
|
||||
parsedTechnologies = structured.technologies;
|
||||
logger.info('✅ Received structured output for tech analysis');
|
||||
}
|
||||
} else {
|
||||
// Fall back to text parsing for non-Claude/Codex models
|
||||
const extracted = extractJson<TechStackExtractionResult>(techResult.text, {
|
||||
logger,
|
||||
requiredKey: 'technologies',
|
||||
requireArray: true,
|
||||
});
|
||||
if (extracted && Array.isArray(extracted.technologies)) {
|
||||
parsedTechnologies = extracted.technologies;
|
||||
logger.info('✅ Extracted tech stack from text response');
|
||||
} else {
|
||||
logger.warn('⚠️ Failed to extract tech stack JSON from response');
|
||||
}
|
||||
}
|
||||
|
||||
for (const tech of newTechStack) {
|
||||
if (!currentSet.has(tech.toLowerCase())) {
|
||||
result.techStackUpdates.added.push(tech);
|
||||
}
|
||||
if (parsedTechnologies) {
|
||||
const newTechStack = parsedTechnologies;
|
||||
|
||||
// Calculate differences
|
||||
const currentSet = new Set(currentTechStack.map((t) => t.toLowerCase()));
|
||||
const newSet = new Set(newTechStack.map((t) => t.toLowerCase()));
|
||||
|
||||
for (const tech of newTechStack) {
|
||||
if (!currentSet.has(tech.toLowerCase())) {
|
||||
result.techStackUpdates.added.push(tech);
|
||||
}
|
||||
}
|
||||
|
||||
for (const tech of currentTechStack) {
|
||||
if (!newSet.has(tech.toLowerCase())) {
|
||||
result.techStackUpdates.removed.push(tech);
|
||||
}
|
||||
for (const tech of currentTechStack) {
|
||||
if (!newSet.has(tech.toLowerCase())) {
|
||||
result.techStackUpdates.removed.push(tech);
|
||||
}
|
||||
}
|
||||
|
||||
// Update spec with new tech stack if there are changes
|
||||
if (
|
||||
result.techStackUpdates.added.length > 0 ||
|
||||
result.techStackUpdates.removed.length > 0
|
||||
) {
|
||||
specContent = updateTechnologyStack(specContent, newTechStack);
|
||||
logger.info(
|
||||
`Updated tech stack: +${result.techStackUpdates.added.length}, -${result.techStackUpdates.removed.length}`
|
||||
);
|
||||
}
|
||||
// Update spec with new tech stack if there are changes
|
||||
if (result.techStackUpdates.added.length > 0 || result.techStackUpdates.removed.length > 0) {
|
||||
specContent = updateTechnologyStack(specContent, newTechStack);
|
||||
logger.info(
|
||||
`Updated tech stack: +${result.techStackUpdates.added.length}, -${result.techStackUpdates.removed.length}`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -26,6 +26,24 @@ export function createRunFeatureHandler(autoModeService: AutoModeService) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check per-worktree capacity before starting
|
||||
const capacity = await autoModeService.checkWorktreeCapacity(projectPath, featureId);
|
||||
if (!capacity.hasCapacity) {
|
||||
const worktreeDesc = capacity.branchName
|
||||
? `worktree "${capacity.branchName}"`
|
||||
: 'main worktree';
|
||||
res.status(429).json({
|
||||
success: false,
|
||||
error: `Agent limit reached for ${worktreeDesc} (${capacity.currentAgents}/${capacity.maxAgents}). Wait for running tasks to complete or increase the limit.`,
|
||||
details: {
|
||||
currentAgents: capacity.currentAgents,
|
||||
maxAgents: capacity.maxAgents,
|
||||
branchName: capacity.branchName,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Start execution in background
|
||||
// executeFeature derives workDir from feature.branchName
|
||||
autoModeService
|
||||
|
||||
@@ -12,8 +12,9 @@ const logger = createLogger('AutoMode');
|
||||
export function createStartHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, maxConcurrency } = req.body as {
|
||||
const { projectPath, branchName, maxConcurrency } = req.body as {
|
||||
projectPath: string;
|
||||
branchName?: string | null;
|
||||
maxConcurrency?: number;
|
||||
};
|
||||
|
||||
@@ -25,26 +26,38 @@ export function createStartHandler(autoModeService: AutoModeService) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize branchName: undefined becomes null
|
||||
const normalizedBranchName = branchName ?? null;
|
||||
const worktreeDesc = normalizedBranchName
|
||||
? `worktree ${normalizedBranchName}`
|
||||
: 'main worktree';
|
||||
|
||||
// Check if already running
|
||||
if (autoModeService.isAutoLoopRunningForProject(projectPath)) {
|
||||
if (autoModeService.isAutoLoopRunningForProject(projectPath, normalizedBranchName)) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Auto mode is already running for this project',
|
||||
message: `Auto mode is already running for ${worktreeDesc}`,
|
||||
alreadyRunning: true,
|
||||
branchName: normalizedBranchName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Start the auto loop for this project
|
||||
await autoModeService.startAutoLoopForProject(projectPath, maxConcurrency ?? 3);
|
||||
// Start the auto loop for this project/worktree
|
||||
const resolvedMaxConcurrency = await autoModeService.startAutoLoopForProject(
|
||||
projectPath,
|
||||
normalizedBranchName,
|
||||
maxConcurrency
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`Started auto loop for project: ${projectPath} with maxConcurrency: ${maxConcurrency ?? 3}`
|
||||
`Started auto loop for ${worktreeDesc} in project: ${projectPath} with maxConcurrency: ${resolvedMaxConcurrency}`
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Auto mode started with max ${maxConcurrency ?? 3} concurrent features`,
|
||||
message: `Auto mode started with max ${resolvedMaxConcurrency} concurrent features`,
|
||||
branchName: normalizedBranchName,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Start auto mode failed');
|
||||
|
||||
@@ -12,11 +12,19 @@ import { getErrorMessage, logError } from '../common.js';
|
||||
export function createStatusHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body as { projectPath?: string };
|
||||
const { projectPath, branchName } = req.body as {
|
||||
projectPath?: string;
|
||||
branchName?: string | null;
|
||||
};
|
||||
|
||||
// If projectPath is provided, return per-project status
|
||||
// If projectPath is provided, return per-project/worktree status
|
||||
if (projectPath) {
|
||||
const projectStatus = autoModeService.getStatusForProject(projectPath);
|
||||
// Normalize branchName: undefined becomes null
|
||||
const normalizedBranchName = branchName ?? null;
|
||||
const projectStatus = autoModeService.getStatusForProject(
|
||||
projectPath,
|
||||
normalizedBranchName
|
||||
);
|
||||
res.json({
|
||||
success: true,
|
||||
isRunning: projectStatus.runningCount > 0,
|
||||
@@ -25,6 +33,7 @@ export function createStatusHandler(autoModeService: AutoModeService) {
|
||||
runningCount: projectStatus.runningCount,
|
||||
maxConcurrency: projectStatus.maxConcurrency,
|
||||
projectPath,
|
||||
branchName: normalizedBranchName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -32,10 +41,12 @@ export function createStatusHandler(autoModeService: AutoModeService) {
|
||||
// Fall back to global status for backward compatibility
|
||||
const status = autoModeService.getStatus();
|
||||
const activeProjects = autoModeService.getActiveAutoLoopProjects();
|
||||
const activeWorktrees = autoModeService.getActiveAutoLoopWorktrees();
|
||||
res.json({
|
||||
success: true,
|
||||
...status,
|
||||
activeAutoLoopProjects: activeProjects,
|
||||
activeAutoLoopWorktrees: activeWorktrees,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get status failed');
|
||||
|
||||
@@ -12,8 +12,9 @@ const logger = createLogger('AutoMode');
|
||||
export function createStopHandler(autoModeService: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body as {
|
||||
const { projectPath, branchName } = req.body as {
|
||||
projectPath: string;
|
||||
branchName?: string | null;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
@@ -24,27 +25,38 @@ export function createStopHandler(autoModeService: AutoModeService) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize branchName: undefined becomes null
|
||||
const normalizedBranchName = branchName ?? null;
|
||||
const worktreeDesc = normalizedBranchName
|
||||
? `worktree ${normalizedBranchName}`
|
||||
: 'main worktree';
|
||||
|
||||
// Check if running
|
||||
if (!autoModeService.isAutoLoopRunningForProject(projectPath)) {
|
||||
if (!autoModeService.isAutoLoopRunningForProject(projectPath, normalizedBranchName)) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Auto mode is not running for this project',
|
||||
message: `Auto mode is not running for ${worktreeDesc}`,
|
||||
wasRunning: false,
|
||||
branchName: normalizedBranchName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop the auto loop for this project
|
||||
const runningCount = await autoModeService.stopAutoLoopForProject(projectPath);
|
||||
// Stop the auto loop for this project/worktree
|
||||
const runningCount = await autoModeService.stopAutoLoopForProject(
|
||||
projectPath,
|
||||
normalizedBranchName
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`Stopped auto loop for project: ${projectPath}, ${runningCount} features still running`
|
||||
`Stopped auto loop for ${worktreeDesc} in project: ${projectPath}, ${runningCount} features still running`
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Auto mode stopped',
|
||||
runningFeaturesCount: runningCount,
|
||||
branchName: normalizedBranchName,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Stop auto mode failed');
|
||||
|
||||
@@ -25,7 +25,11 @@ import {
|
||||
saveBacklogPlan,
|
||||
} from './common.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
|
||||
import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getPromptCustomization,
|
||||
getPhaseModelWithOverrides,
|
||||
} from '../../lib/settings-helpers.js';
|
||||
|
||||
const featureLoader = new FeatureLoader();
|
||||
|
||||
@@ -117,18 +121,42 @@ export async function generateBacklogPlan(
|
||||
content: 'Generating plan with AI...',
|
||||
});
|
||||
|
||||
// Get the model to use from settings or provided override
|
||||
// Get the model to use from settings or provided override with provider info
|
||||
let effectiveModel = model;
|
||||
let thinkingLevel: ThinkingLevel | undefined;
|
||||
if (!effectiveModel) {
|
||||
const settings = await settingsService?.getGlobalSettings();
|
||||
const phaseModelEntry =
|
||||
settings?.phaseModels?.backlogPlanningModel || DEFAULT_PHASE_MODELS.backlogPlanningModel;
|
||||
const resolved = resolvePhaseModel(phaseModelEntry);
|
||||
let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined;
|
||||
let credentials: import('@automaker/types').Credentials | undefined;
|
||||
|
||||
if (effectiveModel) {
|
||||
// Use explicit override - resolve model alias and get credentials
|
||||
const resolved = resolvePhaseModel({ model: effectiveModel });
|
||||
effectiveModel = resolved.model;
|
||||
thinkingLevel = resolved.thinkingLevel;
|
||||
credentials = await settingsService?.getCredentials();
|
||||
} else if (settingsService) {
|
||||
// Use settings-based model with provider info
|
||||
const phaseResult = await getPhaseModelWithOverrides(
|
||||
'backlogPlanningModel',
|
||||
settingsService,
|
||||
projectPath,
|
||||
'[BacklogPlan]'
|
||||
);
|
||||
const resolved = resolvePhaseModel(phaseResult.phaseModel);
|
||||
effectiveModel = resolved.model;
|
||||
thinkingLevel = resolved.thinkingLevel;
|
||||
claudeCompatibleProvider = phaseResult.provider;
|
||||
credentials = phaseResult.credentials;
|
||||
} else {
|
||||
// Fallback to defaults
|
||||
const resolved = resolvePhaseModel(DEFAULT_PHASE_MODELS.backlogPlanningModel);
|
||||
effectiveModel = resolved.model;
|
||||
thinkingLevel = resolved.thinkingLevel;
|
||||
}
|
||||
logger.info('[BacklogPlan] Using model:', effectiveModel);
|
||||
logger.info(
|
||||
'[BacklogPlan] Using model:',
|
||||
effectiveModel,
|
||||
claudeCompatibleProvider ? `via provider: ${claudeCompatibleProvider.name}` : 'direct API'
|
||||
);
|
||||
|
||||
const provider = ProviderFactory.getProviderForModel(effectiveModel);
|
||||
// Strip provider prefix - providers expect bare model IDs
|
||||
@@ -173,6 +201,8 @@ ${userPrompt}`;
|
||||
settingSources: autoLoadClaudeMd ? ['user', 'project'] : undefined,
|
||||
readOnly: true, // Plan generation only generates text, doesn't write files
|
||||
thinkingLevel, // Pass thinking level for extended thinking
|
||||
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
});
|
||||
|
||||
let responseText = '';
|
||||
|
||||
@@ -85,8 +85,9 @@ export function createApplyHandler() {
|
||||
if (!change.feature) continue;
|
||||
|
||||
try {
|
||||
// Create the new feature
|
||||
// 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
|
||||
title: change.feature.title,
|
||||
description: change.feature.description || '',
|
||||
category: change.feature.category || 'Uncategorized',
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
|
||||
import { PathNotAllowedError } from '@automaker/platform';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { simpleQuery } from '../../../providers/simple-query-service.js';
|
||||
@@ -22,6 +21,7 @@ import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getPromptCustomization,
|
||||
getPhaseModelWithOverrides,
|
||||
} from '../../../lib/settings-helpers.js';
|
||||
|
||||
const logger = createLogger('DescribeFile');
|
||||
@@ -155,15 +155,23 @@ ${contentToAnalyze}`;
|
||||
'[DescribeFile]'
|
||||
);
|
||||
|
||||
// Get model from phase settings
|
||||
const settings = await settingsService?.getGlobalSettings();
|
||||
logger.info(`Raw phaseModels from settings:`, JSON.stringify(settings?.phaseModels, null, 2));
|
||||
const phaseModelEntry =
|
||||
settings?.phaseModels?.fileDescriptionModel || DEFAULT_PHASE_MODELS.fileDescriptionModel;
|
||||
logger.info(`fileDescriptionModel entry:`, JSON.stringify(phaseModelEntry));
|
||||
// Get model from phase settings with provider info
|
||||
const {
|
||||
phaseModel: phaseModelEntry,
|
||||
provider,
|
||||
credentials,
|
||||
} = await getPhaseModelWithOverrides(
|
||||
'fileDescriptionModel',
|
||||
settingsService,
|
||||
cwd,
|
||||
'[DescribeFile]'
|
||||
);
|
||||
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
|
||||
|
||||
logger.info(`Resolved model: ${model}, thinkingLevel: ${thinkingLevel}`);
|
||||
logger.info(
|
||||
`Resolved model: ${model}, thinkingLevel: ${thinkingLevel}`,
|
||||
provider ? `via provider: ${provider.name}` : 'direct API'
|
||||
);
|
||||
|
||||
// Use simpleQuery - provider abstraction handles routing to correct provider
|
||||
const result = await simpleQuery({
|
||||
@@ -175,6 +183,8 @@ ${contentToAnalyze}`;
|
||||
thinkingLevel,
|
||||
readOnly: true, // File description only reads, doesn't write
|
||||
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
||||
claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
});
|
||||
|
||||
const description = result.text;
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { createLogger, readImageAsBase64 } from '@automaker/utils';
|
||||
import { DEFAULT_PHASE_MODELS, isCursorModel } from '@automaker/types';
|
||||
import { isCursorModel } from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { simpleQuery } from '../../../providers/simple-query-service.js';
|
||||
import * as secureFs from '../../../lib/secure-fs.js';
|
||||
@@ -22,6 +22,7 @@ import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import {
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getPromptCustomization,
|
||||
getPhaseModelWithOverrides,
|
||||
} from '../../../lib/settings-helpers.js';
|
||||
|
||||
const logger = createLogger('DescribeImage');
|
||||
@@ -273,13 +274,23 @@ export function createDescribeImageHandler(
|
||||
'[DescribeImage]'
|
||||
);
|
||||
|
||||
// Get model from phase settings
|
||||
const settings = await settingsService?.getGlobalSettings();
|
||||
const phaseModelEntry =
|
||||
settings?.phaseModels?.imageDescriptionModel || DEFAULT_PHASE_MODELS.imageDescriptionModel;
|
||||
// Get model from phase settings with provider info
|
||||
const {
|
||||
phaseModel: phaseModelEntry,
|
||||
provider,
|
||||
credentials,
|
||||
} = await getPhaseModelWithOverrides(
|
||||
'imageDescriptionModel',
|
||||
settingsService,
|
||||
cwd,
|
||||
'[DescribeImage]'
|
||||
);
|
||||
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
|
||||
|
||||
logger.info(`[${requestId}] Using model: ${model}`);
|
||||
logger.info(
|
||||
`[${requestId}] Using model: ${model}`,
|
||||
provider ? `via provider: ${provider.name}` : 'direct API'
|
||||
);
|
||||
|
||||
// Get customized prompts from settings
|
||||
const prompts = await getPromptCustomization(settingsService, '[DescribeImage]');
|
||||
@@ -325,6 +336,8 @@ export function createDescribeImageHandler(
|
||||
thinkingLevel,
|
||||
readOnly: true, // Image description only reads, doesn't write
|
||||
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
||||
claudeCompatibleProvider: provider, // Pass provider for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
});
|
||||
|
||||
logger.info(`[${requestId}] simpleQuery completed in ${Date.now() - queryStart}ms`);
|
||||
|
||||
@@ -12,7 +12,7 @@ import { resolveModelString } from '@automaker/model-resolver';
|
||||
import { CLAUDE_MODEL_MAP, type ThinkingLevel } from '@automaker/types';
|
||||
import { simpleQuery } from '../../../providers/simple-query-service.js';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import { getPromptCustomization } from '../../../lib/settings-helpers.js';
|
||||
import { getPromptCustomization, getProviderByModelId } from '../../../lib/settings-helpers.js';
|
||||
import {
|
||||
buildUserPrompt,
|
||||
isValidEnhancementMode,
|
||||
@@ -33,6 +33,8 @@ interface EnhanceRequestBody {
|
||||
model?: string;
|
||||
/** Optional thinking level for Claude models */
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
/** Optional project path for per-project Claude API profile */
|
||||
projectPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,7 +64,7 @@ export function createEnhanceHandler(
|
||||
): (req: Request, res: Response) => Promise<void> {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { originalText, enhancementMode, model, thinkingLevel } =
|
||||
const { originalText, enhancementMode, model, thinkingLevel, projectPath } =
|
||||
req.body as EnhanceRequestBody;
|
||||
|
||||
// Validate required fields
|
||||
@@ -121,8 +123,32 @@ export function createEnhanceHandler(
|
||||
// Build the user prompt with few-shot examples
|
||||
const userPrompt = buildUserPrompt(validMode, trimmedText, true);
|
||||
|
||||
// Resolve the model - use the passed model, default to sonnet for quality
|
||||
const resolvedModel = resolveModelString(model, CLAUDE_MODEL_MAP.sonnet);
|
||||
// Check if the model is a provider model (like "GLM-4.5-Air")
|
||||
// If so, get the provider config and resolved Claude model
|
||||
let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined;
|
||||
let providerResolvedModel: string | undefined;
|
||||
let credentials = await settingsService?.getCredentials();
|
||||
|
||||
if (model && settingsService) {
|
||||
const providerResult = await getProviderByModelId(
|
||||
model,
|
||||
settingsService,
|
||||
'[EnhancePrompt]'
|
||||
);
|
||||
if (providerResult.provider) {
|
||||
claudeCompatibleProvider = providerResult.provider;
|
||||
providerResolvedModel = providerResult.resolvedModel;
|
||||
credentials = providerResult.credentials;
|
||||
logger.info(
|
||||
`Using provider "${providerResult.provider.name}" for model "${model}"` +
|
||||
(providerResolvedModel ? ` -> resolved to "${providerResolvedModel}"` : '')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the model - use provider resolved model, passed model, or default to sonnet
|
||||
const resolvedModel =
|
||||
providerResolvedModel || resolveModelString(model, CLAUDE_MODEL_MAP.sonnet);
|
||||
|
||||
logger.debug(`Using model: ${resolvedModel}`);
|
||||
|
||||
@@ -137,6 +163,8 @@ export function createEnhanceHandler(
|
||||
allowedTools: [],
|
||||
thinkingLevel,
|
||||
readOnly: true, // Prompt enhancement only generates text, doesn't write files
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
|
||||
});
|
||||
|
||||
const enhancedText = result.text;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { Router } from 'express';
|
||||
import { FeatureLoader } from '../../services/feature-loader.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import type { AutoModeService } from '../../services/auto-mode-service.js';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||
import { createListHandler } from './routes/list.js';
|
||||
@@ -16,15 +17,22 @@ import { createBulkDeleteHandler } from './routes/bulk-delete.js';
|
||||
import { createDeleteHandler } from './routes/delete.js';
|
||||
import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent-output.js';
|
||||
import { createGenerateTitleHandler } from './routes/generate-title.js';
|
||||
import { createExportHandler } from './routes/export.js';
|
||||
import { createImportHandler, createConflictCheckHandler } from './routes/import.js';
|
||||
|
||||
export function createFeaturesRoutes(
|
||||
featureLoader: FeatureLoader,
|
||||
settingsService?: SettingsService,
|
||||
events?: EventEmitter
|
||||
events?: EventEmitter,
|
||||
autoModeService?: AutoModeService
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post('/list', validatePathParams('projectPath'), createListHandler(featureLoader));
|
||||
router.post(
|
||||
'/list',
|
||||
validatePathParams('projectPath'),
|
||||
createListHandler(featureLoader, autoModeService)
|
||||
);
|
||||
router.post('/get', validatePathParams('projectPath'), createGetHandler(featureLoader));
|
||||
router.post(
|
||||
'/create',
|
||||
@@ -46,6 +54,13 @@ export function createFeaturesRoutes(
|
||||
router.post('/agent-output', createAgentOutputHandler(featureLoader));
|
||||
router.post('/raw-output', createRawOutputHandler(featureLoader));
|
||||
router.post('/generate-title', createGenerateTitleHandler(settingsService));
|
||||
router.post('/export', validatePathParams('projectPath'), createExportHandler(featureLoader));
|
||||
router.post('/import', validatePathParams('projectPath'), createImportHandler(featureLoader));
|
||||
router.post(
|
||||
'/check-conflicts',
|
||||
validatePathParams('projectPath'),
|
||||
createConflictCheckHandler(featureLoader)
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export function createCreateHandler(featureLoader: FeatureLoader, events?: Event
|
||||
if (events) {
|
||||
events.emit('feature:created', {
|
||||
featureId: created.id,
|
||||
featureName: created.name,
|
||||
featureName: created.title || 'Untitled Feature',
|
||||
projectPath,
|
||||
});
|
||||
}
|
||||
|
||||
96
apps/server/src/routes/features/routes/export.ts
Normal file
96
apps/server/src/routes/features/routes/export.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* POST /export endpoint - Export features to JSON or YAML format
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import {
|
||||
getFeatureExportService,
|
||||
type ExportFormat,
|
||||
type BulkExportOptions,
|
||||
} from '../../../services/feature-export-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
interface ExportRequest {
|
||||
projectPath: string;
|
||||
/** Feature IDs to export. If empty/undefined, exports all features */
|
||||
featureIds?: string[];
|
||||
/** Export format: 'json' or 'yaml' */
|
||||
format?: ExportFormat;
|
||||
/** Whether to include description history */
|
||||
includeHistory?: boolean;
|
||||
/** Whether to include plan spec */
|
||||
includePlanSpec?: boolean;
|
||||
/** Filter by category */
|
||||
category?: string;
|
||||
/** Filter by status */
|
||||
status?: string;
|
||||
/** Pretty print output */
|
||||
prettyPrint?: boolean;
|
||||
/** Optional metadata to include */
|
||||
metadata?: {
|
||||
projectName?: string;
|
||||
projectPath?: string;
|
||||
branch?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export function createExportHandler(featureLoader: FeatureLoader) {
|
||||
const exportService = getFeatureExportService();
|
||||
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const {
|
||||
projectPath,
|
||||
featureIds,
|
||||
format = 'json',
|
||||
includeHistory = true,
|
||||
includePlanSpec = true,
|
||||
category,
|
||||
status,
|
||||
prettyPrint = true,
|
||||
metadata,
|
||||
} = req.body as ExportRequest;
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate format
|
||||
if (format !== 'json' && format !== 'yaml') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'format must be "json" or "yaml"',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const options: BulkExportOptions = {
|
||||
format,
|
||||
includeHistory,
|
||||
includePlanSpec,
|
||||
category,
|
||||
status,
|
||||
featureIds,
|
||||
prettyPrint,
|
||||
metadata,
|
||||
};
|
||||
|
||||
const exportData = await exportService.exportFeatures(projectPath, options);
|
||||
|
||||
// Return the export data as a string in the response
|
||||
res.json({
|
||||
success: true,
|
||||
data: exportData,
|
||||
format,
|
||||
contentType: format === 'json' ? 'application/json' : 'application/x-yaml',
|
||||
filename: `features-export.${format === 'json' ? 'json' : 'yaml'}`,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Export features failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -16,6 +16,7 @@ const logger = createLogger('GenerateTitle');
|
||||
|
||||
interface GenerateTitleRequestBody {
|
||||
description: string;
|
||||
projectPath?: string;
|
||||
}
|
||||
|
||||
interface GenerateTitleSuccessResponse {
|
||||
@@ -33,7 +34,7 @@ export function createGenerateTitleHandler(
|
||||
): (req: Request, res: Response) => Promise<void> {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { description } = req.body as GenerateTitleRequestBody;
|
||||
const { description, projectPath } = req.body as GenerateTitleRequestBody;
|
||||
|
||||
if (!description || typeof description !== 'string') {
|
||||
const response: GenerateTitleErrorResponse = {
|
||||
@@ -60,6 +61,9 @@ export function createGenerateTitleHandler(
|
||||
const prompts = await getPromptCustomization(settingsService, '[GenerateTitle]');
|
||||
const systemPrompt = prompts.titleGeneration.systemPrompt;
|
||||
|
||||
// Get credentials for API calls (uses hardcoded haiku model, no phase setting)
|
||||
const credentials = await settingsService?.getCredentials();
|
||||
|
||||
const userPrompt = `Generate a concise title for this feature:\n\n${trimmedDescription}`;
|
||||
|
||||
// Use simpleQuery - provider abstraction handles all the streaming/extraction
|
||||
@@ -69,6 +73,7 @@ export function createGenerateTitleHandler(
|
||||
cwd: process.cwd(),
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
});
|
||||
|
||||
const title = result.text;
|
||||
|
||||
210
apps/server/src/routes/features/routes/import.ts
Normal file
210
apps/server/src/routes/features/routes/import.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* POST /import endpoint - Import features from JSON or YAML format
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import type { FeatureImportResult, Feature, FeatureExport } from '@automaker/types';
|
||||
import { getFeatureExportService } from '../../../services/feature-export-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
interface ImportRequest {
|
||||
projectPath: string;
|
||||
/** Raw JSON or YAML string containing feature data */
|
||||
data: string;
|
||||
/** Whether to overwrite existing features with same ID */
|
||||
overwrite?: boolean;
|
||||
/** Whether to preserve branch info from imported features */
|
||||
preserveBranchInfo?: boolean;
|
||||
/** Optional category to assign to all imported features */
|
||||
targetCategory?: string;
|
||||
}
|
||||
|
||||
interface ConflictCheckRequest {
|
||||
projectPath: string;
|
||||
/** Raw JSON or YAML string containing feature data */
|
||||
data: string;
|
||||
}
|
||||
|
||||
interface ConflictInfo {
|
||||
featureId: string;
|
||||
title?: string;
|
||||
existingTitle?: string;
|
||||
hasConflict: boolean;
|
||||
}
|
||||
|
||||
export function createImportHandler(featureLoader: FeatureLoader) {
|
||||
const exportService = getFeatureExportService();
|
||||
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const {
|
||||
projectPath,
|
||||
data,
|
||||
overwrite = false,
|
||||
preserveBranchInfo = false,
|
||||
targetCategory,
|
||||
} = req.body as ImportRequest;
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
res.status(400).json({ success: false, error: 'data is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect format and parse the data
|
||||
const format = exportService.detectFormat(data);
|
||||
if (!format) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid data format. Expected valid JSON or YAML.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = exportService.parseImportData(data);
|
||||
if (!parsed) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Failed to parse import data. Ensure it is valid JSON or YAML.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine if this is a single feature or bulk import
|
||||
const isBulkImport =
|
||||
'features' in parsed && Array.isArray((parsed as { features: unknown }).features);
|
||||
|
||||
let results: FeatureImportResult[];
|
||||
|
||||
if (isBulkImport) {
|
||||
// Bulk import
|
||||
results = await exportService.importFeatures(projectPath, data, {
|
||||
overwrite,
|
||||
preserveBranchInfo,
|
||||
targetCategory,
|
||||
});
|
||||
} else {
|
||||
// Single feature import - we know it's not a bulk export at this point
|
||||
// It must be either a Feature or FeatureExport
|
||||
const singleData = parsed as Feature | FeatureExport;
|
||||
|
||||
const result = await exportService.importFeature(projectPath, {
|
||||
data: singleData,
|
||||
overwrite,
|
||||
preserveBranchInfo,
|
||||
targetCategory,
|
||||
});
|
||||
results = [result];
|
||||
}
|
||||
|
||||
const successCount = results.filter((r) => r.success).length;
|
||||
const failureCount = results.filter((r) => !r.success).length;
|
||||
const allSuccessful = failureCount === 0;
|
||||
|
||||
res.json({
|
||||
success: allSuccessful,
|
||||
importedCount: successCount,
|
||||
failedCount: failureCount,
|
||||
results,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Import features failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create handler for checking conflicts before import
|
||||
*/
|
||||
export function createConflictCheckHandler(featureLoader: FeatureLoader) {
|
||||
const exportService = getFeatureExportService();
|
||||
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, data } = req.body as ConflictCheckRequest;
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
res.status(400).json({ success: false, error: 'data is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse the import data
|
||||
const format = exportService.detectFormat(data);
|
||||
if (!format) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid data format. Expected valid JSON or YAML.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = exportService.parseImportData(data);
|
||||
if (!parsed) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Failed to parse import data.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract features from the data using type guards
|
||||
let featuresToCheck: Array<{ id: string; title?: string }> = [];
|
||||
|
||||
if (exportService.isBulkExport(parsed)) {
|
||||
// Bulk export format
|
||||
featuresToCheck = parsed.features.map((f) => ({
|
||||
id: f.feature.id,
|
||||
title: f.feature.title,
|
||||
}));
|
||||
} else if (exportService.isFeatureExport(parsed)) {
|
||||
// Single FeatureExport format
|
||||
featuresToCheck = [
|
||||
{
|
||||
id: parsed.feature.id,
|
||||
title: parsed.feature.title,
|
||||
},
|
||||
];
|
||||
} else if (exportService.isRawFeature(parsed)) {
|
||||
// Raw Feature format
|
||||
featuresToCheck = [{ id: parsed.id, title: parsed.title }];
|
||||
}
|
||||
|
||||
// Check each feature for conflicts in parallel
|
||||
const conflicts: ConflictInfo[] = await Promise.all(
|
||||
featuresToCheck.map(async (feature) => {
|
||||
const existing = await featureLoader.get(projectPath, feature.id);
|
||||
return {
|
||||
featureId: feature.id,
|
||||
title: feature.title,
|
||||
existingTitle: existing?.title,
|
||||
hasConflict: !!existing,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const hasConflicts = conflicts.some((c) => c.hasConflict);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
hasConflicts,
|
||||
conflicts,
|
||||
totalFeatures: featuresToCheck.length,
|
||||
conflictCount: conflicts.filter((c) => c.hasConflict).length,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Conflict check failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,19 @@
|
||||
/**
|
||||
* POST /list endpoint - List all features for a project
|
||||
*
|
||||
* Also performs orphan detection when a project is loaded to identify
|
||||
* features whose branches no longer exist. This runs on every project load/switch.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
export function createListHandler(featureLoader: FeatureLoader) {
|
||||
const logger = createLogger('FeaturesListRoute');
|
||||
|
||||
export function createListHandler(featureLoader: FeatureLoader, autoModeService?: AutoModeService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body as { projectPath: string };
|
||||
@@ -17,6 +24,26 @@ export function createListHandler(featureLoader: FeatureLoader) {
|
||||
}
|
||||
|
||||
const features = await featureLoader.getAll(projectPath);
|
||||
|
||||
// Run orphan detection in background when project is loaded
|
||||
// This detects features whose branches no longer exist (e.g., after merge/delete)
|
||||
// We don't await this to keep the list response fast
|
||||
// Note: detectOrphanedFeatures handles errors internally and always resolves
|
||||
if (autoModeService) {
|
||||
autoModeService.detectOrphanedFeatures(projectPath).then((orphanedFeatures) => {
|
||||
if (orphanedFeatures.length > 0) {
|
||||
logger.info(
|
||||
`[ProjectLoad] Detected ${orphanedFeatures.length} orphaned feature(s) in ${projectPath}`
|
||||
);
|
||||
for (const { feature, missingBranch } of orphanedFeatures) {
|
||||
logger.info(
|
||||
`[ProjectLoad] Orphaned: ${feature.title || feature.id} - branch "${missingBranch}" no longer exists`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ success: true, features });
|
||||
} catch (error) {
|
||||
logError(error, 'List features failed');
|
||||
|
||||
@@ -31,7 +31,9 @@ export function createSaveBoardBackgroundHandler() {
|
||||
await secureFs.mkdir(boardDir, { recursive: true });
|
||||
|
||||
// Decode base64 data (remove data URL prefix if present)
|
||||
const base64Data = data.replace(/^data:image\/\w+;base64,/, '');
|
||||
// Use a regex that handles all data URL formats including those with extra params
|
||||
// e.g., data:image/gif;charset=utf-8;base64,R0lGOD...
|
||||
const base64Data = data.replace(/^data:[^,]+,/, '');
|
||||
const buffer = Buffer.from(base64Data, 'base64');
|
||||
|
||||
// Use a fixed filename for the board background (overwrite previous)
|
||||
|
||||
@@ -31,7 +31,9 @@ export function createSaveImageHandler() {
|
||||
await secureFs.mkdir(imagesDir, { recursive: true });
|
||||
|
||||
// Decode base64 data (remove data URL prefix if present)
|
||||
const base64Data = data.replace(/^data:image\/\w+;base64,/, '');
|
||||
// Use a regex that handles all data URL formats including those with extra params
|
||||
// e.g., data:image/gif;charset=utf-8;base64,R0lGOD...
|
||||
const base64Data = data.replace(/^data:[^,]+,/, '');
|
||||
const buffer = Buffer.from(base64Data, 'base64');
|
||||
|
||||
// Generate unique filename with timestamp
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
isCodexModel,
|
||||
isCursorModel,
|
||||
isOpencodeModel,
|
||||
supportsStructuredOutput,
|
||||
} from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { extractJson } from '../../../lib/json-extractor.js';
|
||||
@@ -34,7 +35,11 @@ import {
|
||||
ValidationComment,
|
||||
ValidationLinkedPR,
|
||||
} from './validation-schema.js';
|
||||
import { getPromptCustomization } from '../../../lib/settings-helpers.js';
|
||||
import {
|
||||
getPromptCustomization,
|
||||
getAutoLoadClaudeMdSetting,
|
||||
getProviderByModelId,
|
||||
} from '../../../lib/settings-helpers.js';
|
||||
import {
|
||||
trySetValidationRunning,
|
||||
clearValidationStatus,
|
||||
@@ -43,7 +48,6 @@ import {
|
||||
logger,
|
||||
} from './validation-common.js';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import { getAutoLoadClaudeMdSetting } from '../../../lib/settings-helpers.js';
|
||||
|
||||
/**
|
||||
* Request body for issue validation
|
||||
@@ -121,8 +125,9 @@ async function runValidation(
|
||||
const prompts = await getPromptCustomization(settingsService, '[ValidateIssue]');
|
||||
const issueValidationSystemPrompt = prompts.issueValidation.systemPrompt;
|
||||
|
||||
// Determine if we should use structured output (Claude/Codex support it, Cursor/OpenCode don't)
|
||||
const useStructuredOutput = isClaudeModel(model) || isCodexModel(model);
|
||||
// Determine if we should use structured output based on model type
|
||||
// Claude and Codex support it; Cursor, Gemini, OpenCode, Copilot don't
|
||||
const useStructuredOutput = supportsStructuredOutput(model);
|
||||
|
||||
// Build the final prompt - for Cursor, include system prompt and JSON schema instructions
|
||||
let finalPrompt = basePrompt;
|
||||
@@ -164,12 +169,33 @@ ${basePrompt}`;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Using model: ${model}`);
|
||||
// Check if the model is a provider model (like "GLM-4.5-Air")
|
||||
// If so, get the provider config and resolved Claude model
|
||||
let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined;
|
||||
let providerResolvedModel: string | undefined;
|
||||
let credentials = await settingsService?.getCredentials();
|
||||
|
||||
if (settingsService) {
|
||||
const providerResult = await getProviderByModelId(model, settingsService, '[ValidateIssue]');
|
||||
if (providerResult.provider) {
|
||||
claudeCompatibleProvider = providerResult.provider;
|
||||
providerResolvedModel = providerResult.resolvedModel;
|
||||
credentials = providerResult.credentials;
|
||||
logger.info(
|
||||
`Using provider "${providerResult.provider.name}" for model "${model}"` +
|
||||
(providerResolvedModel ? ` -> resolved to "${providerResolvedModel}"` : '')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Use provider resolved model if available, otherwise use original model
|
||||
const effectiveModel = providerResolvedModel || (model as string);
|
||||
logger.info(`Using model: ${effectiveModel}`);
|
||||
|
||||
// Use streamingQuery with event callbacks
|
||||
const result = await streamingQuery({
|
||||
prompt: finalPrompt,
|
||||
model: model as string,
|
||||
model: effectiveModel,
|
||||
cwd: projectPath,
|
||||
systemPrompt: useStructuredOutput ? issueValidationSystemPrompt : undefined,
|
||||
abortController,
|
||||
@@ -177,6 +203,8 @@ ${basePrompt}`;
|
||||
reasoningEffort: effectiveReasoningEffort,
|
||||
readOnly: true, // Issue validation only reads code, doesn't write
|
||||
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
||||
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
outputFormat: useStructuredOutput
|
||||
? {
|
||||
type: 'json_schema',
|
||||
|
||||
@@ -4,15 +4,21 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { IdeationService } from '../../../services/ideation-service.js';
|
||||
import type { IdeationContextSources } from '@automaker/types';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const logger = createLogger('ideation:suggestions-generate');
|
||||
|
||||
/**
|
||||
* Creates an Express route handler for generating AI-powered ideation suggestions.
|
||||
* Accepts a prompt, category, and optional context sources configuration,
|
||||
* then returns structured suggestions that can be added to the board.
|
||||
*/
|
||||
export function createSuggestionsGenerateHandler(ideationService: IdeationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, promptId, category, count } = req.body;
|
||||
const { projectPath, promptId, category, count, contextSources } = req.body;
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
@@ -38,7 +44,8 @@ export function createSuggestionsGenerateHandler(ideationService: IdeationServic
|
||||
projectPath,
|
||||
promptId,
|
||||
category,
|
||||
suggestionCount
|
||||
suggestionCount,
|
||||
contextSources as IdeationContextSources | undefined
|
||||
);
|
||||
|
||||
res.json({
|
||||
|
||||
12
apps/server/src/routes/projects/common.ts
Normal file
12
apps/server/src/routes/projects/common.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Common utilities for projects routes
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||
|
||||
const logger = createLogger('Projects');
|
||||
|
||||
// Re-export shared utilities
|
||||
export { getErrorMessageShared as getErrorMessage };
|
||||
export const logError = createLogError(logger);
|
||||
27
apps/server/src/routes/projects/index.ts
Normal file
27
apps/server/src/routes/projects/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Projects routes - HTTP API for multi-project overview and management
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { FeatureLoader } from '../../services/feature-loader.js';
|
||||
import type { AutoModeService } from '../../services/auto-mode-service.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import type { NotificationService } from '../../services/notification-service.js';
|
||||
import { createOverviewHandler } from './routes/overview.js';
|
||||
|
||||
export function createProjectsRoutes(
|
||||
featureLoader: FeatureLoader,
|
||||
autoModeService: AutoModeService,
|
||||
settingsService: SettingsService,
|
||||
notificationService: NotificationService
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
// GET /overview - Get aggregate status for all projects
|
||||
router.get(
|
||||
'/overview',
|
||||
createOverviewHandler(featureLoader, autoModeService, settingsService, notificationService)
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
317
apps/server/src/routes/projects/routes/overview.ts
Normal file
317
apps/server/src/routes/projects/routes/overview.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* GET /overview endpoint - Get aggregate status for all projects
|
||||
*
|
||||
* Returns a complete overview of all projects including:
|
||||
* - Individual project status (features, auto-mode state)
|
||||
* - Aggregate metrics across all projects
|
||||
* - Recent activity feed (placeholder for future implementation)
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import type { AutoModeService } from '../../../services/auto-mode-service.js';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import type { NotificationService } from '../../../services/notification-service.js';
|
||||
import type {
|
||||
ProjectStatus,
|
||||
AggregateStatus,
|
||||
MultiProjectOverview,
|
||||
FeatureStatusCounts,
|
||||
AggregateFeatureCounts,
|
||||
AggregateProjectCounts,
|
||||
ProjectHealthStatus,
|
||||
Feature,
|
||||
ProjectRef,
|
||||
} from '@automaker/types';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
/**
|
||||
* Compute feature status counts from a list of features
|
||||
*/
|
||||
function computeFeatureCounts(features: Feature[]): FeatureStatusCounts {
|
||||
const counts: FeatureStatusCounts = {
|
||||
pending: 0,
|
||||
running: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
verified: 0,
|
||||
};
|
||||
|
||||
for (const feature of features) {
|
||||
switch (feature.status) {
|
||||
case 'pending':
|
||||
case 'ready':
|
||||
counts.pending++;
|
||||
break;
|
||||
case 'running':
|
||||
case 'generating_spec':
|
||||
case 'in_progress':
|
||||
counts.running++;
|
||||
break;
|
||||
case 'waiting_approval':
|
||||
// waiting_approval means agent finished, needs human review - count as pending
|
||||
counts.pending++;
|
||||
break;
|
||||
case 'completed':
|
||||
counts.completed++;
|
||||
break;
|
||||
case 'failed':
|
||||
counts.failed++;
|
||||
break;
|
||||
case 'verified':
|
||||
counts.verified++;
|
||||
break;
|
||||
default:
|
||||
// Unknown status, treat as pending
|
||||
counts.pending++;
|
||||
}
|
||||
}
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the overall health status of a project based on its feature statuses
|
||||
*/
|
||||
function computeHealthStatus(
|
||||
featureCounts: FeatureStatusCounts,
|
||||
isAutoModeRunning: boolean
|
||||
): ProjectHealthStatus {
|
||||
const totalFeatures =
|
||||
featureCounts.pending +
|
||||
featureCounts.running +
|
||||
featureCounts.completed +
|
||||
featureCounts.failed +
|
||||
featureCounts.verified;
|
||||
|
||||
// If there are failed features, the project has errors
|
||||
if (featureCounts.failed > 0) {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
// If there are running features or auto mode is running with pending work
|
||||
if (featureCounts.running > 0 || (isAutoModeRunning && featureCounts.pending > 0)) {
|
||||
return 'active';
|
||||
}
|
||||
|
||||
// Pending work but no active execution
|
||||
if (featureCounts.pending > 0) {
|
||||
return 'waiting';
|
||||
}
|
||||
|
||||
// If all features are completed or verified
|
||||
if (totalFeatures > 0 && featureCounts.pending === 0 && featureCounts.running === 0) {
|
||||
return 'completed';
|
||||
}
|
||||
|
||||
// Default to idle
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most recent activity timestamp from features
|
||||
*/
|
||||
function getLastActivityAt(features: Feature[]): string | undefined {
|
||||
if (features.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let latestTimestamp: number = 0;
|
||||
|
||||
for (const feature of features) {
|
||||
// Check startedAt timestamp (the main timestamp available on Feature)
|
||||
if (feature.startedAt) {
|
||||
const timestamp = new Date(feature.startedAt).getTime();
|
||||
if (!isNaN(timestamp) && timestamp > latestTimestamp) {
|
||||
latestTimestamp = timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
// Also check planSpec timestamps if available
|
||||
if (feature.planSpec?.generatedAt) {
|
||||
const timestamp = new Date(feature.planSpec.generatedAt).getTime();
|
||||
if (!isNaN(timestamp) && timestamp > latestTimestamp) {
|
||||
latestTimestamp = timestamp;
|
||||
}
|
||||
}
|
||||
if (feature.planSpec?.approvedAt) {
|
||||
const timestamp = new Date(feature.planSpec.approvedAt).getTime();
|
||||
if (!isNaN(timestamp) && timestamp > latestTimestamp) {
|
||||
latestTimestamp = timestamp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return latestTimestamp > 0 ? new Date(latestTimestamp).toISOString() : undefined;
|
||||
}
|
||||
|
||||
export function createOverviewHandler(
|
||||
featureLoader: FeatureLoader,
|
||||
autoModeService: AutoModeService,
|
||||
settingsService: SettingsService,
|
||||
notificationService: NotificationService
|
||||
) {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Get all projects from settings
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
const projectRefs: ProjectRef[] = settings.projects || [];
|
||||
|
||||
// Get all running agents once to count live running features per project
|
||||
const allRunningAgents = await autoModeService.getRunningAgents();
|
||||
|
||||
// Collect project statuses in parallel
|
||||
const projectStatusPromises = projectRefs.map(async (projectRef): Promise<ProjectStatus> => {
|
||||
try {
|
||||
// Load features for this project
|
||||
const features = await featureLoader.getAll(projectRef.path);
|
||||
const featureCounts = computeFeatureCounts(features);
|
||||
const totalFeatures = features.length;
|
||||
|
||||
// Get auto-mode status for this project (main worktree, branchName = null)
|
||||
const autoModeStatus = autoModeService.getStatusForProject(projectRef.path, null);
|
||||
const isAutoModeRunning = autoModeStatus.isAutoLoopRunning;
|
||||
|
||||
// Count live running features for this project (across all branches)
|
||||
// This ensures we only count features that are actually running in memory
|
||||
const liveRunningCount = allRunningAgents.filter(
|
||||
(agent) => agent.projectPath === projectRef.path
|
||||
).length;
|
||||
featureCounts.running = liveRunningCount;
|
||||
|
||||
// Get notification count for this project
|
||||
let unreadNotificationCount = 0;
|
||||
try {
|
||||
const notifications = await notificationService.getNotifications(projectRef.path);
|
||||
unreadNotificationCount = notifications.filter((n) => !n.read).length;
|
||||
} catch {
|
||||
// Ignore notification errors - project may not have any notifications yet
|
||||
}
|
||||
|
||||
// Compute health status
|
||||
const healthStatus = computeHealthStatus(featureCounts, isAutoModeRunning);
|
||||
|
||||
// Get last activity timestamp
|
||||
const lastActivityAt = getLastActivityAt(features);
|
||||
|
||||
return {
|
||||
projectId: projectRef.id,
|
||||
projectName: projectRef.name,
|
||||
projectPath: projectRef.path,
|
||||
healthStatus,
|
||||
featureCounts,
|
||||
totalFeatures,
|
||||
lastActivityAt,
|
||||
isAutoModeRunning,
|
||||
activeBranch: autoModeStatus.branchName ?? undefined,
|
||||
unreadNotificationCount,
|
||||
};
|
||||
} catch (error) {
|
||||
logError(error, `Failed to load project status: ${projectRef.name}`);
|
||||
// Return a minimal status for projects that fail to load
|
||||
return {
|
||||
projectId: projectRef.id,
|
||||
projectName: projectRef.name,
|
||||
projectPath: projectRef.path,
|
||||
healthStatus: 'error' as ProjectHealthStatus,
|
||||
featureCounts: {
|
||||
pending: 0,
|
||||
running: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
verified: 0,
|
||||
},
|
||||
totalFeatures: 0,
|
||||
isAutoModeRunning: false,
|
||||
unreadNotificationCount: 0,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const projectStatuses = await Promise.all(projectStatusPromises);
|
||||
|
||||
// Compute aggregate metrics
|
||||
const aggregateFeatureCounts: AggregateFeatureCounts = {
|
||||
total: 0,
|
||||
pending: 0,
|
||||
running: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
verified: 0,
|
||||
};
|
||||
|
||||
const aggregateProjectCounts: AggregateProjectCounts = {
|
||||
total: projectStatuses.length,
|
||||
active: 0,
|
||||
idle: 0,
|
||||
waiting: 0,
|
||||
withErrors: 0,
|
||||
allCompleted: 0,
|
||||
};
|
||||
|
||||
let totalUnreadNotifications = 0;
|
||||
let projectsWithAutoModeRunning = 0;
|
||||
|
||||
for (const status of projectStatuses) {
|
||||
// Aggregate feature counts
|
||||
aggregateFeatureCounts.total += status.totalFeatures;
|
||||
aggregateFeatureCounts.pending += status.featureCounts.pending;
|
||||
aggregateFeatureCounts.running += status.featureCounts.running;
|
||||
aggregateFeatureCounts.completed += status.featureCounts.completed;
|
||||
aggregateFeatureCounts.failed += status.featureCounts.failed;
|
||||
aggregateFeatureCounts.verified += status.featureCounts.verified;
|
||||
|
||||
// Aggregate project counts by health status
|
||||
switch (status.healthStatus) {
|
||||
case 'active':
|
||||
aggregateProjectCounts.active++;
|
||||
break;
|
||||
case 'idle':
|
||||
aggregateProjectCounts.idle++;
|
||||
break;
|
||||
case 'waiting':
|
||||
aggregateProjectCounts.waiting++;
|
||||
break;
|
||||
case 'error':
|
||||
aggregateProjectCounts.withErrors++;
|
||||
break;
|
||||
case 'completed':
|
||||
aggregateProjectCounts.allCompleted++;
|
||||
break;
|
||||
}
|
||||
|
||||
// Aggregate notifications
|
||||
totalUnreadNotifications += status.unreadNotificationCount;
|
||||
|
||||
// Count projects with auto-mode running
|
||||
if (status.isAutoModeRunning) {
|
||||
projectsWithAutoModeRunning++;
|
||||
}
|
||||
}
|
||||
|
||||
const aggregateStatus: AggregateStatus = {
|
||||
projectCounts: aggregateProjectCounts,
|
||||
featureCounts: aggregateFeatureCounts,
|
||||
totalUnreadNotifications,
|
||||
projectsWithAutoModeRunning,
|
||||
computedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Build the response (recentActivity is empty for now - can be populated later)
|
||||
const overview: MultiProjectOverview = {
|
||||
projects: projectStatuses,
|
||||
aggregate: aggregateStatus,
|
||||
recentActivity: [], // Placeholder for future activity feed implementation
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
...overview,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get project overview failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -52,3 +52,8 @@ export async function persistApiKeyToEnv(key: string, value: string): Promise<vo
|
||||
// Re-export shared utilities
|
||||
export { getErrorMessageShared as getErrorMessage };
|
||||
export const logError = createLogError(logger);
|
||||
|
||||
/**
|
||||
* Marker file used to indicate a provider has been explicitly disconnected by user
|
||||
*/
|
||||
export const COPILOT_DISCONNECTED_MARKER_FILE = '.copilot-disconnected';
|
||||
|
||||
@@ -24,6 +24,17 @@ import { createDeauthCursorHandler } from './routes/deauth-cursor.js';
|
||||
import { createAuthOpencodeHandler } from './routes/auth-opencode.js';
|
||||
import { createDeauthOpencodeHandler } from './routes/deauth-opencode.js';
|
||||
import { createOpencodeStatusHandler } from './routes/opencode-status.js';
|
||||
import { createGeminiStatusHandler } from './routes/gemini-status.js';
|
||||
import { createAuthGeminiHandler } from './routes/auth-gemini.js';
|
||||
import { createDeauthGeminiHandler } from './routes/deauth-gemini.js';
|
||||
import { createCopilotStatusHandler } from './routes/copilot-status.js';
|
||||
import { createAuthCopilotHandler } from './routes/auth-copilot.js';
|
||||
import { createDeauthCopilotHandler } from './routes/deauth-copilot.js';
|
||||
import {
|
||||
createGetCopilotModelsHandler,
|
||||
createRefreshCopilotModelsHandler,
|
||||
createClearCopilotCacheHandler,
|
||||
} from './routes/copilot-models.js';
|
||||
import {
|
||||
createGetOpencodeModelsHandler,
|
||||
createRefreshOpencodeModelsHandler,
|
||||
@@ -72,6 +83,21 @@ export function createSetupRoutes(): Router {
|
||||
router.post('/auth-opencode', createAuthOpencodeHandler());
|
||||
router.post('/deauth-opencode', createDeauthOpencodeHandler());
|
||||
|
||||
// Gemini CLI routes
|
||||
router.get('/gemini-status', createGeminiStatusHandler());
|
||||
router.post('/auth-gemini', createAuthGeminiHandler());
|
||||
router.post('/deauth-gemini', createDeauthGeminiHandler());
|
||||
|
||||
// Copilot CLI routes
|
||||
router.get('/copilot-status', createCopilotStatusHandler());
|
||||
router.post('/auth-copilot', createAuthCopilotHandler());
|
||||
router.post('/deauth-copilot', createDeauthCopilotHandler());
|
||||
|
||||
// Copilot Dynamic Model Discovery routes
|
||||
router.get('/copilot/models', createGetCopilotModelsHandler());
|
||||
router.post('/copilot/models/refresh', createRefreshCopilotModelsHandler());
|
||||
router.post('/copilot/cache/clear', createClearCopilotCacheHandler());
|
||||
|
||||
// OpenCode Dynamic Model Discovery routes
|
||||
router.get('/opencode/models', createGetOpencodeModelsHandler());
|
||||
router.post('/opencode/models/refresh', createRefreshOpencodeModelsHandler());
|
||||
|
||||
30
apps/server/src/routes/setup/routes/auth-copilot.ts
Normal file
30
apps/server/src/routes/setup/routes/auth-copilot.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* POST /auth-copilot endpoint - Connect Copilot CLI to the app
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { connectCopilot } from '../../../services/copilot-connection-service.js';
|
||||
|
||||
/**
|
||||
* Creates handler for POST /api/setup/auth-copilot
|
||||
* Removes the disconnection marker to allow Copilot CLI to be used
|
||||
*/
|
||||
export function createAuthCopilotHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
await connectCopilot();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Copilot CLI connected to app',
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Auth Copilot failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
42
apps/server/src/routes/setup/routes/auth-gemini.ts
Normal file
42
apps/server/src/routes/setup/routes/auth-gemini.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* POST /auth-gemini endpoint - Connect Gemini CLI to the app
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
const DISCONNECTED_MARKER_FILE = '.gemini-disconnected';
|
||||
|
||||
/**
|
||||
* Creates handler for POST /api/setup/auth-gemini
|
||||
* Removes the disconnection marker to allow Gemini CLI to be used
|
||||
*/
|
||||
export function createAuthGeminiHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const projectRoot = process.cwd();
|
||||
const automakerDir = path.join(projectRoot, '.automaker');
|
||||
const markerPath = path.join(automakerDir, DISCONNECTED_MARKER_FILE);
|
||||
|
||||
// Remove the disconnection marker if it exists
|
||||
try {
|
||||
await fs.unlink(markerPath);
|
||||
} catch {
|
||||
// File doesn't exist, nothing to remove
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Gemini CLI connected to app',
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Auth Gemini failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
139
apps/server/src/routes/setup/routes/copilot-models.ts
Normal file
139
apps/server/src/routes/setup/routes/copilot-models.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Copilot Dynamic Models API Routes
|
||||
*
|
||||
* Provides endpoints for:
|
||||
* - GET /api/setup/copilot/models - Get available models (cached or refreshed)
|
||||
* - POST /api/setup/copilot/models/refresh - Force refresh models from CLI
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { CopilotProvider } from '../../../providers/copilot-provider.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import type { ModelDefinition } from '@automaker/types';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('CopilotModelsRoute');
|
||||
|
||||
// Singleton provider instance for caching
|
||||
let providerInstance: CopilotProvider | null = null;
|
||||
|
||||
function getProvider(): CopilotProvider {
|
||||
if (!providerInstance) {
|
||||
providerInstance = new CopilotProvider();
|
||||
}
|
||||
return providerInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response type for models endpoint
|
||||
*/
|
||||
interface ModelsResponse {
|
||||
success: boolean;
|
||||
models?: ModelDefinition[];
|
||||
count?: number;
|
||||
cached?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates handler for GET /api/setup/copilot/models
|
||||
*
|
||||
* Returns currently available models (from cache if available).
|
||||
* Query params:
|
||||
* - refresh=true: Force refresh from CLI before returning
|
||||
*
|
||||
* Note: If cache is empty, this will trigger a refresh to get dynamic models.
|
||||
*/
|
||||
export function createGetCopilotModelsHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const provider = getProvider();
|
||||
const forceRefresh = req.query.refresh === 'true';
|
||||
|
||||
let models: ModelDefinition[];
|
||||
let cached = true;
|
||||
|
||||
if (forceRefresh) {
|
||||
models = await provider.refreshModels();
|
||||
cached = false;
|
||||
} else {
|
||||
// Check if we have cached models
|
||||
if (!provider.hasCachedModels()) {
|
||||
models = await provider.refreshModels();
|
||||
cached = false;
|
||||
} else {
|
||||
models = provider.getAvailableModels();
|
||||
}
|
||||
}
|
||||
|
||||
const response: ModelsResponse = {
|
||||
success: true,
|
||||
models,
|
||||
count: models.length,
|
||||
cached,
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
logError(error, 'Get Copilot models failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
} as ModelsResponse);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates handler for POST /api/setup/copilot/models/refresh
|
||||
*
|
||||
* Forces a refresh of models from the Copilot CLI.
|
||||
*/
|
||||
export function createRefreshCopilotModelsHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const provider = getProvider();
|
||||
const models = await provider.refreshModels();
|
||||
|
||||
const response: ModelsResponse = {
|
||||
success: true,
|
||||
models,
|
||||
count: models.length,
|
||||
cached: false,
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
logError(error, 'Refresh Copilot models failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
} as ModelsResponse);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates handler for POST /api/setup/copilot/cache/clear
|
||||
*
|
||||
* Clears the model cache, forcing a fresh fetch on next access.
|
||||
*/
|
||||
export function createClearCopilotCacheHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const provider = getProvider();
|
||||
provider.clearModelCache();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Copilot model cache cleared',
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Clear Copilot cache failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
78
apps/server/src/routes/setup/routes/copilot-status.ts
Normal file
78
apps/server/src/routes/setup/routes/copilot-status.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* GET /copilot-status endpoint - Get Copilot CLI installation and auth status
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { CopilotProvider } from '../../../providers/copilot-provider.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
const DISCONNECTED_MARKER_FILE = '.copilot-disconnected';
|
||||
|
||||
async function isCopilotDisconnectedFromApp(): Promise<boolean> {
|
||||
try {
|
||||
const projectRoot = process.cwd();
|
||||
const markerPath = path.join(projectRoot, '.automaker', DISCONNECTED_MARKER_FILE);
|
||||
await fs.access(markerPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates handler for GET /api/setup/copilot-status
|
||||
* Returns Copilot CLI installation and authentication status
|
||||
*/
|
||||
export function createCopilotStatusHandler() {
|
||||
const installCommand = 'npm install -g @github/copilot';
|
||||
const loginCommand = 'gh auth login';
|
||||
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Check if user has manually disconnected from the app
|
||||
if (await isCopilotDisconnectedFromApp()) {
|
||||
res.json({
|
||||
success: true,
|
||||
installed: true,
|
||||
version: null,
|
||||
path: null,
|
||||
auth: {
|
||||
authenticated: false,
|
||||
method: 'none',
|
||||
},
|
||||
installCommand,
|
||||
loginCommand,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = new CopilotProvider();
|
||||
const status = await provider.detectInstallation();
|
||||
const auth = await provider.checkAuth();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
installed: status.installed,
|
||||
version: status.version || null,
|
||||
path: status.path || null,
|
||||
auth: {
|
||||
authenticated: auth.authenticated,
|
||||
method: auth.method,
|
||||
login: auth.login,
|
||||
host: auth.host,
|
||||
error: auth.error,
|
||||
},
|
||||
installCommand,
|
||||
loginCommand,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get Copilot status failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
30
apps/server/src/routes/setup/routes/deauth-copilot.ts
Normal file
30
apps/server/src/routes/setup/routes/deauth-copilot.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* POST /deauth-copilot endpoint - Disconnect Copilot CLI from the app
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { disconnectCopilot } from '../../../services/copilot-connection-service.js';
|
||||
|
||||
/**
|
||||
* Creates handler for POST /api/setup/deauth-copilot
|
||||
* Creates a marker file to disconnect Copilot CLI from the app
|
||||
*/
|
||||
export function createDeauthCopilotHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
await disconnectCopilot();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Copilot CLI disconnected from app',
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Deauth Copilot failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
42
apps/server/src/routes/setup/routes/deauth-gemini.ts
Normal file
42
apps/server/src/routes/setup/routes/deauth-gemini.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* POST /deauth-gemini endpoint - Disconnect Gemini CLI from the app
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
const DISCONNECTED_MARKER_FILE = '.gemini-disconnected';
|
||||
|
||||
/**
|
||||
* Creates handler for POST /api/setup/deauth-gemini
|
||||
* Creates a marker file to disconnect Gemini CLI from the app
|
||||
*/
|
||||
export function createDeauthGeminiHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const projectRoot = process.cwd();
|
||||
const automakerDir = path.join(projectRoot, '.automaker');
|
||||
|
||||
// Ensure .automaker directory exists
|
||||
await fs.mkdir(automakerDir, { recursive: true });
|
||||
|
||||
const markerPath = path.join(automakerDir, DISCONNECTED_MARKER_FILE);
|
||||
|
||||
// Create the disconnection marker
|
||||
await fs.writeFile(markerPath, 'Gemini CLI disconnected from app');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Gemini CLI disconnected from app',
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Deauth Gemini failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
79
apps/server/src/routes/setup/routes/gemini-status.ts
Normal file
79
apps/server/src/routes/setup/routes/gemini-status.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* GET /gemini-status endpoint - Get Gemini CLI installation and auth status
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { GeminiProvider } from '../../../providers/gemini-provider.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
const DISCONNECTED_MARKER_FILE = '.gemini-disconnected';
|
||||
|
||||
async function isGeminiDisconnectedFromApp(): Promise<boolean> {
|
||||
try {
|
||||
const projectRoot = process.cwd();
|
||||
const markerPath = path.join(projectRoot, '.automaker', DISCONNECTED_MARKER_FILE);
|
||||
await fs.access(markerPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates handler for GET /api/setup/gemini-status
|
||||
* Returns Gemini CLI installation and authentication status
|
||||
*/
|
||||
export function createGeminiStatusHandler() {
|
||||
const installCommand = 'npm install -g @google/gemini-cli';
|
||||
const loginCommand = 'gemini';
|
||||
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Check if user has manually disconnected from the app
|
||||
if (await isGeminiDisconnectedFromApp()) {
|
||||
res.json({
|
||||
success: true,
|
||||
installed: true,
|
||||
version: null,
|
||||
path: null,
|
||||
auth: {
|
||||
authenticated: false,
|
||||
method: 'none',
|
||||
hasApiKey: false,
|
||||
},
|
||||
installCommand,
|
||||
loginCommand,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = new GeminiProvider();
|
||||
const status = await provider.detectInstallation();
|
||||
const auth = await provider.checkAuth();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
installed: status.installed,
|
||||
version: status.version || null,
|
||||
path: status.path || null,
|
||||
auth: {
|
||||
authenticated: auth.authenticated,
|
||||
method: auth.method,
|
||||
hasApiKey: auth.hasApiKey || false,
|
||||
hasEnvApiKey: auth.hasEnvApiKey || false,
|
||||
error: auth.error,
|
||||
},
|
||||
installCommand,
|
||||
loginCommand,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get Gemini status failed');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Common utilities and state for suggestions routes
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||
|
||||
const logger = createLogger('Suggestions');
|
||||
|
||||
// Shared state for tracking generation status - private
|
||||
let isRunning = false;
|
||||
let currentAbortController: AbortController | null = null;
|
||||
|
||||
/**
|
||||
* Get the current running state
|
||||
*/
|
||||
export function getSuggestionsStatus(): {
|
||||
isRunning: boolean;
|
||||
currentAbortController: AbortController | null;
|
||||
} {
|
||||
return { isRunning, currentAbortController };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the running state and abort controller
|
||||
*/
|
||||
export function setRunningState(running: boolean, controller: AbortController | null = null): void {
|
||||
isRunning = running;
|
||||
currentAbortController = controller;
|
||||
}
|
||||
|
||||
// Re-export shared utilities
|
||||
export { getErrorMessageShared as getErrorMessage };
|
||||
export const logError = createLogError(logger);
|
||||
@@ -1,296 +0,0 @@
|
||||
/**
|
||||
* Business logic for generating suggestions
|
||||
*
|
||||
* Model is configurable via phaseModels.suggestionsModel in settings
|
||||
* (AI Suggestions in the UI). Supports both Claude and Cursor models.
|
||||
*/
|
||||
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { DEFAULT_PHASE_MODELS, isCursorModel, type ThinkingLevel } from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { extractJsonWithArray } from '../../lib/json-extractor.js';
|
||||
import { streamingQuery } from '../../providers/simple-query-service.js';
|
||||
import { FeatureLoader } from '../../services/feature-loader.js';
|
||||
import { getAppSpecPath } from '@automaker/platform';
|
||||
import * as secureFs from '../../lib/secure-fs.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import { getAutoLoadClaudeMdSetting, getPromptCustomization } from '../../lib/settings-helpers.js';
|
||||
|
||||
const logger = createLogger('Suggestions');
|
||||
|
||||
/**
|
||||
* Extract implemented features from app_spec.txt XML content
|
||||
*
|
||||
* Note: This uses regex-based parsing which is sufficient for our controlled
|
||||
* XML structure. If more complex XML parsing is needed in the future, consider
|
||||
* using a library like 'fast-xml-parser' or 'xml2js'.
|
||||
*/
|
||||
function extractImplementedFeatures(specContent: string): string[] {
|
||||
const features: string[] = [];
|
||||
|
||||
// Match <implemented_features>...</implemented_features> section
|
||||
const implementedMatch = specContent.match(
|
||||
/<implemented_features>([\s\S]*?)<\/implemented_features>/
|
||||
);
|
||||
|
||||
if (implementedMatch) {
|
||||
const implementedSection = implementedMatch[1];
|
||||
|
||||
// Extract feature names from <name>...</name> tags using matchAll
|
||||
const nameRegex = /<name>(.*?)<\/name>/g;
|
||||
const matches = implementedSection.matchAll(nameRegex);
|
||||
|
||||
for (const match of matches) {
|
||||
features.push(match[1].trim());
|
||||
}
|
||||
}
|
||||
|
||||
return features;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load existing context (app spec and backlog features) to avoid duplicates
|
||||
*/
|
||||
async function loadExistingContext(projectPath: string): Promise<string> {
|
||||
let context = '';
|
||||
|
||||
// 1. Read app_spec.txt for implemented features
|
||||
try {
|
||||
const appSpecPath = getAppSpecPath(projectPath);
|
||||
const specContent = (await secureFs.readFile(appSpecPath, 'utf-8')) as string;
|
||||
|
||||
if (specContent && specContent.trim().length > 0) {
|
||||
const implementedFeatures = extractImplementedFeatures(specContent);
|
||||
|
||||
if (implementedFeatures.length > 0) {
|
||||
context += '\n\n=== ALREADY IMPLEMENTED FEATURES ===\n';
|
||||
context += 'These features are already implemented in the codebase:\n';
|
||||
context += implementedFeatures.map((feature) => `- ${feature}`).join('\n') + '\n';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// app_spec.txt doesn't exist or can't be read - that's okay
|
||||
logger.debug('No app_spec.txt found or error reading it:', error);
|
||||
}
|
||||
|
||||
// 2. Load existing features from backlog
|
||||
try {
|
||||
const featureLoader = new FeatureLoader();
|
||||
const features = await featureLoader.getAll(projectPath);
|
||||
|
||||
if (features.length > 0) {
|
||||
context += '\n\n=== EXISTING FEATURES IN BACKLOG ===\n';
|
||||
context += 'These features are already planned or in progress:\n';
|
||||
context +=
|
||||
features
|
||||
.map((feature) => {
|
||||
const status = feature.status || 'pending';
|
||||
const title = feature.title || feature.description?.substring(0, 50) || 'Untitled';
|
||||
return `- ${title} (${status})`;
|
||||
})
|
||||
.join('\n') + '\n';
|
||||
}
|
||||
} catch (error) {
|
||||
// Features directory doesn't exist or can't be read - that's okay
|
||||
logger.debug('No features found or error loading them:', error);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON Schema for suggestions output
|
||||
*/
|
||||
const suggestionsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
suggestions: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
category: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
priority: {
|
||||
type: 'number',
|
||||
minimum: 1,
|
||||
maximum: 3,
|
||||
},
|
||||
reasoning: { type: 'string' },
|
||||
},
|
||||
required: ['category', 'description', 'priority', 'reasoning'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['suggestions'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
export async function generateSuggestions(
|
||||
projectPath: string,
|
||||
suggestionType: string,
|
||||
events: EventEmitter,
|
||||
abortController: AbortController,
|
||||
settingsService?: SettingsService,
|
||||
modelOverride?: string,
|
||||
thinkingLevelOverride?: ThinkingLevel
|
||||
): Promise<void> {
|
||||
// Get customized prompts from settings
|
||||
const prompts = await getPromptCustomization(settingsService, '[Suggestions]');
|
||||
|
||||
// Map suggestion types to their prompts
|
||||
const typePrompts: Record<string, string> = {
|
||||
features: prompts.suggestions.featuresPrompt,
|
||||
refactoring: prompts.suggestions.refactoringPrompt,
|
||||
security: prompts.suggestions.securityPrompt,
|
||||
performance: prompts.suggestions.performancePrompt,
|
||||
};
|
||||
|
||||
// Load existing context to avoid duplicates
|
||||
const existingContext = await loadExistingContext(projectPath);
|
||||
|
||||
const prompt = `${typePrompts[suggestionType] || typePrompts.features}
|
||||
${existingContext}
|
||||
|
||||
${existingContext ? '\nIMPORTANT: Do NOT suggest features that are already implemented or already in the backlog above. Focus on NEW ideas that complement what already exists.\n' : ''}
|
||||
${prompts.suggestions.baseTemplate}`;
|
||||
|
||||
// Don't send initial message - let the agent output speak for itself
|
||||
// The first agent message will be captured as an info entry
|
||||
|
||||
// Load autoLoadClaudeMd setting
|
||||
const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting(
|
||||
projectPath,
|
||||
settingsService,
|
||||
'[Suggestions]'
|
||||
);
|
||||
|
||||
// Get model from phase settings (AI Suggestions = suggestionsModel)
|
||||
// Use override if provided, otherwise fall back to settings
|
||||
const settings = await settingsService?.getGlobalSettings();
|
||||
let model: string;
|
||||
let thinkingLevel: ThinkingLevel | undefined;
|
||||
|
||||
if (modelOverride) {
|
||||
// Use explicit override - resolve the model string
|
||||
const resolved = resolvePhaseModel({
|
||||
model: modelOverride,
|
||||
thinkingLevel: thinkingLevelOverride,
|
||||
});
|
||||
model = resolved.model;
|
||||
thinkingLevel = resolved.thinkingLevel;
|
||||
} else {
|
||||
// Use settings-based model
|
||||
const phaseModelEntry =
|
||||
settings?.phaseModels?.suggestionsModel || DEFAULT_PHASE_MODELS.suggestionsModel;
|
||||
const resolved = resolvePhaseModel(phaseModelEntry);
|
||||
model = resolved.model;
|
||||
thinkingLevel = resolved.thinkingLevel;
|
||||
}
|
||||
|
||||
logger.info('[Suggestions] Using model:', model);
|
||||
|
||||
let responseText = '';
|
||||
|
||||
// Determine if we should use structured output (Claude supports it, Cursor doesn't)
|
||||
const useStructuredOutput = !isCursorModel(model);
|
||||
|
||||
// Build the final prompt - for Cursor, include JSON schema instructions
|
||||
let finalPrompt = prompt;
|
||||
if (!useStructuredOutput) {
|
||||
finalPrompt = `${prompt}
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
1. DO NOT write any files. Return the JSON in your response only.
|
||||
2. After analyzing the project, respond with ONLY a JSON object - no explanations, no markdown, just raw JSON.
|
||||
3. The JSON must match this exact schema:
|
||||
|
||||
${JSON.stringify(suggestionsSchema, null, 2)}
|
||||
|
||||
Your entire response should be valid JSON starting with { and ending with }. No text before or after.`;
|
||||
}
|
||||
|
||||
// Use streamingQuery with event callbacks
|
||||
const result = await streamingQuery({
|
||||
prompt: finalPrompt,
|
||||
model,
|
||||
cwd: projectPath,
|
||||
maxTurns: 250,
|
||||
allowedTools: ['Read', 'Glob', 'Grep'],
|
||||
abortController,
|
||||
thinkingLevel,
|
||||
readOnly: true, // Suggestions only reads code, doesn't write
|
||||
settingSources: autoLoadClaudeMd ? ['user', 'project', 'local'] : undefined,
|
||||
outputFormat: useStructuredOutput
|
||||
? {
|
||||
type: 'json_schema',
|
||||
schema: suggestionsSchema,
|
||||
}
|
||||
: undefined,
|
||||
onText: (text) => {
|
||||
responseText += text;
|
||||
events.emit('suggestions:event', {
|
||||
type: 'suggestions_progress',
|
||||
content: text,
|
||||
});
|
||||
},
|
||||
onToolUse: (tool, input) => {
|
||||
events.emit('suggestions:event', {
|
||||
type: 'suggestions_tool',
|
||||
tool,
|
||||
input,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Use structured output if available, otherwise fall back to parsing text
|
||||
try {
|
||||
let structuredOutput: { suggestions: Array<Record<string, unknown>> } | null = null;
|
||||
|
||||
if (result.structured_output) {
|
||||
structuredOutput = result.structured_output as {
|
||||
suggestions: Array<Record<string, unknown>>;
|
||||
};
|
||||
logger.debug('Received structured output:', structuredOutput);
|
||||
} else if (responseText) {
|
||||
// Fallback: try to parse from text using shared extraction utility
|
||||
logger.warn('No structured output received, attempting to parse from text');
|
||||
structuredOutput = extractJsonWithArray<{ suggestions: Array<Record<string, unknown>> }>(
|
||||
responseText,
|
||||
'suggestions',
|
||||
{ logger }
|
||||
);
|
||||
}
|
||||
|
||||
if (structuredOutput && structuredOutput.suggestions) {
|
||||
// Use structured output directly
|
||||
events.emit('suggestions:event', {
|
||||
type: 'suggestions_complete',
|
||||
suggestions: structuredOutput.suggestions.map((s: Record<string, unknown>, i: number) => ({
|
||||
...s,
|
||||
id: s.id || `suggestion-${Date.now()}-${i}`,
|
||||
})),
|
||||
});
|
||||
} else {
|
||||
throw new Error('No valid JSON found in response');
|
||||
}
|
||||
} catch (error) {
|
||||
// Log the parsing error for debugging
|
||||
logger.error('Failed to parse suggestions JSON from AI response:', error);
|
||||
// Return generic suggestions if parsing fails
|
||||
events.emit('suggestions:event', {
|
||||
type: 'suggestions_complete',
|
||||
suggestions: [
|
||||
{
|
||||
id: `suggestion-${Date.now()}-0`,
|
||||
category: 'Analysis',
|
||||
description: 'Review the AI analysis output for insights',
|
||||
priority: 1,
|
||||
reasoning: 'The AI provided analysis but suggestions need manual review',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* Suggestions routes - HTTP API for AI-powered feature suggestions
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||
import { createGenerateHandler } from './routes/generate.js';
|
||||
import { createStopHandler } from './routes/stop.js';
|
||||
import { createStatusHandler } from './routes/status.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
|
||||
export function createSuggestionsRoutes(
|
||||
events: EventEmitter,
|
||||
settingsService?: SettingsService
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
router.post(
|
||||
'/generate',
|
||||
validatePathParams('projectPath'),
|
||||
createGenerateHandler(events, settingsService)
|
||||
);
|
||||
router.post('/stop', createStopHandler());
|
||||
router.get('/status', createStatusHandler());
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* POST /generate endpoint - Generate suggestions
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { EventEmitter } from '../../../lib/events.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { ThinkingLevel } from '@automaker/types';
|
||||
import { getSuggestionsStatus, setRunningState, getErrorMessage, logError } from '../common.js';
|
||||
import { generateSuggestions } from '../generate-suggestions.js';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
|
||||
const logger = createLogger('Suggestions');
|
||||
|
||||
export function createGenerateHandler(events: EventEmitter, settingsService?: SettingsService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const {
|
||||
projectPath,
|
||||
suggestionType = 'features',
|
||||
model,
|
||||
thinkingLevel,
|
||||
} = req.body as {
|
||||
projectPath: string;
|
||||
suggestionType?: string;
|
||||
model?: string;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { isRunning } = getSuggestionsStatus();
|
||||
if (isRunning) {
|
||||
res.json({
|
||||
success: false,
|
||||
error: 'Suggestions generation is already running',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setRunningState(true);
|
||||
const abortController = new AbortController();
|
||||
setRunningState(true, abortController);
|
||||
|
||||
// Start generation in background
|
||||
generateSuggestions(
|
||||
projectPath,
|
||||
suggestionType,
|
||||
events,
|
||||
abortController,
|
||||
settingsService,
|
||||
model,
|
||||
thinkingLevel
|
||||
)
|
||||
.catch((error) => {
|
||||
logError(error, 'Generate suggestions failed (background)');
|
||||
events.emit('suggestions:event', {
|
||||
type: 'suggestions_error',
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setRunningState(false, null);
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logError(error, 'Generate suggestions failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* GET /status endpoint - Get status
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getSuggestionsStatus, getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createStatusHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { isRunning } = getSuggestionsStatus();
|
||||
res.json({ success: true, isRunning });
|
||||
} catch (error) {
|
||||
logError(error, 'Get status failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* POST /stop endpoint - Stop suggestions generation
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getSuggestionsStatus, setRunningState, getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createStopHandler() {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { currentAbortController } = getSuggestionsStatus();
|
||||
if (currentAbortController) {
|
||||
currentAbortController.abort();
|
||||
}
|
||||
setRunningState(false, null);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logError(error, 'Stop suggestions failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -42,12 +42,18 @@ import { createStartDevHandler } from './routes/start-dev.js';
|
||||
import { createStopDevHandler } from './routes/stop-dev.js';
|
||||
import { createListDevServersHandler } from './routes/list-dev-servers.js';
|
||||
import { createGetDevServerLogsHandler } from './routes/dev-server-logs.js';
|
||||
import { createStartTestsHandler } from './routes/start-tests.js';
|
||||
import { createStopTestsHandler } from './routes/stop-tests.js';
|
||||
import { createGetTestLogsHandler } from './routes/test-logs.js';
|
||||
import {
|
||||
createGetInitScriptHandler,
|
||||
createPutInitScriptHandler,
|
||||
createDeleteInitScriptHandler,
|
||||
createRunInitScriptHandler,
|
||||
} from './routes/init-script.js';
|
||||
import { createDiscardChangesHandler } from './routes/discard-changes.js';
|
||||
import { createListRemotesHandler } from './routes/list-remotes.js';
|
||||
import { createAddRemoteHandler } from './routes/add-remote.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
|
||||
export function createWorktreeRoutes(
|
||||
@@ -128,7 +134,7 @@ export function createWorktreeRoutes(
|
||||
router.post(
|
||||
'/start-dev',
|
||||
validatePathParams('projectPath', 'worktreePath'),
|
||||
createStartDevHandler()
|
||||
createStartDevHandler(settingsService)
|
||||
);
|
||||
router.post('/stop-dev', createStopDevHandler());
|
||||
router.post('/list-dev-servers', createListDevServersHandler());
|
||||
@@ -138,6 +144,15 @@ export function createWorktreeRoutes(
|
||||
createGetDevServerLogsHandler()
|
||||
);
|
||||
|
||||
// Test runner routes
|
||||
router.post(
|
||||
'/start-tests',
|
||||
validatePathParams('worktreePath', 'projectPath?'),
|
||||
createStartTestsHandler(settingsService)
|
||||
);
|
||||
router.post('/stop-tests', createStopTestsHandler());
|
||||
router.get('/test-logs', validatePathParams('worktreePath?'), createGetTestLogsHandler());
|
||||
|
||||
// Init script routes
|
||||
router.get('/init-script', createGetInitScriptHandler());
|
||||
router.put('/init-script', validatePathParams('projectPath'), createPutInitScriptHandler());
|
||||
@@ -148,5 +163,29 @@ export function createWorktreeRoutes(
|
||||
createRunInitScriptHandler(events)
|
||||
);
|
||||
|
||||
// Discard changes route
|
||||
router.post(
|
||||
'/discard-changes',
|
||||
validatePathParams('worktreePath'),
|
||||
requireGitRepoOnly,
|
||||
createDiscardChangesHandler()
|
||||
);
|
||||
|
||||
// List remotes route
|
||||
router.post(
|
||||
'/list-remotes',
|
||||
validatePathParams('worktreePath'),
|
||||
requireValidWorktree,
|
||||
createListRemotesHandler()
|
||||
);
|
||||
|
||||
// Add remote route
|
||||
router.post(
|
||||
'/add-remote',
|
||||
validatePathParams('worktreePath'),
|
||||
requireGitRepoOnly,
|
||||
createAddRemoteHandler()
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
166
apps/server/src/routes/worktree/routes/add-remote.ts
Normal file
166
apps/server/src/routes/worktree/routes/add-remote.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* POST /add-remote endpoint - Add a new remote to a git repository
|
||||
*
|
||||
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||
* the requireValidWorktree middleware in index.ts
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { getErrorMessage, logWorktreeError } from '../common.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/** Maximum allowed length for remote names */
|
||||
const MAX_REMOTE_NAME_LENGTH = 250;
|
||||
|
||||
/** Maximum allowed length for remote URLs */
|
||||
const MAX_REMOTE_URL_LENGTH = 2048;
|
||||
|
||||
/** Timeout for git fetch operations (30 seconds) */
|
||||
const FETCH_TIMEOUT_MS = 30000;
|
||||
|
||||
/**
|
||||
* Validate remote name - must be alphanumeric with dashes/underscores
|
||||
* Git remote names have similar restrictions to branch names
|
||||
*/
|
||||
function isValidRemoteName(name: string): boolean {
|
||||
// Remote names should be alphanumeric, may contain dashes, underscores, periods
|
||||
// Cannot start with a dash or period, cannot be empty
|
||||
if (!name || name.length === 0 || name.length > MAX_REMOTE_NAME_LENGTH) {
|
||||
return false;
|
||||
}
|
||||
return /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate remote URL - basic validation for git remote URLs
|
||||
* Supports HTTPS, SSH, and git:// protocols
|
||||
*/
|
||||
function isValidRemoteUrl(url: string): boolean {
|
||||
if (!url || url.length === 0 || url.length > MAX_REMOTE_URL_LENGTH) {
|
||||
return false;
|
||||
}
|
||||
// Support common git URL formats:
|
||||
// - https://github.com/user/repo.git
|
||||
// - git@github.com:user/repo.git
|
||||
// - git://github.com/user/repo.git
|
||||
// - ssh://git@github.com/user/repo.git
|
||||
const httpsPattern = /^https?:\/\/.+/;
|
||||
const sshPattern = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+:.+/;
|
||||
const gitProtocolPattern = /^git:\/\/.+/;
|
||||
const sshProtocolPattern = /^ssh:\/\/.+/;
|
||||
|
||||
return (
|
||||
httpsPattern.test(url) ||
|
||||
sshPattern.test(url) ||
|
||||
gitProtocolPattern.test(url) ||
|
||||
sshProtocolPattern.test(url)
|
||||
);
|
||||
}
|
||||
|
||||
export function createAddRemoteHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { worktreePath, remoteName, remoteUrl } = req.body as {
|
||||
worktreePath: string;
|
||||
remoteName: string;
|
||||
remoteUrl: string;
|
||||
};
|
||||
|
||||
// Validate required fields
|
||||
const requiredFields = { worktreePath, remoteName, remoteUrl };
|
||||
for (const [key, value] of Object.entries(requiredFields)) {
|
||||
if (!value) {
|
||||
res.status(400).json({ success: false, error: `${key} required` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate remote name
|
||||
if (!isValidRemoteName(remoteName)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error:
|
||||
'Invalid remote name. Must start with alphanumeric character and contain only letters, numbers, dashes, underscores, or periods.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate remote URL
|
||||
if (!isValidRemoteUrl(remoteUrl)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid remote URL. Must be a valid git URL (HTTPS, SSH, or git:// protocol).',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if remote already exists
|
||||
try {
|
||||
const { stdout: existingRemotes } = await execFileAsync('git', ['remote'], {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
const remoteNames = existingRemotes
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((r) => r.trim());
|
||||
if (remoteNames.includes(remoteName)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Remote '${remoteName}' already exists`,
|
||||
code: 'REMOTE_EXISTS',
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
// If git remote fails, continue with adding the remote. Log for debugging.
|
||||
logWorktreeError(
|
||||
error,
|
||||
'Checking for existing remotes failed, proceeding to add.',
|
||||
worktreePath
|
||||
);
|
||||
}
|
||||
|
||||
// Add the remote using execFile with array arguments to prevent command injection
|
||||
await execFileAsync('git', ['remote', 'add', remoteName, remoteUrl], {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
|
||||
// Optionally fetch from the new remote to get its branches
|
||||
let fetchSucceeded = false;
|
||||
try {
|
||||
await execFileAsync('git', ['fetch', remoteName, '--quiet'], {
|
||||
cwd: worktreePath,
|
||||
timeout: FETCH_TIMEOUT_MS,
|
||||
});
|
||||
fetchSucceeded = true;
|
||||
} catch (fetchError) {
|
||||
// Fetch failed (maybe offline or invalid URL), but remote was added successfully
|
||||
logWorktreeError(
|
||||
fetchError,
|
||||
`Fetch from new remote '${remoteName}' failed (remote added successfully)`,
|
||||
worktreePath
|
||||
);
|
||||
fetchSucceeded = false;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
remoteName,
|
||||
remoteUrl,
|
||||
fetched: fetchSucceeded,
|
||||
message: fetchSucceeded
|
||||
? `Successfully added remote '${remoteName}' and fetched its branches`
|
||||
: `Successfully added remote '${remoteName}' (fetch failed - you may need to fetch manually)`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const worktreePath = req.body?.worktreePath;
|
||||
logWorktreeError(error, 'Add remote failed', worktreePath);
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -39,7 +39,10 @@ export function createDiffsHandler() {
|
||||
}
|
||||
|
||||
// Git worktrees are stored in project directory
|
||||
const worktreePath = path.join(projectPath, '.worktrees', featureId);
|
||||
// Sanitize featureId the same way it's sanitized when creating worktrees
|
||||
// (see create.ts: branchName.replace(/[^a-zA-Z0-9_-]/g, '-'))
|
||||
const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-');
|
||||
const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId);
|
||||
|
||||
try {
|
||||
// Check if worktree exists
|
||||
|
||||
112
apps/server/src/routes/worktree/routes/discard-changes.ts
Normal file
112
apps/server/src/routes/worktree/routes/discard-changes.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* POST /discard-changes endpoint - Discard all uncommitted changes in a worktree
|
||||
*
|
||||
* This performs a destructive operation that:
|
||||
* 1. Resets staged changes (git reset HEAD)
|
||||
* 2. Discards modified tracked files (git checkout .)
|
||||
* 3. Removes untracked files and directories (git clean -fd)
|
||||
*
|
||||
* Note: Git repository validation (isGitRepo) is handled by
|
||||
* the requireGitRepoOnly 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);
|
||||
|
||||
export function createDiscardChangesHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { worktreePath } = req.body as {
|
||||
worktreePath: string;
|
||||
};
|
||||
|
||||
if (!worktreePath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'worktreePath required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for uncommitted changes first
|
||||
const { stdout: status } = await execAsync('git status --porcelain', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
|
||||
if (!status.trim()) {
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
discarded: false,
|
||||
message: 'No changes to discard',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Count the files that will be affected
|
||||
const lines = status.trim().split('\n').filter(Boolean);
|
||||
const fileCount = lines.length;
|
||||
|
||||
// Get branch name before discarding
|
||||
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
const branchName = branchOutput.trim();
|
||||
|
||||
// Discard all changes:
|
||||
// 1. Reset any staged changes
|
||||
await execAsync('git reset HEAD', { cwd: worktreePath }).catch(() => {
|
||||
// Ignore errors - might fail if there's nothing staged
|
||||
});
|
||||
|
||||
// 2. Discard changes in tracked files
|
||||
await execAsync('git checkout .', { cwd: worktreePath }).catch(() => {
|
||||
// Ignore errors - might fail if there are no tracked changes
|
||||
});
|
||||
|
||||
// 3. Remove untracked files and directories
|
||||
await execAsync('git clean -fd', { cwd: worktreePath }).catch(() => {
|
||||
// Ignore errors - might fail if there are no untracked files
|
||||
});
|
||||
|
||||
// Verify all changes were discarded
|
||||
const { stdout: finalStatus } = await execAsync('git status --porcelain', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
|
||||
if (finalStatus.trim()) {
|
||||
// Some changes couldn't be discarded (possibly ignored files or permission issues)
|
||||
const remainingCount = finalStatus.trim().split('\n').filter(Boolean).length;
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
discarded: true,
|
||||
filesDiscarded: fileCount - remainingCount,
|
||||
filesRemaining: remainingCount,
|
||||
branch: branchName,
|
||||
message: `Discarded ${fileCount - remainingCount} files, ${remainingCount} files could not be removed`,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
discarded: true,
|
||||
filesDiscarded: fileCount,
|
||||
filesRemaining: 0,
|
||||
branch: branchName,
|
||||
message: `Discarded ${fileCount} ${fileCount === 1 ? 'file' : 'files'}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error, 'Discard changes failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -37,7 +37,10 @@ export function createFileDiffHandler() {
|
||||
}
|
||||
|
||||
// Git worktrees are stored in project directory
|
||||
const worktreePath = path.join(projectPath, '.worktrees', featureId);
|
||||
// Sanitize featureId the same way it's sanitized when creating worktrees
|
||||
// (see create.ts: branchName.replace(/[^a-zA-Z0-9_-]/g, '-'))
|
||||
const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-');
|
||||
const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId);
|
||||
|
||||
try {
|
||||
await secureFs.access(worktreePath);
|
||||
|
||||
@@ -10,14 +10,14 @@ import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { DEFAULT_PHASE_MODELS, isCursorModel, stripProviderPrefix } from '@automaker/types';
|
||||
import { isCursorModel, stripProviderPrefix } from '@automaker/types';
|
||||
import { resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { mergeCommitMessagePrompts } from '@automaker/prompts';
|
||||
import { ProviderFactory } from '../../../providers/provider-factory.js';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { getPhaseModelWithOverrides } from '../../../lib/settings-helpers.js';
|
||||
|
||||
const logger = createLogger('GenerateCommitMessage');
|
||||
const execAsync = promisify(exec);
|
||||
@@ -74,33 +74,6 @@ interface GenerateCommitMessageErrorResponse {
|
||||
error: string;
|
||||
}
|
||||
|
||||
async function extractTextFromStream(
|
||||
stream: AsyncIterable<{
|
||||
type: string;
|
||||
subtype?: string;
|
||||
result?: string;
|
||||
message?: {
|
||||
content?: Array<{ type: string; text?: string }>;
|
||||
};
|
||||
}>
|
||||
): Promise<string> {
|
||||
let responseText = '';
|
||||
|
||||
for await (const msg of stream) {
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
responseText += block.text;
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'result' && msg.subtype === 'success') {
|
||||
responseText = msg.result || responseText;
|
||||
}
|
||||
}
|
||||
|
||||
return responseText;
|
||||
}
|
||||
|
||||
export function createGenerateCommitMessageHandler(
|
||||
settingsService?: SettingsService
|
||||
): (req: Request, res: Response) => Promise<void> {
|
||||
@@ -184,68 +157,69 @@ export function createGenerateCommitMessageHandler(
|
||||
|
||||
const userPrompt = `Generate a commit message for these changes:\n\n\`\`\`diff\n${truncatedDiff}\n\`\`\``;
|
||||
|
||||
// Get model from phase settings
|
||||
const settings = await settingsService?.getGlobalSettings();
|
||||
const phaseModelEntry =
|
||||
settings?.phaseModels?.commitMessageModel || DEFAULT_PHASE_MODELS.commitMessageModel;
|
||||
const { model } = resolvePhaseModel(phaseModelEntry);
|
||||
// Get model from phase settings with provider info
|
||||
const {
|
||||
phaseModel: phaseModelEntry,
|
||||
provider: claudeCompatibleProvider,
|
||||
credentials,
|
||||
} = await getPhaseModelWithOverrides(
|
||||
'commitMessageModel',
|
||||
settingsService,
|
||||
worktreePath,
|
||||
'[GenerateCommitMessage]'
|
||||
);
|
||||
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
|
||||
|
||||
logger.info(`Using model for commit message: ${model}`);
|
||||
logger.info(
|
||||
`Using model for commit message: ${model}`,
|
||||
claudeCompatibleProvider ? `via provider: ${claudeCompatibleProvider.name}` : 'direct API'
|
||||
);
|
||||
|
||||
// Get the effective system prompt (custom or default)
|
||||
const systemPrompt = await getSystemPrompt(settingsService);
|
||||
|
||||
let message: string;
|
||||
// Get provider for the model type
|
||||
const aiProvider = ProviderFactory.getProviderForModel(model);
|
||||
const bareModel = stripProviderPrefix(model);
|
||||
|
||||
// Route to appropriate provider based on model type
|
||||
if (isCursorModel(model)) {
|
||||
// Use Cursor provider for Cursor models
|
||||
logger.info(`Using Cursor provider for model: ${model}`);
|
||||
// For Cursor models, combine prompts since Cursor doesn't support systemPrompt separation
|
||||
const effectivePrompt = isCursorModel(model)
|
||||
? `${systemPrompt}\n\n${userPrompt}`
|
||||
: userPrompt;
|
||||
const effectiveSystemPrompt = isCursorModel(model) ? undefined : systemPrompt;
|
||||
|
||||
const provider = ProviderFactory.getProviderForModel(model);
|
||||
const bareModel = stripProviderPrefix(model);
|
||||
logger.info(`Using ${aiProvider.getName()} provider for model: ${model}`);
|
||||
|
||||
const cursorPrompt = `${systemPrompt}\n\n${userPrompt}`;
|
||||
let responseText = '';
|
||||
const stream = aiProvider.executeQuery({
|
||||
prompt: effectivePrompt,
|
||||
model: bareModel,
|
||||
cwd: worktreePath,
|
||||
systemPrompt: effectiveSystemPrompt,
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
readOnly: true,
|
||||
thinkingLevel, // Pass thinking level for extended thinking support
|
||||
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
});
|
||||
|
||||
let responseText = '';
|
||||
const cursorStream = provider.executeQuery({
|
||||
prompt: cursorPrompt,
|
||||
model: bareModel,
|
||||
cwd: worktreePath,
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
// Wrap with timeout to prevent indefinite hangs
|
||||
for await (const msg of withTimeout(cursorStream, AI_TIMEOUT_MS)) {
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
responseText += block.text;
|
||||
}
|
||||
// Wrap with timeout to prevent indefinite hangs
|
||||
for await (const msg of withTimeout(stream, AI_TIMEOUT_MS)) {
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
for (const block of msg.message.content) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
responseText += block.text;
|
||||
}
|
||||
}
|
||||
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
|
||||
// Use result if available (some providers return final text here)
|
||||
responseText = msg.result;
|
||||
}
|
||||
|
||||
message = responseText.trim();
|
||||
} else {
|
||||
// Use Claude SDK for Claude models
|
||||
const stream = query({
|
||||
prompt: userPrompt,
|
||||
options: {
|
||||
model,
|
||||
systemPrompt,
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
permissionMode: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
// Wrap with timeout to prevent indefinite hangs
|
||||
message = await extractTextFromStream(withTimeout(stream, AI_TIMEOUT_MS));
|
||||
}
|
||||
|
||||
const message = responseText.trim();
|
||||
|
||||
if (!message || message.trim().length === 0) {
|
||||
logger.warn('Received empty response from model');
|
||||
const response: GenerateCommitMessageErrorResponse = {
|
||||
|
||||
@@ -28,7 +28,10 @@ export function createInfoHandler() {
|
||||
}
|
||||
|
||||
// Check if worktree exists (git worktrees are stored in project directory)
|
||||
const worktreePath = path.join(projectPath, '.worktrees', featureId);
|
||||
// Sanitize featureId the same way it's sanitized when creating worktrees
|
||||
// (see create.ts: branchName.replace(/[^a-zA-Z0-9_-]/g, '-'))
|
||||
const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-');
|
||||
const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId);
|
||||
try {
|
||||
await secureFs.access(worktreePath);
|
||||
const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||||
|
||||
@@ -110,9 +110,22 @@ export function createListBranchesHandler() {
|
||||
}
|
||||
}
|
||||
|
||||
// Get ahead/behind count for current branch
|
||||
// Check if any remotes are configured for this repository
|
||||
let hasAnyRemotes = false;
|
||||
try {
|
||||
const { stdout: remotesOutput } = await execAsync('git remote', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
hasAnyRemotes = remotesOutput.trim().length > 0;
|
||||
} catch {
|
||||
// If git remote fails, assume no remotes
|
||||
hasAnyRemotes = false;
|
||||
}
|
||||
|
||||
// Get ahead/behind count for current branch and check if remote branch exists
|
||||
let aheadCount = 0;
|
||||
let behindCount = 0;
|
||||
let hasRemoteBranch = false;
|
||||
try {
|
||||
// First check if there's a remote tracking branch
|
||||
const { stdout: upstreamOutput } = await execAsync(
|
||||
@@ -121,6 +134,7 @@ export function createListBranchesHandler() {
|
||||
);
|
||||
|
||||
if (upstreamOutput.trim()) {
|
||||
hasRemoteBranch = true;
|
||||
const { stdout: aheadBehindOutput } = await execAsync(
|
||||
`git rev-list --left-right --count ${currentBranch}@{upstream}...HEAD`,
|
||||
{ cwd: worktreePath }
|
||||
@@ -130,7 +144,18 @@ export function createListBranchesHandler() {
|
||||
behindCount = behind || 0;
|
||||
}
|
||||
} catch {
|
||||
// No upstream branch set, that's okay
|
||||
// No upstream branch set - check if the branch exists on any remote
|
||||
try {
|
||||
// Check if there's a matching branch on origin (most common remote)
|
||||
const { stdout: remoteBranchOutput } = await execAsync(
|
||||
`git ls-remote --heads origin ${currentBranch}`,
|
||||
{ cwd: worktreePath, timeout: 5000 }
|
||||
);
|
||||
hasRemoteBranch = remoteBranchOutput.trim().length > 0;
|
||||
} catch {
|
||||
// No remote branch found or origin doesn't exist
|
||||
hasRemoteBranch = false;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
@@ -140,6 +165,8 @@ export function createListBranchesHandler() {
|
||||
branches,
|
||||
aheadCount,
|
||||
behindCount,
|
||||
hasRemoteBranch,
|
||||
hasAnyRemotes,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
127
apps/server/src/routes/worktree/routes/list-remotes.ts
Normal file
127
apps/server/src/routes/worktree/routes/list-remotes.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* POST /list-remotes endpoint - List all remotes and their branches
|
||||
*
|
||||
* 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, logWorktreeError } from '../common.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
interface RemoteBranch {
|
||||
name: string;
|
||||
fullRef: string;
|
||||
}
|
||||
|
||||
interface RemoteInfo {
|
||||
name: string;
|
||||
url: string;
|
||||
branches: RemoteBranch[];
|
||||
}
|
||||
|
||||
export function createListRemotesHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { worktreePath } = req.body as {
|
||||
worktreePath: string;
|
||||
};
|
||||
|
||||
if (!worktreePath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'worktreePath required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get list of remotes
|
||||
const { stdout: remotesOutput } = await execAsync('git remote -v', {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
|
||||
// Parse remotes (each remote appears twice - once for fetch, once for push)
|
||||
const remotesSet = new Map<string, string>();
|
||||
remotesOutput
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line.trim())
|
||||
.forEach((line) => {
|
||||
const match = line.match(/^(\S+)\s+(\S+)\s+\(fetch\)$/);
|
||||
if (match) {
|
||||
remotesSet.set(match[1], match[2]);
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch latest from all remotes (silently, don't fail if offline)
|
||||
try {
|
||||
await execAsync('git fetch --all --quiet', {
|
||||
cwd: worktreePath,
|
||||
timeout: 15000, // 15 second timeout
|
||||
});
|
||||
} catch {
|
||||
// Ignore fetch errors - we'll use cached remote refs
|
||||
}
|
||||
|
||||
// Get all remote branches
|
||||
const { stdout: remoteBranchesOutput } = await execAsync(
|
||||
'git branch -r --format="%(refname:short)"',
|
||||
{ cwd: worktreePath }
|
||||
);
|
||||
|
||||
// Group branches by remote
|
||||
const remotesBranches = new Map<string, RemoteBranch[]>();
|
||||
remotesSet.forEach((_, remoteName) => {
|
||||
remotesBranches.set(remoteName, []);
|
||||
});
|
||||
|
||||
remoteBranchesOutput
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line.trim())
|
||||
.forEach((line) => {
|
||||
const cleanLine = line.trim().replace(/^['"]|['"]$/g, '');
|
||||
// Skip HEAD pointers like "origin/HEAD"
|
||||
if (cleanLine.includes('/HEAD')) return;
|
||||
|
||||
// Parse remote name from branch ref (e.g., "origin/main" -> "origin")
|
||||
const slashIndex = cleanLine.indexOf('/');
|
||||
if (slashIndex === -1) return;
|
||||
|
||||
const remoteName = cleanLine.substring(0, slashIndex);
|
||||
const branchName = cleanLine.substring(slashIndex + 1);
|
||||
|
||||
if (remotesBranches.has(remoteName)) {
|
||||
remotesBranches.get(remoteName)!.push({
|
||||
name: branchName,
|
||||
fullRef: cleanLine,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Build final result
|
||||
const remotes: RemoteInfo[] = [];
|
||||
remotesSet.forEach((url, name) => {
|
||||
remotes.push({
|
||||
name,
|
||||
url,
|
||||
branches: remotesBranches.get(name) || [],
|
||||
});
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
remotes,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const worktreePath = req.body?.worktreePath;
|
||||
logWorktreeError(error, 'List remotes failed', worktreePath);
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -39,8 +39,15 @@ interface GitHubRemoteCacheEntry {
|
||||
checkedAt: number;
|
||||
}
|
||||
|
||||
interface GitHubPRCacheEntry {
|
||||
prs: Map<string, WorktreePRInfo>;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
const githubRemoteCache = new Map<string, GitHubRemoteCacheEntry>();
|
||||
const githubPRCache = new Map<string, GitHubPRCacheEntry>();
|
||||
const GITHUB_REMOTE_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const GITHUB_PR_CACHE_TTL_MS = 2 * 60 * 1000; // 2 minutes - avoid hitting GitHub on every poll
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
@@ -180,9 +187,21 @@ async function getGitHubRemoteStatus(projectPath: string): Promise<GitHubRemoteS
|
||||
* This also allows detecting PRs that were created outside the app.
|
||||
*
|
||||
* Uses cached GitHub remote status to avoid repeated warnings when the
|
||||
* project doesn't have a GitHub remote configured.
|
||||
* project doesn't have a GitHub remote configured. Results are cached
|
||||
* briefly to avoid hammering GitHub on frequent worktree polls.
|
||||
*/
|
||||
async function fetchGitHubPRs(projectPath: string): Promise<Map<string, WorktreePRInfo>> {
|
||||
async function fetchGitHubPRs(
|
||||
projectPath: string,
|
||||
forceRefresh = false
|
||||
): Promise<Map<string, WorktreePRInfo>> {
|
||||
const now = Date.now();
|
||||
const cached = githubPRCache.get(projectPath);
|
||||
|
||||
// Return cached result if valid and not forcing refresh
|
||||
if (!forceRefresh && cached && now - cached.fetchedAt < GITHUB_PR_CACHE_TTL_MS) {
|
||||
return cached.prs;
|
||||
}
|
||||
|
||||
const prMap = new Map<string, WorktreePRInfo>();
|
||||
|
||||
try {
|
||||
@@ -225,8 +244,22 @@ async function fetchGitHubPRs(projectPath: string): Promise<Map<string, Worktree
|
||||
createdAt: pr.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Only update cache on successful fetch
|
||||
githubPRCache.set(projectPath, {
|
||||
prs: prMap,
|
||||
fetchedAt: Date.now(),
|
||||
});
|
||||
} catch (error) {
|
||||
// Silently fail - PR detection is optional
|
||||
// On fetch failure, return stale cached data if available to avoid
|
||||
// repeated API calls during GitHub API flakiness or temporary outages
|
||||
if (cached) {
|
||||
logger.warn(`Failed to fetch GitHub PRs, returning stale cache: ${getErrorMessage(error)}`);
|
||||
// Extend cache TTL to avoid repeated retries during outages
|
||||
githubPRCache.set(projectPath, { prs: cached.prs, fetchedAt: Date.now() });
|
||||
return cached.prs;
|
||||
}
|
||||
// No cache available, log warning and return empty map
|
||||
logger.warn(`Failed to fetch GitHub PRs: ${getErrorMessage(error)}`);
|
||||
}
|
||||
|
||||
@@ -364,7 +397,7 @@ export function createListHandler() {
|
||||
// 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
|
||||
? await fetchGitHubPRs(projectPath)
|
||||
? await fetchGitHubPRs(projectPath, forceRefreshGitHub)
|
||||
: new Map<string, WorktreePRInfo>();
|
||||
|
||||
for (const worktree of worktrees) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* POST /merge endpoint - Merge feature (merge worktree branch into main)
|
||||
* POST /merge endpoint - Merge feature (merge worktree branch into a target branch)
|
||||
*
|
||||
* Allows merging a worktree branch into any target branch (defaults to 'main').
|
||||
*
|
||||
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||
* the requireValidProject middleware in index.ts
|
||||
@@ -8,18 +10,21 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { getErrorMessage, logError, isValidBranchName, execGitCommand } from '../common.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const logger = createLogger('Worktree');
|
||||
|
||||
export function createMergeHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, branchName, worktreePath, options } = req.body as {
|
||||
const { projectPath, branchName, worktreePath, targetBranch, options } = req.body as {
|
||||
projectPath: string;
|
||||
branchName: string;
|
||||
worktreePath: string;
|
||||
options?: { squash?: boolean; message?: string };
|
||||
targetBranch?: string; // Branch to merge into (defaults to 'main')
|
||||
options?: { squash?: boolean; message?: string; deleteWorktreeAndBranch?: boolean };
|
||||
};
|
||||
|
||||
if (!projectPath || !branchName || !worktreePath) {
|
||||
@@ -30,7 +35,10 @@ export function createMergeHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate branch exists
|
||||
// Determine the target branch (default to 'main')
|
||||
const mergeTo = targetBranch || 'main';
|
||||
|
||||
// Validate source branch exists
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify ${branchName}`, { cwd: projectPath });
|
||||
} catch {
|
||||
@@ -41,12 +49,44 @@ export function createMergeHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Merge the feature branch
|
||||
// Validate target branch exists
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify ${mergeTo}`, { cwd: projectPath });
|
||||
} catch {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Target branch "${mergeTo}" does not exist`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Merge the feature branch into the target branch
|
||||
const mergeCmd = options?.squash
|
||||
? `git merge --squash ${branchName}`
|
||||
: `git merge ${branchName} -m "${options?.message || `Merge ${branchName}`}"`;
|
||||
: `git merge ${branchName} -m "${options?.message || `Merge ${branchName} into ${mergeTo}`}"`;
|
||||
|
||||
await execAsync(mergeCmd, { cwd: projectPath });
|
||||
try {
|
||||
await execAsync(mergeCmd, { cwd: projectPath });
|
||||
} catch (mergeError: unknown) {
|
||||
// Check if this is a merge conflict
|
||||
const err = mergeError as { stdout?: string; stderr?: string; message?: string };
|
||||
const output = `${err.stdout || ''} ${err.stderr || ''} ${err.message || ''}`;
|
||||
const hasConflicts =
|
||||
output.includes('CONFLICT') || output.includes('Automatic merge failed');
|
||||
|
||||
if (hasConflicts) {
|
||||
// Return conflict-specific error message that frontend can detect
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
error: `Merge CONFLICT: Automatic merge of "${branchName}" into "${mergeTo}" failed. Please resolve conflicts manually.`,
|
||||
hasConflicts: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-throw non-conflict errors to be handled by outer catch
|
||||
throw mergeError;
|
||||
}
|
||||
|
||||
// If squash merge, need to commit
|
||||
if (options?.squash) {
|
||||
@@ -55,17 +95,46 @@ export function createMergeHandler() {
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up worktree and branch
|
||||
try {
|
||||
await execAsync(`git worktree remove "${worktreePath}" --force`, {
|
||||
cwd: projectPath,
|
||||
});
|
||||
await execAsync(`git branch -D ${branchName}`, { cwd: projectPath });
|
||||
} catch {
|
||||
// Cleanup errors are non-fatal
|
||||
// Optionally delete the worktree and branch after merging
|
||||
let worktreeDeleted = false;
|
||||
let branchDeleted = false;
|
||||
|
||||
if (options?.deleteWorktreeAndBranch) {
|
||||
// Remove the worktree
|
||||
try {
|
||||
await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath);
|
||||
worktreeDeleted = true;
|
||||
} catch {
|
||||
// Try with prune if remove fails
|
||||
try {
|
||||
await execGitCommand(['worktree', 'prune'], projectPath);
|
||||
worktreeDeleted = true;
|
||||
} catch {
|
||||
logger.warn(`Failed to remove worktree: ${worktreePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the branch (but not main/master)
|
||||
if (branchName !== 'main' && branchName !== 'master') {
|
||||
if (!isValidBranchName(branchName)) {
|
||||
logger.warn(`Invalid branch name detected, skipping deletion: ${branchName}`);
|
||||
} else {
|
||||
try {
|
||||
await execGitCommand(['branch', '-D', branchName], projectPath);
|
||||
branchDeleted = true;
|
||||
} catch {
|
||||
logger.warn(`Failed to delete branch: ${branchName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, mergedBranch: branchName });
|
||||
res.json({
|
||||
success: true,
|
||||
mergedBranch: branchName,
|
||||
targetBranch: mergeTo,
|
||||
deleted: options?.deleteWorktreeAndBranch ? { worktreeDeleted, branchDeleted } : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Merge worktree failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
|
||||
@@ -15,9 +15,10 @@ const execAsync = promisify(exec);
|
||||
export function createPushHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { worktreePath, force } = req.body as {
|
||||
const { worktreePath, force, remote } = req.body as {
|
||||
worktreePath: string;
|
||||
force?: boolean;
|
||||
remote?: string;
|
||||
};
|
||||
|
||||
if (!worktreePath) {
|
||||
@@ -34,15 +35,18 @@ export function createPushHandler() {
|
||||
});
|
||||
const branchName = branchOutput.trim();
|
||||
|
||||
// Use specified remote or default to 'origin'
|
||||
const targetRemote = remote || 'origin';
|
||||
|
||||
// Push the branch
|
||||
const forceFlag = force ? '--force' : '';
|
||||
try {
|
||||
await execAsync(`git push -u origin ${branchName} ${forceFlag}`, {
|
||||
await execAsync(`git push -u ${targetRemote} ${branchName} ${forceFlag}`, {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
} catch {
|
||||
// Try setting upstream
|
||||
await execAsync(`git push --set-upstream origin ${branchName} ${forceFlag}`, {
|
||||
await execAsync(`git push --set-upstream ${targetRemote} ${branchName} ${forceFlag}`, {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
}
|
||||
@@ -52,7 +56,7 @@ export function createPushHandler() {
|
||||
result: {
|
||||
branch: branchName,
|
||||
pushed: true,
|
||||
message: `Successfully pushed ${branchName} to origin`,
|
||||
message: `Successfully pushed ${branchName} to ${targetRemote}`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
/**
|
||||
* POST /start-dev endpoint - Start a dev server for a worktree
|
||||
*
|
||||
* Spins up a development server (npm run dev) in the worktree directory
|
||||
* on a unique port, allowing preview of the worktree's changes without
|
||||
* affecting the main dev server.
|
||||
* Spins up a development server in the worktree directory on a unique port,
|
||||
* allowing preview of the worktree's changes without affecting the main dev server.
|
||||
*
|
||||
* If a custom devCommand is configured in project settings, it will be used.
|
||||
* Otherwise, auto-detection based on package manager (npm/yarn/pnpm/bun run dev) is used.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import { getDevServerService } from '../../../services/dev-server-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
export function createStartDevHandler() {
|
||||
const logger = createLogger('start-dev');
|
||||
|
||||
export function createStartDevHandler(settingsService?: SettingsService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, worktreePath } = req.body as {
|
||||
@@ -34,8 +40,25 @@ export function createStartDevHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get custom dev command from project settings (if configured)
|
||||
let customCommand: string | undefined;
|
||||
if (settingsService) {
|
||||
const projectSettings = await settingsService.getProjectSettings(projectPath);
|
||||
const devCommand = projectSettings?.devCommand?.trim();
|
||||
if (devCommand) {
|
||||
customCommand = devCommand;
|
||||
logger.debug(`Using custom dev command from project settings: ${customCommand}`);
|
||||
} else {
|
||||
logger.debug('No custom dev command configured, using auto-detection');
|
||||
}
|
||||
}
|
||||
|
||||
const devServerService = getDevServerService();
|
||||
const result = await devServerService.startDevServer(projectPath, worktreePath);
|
||||
const result = await devServerService.startDevServer(
|
||||
projectPath,
|
||||
worktreePath,
|
||||
customCommand
|
||||
);
|
||||
|
||||
if (result.success && result.result) {
|
||||
res.json({
|
||||
|
||||
92
apps/server/src/routes/worktree/routes/start-tests.ts
Normal file
92
apps/server/src/routes/worktree/routes/start-tests.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* POST /start-tests endpoint - Start tests for a worktree
|
||||
*
|
||||
* Runs the test command configured in project settings.
|
||||
* If no testCommand is configured, returns an error.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import { getTestRunnerService } from '../../../services/test-runner-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createStartTestsHandler(settingsService?: SettingsService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const body = req.body;
|
||||
|
||||
// Validate request body
|
||||
if (!body || typeof body !== 'object') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Request body must be an object',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const worktreePath = typeof body.worktreePath === 'string' ? body.worktreePath : undefined;
|
||||
const projectPath = typeof body.projectPath === 'string' ? body.projectPath : undefined;
|
||||
const testFile = typeof body.testFile === 'string' ? body.testFile : undefined;
|
||||
|
||||
if (!worktreePath) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'worktreePath is required and must be a string',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get project settings to find the test command
|
||||
// Use projectPath if provided, otherwise use worktreePath
|
||||
const settingsPath = projectPath || worktreePath;
|
||||
|
||||
if (!settingsService) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Settings service not available',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const projectSettings = await settingsService.getProjectSettings(settingsPath);
|
||||
const testCommand = projectSettings?.testCommand;
|
||||
|
||||
if (!testCommand) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error:
|
||||
'No test command configured. Please configure a test command in Project Settings > Testing Configuration.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const testRunnerService = getTestRunnerService();
|
||||
const result = await testRunnerService.startTests(worktreePath, {
|
||||
command: testCommand,
|
||||
testFile,
|
||||
});
|
||||
|
||||
if (result.success && result.result) {
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
sessionId: result.result.sessionId,
|
||||
worktreePath: result.result.worktreePath,
|
||||
command: result.result.command,
|
||||
status: result.result.status,
|
||||
testFile: result.result.testFile,
|
||||
message: result.result.message,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: result.error || 'Failed to start tests',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error, 'Start tests failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -28,7 +28,10 @@ export function createStatusHandler() {
|
||||
}
|
||||
|
||||
// Git worktrees are stored in project directory
|
||||
const worktreePath = path.join(projectPath, '.worktrees', featureId);
|
||||
// Sanitize featureId the same way it's sanitized when creating worktrees
|
||||
// (see create.ts: branchName.replace(/[^a-zA-Z0-9_-]/g, '-'))
|
||||
const sanitizedFeatureId = featureId.replace(/[^a-zA-Z0-9_-]/g, '-');
|
||||
const worktreePath = path.join(projectPath, '.worktrees', sanitizedFeatureId);
|
||||
|
||||
try {
|
||||
await secureFs.access(worktreePath);
|
||||
|
||||
58
apps/server/src/routes/worktree/routes/stop-tests.ts
Normal file
58
apps/server/src/routes/worktree/routes/stop-tests.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* POST /stop-tests endpoint - Stop a running test session
|
||||
*
|
||||
* Stops the test runner process for a specific session,
|
||||
* cancelling any ongoing tests and freeing up resources.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getTestRunnerService } from '../../../services/test-runner-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createStopTestsHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const body = req.body;
|
||||
|
||||
// Validate request body
|
||||
if (!body || typeof body !== 'object') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Request body must be an object',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = typeof body.sessionId === 'string' ? body.sessionId : undefined;
|
||||
|
||||
if (!sessionId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'sessionId is required and must be a string',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const testRunnerService = getTestRunnerService();
|
||||
const result = await testRunnerService.stopTests(sessionId);
|
||||
|
||||
if (result.success && result.result) {
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
sessionId: result.result.sessionId,
|
||||
message: result.result.message,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: result.error || 'Failed to stop tests',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logError(error, 'Stop tests failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
160
apps/server/src/routes/worktree/routes/test-logs.ts
Normal file
160
apps/server/src/routes/worktree/routes/test-logs.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* GET /test-logs endpoint - Get buffered logs for a test runner session
|
||||
*
|
||||
* Returns the scrollback buffer containing historical log output for a test run.
|
||||
* Used by clients to populate the log panel on initial connection
|
||||
* before subscribing to real-time updates via WebSocket.
|
||||
*
|
||||
* Query parameters:
|
||||
* - worktreePath: Path to the worktree (optional if sessionId provided)
|
||||
* - sessionId: Specific test session ID (optional, uses active session if not provided)
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { getTestRunnerService } from '../../../services/test-runner-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
interface SessionInfo {
|
||||
sessionId: string;
|
||||
worktreePath?: string;
|
||||
command?: string;
|
||||
testFile?: string;
|
||||
exitCode?: number | null;
|
||||
}
|
||||
|
||||
interface OutputResult {
|
||||
sessionId: string;
|
||||
status: string;
|
||||
output: string;
|
||||
startedAt: string;
|
||||
finishedAt?: string | null;
|
||||
}
|
||||
|
||||
function buildLogsResponse(session: SessionInfo, output: OutputResult) {
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
sessionId: session.sessionId,
|
||||
worktreePath: session.worktreePath,
|
||||
command: session.command,
|
||||
status: output.status,
|
||||
testFile: session.testFile,
|
||||
logs: output.output,
|
||||
startedAt: output.startedAt,
|
||||
finishedAt: output.finishedAt,
|
||||
exitCode: session.exitCode ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createGetTestLogsHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { worktreePath, sessionId } = req.query as {
|
||||
worktreePath?: string;
|
||||
sessionId?: string;
|
||||
};
|
||||
|
||||
const testRunnerService = getTestRunnerService();
|
||||
|
||||
// If sessionId is provided, get logs for that specific session
|
||||
if (sessionId) {
|
||||
const result = testRunnerService.getSessionOutput(sessionId);
|
||||
|
||||
if (result.success && result.result) {
|
||||
const session = testRunnerService.getSession(sessionId);
|
||||
res.json(
|
||||
buildLogsResponse(
|
||||
{
|
||||
sessionId: result.result.sessionId,
|
||||
worktreePath: session?.worktreePath,
|
||||
command: session?.command,
|
||||
testFile: session?.testFile,
|
||||
exitCode: session?.exitCode,
|
||||
},
|
||||
result.result
|
||||
)
|
||||
);
|
||||
} else {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: result.error || 'Failed to get test logs',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If worktreePath is provided, get logs for the active session
|
||||
if (worktreePath) {
|
||||
const activeSession = testRunnerService.getActiveSession(worktreePath);
|
||||
|
||||
if (activeSession) {
|
||||
const result = testRunnerService.getSessionOutput(activeSession.id);
|
||||
|
||||
if (result.success && result.result) {
|
||||
res.json(
|
||||
buildLogsResponse(
|
||||
{
|
||||
sessionId: activeSession.id,
|
||||
worktreePath: activeSession.worktreePath,
|
||||
command: activeSession.command,
|
||||
testFile: activeSession.testFile,
|
||||
exitCode: activeSession.exitCode,
|
||||
},
|
||||
result.result
|
||||
)
|
||||
);
|
||||
} else {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: result.error || 'Failed to get test logs',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// No active session - check for most recent session for this worktree
|
||||
const sessions = testRunnerService.listSessions(worktreePath);
|
||||
if (sessions.result.sessions.length > 0) {
|
||||
// Get the most recent session (list is not sorted, so find it)
|
||||
const mostRecent = sessions.result.sessions.reduce((latest, current) => {
|
||||
const latestTime = new Date(latest.startedAt).getTime();
|
||||
const currentTime = new Date(current.startedAt).getTime();
|
||||
return currentTime > latestTime ? current : latest;
|
||||
});
|
||||
|
||||
const result = testRunnerService.getSessionOutput(mostRecent.sessionId);
|
||||
if (result.success && result.result) {
|
||||
res.json(
|
||||
buildLogsResponse(
|
||||
{
|
||||
sessionId: mostRecent.sessionId,
|
||||
worktreePath: mostRecent.worktreePath,
|
||||
command: mostRecent.command,
|
||||
testFile: mostRecent.testFile,
|
||||
exitCode: mostRecent.exitCode,
|
||||
},
|
||||
result.result
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'No test sessions found for this worktree',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Neither sessionId nor worktreePath provided
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Either worktreePath or sessionId query parameter is required',
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get test logs failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
getSkillsConfiguration,
|
||||
getSubagentsConfiguration,
|
||||
getCustomSubagents,
|
||||
getProviderByModelId,
|
||||
} from '../lib/settings-helpers.js';
|
||||
|
||||
interface Message {
|
||||
@@ -274,6 +275,30 @@ export class AgentService {
|
||||
? await getCustomSubagents(this.settingsService, effectiveWorkDir)
|
||||
: undefined;
|
||||
|
||||
// Get credentials for API calls
|
||||
const credentials = await this.settingsService?.getCredentials();
|
||||
|
||||
// Try to find a provider for the model (if it's a provider model like "GLM-4.7")
|
||||
// This allows users to select provider models in the Agent Runner UI
|
||||
let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined;
|
||||
let providerResolvedModel: string | undefined;
|
||||
const requestedModel = model || session.model;
|
||||
if (requestedModel && this.settingsService) {
|
||||
const providerResult = await getProviderByModelId(
|
||||
requestedModel,
|
||||
this.settingsService,
|
||||
'[AgentService]'
|
||||
);
|
||||
if (providerResult.provider) {
|
||||
claudeCompatibleProvider = providerResult.provider;
|
||||
providerResolvedModel = providerResult.resolvedModel;
|
||||
this.logger.info(
|
||||
`[AgentService] Using provider "${providerResult.provider.name}" for model "${requestedModel}"` +
|
||||
(providerResolvedModel ? ` -> resolved to "${providerResolvedModel}"` : '')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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({
|
||||
@@ -299,10 +324,16 @@ export class AgentService {
|
||||
// Use thinking level and reasoning effort from request, or fall back to session's stored values
|
||||
const effectiveThinkingLevel = thinkingLevel ?? session.thinkingLevel;
|
||||
const effectiveReasoningEffort = reasoningEffort ?? session.reasoningEffort;
|
||||
|
||||
// When using a provider model, use the resolved Claude model (from mapsToClaudeModel)
|
||||
// e.g., "GLM-4.5-Air" -> "claude-haiku-4-5"
|
||||
const modelForSdk = providerResolvedModel || model;
|
||||
const sessionModelForSdk = providerResolvedModel ? undefined : session.model;
|
||||
|
||||
const sdkOptions = createChatOptions({
|
||||
cwd: effectiveWorkDir,
|
||||
model: model,
|
||||
sessionModel: session.model,
|
||||
model: modelForSdk,
|
||||
sessionModel: sessionModelForSdk,
|
||||
systemPrompt: combinedSystemPrompt,
|
||||
abortController: session.abortController!,
|
||||
autoLoadClaudeMd,
|
||||
@@ -378,6 +409,8 @@ export class AgentService {
|
||||
agents: customSubagents, // Pass custom subagents for task delegation
|
||||
thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models
|
||||
reasoningEffort: effectiveReasoningEffort, // Pass reasoning effort for Codex models
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration (GLM, MiniMax, etc.)
|
||||
};
|
||||
|
||||
// Build prompt content with images
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -468,10 +468,41 @@ export class ClaudeUsageService {
|
||||
|
||||
/**
|
||||
* Strip ANSI escape codes from text
|
||||
* Handles CSI, OSC, and other common ANSI sequences
|
||||
*/
|
||||
private stripAnsiCodes(text: string): string {
|
||||
// First strip ANSI sequences (colors, etc) and handle CR
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return text.replace(/\x1B\[[0-9;]*[A-Za-z]/g, '');
|
||||
let clean = text
|
||||
// CSI sequences: ESC [ ... (letter or @)
|
||||
.replace(/\x1B\[[0-9;?]*[A-Za-z@]/g, '')
|
||||
// OSC sequences: ESC ] ... terminated by BEL, ST, or another ESC
|
||||
.replace(/\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)?/g, '')
|
||||
// Other ESC sequences: ESC (letter)
|
||||
.replace(/\x1B[A-Za-z]/g, '')
|
||||
// Carriage returns: replace with newline to avoid concatenation
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\r/g, '\n');
|
||||
|
||||
// Handle backspaces (\x08) by applying them
|
||||
// If we encounter a backspace, remove the character before it
|
||||
while (clean.includes('\x08')) {
|
||||
clean = clean.replace(/[^\x08]\x08/, '');
|
||||
clean = clean.replace(/^\x08+/, '');
|
||||
}
|
||||
|
||||
// Explicitly strip known "Synchronized Output" and "Window Title" garbage
|
||||
// even if ESC is missing (seen in some environments)
|
||||
clean = clean
|
||||
.replace(/\[\?2026[hl]/g, '') // CSI ? 2026 h/l
|
||||
.replace(/\]0;[^\x07]*\x07/g, '') // OSC 0; Title BEL
|
||||
.replace(/\]0;.*?(\[\?|$)/g, ''); // OSC 0; Title ... (unterminated or hit next sequence)
|
||||
|
||||
// Strip remaining non-printable control characters (except newline \n)
|
||||
// ASCII 0-8, 11-31, 127
|
||||
clean = clean.replace(/[\x00-\x08\x0B-\x1F\x7F]/g, '');
|
||||
|
||||
return clean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -550,7 +581,7 @@ export class ClaudeUsageService {
|
||||
sectionLabel: string,
|
||||
type: string
|
||||
): { percentage: number; resetTime: string; resetText: string } {
|
||||
let percentage = 0;
|
||||
let percentage: number | null = null;
|
||||
let resetTime = this.getDefaultResetTime(type);
|
||||
let resetText = '';
|
||||
|
||||
@@ -564,7 +595,7 @@ export class ClaudeUsageService {
|
||||
}
|
||||
|
||||
if (sectionIndex === -1) {
|
||||
return { percentage, resetTime, resetText };
|
||||
return { percentage: 0, resetTime, resetText };
|
||||
}
|
||||
|
||||
// Look at the lines following the section header (within a window of 5 lines)
|
||||
@@ -572,7 +603,8 @@ export class ClaudeUsageService {
|
||||
|
||||
for (const line of searchWindow) {
|
||||
// Extract percentage - only take the first match (avoid picking up next section's data)
|
||||
if (percentage === 0) {
|
||||
// Use null to track "not found" since 0% is a valid percentage (100% left = 0% used)
|
||||
if (percentage === null) {
|
||||
const percentMatch = line.match(/(\d{1,3})\s*%\s*(left|used|remaining)/i);
|
||||
if (percentMatch) {
|
||||
const value = parseInt(percentMatch[1], 10);
|
||||
@@ -584,18 +616,31 @@ export class ClaudeUsageService {
|
||||
|
||||
// Extract reset time - only take the first match
|
||||
if (!resetText && line.toLowerCase().includes('reset')) {
|
||||
resetText = line;
|
||||
// Only extract the part starting from "Resets" (or "Reset") to avoid garbage prefixes
|
||||
const match = line.match(/(Resets?.*)$/i);
|
||||
// If regex fails despite 'includes', likely a complex string issues - verify match before using line
|
||||
// Only fallback to line if it's reasonably short/clean, otherwise skip it to avoid showing garbage
|
||||
if (match) {
|
||||
resetText = match[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the reset time if we found one
|
||||
if (resetText) {
|
||||
// Clean up resetText: remove percentage info if it was matched on the same line
|
||||
// e.g. "46%used Resets5:59pm" -> " Resets5:59pm"
|
||||
resetText = resetText.replace(/(\d{1,3})\s*%\s*(left|used|remaining)/i, '').trim();
|
||||
|
||||
// Ensure space after "Resets" if missing (e.g. "Resets5:59pm" -> "Resets 5:59pm")
|
||||
resetText = resetText.replace(/(resets?)(\d)/i, '$1 $2');
|
||||
|
||||
resetTime = this.parseResetTime(resetText, type);
|
||||
// Strip timezone like "(Asia/Dubai)" from the display text
|
||||
resetText = resetText.replace(/\s*\([A-Za-z_\/]+\)\s*$/, '').trim();
|
||||
}
|
||||
|
||||
return { percentage, resetTime, resetText };
|
||||
return { percentage: percentage ?? 0, resetTime, resetText };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -624,7 +669,7 @@ export class ClaudeUsageService {
|
||||
}
|
||||
|
||||
// Try to parse simple time-only format: "Resets 11am" or "Resets 3pm"
|
||||
const simpleTimeMatch = text.match(/resets\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i);
|
||||
const simpleTimeMatch = text.match(/resets\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i);
|
||||
if (simpleTimeMatch) {
|
||||
let hours = parseInt(simpleTimeMatch[1], 10);
|
||||
const minutes = simpleTimeMatch[2] ? parseInt(simpleTimeMatch[2], 10) : 0;
|
||||
@@ -649,8 +694,11 @@ export class ClaudeUsageService {
|
||||
}
|
||||
|
||||
// Try to parse date format: "Resets Dec 22 at 8pm" or "Resets Jan 15, 3:30pm"
|
||||
// The regex explicitly matches only valid 3-letter month abbreviations to avoid
|
||||
// matching words like "Resets" when there's no space separator.
|
||||
// Optional "resets\s*" prefix handles cases with or without space after "Resets"
|
||||
const dateMatch = text.match(
|
||||
/([A-Za-z]{3,})\s+(\d{1,2})(?:\s+at\s+|\s*,?\s*)(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i
|
||||
/(?:resets\s*)?(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+(\d{1,2})(?:\s+at\s+|\s*,?\s*)(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i
|
||||
);
|
||||
if (dateMatch) {
|
||||
const monthName = dateMatch[1];
|
||||
|
||||
80
apps/server/src/services/copilot-connection-service.ts
Normal file
80
apps/server/src/services/copilot-connection-service.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Copilot Connection Service
|
||||
*
|
||||
* Handles the connection and disconnection of Copilot CLI to the app.
|
||||
* Uses a marker file to track the disconnected state.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { COPILOT_DISCONNECTED_MARKER_FILE } from '../routes/setup/common.js';
|
||||
|
||||
const logger = createLogger('CopilotConnectionService');
|
||||
|
||||
/**
|
||||
* Get the path to the disconnected marker file
|
||||
*/
|
||||
function getMarkerPath(projectRoot?: string): string {
|
||||
const root = projectRoot || process.cwd();
|
||||
const automakerDir = path.join(root, '.automaker');
|
||||
return path.join(automakerDir, COPILOT_DISCONNECTED_MARKER_FILE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect Copilot CLI to the app by removing the disconnected marker
|
||||
*
|
||||
* @param projectRoot - Optional project root directory (defaults to cwd)
|
||||
* @returns Promise that resolves when the connection is established
|
||||
*/
|
||||
export async function connectCopilot(projectRoot?: string): Promise<void> {
|
||||
const markerPath = getMarkerPath(projectRoot);
|
||||
|
||||
try {
|
||||
await fs.unlink(markerPath);
|
||||
logger.info('Copilot CLI connected to app (marker removed)');
|
||||
} catch (error) {
|
||||
// File doesn't exist - that's fine, Copilot is already connected
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
logger.error('Failed to remove disconnected marker:', error);
|
||||
throw error;
|
||||
}
|
||||
logger.debug('Copilot already connected (no marker file found)');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect Copilot CLI from the app by creating the disconnected marker
|
||||
*
|
||||
* @param projectRoot - Optional project root directory (defaults to cwd)
|
||||
* @returns Promise that resolves when the disconnection is complete
|
||||
*/
|
||||
export async function disconnectCopilot(projectRoot?: string): Promise<void> {
|
||||
const root = projectRoot || process.cwd();
|
||||
const automakerDir = path.join(root, '.automaker');
|
||||
const markerPath = path.join(automakerDir, COPILOT_DISCONNECTED_MARKER_FILE);
|
||||
|
||||
// Ensure .automaker directory exists
|
||||
await fs.mkdir(automakerDir, { recursive: true });
|
||||
|
||||
// Create the disconnection marker
|
||||
await fs.writeFile(markerPath, 'Copilot CLI disconnected from app');
|
||||
logger.info('Copilot CLI disconnected from app (marker created)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Copilot CLI is connected (not disconnected)
|
||||
*
|
||||
* @param projectRoot - Optional project root directory (defaults to cwd)
|
||||
* @returns Promise that resolves to true if connected, false if disconnected
|
||||
*/
|
||||
export async function isCopilotConnected(projectRoot?: string): Promise<boolean> {
|
||||
const markerPath = getMarkerPath(projectRoot);
|
||||
|
||||
try {
|
||||
await fs.access(markerPath);
|
||||
return false; // Marker exists = disconnected
|
||||
} catch {
|
||||
return true; // Marker doesn't exist = connected
|
||||
}
|
||||
}
|
||||
@@ -273,12 +273,56 @@ class DevServerService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a custom command string into cmd and args
|
||||
* Handles quoted strings with spaces (e.g., "my command" arg1 arg2)
|
||||
*/
|
||||
private parseCustomCommand(command: string): { cmd: string; args: string[] } {
|
||||
const tokens: string[] = [];
|
||||
let current = '';
|
||||
let inQuote = false;
|
||||
let quoteChar = '';
|
||||
|
||||
for (let i = 0; i < command.length; i++) {
|
||||
const char = command[i];
|
||||
|
||||
if (inQuote) {
|
||||
if (char === quoteChar) {
|
||||
inQuote = false;
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
} else if (char === '"' || char === "'") {
|
||||
inQuote = true;
|
||||
quoteChar = char;
|
||||
} else if (char === ' ') {
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
current = '';
|
||||
}
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
}
|
||||
|
||||
const [cmd, ...args] = tokens;
|
||||
return { cmd: cmd || '', args };
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a dev server for a worktree
|
||||
* @param projectPath - The project root path
|
||||
* @param worktreePath - The worktree directory path
|
||||
* @param customCommand - Optional custom command to run instead of auto-detected dev command
|
||||
*/
|
||||
async startDevServer(
|
||||
projectPath: string,
|
||||
worktreePath: string
|
||||
worktreePath: string,
|
||||
customCommand?: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
result?: {
|
||||
@@ -311,22 +355,41 @@ class DevServerService {
|
||||
};
|
||||
}
|
||||
|
||||
// Check for package.json
|
||||
const packageJsonPath = path.join(worktreePath, 'package.json');
|
||||
if (!(await this.fileExists(packageJsonPath))) {
|
||||
return {
|
||||
success: false,
|
||||
error: `No package.json found in: ${worktreePath}`,
|
||||
};
|
||||
}
|
||||
// Determine the dev command to use
|
||||
let devCommand: { cmd: string; args: string[] };
|
||||
|
||||
// Get dev command
|
||||
const devCommand = await this.getDevCommand(worktreePath);
|
||||
if (!devCommand) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Could not determine dev command for: ${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
|
||||
|
||||
@@ -21,6 +21,7 @@ import { createLogger } from '@automaker/utils';
|
||||
import type { EventEmitter } from '../lib/events.js';
|
||||
import type { SettingsService } from './settings-service.js';
|
||||
import type { EventHistoryService } from './event-history-service.js';
|
||||
import type { FeatureLoader } from './feature-loader.js';
|
||||
import type {
|
||||
EventHook,
|
||||
EventHookTrigger,
|
||||
@@ -57,6 +58,7 @@ interface HookContext {
|
||||
interface AutoModeEventPayload {
|
||||
type?: string;
|
||||
featureId?: string;
|
||||
featureName?: string;
|
||||
passes?: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
@@ -83,19 +85,22 @@ export class EventHookService {
|
||||
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;
|
||||
|
||||
/**
|
||||
* Initialize the service with event emitter, settings service, and event history service
|
||||
* Initialize the service with event emitter, settings service, event history service, and feature loader
|
||||
*/
|
||||
initialize(
|
||||
emitter: EventEmitter,
|
||||
settingsService: SettingsService,
|
||||
eventHistoryService?: EventHistoryService
|
||||
eventHistoryService?: EventHistoryService,
|
||||
featureLoader?: FeatureLoader
|
||||
): void {
|
||||
this.emitter = emitter;
|
||||
this.settingsService = settingsService;
|
||||
this.eventHistoryService = eventHistoryService || null;
|
||||
this.featureLoader = featureLoader || null;
|
||||
|
||||
// Subscribe to events
|
||||
this.unsubscribe = emitter.subscribe((type, payload) => {
|
||||
@@ -120,6 +125,7 @@ export class EventHookService {
|
||||
this.emitter = null;
|
||||
this.settingsService = null;
|
||||
this.eventHistoryService = null;
|
||||
this.featureLoader = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,9 +155,24 @@ export class EventHookService {
|
||||
|
||||
if (!trigger) return;
|
||||
|
||||
// Load feature name if we have featureId but no featureName
|
||||
let featureName: string | undefined = undefined;
|
||||
if (payload.featureId && 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);
|
||||
}
|
||||
}
|
||||
|
||||
// Build context for variable substitution
|
||||
// Use loaded featureName (from feature.title) or fall back to payload.featureName
|
||||
const context: HookContext = {
|
||||
featureId: payload.featureId,
|
||||
featureName: featureName || payload.featureName,
|
||||
projectPath: payload.projectPath,
|
||||
projectName: payload.projectPath ? this.extractProjectName(payload.projectPath) : undefined,
|
||||
error: payload.error || payload.message,
|
||||
@@ -313,6 +334,7 @@ export class EventHookService {
|
||||
eventType: context.eventType,
|
||||
timestamp: context.timestamp,
|
||||
featureId: context.featureId,
|
||||
featureName: context.featureName,
|
||||
projectPath: context.projectPath,
|
||||
projectName: context.projectName,
|
||||
error: context.error,
|
||||
|
||||
540
apps/server/src/services/feature-export-service.ts
Normal file
540
apps/server/src/services/feature-export-service.ts
Normal file
@@ -0,0 +1,540 @@
|
||||
/**
|
||||
* Feature Export Service - Handles exporting and importing features in JSON/YAML formats
|
||||
*
|
||||
* Provides functionality to:
|
||||
* - Export single features to JSON or YAML format
|
||||
* - Export multiple features (bulk export)
|
||||
* - Import features from JSON or YAML data
|
||||
* - Validate import data for compatibility
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { stringify as yamlStringify, parse as yamlParse } from 'yaml';
|
||||
import type { Feature, FeatureExport, FeatureImport, FeatureImportResult } from '@automaker/types';
|
||||
import { FeatureLoader } from './feature-loader.js';
|
||||
|
||||
const logger = createLogger('FeatureExportService');
|
||||
|
||||
/** Current export format version */
|
||||
export const FEATURE_EXPORT_VERSION = '1.0.0';
|
||||
|
||||
/** Supported export formats */
|
||||
export type ExportFormat = 'json' | 'yaml';
|
||||
|
||||
/** Options for exporting features */
|
||||
export interface ExportOptions {
|
||||
/** Format to export in (default: 'json') */
|
||||
format?: ExportFormat;
|
||||
/** Whether to include description history (default: true) */
|
||||
includeHistory?: boolean;
|
||||
/** Whether to include plan spec (default: true) */
|
||||
includePlanSpec?: boolean;
|
||||
/** Optional metadata to include */
|
||||
metadata?: {
|
||||
projectName?: string;
|
||||
projectPath?: string;
|
||||
branch?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/** Who/what is performing the export */
|
||||
exportedBy?: string;
|
||||
/** Pretty print output (default: true) */
|
||||
prettyPrint?: boolean;
|
||||
}
|
||||
|
||||
/** Options for bulk export */
|
||||
export interface BulkExportOptions extends ExportOptions {
|
||||
/** Filter by category */
|
||||
category?: string;
|
||||
/** Filter by status */
|
||||
status?: string;
|
||||
/** Feature IDs to include (if not specified, exports all) */
|
||||
featureIds?: string[];
|
||||
}
|
||||
|
||||
/** Result of a bulk export */
|
||||
export interface BulkExportResult {
|
||||
/** Export format version */
|
||||
version: string;
|
||||
/** ISO date string when the export was created */
|
||||
exportedAt: string;
|
||||
/** Number of features exported */
|
||||
count: number;
|
||||
/** The exported features */
|
||||
features: FeatureExport[];
|
||||
/** Export metadata */
|
||||
metadata?: {
|
||||
projectName?: string;
|
||||
projectPath?: string;
|
||||
branch?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* FeatureExportService - Manages feature export and import operations
|
||||
*/
|
||||
export class FeatureExportService {
|
||||
private featureLoader: FeatureLoader;
|
||||
|
||||
constructor(featureLoader?: FeatureLoader) {
|
||||
this.featureLoader = featureLoader || new FeatureLoader();
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a single feature to the specified format
|
||||
*
|
||||
* @param projectPath - Path to the project
|
||||
* @param featureId - ID of the feature to export
|
||||
* @param options - Export options
|
||||
* @returns Promise resolving to the exported feature string
|
||||
*/
|
||||
async exportFeature(
|
||||
projectPath: string,
|
||||
featureId: string,
|
||||
options: ExportOptions = {}
|
||||
): Promise<string> {
|
||||
const feature = await this.featureLoader.get(projectPath, featureId);
|
||||
if (!feature) {
|
||||
throw new Error(`Feature ${featureId} not found`);
|
||||
}
|
||||
|
||||
return this.exportFeatureData(feature, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export feature data to the specified format (without fetching from disk)
|
||||
*
|
||||
* @param feature - The feature to export
|
||||
* @param options - Export options
|
||||
* @returns The exported feature string
|
||||
*/
|
||||
exportFeatureData(feature: Feature, options: ExportOptions = {}): string {
|
||||
const {
|
||||
format = 'json',
|
||||
includeHistory = true,
|
||||
includePlanSpec = true,
|
||||
metadata,
|
||||
exportedBy,
|
||||
prettyPrint = true,
|
||||
} = options;
|
||||
|
||||
// Prepare feature data, optionally excluding some fields
|
||||
const featureData = this.prepareFeatureForExport(feature, {
|
||||
includeHistory,
|
||||
includePlanSpec,
|
||||
});
|
||||
|
||||
const exportData: FeatureExport = {
|
||||
version: FEATURE_EXPORT_VERSION,
|
||||
feature: featureData,
|
||||
exportedAt: new Date().toISOString(),
|
||||
...(exportedBy ? { exportedBy } : {}),
|
||||
...(metadata ? { metadata } : {}),
|
||||
};
|
||||
|
||||
return this.serialize(exportData, format, prettyPrint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export multiple features to the specified format
|
||||
*
|
||||
* @param projectPath - Path to the project
|
||||
* @param options - Bulk export options
|
||||
* @returns Promise resolving to the exported features string
|
||||
*/
|
||||
async exportFeatures(projectPath: string, options: BulkExportOptions = {}): Promise<string> {
|
||||
const {
|
||||
format = 'json',
|
||||
category,
|
||||
status,
|
||||
featureIds,
|
||||
includeHistory = true,
|
||||
includePlanSpec = true,
|
||||
metadata,
|
||||
prettyPrint = true,
|
||||
} = options;
|
||||
|
||||
// Get all features
|
||||
let features = await this.featureLoader.getAll(projectPath);
|
||||
|
||||
// Apply filters
|
||||
if (featureIds && featureIds.length > 0) {
|
||||
const idSet = new Set(featureIds);
|
||||
features = features.filter((f) => idSet.has(f.id));
|
||||
}
|
||||
if (category) {
|
||||
features = features.filter((f) => f.category === category);
|
||||
}
|
||||
if (status) {
|
||||
features = features.filter((f) => f.status === status);
|
||||
}
|
||||
|
||||
// Generate timestamp once for consistent export time across all features
|
||||
const exportedAt = new Date().toISOString();
|
||||
|
||||
// Prepare feature exports
|
||||
const featureExports: FeatureExport[] = features.map((feature) => ({
|
||||
version: FEATURE_EXPORT_VERSION,
|
||||
feature: this.prepareFeatureForExport(feature, { includeHistory, includePlanSpec }),
|
||||
exportedAt,
|
||||
}));
|
||||
|
||||
const bulkExport: BulkExportResult = {
|
||||
version: FEATURE_EXPORT_VERSION,
|
||||
exportedAt,
|
||||
count: featureExports.length,
|
||||
features: featureExports,
|
||||
...(metadata ? { metadata } : {}),
|
||||
};
|
||||
|
||||
logger.info(`Exported ${featureExports.length} features from ${projectPath}`);
|
||||
|
||||
return this.serialize(bulkExport, format, prettyPrint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a feature from JSON or YAML data
|
||||
*
|
||||
* @param projectPath - Path to the project
|
||||
* @param importData - Import configuration
|
||||
* @returns Promise resolving to the import result
|
||||
*/
|
||||
async importFeature(
|
||||
projectPath: string,
|
||||
importData: FeatureImport
|
||||
): Promise<FeatureImportResult> {
|
||||
const warnings: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
try {
|
||||
// Extract feature from data (handle both raw Feature and wrapped FeatureExport)
|
||||
const feature = this.extractFeatureFromImport(importData.data);
|
||||
if (!feature) {
|
||||
return {
|
||||
success: false,
|
||||
importedAt: new Date().toISOString(),
|
||||
errors: ['Invalid import data: could not extract feature'],
|
||||
};
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
const validationErrors = this.validateFeature(feature);
|
||||
if (validationErrors.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
importedAt: new Date().toISOString(),
|
||||
errors: validationErrors,
|
||||
};
|
||||
}
|
||||
|
||||
// Determine the feature ID to use
|
||||
const featureId = importData.newId || feature.id || this.featureLoader.generateFeatureId();
|
||||
|
||||
// Check for existing feature
|
||||
const existingFeature = await this.featureLoader.get(projectPath, featureId);
|
||||
if (existingFeature && !importData.overwrite) {
|
||||
return {
|
||||
success: false,
|
||||
importedAt: new Date().toISOString(),
|
||||
errors: [`Feature with ID ${featureId} already exists. Set overwrite: true to replace.`],
|
||||
};
|
||||
}
|
||||
|
||||
// Prepare feature for import
|
||||
const featureToImport: Feature = {
|
||||
...feature,
|
||||
id: featureId,
|
||||
// Optionally override category
|
||||
...(importData.targetCategory ? { category: importData.targetCategory } : {}),
|
||||
// Clear branch info if not preserving
|
||||
...(importData.preserveBranchInfo ? {} : { branchName: undefined }),
|
||||
};
|
||||
|
||||
// Clear runtime-specific fields that shouldn't be imported
|
||||
delete featureToImport.titleGenerating;
|
||||
delete featureToImport.error;
|
||||
|
||||
// Handle image paths - they won't be valid after import
|
||||
if (featureToImport.imagePaths && featureToImport.imagePaths.length > 0) {
|
||||
warnings.push(
|
||||
`Feature had ${featureToImport.imagePaths.length} image path(s) that were cleared during import. Images must be re-attached.`
|
||||
);
|
||||
featureToImport.imagePaths = [];
|
||||
}
|
||||
|
||||
// Handle text file paths - they won't be valid after import
|
||||
if (featureToImport.textFilePaths && featureToImport.textFilePaths.length > 0) {
|
||||
warnings.push(
|
||||
`Feature had ${featureToImport.textFilePaths.length} text file path(s) that were cleared during import. Files must be re-attached.`
|
||||
);
|
||||
featureToImport.textFilePaths = [];
|
||||
}
|
||||
|
||||
// Create or update the feature
|
||||
if (existingFeature) {
|
||||
await this.featureLoader.update(projectPath, featureId, featureToImport);
|
||||
logger.info(`Updated feature ${featureId} via import`);
|
||||
} else {
|
||||
await this.featureLoader.create(projectPath, featureToImport);
|
||||
logger.info(`Created feature ${featureId} via import`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
featureId,
|
||||
importedAt: new Date().toISOString(),
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
wasOverwritten: !!existingFeature,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to import feature:', error);
|
||||
return {
|
||||
success: false,
|
||||
importedAt: new Date().toISOString(),
|
||||
errors: [`Import failed: ${error instanceof Error ? error.message : String(error)}`],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import multiple features from JSON or YAML data
|
||||
*
|
||||
* @param projectPath - Path to the project
|
||||
* @param data - Raw JSON or YAML string, or parsed data
|
||||
* @param options - Import options applied to all features
|
||||
* @returns Promise resolving to array of import results
|
||||
*/
|
||||
async importFeatures(
|
||||
projectPath: string,
|
||||
data: string | BulkExportResult,
|
||||
options: Omit<FeatureImport, 'data'> = {}
|
||||
): Promise<FeatureImportResult[]> {
|
||||
let bulkData: BulkExportResult;
|
||||
|
||||
// Parse if string
|
||||
if (typeof data === 'string') {
|
||||
const parsed = this.parseImportData(data);
|
||||
if (!parsed || !this.isBulkExport(parsed)) {
|
||||
return [
|
||||
{
|
||||
success: false,
|
||||
importedAt: new Date().toISOString(),
|
||||
errors: ['Invalid bulk import data: expected BulkExportResult format'],
|
||||
},
|
||||
];
|
||||
}
|
||||
bulkData = parsed as BulkExportResult;
|
||||
} else {
|
||||
bulkData = data;
|
||||
}
|
||||
|
||||
// Import each feature
|
||||
const results: FeatureImportResult[] = [];
|
||||
for (const featureExport of bulkData.features) {
|
||||
const result = await this.importFeature(projectPath, {
|
||||
data: featureExport,
|
||||
...options,
|
||||
});
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
const successCount = results.filter((r) => r.success).length;
|
||||
logger.info(`Bulk import complete: ${successCount}/${results.length} features imported`);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse import data from JSON or YAML string
|
||||
*
|
||||
* @param data - Raw JSON or YAML string
|
||||
* @returns Parsed data or null if parsing fails
|
||||
*/
|
||||
parseImportData(data: string): Feature | FeatureExport | BulkExportResult | null {
|
||||
const trimmed = data.trim();
|
||||
|
||||
// Try JSON first
|
||||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
// Fall through to YAML
|
||||
}
|
||||
}
|
||||
|
||||
// Try YAML
|
||||
try {
|
||||
return yamlParse(trimmed);
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse import data:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the format of import data
|
||||
*
|
||||
* @param data - Raw string data
|
||||
* @returns Detected format or null if unknown
|
||||
*/
|
||||
detectFormat(data: string): ExportFormat | null {
|
||||
const trimmed = data.trim();
|
||||
|
||||
// JSON detection
|
||||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||||
try {
|
||||
JSON.parse(trimmed);
|
||||
return 'json';
|
||||
} catch {
|
||||
// Not valid JSON
|
||||
}
|
||||
}
|
||||
|
||||
// YAML detection (if it parses and wasn't JSON)
|
||||
try {
|
||||
yamlParse(trimmed);
|
||||
return 'yaml';
|
||||
} catch {
|
||||
// Not valid YAML either
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare a feature for export by optionally removing fields
|
||||
*/
|
||||
private prepareFeatureForExport(
|
||||
feature: Feature,
|
||||
options: { includeHistory?: boolean; includePlanSpec?: boolean }
|
||||
): Feature {
|
||||
const { includeHistory = true, includePlanSpec = true } = options;
|
||||
|
||||
// Clone to avoid modifying original
|
||||
const exported: Feature = { ...feature };
|
||||
|
||||
// Remove transient fields that shouldn't be exported
|
||||
delete exported.titleGenerating;
|
||||
delete exported.error;
|
||||
|
||||
// Optionally exclude history
|
||||
if (!includeHistory) {
|
||||
delete exported.descriptionHistory;
|
||||
}
|
||||
|
||||
// Optionally exclude plan spec
|
||||
if (!includePlanSpec) {
|
||||
delete exported.planSpec;
|
||||
}
|
||||
|
||||
return exported;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a Feature from import data (handles both raw and wrapped formats)
|
||||
*/
|
||||
private extractFeatureFromImport(data: Feature | FeatureExport): Feature | null {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if it's a FeatureExport wrapper
|
||||
if ('version' in data && 'feature' in data && 'exportedAt' in data) {
|
||||
const exportData = data as FeatureExport;
|
||||
return exportData.feature;
|
||||
}
|
||||
|
||||
// Assume it's a raw Feature
|
||||
return data as Feature;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if parsed data is a bulk export
|
||||
*/
|
||||
isBulkExport(data: unknown): data is BulkExportResult {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const obj = data as Record<string, unknown>;
|
||||
return 'version' in obj && 'features' in obj && Array.isArray(obj.features);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if parsed data is a single FeatureExport
|
||||
*/
|
||||
isFeatureExport(data: unknown): data is FeatureExport {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const obj = data as Record<string, unknown>;
|
||||
return (
|
||||
'version' in obj &&
|
||||
'feature' in obj &&
|
||||
'exportedAt' in obj &&
|
||||
typeof obj.feature === 'object' &&
|
||||
obj.feature !== null &&
|
||||
'id' in (obj.feature as Record<string, unknown>)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if parsed data is a raw Feature
|
||||
*/
|
||||
isRawFeature(data: unknown): data is Feature {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const obj = data as Record<string, unknown>;
|
||||
// A raw feature has 'id' but not the 'version' + 'feature' wrapper of FeatureExport
|
||||
return 'id' in obj && !('feature' in obj && 'version' in obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a feature has required fields
|
||||
*/
|
||||
private validateFeature(feature: Feature): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!feature.description && !feature.title) {
|
||||
errors.push('Feature must have at least a title or description');
|
||||
}
|
||||
|
||||
if (!feature.category) {
|
||||
errors.push('Feature must have a category');
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize export data to string (handles both single feature and bulk exports)
|
||||
*/
|
||||
private serialize<T extends FeatureExport | BulkExportResult>(
|
||||
data: T,
|
||||
format: ExportFormat,
|
||||
prettyPrint: boolean
|
||||
): string {
|
||||
if (format === 'yaml') {
|
||||
return yamlStringify(data, {
|
||||
indent: 2,
|
||||
lineWidth: 120,
|
||||
});
|
||||
}
|
||||
|
||||
return prettyPrint ? JSON.stringify(data, null, 2) : JSON.stringify(data);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let featureExportServiceInstance: FeatureExportService | null = null;
|
||||
|
||||
/**
|
||||
* Get the singleton feature export service instance
|
||||
*/
|
||||
export function getFeatureExportService(): FeatureExportService {
|
||||
if (!featureExportServiceInstance) {
|
||||
featureExportServiceInstance = new FeatureExportService();
|
||||
}
|
||||
return featureExportServiceInstance;
|
||||
}
|
||||
@@ -23,7 +23,9 @@ import type {
|
||||
SendMessageOptions,
|
||||
PromptCategory,
|
||||
IdeationPrompt,
|
||||
IdeationContextSources,
|
||||
} from '@automaker/types';
|
||||
import { DEFAULT_IDEATION_CONTEXT_SOURCES } from '@automaker/types';
|
||||
import {
|
||||
getIdeationDir,
|
||||
getIdeasDir,
|
||||
@@ -32,16 +34,22 @@ import {
|
||||
getIdeationSessionsDir,
|
||||
getIdeationSessionPath,
|
||||
getIdeationAnalysisPath,
|
||||
getAppSpecPath,
|
||||
ensureIdeationDir,
|
||||
} from '@automaker/platform';
|
||||
import { extractXmlElements, extractImplementedFeatures } from '../lib/xml-extractor.js';
|
||||
import { createLogger, loadContextFiles, isAbortError } from '@automaker/utils';
|
||||
import { ProviderFactory } from '../providers/provider-factory.js';
|
||||
import type { SettingsService } from './settings-service.js';
|
||||
import type { FeatureLoader } from './feature-loader.js';
|
||||
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
|
||||
import { resolveModelString } from '@automaker/model-resolver';
|
||||
import { resolveModelString, resolvePhaseModel } from '@automaker/model-resolver';
|
||||
import { stripProviderPrefix } from '@automaker/types';
|
||||
import { getPromptCustomization } from '../lib/settings-helpers.js';
|
||||
import {
|
||||
getPromptCustomization,
|
||||
getProviderByModelId,
|
||||
getPhaseModelWithOverrides,
|
||||
} from '../lib/settings-helpers.js';
|
||||
|
||||
const logger = createLogger('IdeationService');
|
||||
|
||||
@@ -208,7 +216,27 @@ export class IdeationService {
|
||||
);
|
||||
|
||||
// Resolve model alias to canonical identifier (with prefix)
|
||||
const modelId = resolveModelString(options?.model ?? 'sonnet');
|
||||
let modelId = resolveModelString(options?.model ?? 'sonnet');
|
||||
|
||||
// Try to find a provider for this model (e.g., GLM, MiniMax models)
|
||||
let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined;
|
||||
let credentials = await this.settingsService?.getCredentials();
|
||||
|
||||
if (this.settingsService && options?.model) {
|
||||
const providerResult = await getProviderByModelId(
|
||||
options.model,
|
||||
this.settingsService,
|
||||
'[IdeationService]'
|
||||
);
|
||||
if (providerResult.provider) {
|
||||
claudeCompatibleProvider = providerResult.provider;
|
||||
// Use resolved model from provider if available (maps to Claude model)
|
||||
if (providerResult.resolvedModel) {
|
||||
modelId = providerResult.resolvedModel;
|
||||
}
|
||||
credentials = providerResult.credentials ?? credentials;
|
||||
}
|
||||
}
|
||||
|
||||
// Create SDK options
|
||||
const sdkOptions = createChatOptions({
|
||||
@@ -232,6 +260,8 @@ export class IdeationService {
|
||||
maxTurns: 1, // Single turn for ideation
|
||||
abortController: activeSession.abortController!,
|
||||
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
|
||||
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
};
|
||||
|
||||
const stream = provider.executeQuery(executeOptions);
|
||||
@@ -612,8 +642,12 @@ export class IdeationService {
|
||||
projectPath: string,
|
||||
promptId: string,
|
||||
category: IdeaCategory,
|
||||
count: number = 10
|
||||
count: number = 10,
|
||||
contextSources?: IdeationContextSources
|
||||
): Promise<AnalysisSuggestion[]> {
|
||||
const suggestionCount = Math.min(Math.max(Math.floor(count ?? 10), 1), 20);
|
||||
// Merge with defaults for backward compatibility
|
||||
const sources = { ...DEFAULT_IDEATION_CONTEXT_SOURCES, ...contextSources };
|
||||
validateWorkingDirectory(projectPath);
|
||||
|
||||
// Get the prompt
|
||||
@@ -630,16 +664,26 @@ export class IdeationService {
|
||||
});
|
||||
|
||||
try {
|
||||
// Load context files
|
||||
// Load context files (respecting toggle settings)
|
||||
const contextResult = await loadContextFiles({
|
||||
projectPath,
|
||||
fsModule: secureFs as Parameters<typeof loadContextFiles>[0]['fsModule'],
|
||||
includeContextFiles: sources.useContextFiles,
|
||||
includeMemory: sources.useMemoryFiles,
|
||||
});
|
||||
|
||||
// Build context from multiple sources
|
||||
let contextPrompt = contextResult.formattedPrompt;
|
||||
|
||||
// If no context files, try to gather basic project info
|
||||
// Add app spec context if enabled
|
||||
if (sources.useAppSpec) {
|
||||
const appSpecContext = await this.buildAppSpecContext(projectPath);
|
||||
if (appSpecContext) {
|
||||
contextPrompt = contextPrompt ? `${contextPrompt}\n\n${appSpecContext}` : appSpecContext;
|
||||
}
|
||||
}
|
||||
|
||||
// If no context was found, try to gather basic project info
|
||||
if (!contextPrompt) {
|
||||
const projectInfo = await this.gatherBasicProjectInfo(projectPath);
|
||||
if (projectInfo) {
|
||||
@@ -647,8 +691,11 @@ export class IdeationService {
|
||||
}
|
||||
}
|
||||
|
||||
// Gather existing features and ideas to prevent duplicates
|
||||
const existingWorkContext = await this.gatherExistingWorkContext(projectPath);
|
||||
// Gather existing features and ideas to prevent duplicates (respecting toggle settings)
|
||||
const existingWorkContext = await this.gatherExistingWorkContext(projectPath, {
|
||||
includeFeatures: sources.useExistingFeatures,
|
||||
includeIdeas: sources.useExistingIdeas,
|
||||
});
|
||||
|
||||
// Get customized prompts from settings
|
||||
const prompts = await getPromptCustomization(this.settingsService, '[IdeationService]');
|
||||
@@ -658,12 +705,28 @@ export class IdeationService {
|
||||
prompts.ideation.suggestionsSystemPrompt,
|
||||
contextPrompt,
|
||||
category,
|
||||
count,
|
||||
suggestionCount,
|
||||
existingWorkContext
|
||||
);
|
||||
|
||||
// Resolve model alias to canonical identifier (with prefix)
|
||||
const modelId = resolveModelString('sonnet');
|
||||
// Get model from phase settings with provider info (ideationModel)
|
||||
const phaseResult = await getPhaseModelWithOverrides(
|
||||
'ideationModel',
|
||||
this.settingsService,
|
||||
projectPath,
|
||||
'[IdeationService]'
|
||||
);
|
||||
const resolved = resolvePhaseModel(phaseResult.phaseModel);
|
||||
// resolvePhaseModel already resolves model aliases internally - no need to call resolveModelString again
|
||||
const modelId = resolved.model;
|
||||
const claudeCompatibleProvider = phaseResult.provider;
|
||||
const credentials = phaseResult.credentials;
|
||||
|
||||
logger.info(
|
||||
'generateSuggestions using model:',
|
||||
modelId,
|
||||
claudeCompatibleProvider ? `via provider: ${claudeCompatibleProvider.name}` : 'direct API'
|
||||
);
|
||||
|
||||
// Create SDK options
|
||||
const sdkOptions = createChatOptions({
|
||||
@@ -688,6 +751,9 @@ export class IdeationService {
|
||||
// Disable all tools - we just want text generation, not codebase analysis
|
||||
allowedTools: [],
|
||||
abortController: new AbortController(),
|
||||
readOnly: true, // Suggestions only need to return JSON, never write files
|
||||
claudeCompatibleProvider, // Pass provider for alternative endpoint configuration
|
||||
credentials, // Pass credentials for resolving 'credentials' apiKeySource
|
||||
};
|
||||
|
||||
const stream = provider.executeQuery(executeOptions);
|
||||
@@ -706,7 +772,11 @@ export class IdeationService {
|
||||
}
|
||||
|
||||
// Parse the response into structured suggestions
|
||||
const suggestions = this.parseSuggestionsFromResponse(responseText, category);
|
||||
const suggestions = this.parseSuggestionsFromResponse(
|
||||
responseText,
|
||||
category,
|
||||
suggestionCount
|
||||
);
|
||||
|
||||
// Emit complete event
|
||||
this.events.emit('ideation:suggestions', {
|
||||
@@ -769,40 +839,47 @@ ${contextSection}${existingWorkSection}`;
|
||||
*/
|
||||
private parseSuggestionsFromResponse(
|
||||
response: string,
|
||||
category: IdeaCategory
|
||||
category: IdeaCategory,
|
||||
count: number
|
||||
): AnalysisSuggestion[] {
|
||||
try {
|
||||
// Try to extract JSON from the response
|
||||
const jsonMatch = response.match(/\[[\s\S]*\]/);
|
||||
if (!jsonMatch) {
|
||||
logger.warn('No JSON array found in response, falling back to text parsing');
|
||||
return this.parseTextResponse(response, category);
|
||||
return this.parseTextResponse(response, category, count);
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return this.parseTextResponse(response, category);
|
||||
return this.parseTextResponse(response, category, count);
|
||||
}
|
||||
|
||||
return parsed.map((item: any, index: number) => ({
|
||||
id: this.generateId('sug'),
|
||||
category,
|
||||
title: item.title || `Suggestion ${index + 1}`,
|
||||
description: item.description || '',
|
||||
rationale: item.rationale || '',
|
||||
priority: item.priority || 'medium',
|
||||
relatedFiles: item.relatedFiles || [],
|
||||
}));
|
||||
return parsed
|
||||
.map((item: any, index: number) => ({
|
||||
id: this.generateId('sug'),
|
||||
category,
|
||||
title: item.title || `Suggestion ${index + 1}`,
|
||||
description: item.description || '',
|
||||
rationale: item.rationale || '',
|
||||
priority: item.priority || 'medium',
|
||||
relatedFiles: item.relatedFiles || [],
|
||||
}))
|
||||
.slice(0, count);
|
||||
} catch (error) {
|
||||
logger.warn('Failed to parse JSON response:', error);
|
||||
return this.parseTextResponse(response, category);
|
||||
return this.parseTextResponse(response, category, count);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: parse text response into suggestions
|
||||
*/
|
||||
private parseTextResponse(response: string, category: IdeaCategory): AnalysisSuggestion[] {
|
||||
private parseTextResponse(
|
||||
response: string,
|
||||
category: IdeaCategory,
|
||||
count: number
|
||||
): AnalysisSuggestion[] {
|
||||
const suggestions: AnalysisSuggestion[] = [];
|
||||
|
||||
// Try to find numbered items or headers
|
||||
@@ -862,7 +939,7 @@ ${contextSection}${existingWorkSection}`;
|
||||
});
|
||||
}
|
||||
|
||||
return suggestions.slice(0, 5); // Max 5 suggestions
|
||||
return suggestions.slice(0, count);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -1300,6 +1377,68 @@ ${contextSection}${existingWorkSection}`;
|
||||
return descriptions[category] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build context from app_spec.txt for suggestion generation
|
||||
* Extracts project name, overview, capabilities, and implemented features
|
||||
*/
|
||||
private async buildAppSpecContext(projectPath: string): Promise<string> {
|
||||
try {
|
||||
const specPath = getAppSpecPath(projectPath);
|
||||
const specContent = (await secureFs.readFile(specPath, 'utf-8')) as string;
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push('## App Specification');
|
||||
|
||||
// Extract project name
|
||||
const projectNames = extractXmlElements(specContent, 'project_name');
|
||||
if (projectNames.length > 0 && projectNames[0]) {
|
||||
parts.push(`**Project:** ${projectNames[0]}`);
|
||||
}
|
||||
|
||||
// Extract overview
|
||||
const overviews = extractXmlElements(specContent, 'overview');
|
||||
if (overviews.length > 0 && overviews[0]) {
|
||||
parts.push(`**Overview:** ${overviews[0]}`);
|
||||
}
|
||||
|
||||
// Extract core capabilities
|
||||
const capabilities = extractXmlElements(specContent, 'capability');
|
||||
if (capabilities.length > 0) {
|
||||
parts.push('**Core Capabilities:**');
|
||||
for (const cap of capabilities) {
|
||||
parts.push(`- ${cap}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract implemented features
|
||||
const implementedFeatures = extractImplementedFeatures(specContent);
|
||||
if (implementedFeatures.length > 0) {
|
||||
parts.push('**Implemented Features:**');
|
||||
for (const feature of implementedFeatures) {
|
||||
if (feature.description) {
|
||||
parts.push(`- ${feature.name}: ${feature.description}`);
|
||||
} else {
|
||||
parts.push(`- ${feature.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only return content if we extracted something meaningful
|
||||
if (parts.length > 1) {
|
||||
return parts.join('\n');
|
||||
}
|
||||
return '';
|
||||
} catch (error) {
|
||||
// If file doesn't exist, return empty string silently
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return '';
|
||||
}
|
||||
// For other errors, log and return empty string
|
||||
logger.warn('Failed to build app spec context:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gather basic project information for context when no context files exist
|
||||
*/
|
||||
@@ -1395,11 +1534,15 @@ ${contextSection}${existingWorkSection}`;
|
||||
* Gather existing features and ideas to prevent duplicate suggestions
|
||||
* Returns a concise list of titles grouped by status to avoid polluting context
|
||||
*/
|
||||
private async gatherExistingWorkContext(projectPath: string): Promise<string> {
|
||||
private async gatherExistingWorkContext(
|
||||
projectPath: string,
|
||||
options?: { includeFeatures?: boolean; includeIdeas?: boolean }
|
||||
): Promise<string> {
|
||||
const { includeFeatures = true, includeIdeas = true } = options ?? {};
|
||||
const parts: string[] = [];
|
||||
|
||||
// Load existing features from the board
|
||||
if (this.featureLoader) {
|
||||
if (includeFeatures && this.featureLoader) {
|
||||
try {
|
||||
const features = await this.featureLoader.getAll(projectPath);
|
||||
if (features.length > 0) {
|
||||
@@ -1447,34 +1590,36 @@ ${contextSection}${existingWorkSection}`;
|
||||
}
|
||||
|
||||
// Load existing ideas
|
||||
try {
|
||||
const ideas = await this.getIdeas(projectPath);
|
||||
// Filter out archived ideas
|
||||
const activeIdeas = ideas.filter((idea) => idea.status !== 'archived');
|
||||
if (includeIdeas) {
|
||||
try {
|
||||
const ideas = await this.getIdeas(projectPath);
|
||||
// Filter out archived ideas
|
||||
const activeIdeas = ideas.filter((idea) => idea.status !== 'archived');
|
||||
|
||||
if (activeIdeas.length > 0) {
|
||||
parts.push('## Existing Ideas (Do NOT regenerate these)');
|
||||
parts.push(
|
||||
'The following ideas have already been captured. Do NOT suggest similar ideas:\n'
|
||||
);
|
||||
if (activeIdeas.length > 0) {
|
||||
parts.push('## Existing Ideas (Do NOT regenerate these)');
|
||||
parts.push(
|
||||
'The following ideas have already been captured. Do NOT suggest similar ideas:\n'
|
||||
);
|
||||
|
||||
// Group by category for organization
|
||||
const byCategory: Record<string, string[]> = {};
|
||||
for (const idea of activeIdeas) {
|
||||
const cat = idea.category || 'feature';
|
||||
if (!byCategory[cat]) {
|
||||
byCategory[cat] = [];
|
||||
// Group by category for organization
|
||||
const byCategory: Record<string, string[]> = {};
|
||||
for (const idea of activeIdeas) {
|
||||
const cat = idea.category || 'feature';
|
||||
if (!byCategory[cat]) {
|
||||
byCategory[cat] = [];
|
||||
}
|
||||
byCategory[cat].push(idea.title);
|
||||
}
|
||||
byCategory[cat].push(idea.title);
|
||||
}
|
||||
|
||||
for (const [category, titles] of Object.entries(byCategory)) {
|
||||
parts.push(`**${category}:** ${titles.join(', ')}`);
|
||||
for (const [category, titles] of Object.entries(byCategory)) {
|
||||
parts.push(`**${category}:** ${titles.join(', ')}`);
|
||||
}
|
||||
parts.push('');
|
||||
}
|
||||
parts.push('');
|
||||
} catch (error) {
|
||||
logger.warn('Failed to load existing ideas:', error);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to load existing ideas:', error);
|
||||
}
|
||||
|
||||
return parts.join('\n');
|
||||
|
||||
@@ -234,51 +234,75 @@ export class PipelineService {
|
||||
*
|
||||
* Determines what status a feature should transition to based on current status.
|
||||
* Flow: in_progress -> pipeline_step_0 -> pipeline_step_1 -> ... -> final status
|
||||
* Steps in the excludedStepIds array will be skipped.
|
||||
*
|
||||
* @param currentStatus - Current feature status
|
||||
* @param config - Pipeline configuration (or null if no pipeline)
|
||||
* @param skipTests - Whether to skip tests (affects final status)
|
||||
* @param excludedStepIds - Optional array of step IDs to skip
|
||||
* @returns The next status in the pipeline flow
|
||||
*/
|
||||
getNextStatus(
|
||||
currentStatus: FeatureStatusWithPipeline,
|
||||
config: PipelineConfig | null,
|
||||
skipTests: boolean
|
||||
skipTests: boolean,
|
||||
excludedStepIds?: string[]
|
||||
): FeatureStatusWithPipeline {
|
||||
const steps = config?.steps || [];
|
||||
const exclusions = new Set(excludedStepIds || []);
|
||||
|
||||
// Sort steps by order
|
||||
const sortedSteps = [...steps].sort((a, b) => a.order - b.order);
|
||||
// Sort steps by order and filter out excluded steps
|
||||
const sortedSteps = [...steps]
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.filter((step) => !exclusions.has(step.id));
|
||||
|
||||
// If no pipeline steps, use original logic
|
||||
// If no pipeline steps (or all excluded), use original logic
|
||||
if (sortedSteps.length === 0) {
|
||||
if (currentStatus === 'in_progress') {
|
||||
// If coming from in_progress or already in a pipeline step, go to final status
|
||||
if (currentStatus === 'in_progress' || currentStatus.startsWith('pipeline_')) {
|
||||
return skipTests ? 'waiting_approval' : 'verified';
|
||||
}
|
||||
return currentStatus;
|
||||
}
|
||||
|
||||
// Coming from in_progress -> go to first pipeline step
|
||||
// Coming from in_progress -> go to first non-excluded pipeline step
|
||||
if (currentStatus === 'in_progress') {
|
||||
return `pipeline_${sortedSteps[0].id}`;
|
||||
}
|
||||
|
||||
// Coming from a pipeline step -> go to next step or final status
|
||||
// Coming from a pipeline step -> go to next non-excluded step or final status
|
||||
if (currentStatus.startsWith('pipeline_')) {
|
||||
const currentStepId = currentStatus.replace('pipeline_', '');
|
||||
const currentIndex = sortedSteps.findIndex((s) => s.id === currentStepId);
|
||||
|
||||
if (currentIndex === -1) {
|
||||
// Step not found, go to final status
|
||||
// Current step not found in filtered list (might be excluded or invalid)
|
||||
// Find next valid step after this one from the original sorted list
|
||||
const allSortedSteps = [...steps].sort((a, b) => a.order - b.order);
|
||||
const originalIndex = allSortedSteps.findIndex((s) => s.id === currentStepId);
|
||||
|
||||
if (originalIndex === -1) {
|
||||
// Step truly doesn't exist, go to final status
|
||||
return skipTests ? 'waiting_approval' : 'verified';
|
||||
}
|
||||
|
||||
// Find the next non-excluded step after the current one
|
||||
for (let i = originalIndex + 1; i < allSortedSteps.length; i++) {
|
||||
if (!exclusions.has(allSortedSteps[i].id)) {
|
||||
return `pipeline_${allSortedSteps[i].id}`;
|
||||
}
|
||||
}
|
||||
|
||||
// No more non-excluded steps, go to final status
|
||||
return skipTests ? 'waiting_approval' : 'verified';
|
||||
}
|
||||
|
||||
if (currentIndex < sortedSteps.length - 1) {
|
||||
// Go to next step
|
||||
// Go to next non-excluded step
|
||||
return `pipeline_${sortedSteps[currentIndex + 1].id}`;
|
||||
}
|
||||
|
||||
// Last step completed, go to final status
|
||||
// Last non-excluded step completed, go to final status
|
||||
return skipTests ? 'waiting_approval' : 'verified';
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,9 @@ import type {
|
||||
WorktreeInfo,
|
||||
PhaseModelConfig,
|
||||
PhaseModelEntry,
|
||||
ClaudeApiProfile,
|
||||
ClaudeCompatibleProvider,
|
||||
ProviderModel,
|
||||
} from '../types/settings.js';
|
||||
import {
|
||||
DEFAULT_GLOBAL_SETTINGS,
|
||||
@@ -41,7 +44,12 @@ import {
|
||||
CREDENTIALS_VERSION,
|
||||
PROJECT_SETTINGS_VERSION,
|
||||
} from '../types/settings.js';
|
||||
import { migrateModelId, migrateCursorModelIds, migrateOpencodeModelIds } from '@automaker/types';
|
||||
import {
|
||||
DEFAULT_MAX_CONCURRENCY,
|
||||
migrateModelId,
|
||||
migrateCursorModelIds,
|
||||
migrateOpencodeModelIds,
|
||||
} from '@automaker/types';
|
||||
|
||||
const logger = createLogger('SettingsService');
|
||||
|
||||
@@ -166,6 +174,63 @@ export class SettingsService {
|
||||
needsSave = true;
|
||||
}
|
||||
|
||||
// Migration v4 -> v5: Auto-create "Direct Anthropic" profile for existing users
|
||||
// If user has an Anthropic API key in credentials but no profiles, create a
|
||||
// "Direct Anthropic" profile that references the credentials and set it as active.
|
||||
if (storedVersion < 5) {
|
||||
try {
|
||||
const credentials = await this.getCredentials();
|
||||
const hasAnthropicKey = !!credentials.apiKeys?.anthropic;
|
||||
const hasNoProfiles = !result.claudeApiProfiles || result.claudeApiProfiles.length === 0;
|
||||
const hasNoActiveProfile = !result.activeClaudeApiProfileId;
|
||||
|
||||
if (hasAnthropicKey && hasNoProfiles && hasNoActiveProfile) {
|
||||
const directAnthropicProfile = {
|
||||
id: `profile-${Date.now()}-direct-anthropic`,
|
||||
name: 'Direct Anthropic',
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
apiKeySource: 'credentials' as const,
|
||||
useAuthToken: false,
|
||||
};
|
||||
|
||||
result.claudeApiProfiles = [directAnthropicProfile];
|
||||
result.activeClaudeApiProfileId = directAnthropicProfile.id;
|
||||
|
||||
logger.info(
|
||||
'Migration v4->v5: Created "Direct Anthropic" profile using existing credentials'
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
'Migration v4->v5: Could not check credentials for auto-profile creation:',
|
||||
error
|
||||
);
|
||||
}
|
||||
needsSave = true;
|
||||
}
|
||||
|
||||
// Migration v5 -> v6: Convert claudeApiProfiles to claudeCompatibleProviders
|
||||
// The new system uses a models[] array instead of modelMappings, and removes
|
||||
// the "active profile" concept - models are selected directly in phase model configs.
|
||||
if (storedVersion < 6) {
|
||||
const legacyProfiles = settings.claudeApiProfiles || [];
|
||||
if (
|
||||
legacyProfiles.length > 0 &&
|
||||
(!result.claudeCompatibleProviders || result.claudeCompatibleProviders.length === 0)
|
||||
) {
|
||||
logger.info(
|
||||
`Migration v5->v6: Converting ${legacyProfiles.length} Claude API profile(s) to compatible providers`
|
||||
);
|
||||
result.claudeCompatibleProviders = this.migrateProfilesToProviders(legacyProfiles);
|
||||
}
|
||||
// Remove the deprecated activeClaudeApiProfileId field
|
||||
if (result.activeClaudeApiProfileId) {
|
||||
logger.info('Migration v5->v6: Removing deprecated activeClaudeApiProfileId');
|
||||
delete result.activeClaudeApiProfileId;
|
||||
}
|
||||
needsSave = true;
|
||||
}
|
||||
|
||||
// Update version if any migration occurred
|
||||
if (needsSave) {
|
||||
result.version = SETTINGS_VERSION;
|
||||
@@ -250,6 +315,139 @@ export class SettingsService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate ClaudeApiProfiles to ClaudeCompatibleProviders
|
||||
*
|
||||
* Converts the legacy profile format (with modelMappings) to the new
|
||||
* provider format (with models[] array). Each model mapping entry becomes
|
||||
* a ProviderModel with appropriate tier assignment.
|
||||
*
|
||||
* @param profiles - Legacy ClaudeApiProfile array
|
||||
* @returns Array of ClaudeCompatibleProvider
|
||||
*/
|
||||
private migrateProfilesToProviders(profiles: ClaudeApiProfile[]): ClaudeCompatibleProvider[] {
|
||||
return profiles.map((profile): ClaudeCompatibleProvider => {
|
||||
// Convert modelMappings to models array
|
||||
const models: ProviderModel[] = [];
|
||||
|
||||
if (profile.modelMappings) {
|
||||
// Haiku mapping
|
||||
if (profile.modelMappings.haiku) {
|
||||
models.push({
|
||||
id: profile.modelMappings.haiku,
|
||||
displayName: this.inferModelDisplayName(profile.modelMappings.haiku, 'haiku'),
|
||||
mapsToClaudeModel: 'haiku',
|
||||
});
|
||||
}
|
||||
// Sonnet mapping
|
||||
if (profile.modelMappings.sonnet) {
|
||||
models.push({
|
||||
id: profile.modelMappings.sonnet,
|
||||
displayName: this.inferModelDisplayName(profile.modelMappings.sonnet, 'sonnet'),
|
||||
mapsToClaudeModel: 'sonnet',
|
||||
});
|
||||
}
|
||||
// Opus mapping
|
||||
if (profile.modelMappings.opus) {
|
||||
models.push({
|
||||
id: profile.modelMappings.opus,
|
||||
displayName: this.inferModelDisplayName(profile.modelMappings.opus, 'opus'),
|
||||
mapsToClaudeModel: 'opus',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Infer provider type from base URL or name
|
||||
const providerType = this.inferProviderType(profile);
|
||||
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
providerType,
|
||||
enabled: true,
|
||||
baseUrl: profile.baseUrl,
|
||||
apiKeySource: profile.apiKeySource ?? 'inline',
|
||||
apiKey: profile.apiKey,
|
||||
useAuthToken: profile.useAuthToken,
|
||||
timeoutMs: profile.timeoutMs,
|
||||
disableNonessentialTraffic: profile.disableNonessentialTraffic,
|
||||
models,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer a display name for a model based on its ID and tier
|
||||
*
|
||||
* @param modelId - The raw model ID
|
||||
* @param tier - The tier hint (haiku/sonnet/opus)
|
||||
* @returns A user-friendly display name
|
||||
*/
|
||||
private inferModelDisplayName(modelId: string, tier: 'haiku' | 'sonnet' | 'opus'): string {
|
||||
// Common patterns in model IDs
|
||||
const lowerModelId = modelId.toLowerCase();
|
||||
|
||||
// GLM models
|
||||
if (lowerModelId.includes('glm')) {
|
||||
return modelId.replace(/-/g, ' ').replace(/glm/i, 'GLM');
|
||||
}
|
||||
|
||||
// MiniMax models
|
||||
if (lowerModelId.includes('minimax')) {
|
||||
return modelId.replace(/-/g, ' ').replace(/minimax/i, 'MiniMax');
|
||||
}
|
||||
|
||||
// Claude models via OpenRouter or similar
|
||||
if (lowerModelId.includes('claude')) {
|
||||
return modelId;
|
||||
}
|
||||
|
||||
// Default: use model ID as display name with tier in parentheses
|
||||
return `${modelId} (${tier})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer provider type from profile configuration
|
||||
*
|
||||
* @param profile - The legacy profile
|
||||
* @returns The inferred provider type
|
||||
*/
|
||||
private inferProviderType(profile: ClaudeApiProfile): ClaudeCompatibleProvider['providerType'] {
|
||||
const baseUrl = profile.baseUrl.toLowerCase();
|
||||
const name = profile.name.toLowerCase();
|
||||
|
||||
// Check URL patterns
|
||||
if (baseUrl.includes('z.ai') || baseUrl.includes('zhipuai')) {
|
||||
return 'glm';
|
||||
}
|
||||
if (baseUrl.includes('minimax')) {
|
||||
return 'minimax';
|
||||
}
|
||||
if (baseUrl.includes('openrouter')) {
|
||||
return 'openrouter';
|
||||
}
|
||||
if (baseUrl.includes('anthropic.com')) {
|
||||
return 'anthropic';
|
||||
}
|
||||
|
||||
// Check name patterns
|
||||
if (name.includes('glm') || name.includes('zhipu')) {
|
||||
return 'glm';
|
||||
}
|
||||
if (name.includes('minimax')) {
|
||||
return 'minimax';
|
||||
}
|
||||
if (name.includes('openrouter')) {
|
||||
return 'openrouter';
|
||||
}
|
||||
if (name.includes('anthropic') || name.includes('direct')) {
|
||||
return 'anthropic';
|
||||
}
|
||||
|
||||
// Default to custom
|
||||
return 'custom';
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate model-related settings to canonical format
|
||||
*
|
||||
@@ -372,18 +570,29 @@ export class SettingsService {
|
||||
ignoreEmptyArrayOverwrite('recentFolders');
|
||||
ignoreEmptyArrayOverwrite('mcpServers');
|
||||
ignoreEmptyArrayOverwrite('enabledCursorModels');
|
||||
ignoreEmptyArrayOverwrite('claudeApiProfiles');
|
||||
// Note: claudeCompatibleProviders intentionally NOT guarded - users should be able to delete all providers
|
||||
|
||||
// Empty object overwrite guard
|
||||
if (
|
||||
sanitizedUpdates.lastSelectedSessionByProject &&
|
||||
typeof sanitizedUpdates.lastSelectedSessionByProject === 'object' &&
|
||||
!Array.isArray(sanitizedUpdates.lastSelectedSessionByProject) &&
|
||||
Object.keys(sanitizedUpdates.lastSelectedSessionByProject).length === 0 &&
|
||||
current.lastSelectedSessionByProject &&
|
||||
Object.keys(current.lastSelectedSessionByProject).length > 0
|
||||
) {
|
||||
delete sanitizedUpdates.lastSelectedSessionByProject;
|
||||
}
|
||||
const ignoreEmptyObjectOverwrite = <K extends keyof GlobalSettings>(key: K): void => {
|
||||
const nextVal = sanitizedUpdates[key] as unknown;
|
||||
const curVal = current[key] as unknown;
|
||||
if (
|
||||
nextVal &&
|
||||
typeof nextVal === 'object' &&
|
||||
!Array.isArray(nextVal) &&
|
||||
Object.keys(nextVal).length === 0 &&
|
||||
curVal &&
|
||||
typeof curVal === 'object' &&
|
||||
!Array.isArray(curVal) &&
|
||||
Object.keys(curVal).length > 0
|
||||
) {
|
||||
delete sanitizedUpdates[key];
|
||||
}
|
||||
};
|
||||
|
||||
ignoreEmptyObjectOverwrite('lastSelectedSessionByProject');
|
||||
ignoreEmptyObjectOverwrite('autoModeByWorktree');
|
||||
|
||||
// If a request attempted to wipe projects, also ignore theme changes in that same request.
|
||||
if (attemptedProjectWipe) {
|
||||
@@ -412,6 +621,21 @@ export class SettingsService {
|
||||
};
|
||||
}
|
||||
|
||||
// Deep merge autoModeByWorktree if provided (preserves other worktree entries)
|
||||
if (sanitizedUpdates.autoModeByWorktree) {
|
||||
type WorktreeEntry = { maxConcurrency: number; branchName: string | null };
|
||||
const mergedAutoModeByWorktree: Record<string, WorktreeEntry> = {
|
||||
...current.autoModeByWorktree,
|
||||
};
|
||||
for (const [key, value] of Object.entries(sanitizedUpdates.autoModeByWorktree)) {
|
||||
mergedAutoModeByWorktree[key] = {
|
||||
...mergedAutoModeByWorktree[key],
|
||||
...value,
|
||||
};
|
||||
}
|
||||
updated.autoModeByWorktree = mergedAutoModeByWorktree;
|
||||
}
|
||||
|
||||
await writeSettingsJson(settingsPath, updated);
|
||||
logger.info('Global settings updated');
|
||||
|
||||
@@ -597,6 +821,51 @@ export class SettingsService {
|
||||
};
|
||||
}
|
||||
|
||||
// Handle activeClaudeApiProfileId special cases:
|
||||
// - "__USE_GLOBAL__" marker means delete the key (use global setting)
|
||||
// - null means explicit "Direct Anthropic API"
|
||||
// - string means specific profile ID
|
||||
if (
|
||||
'activeClaudeApiProfileId' in updates &&
|
||||
updates.activeClaudeApiProfileId === '__USE_GLOBAL__'
|
||||
) {
|
||||
delete updated.activeClaudeApiProfileId;
|
||||
}
|
||||
|
||||
// Handle phaseModelOverrides special cases:
|
||||
// - "__CLEAR__" marker means delete the key (use global settings for all phases)
|
||||
// - object means partial overrides for specific phases
|
||||
if (
|
||||
'phaseModelOverrides' in updates &&
|
||||
(updates as Record<string, unknown>).phaseModelOverrides === '__CLEAR__'
|
||||
) {
|
||||
delete updated.phaseModelOverrides;
|
||||
}
|
||||
|
||||
// Handle defaultFeatureModel special cases:
|
||||
// - "__CLEAR__" marker means delete the key (use global setting)
|
||||
// - object means project-specific override
|
||||
if (
|
||||
'defaultFeatureModel' in updates &&
|
||||
(updates as Record<string, unknown>).defaultFeatureModel === '__CLEAR__'
|
||||
) {
|
||||
delete updated.defaultFeatureModel;
|
||||
}
|
||||
|
||||
// Handle devCommand special cases:
|
||||
// - null means delete the key (use auto-detection)
|
||||
// - string means custom command
|
||||
if ('devCommand' in updates && updates.devCommand === null) {
|
||||
delete updated.devCommand;
|
||||
}
|
||||
|
||||
// Handle testCommand special cases:
|
||||
// - null means delete the key (use auto-detection)
|
||||
// - string means custom command
|
||||
if ('testCommand' in updates && updates.testCommand === null) {
|
||||
delete updated.testCommand;
|
||||
}
|
||||
|
||||
await writeSettingsJson(settingsPath, updated);
|
||||
logger.info(`Project settings updated for ${projectPath}`);
|
||||
|
||||
@@ -682,7 +951,7 @@ export class SettingsService {
|
||||
theme: (appState.theme as GlobalSettings['theme']) || 'dark',
|
||||
sidebarOpen: appState.sidebarOpen !== undefined ? (appState.sidebarOpen as boolean) : true,
|
||||
chatHistoryOpen: (appState.chatHistoryOpen as boolean) || false,
|
||||
maxConcurrency: (appState.maxConcurrency as number) || 3,
|
||||
maxConcurrency: (appState.maxConcurrency as number) || DEFAULT_MAX_CONCURRENCY,
|
||||
defaultSkipTests:
|
||||
appState.defaultSkipTests !== undefined ? (appState.defaultSkipTests as boolean) : true,
|
||||
enableDependencyBlocking:
|
||||
|
||||
682
apps/server/src/services/test-runner-service.ts
Normal file
682
apps/server/src/services/test-runner-service.ts
Normal file
@@ -0,0 +1,682 @@
|
||||
/**
|
||||
* Test Runner Service
|
||||
*
|
||||
* Manages test execution processes for git worktrees.
|
||||
* Runs user-configured test commands with output streaming.
|
||||
*
|
||||
* Features:
|
||||
* - Process management with graceful shutdown
|
||||
* - Output buffering and throttling for WebSocket streaming
|
||||
* - Support for running all tests or specific files
|
||||
* - Cross-platform process cleanup (Windows/Unix)
|
||||
*/
|
||||
|
||||
import { spawn, execSync, type ChildProcess } from 'child_process';
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { EventEmitter } from '../lib/events.js';
|
||||
|
||||
const logger = createLogger('TestRunnerService');
|
||||
|
||||
// Maximum scrollback buffer size (characters)
|
||||
const MAX_SCROLLBACK_SIZE = 50000; // ~50KB per test run
|
||||
|
||||
// Throttle output to prevent overwhelming WebSocket under heavy load
|
||||
// Note: Too aggressive throttling (< 50ms) can cause memory issues and UI crashes
|
||||
// due to rapid React state updates and string concatenation overhead
|
||||
const OUTPUT_THROTTLE_MS = 100; // ~10fps - balances responsiveness with stability
|
||||
const OUTPUT_BATCH_SIZE = 8192; // Larger batch size to reduce event frequency
|
||||
|
||||
/**
|
||||
* Status of a test run
|
||||
*/
|
||||
export type TestRunStatus = 'pending' | 'running' | 'passed' | 'failed' | 'cancelled' | 'error';
|
||||
|
||||
/**
|
||||
* Information about an active test run session
|
||||
*/
|
||||
export interface TestRunSession {
|
||||
/** Unique identifier for this test run */
|
||||
id: string;
|
||||
/** Path to the worktree where tests are running */
|
||||
worktreePath: string;
|
||||
/** The command being run */
|
||||
command: string;
|
||||
/** The spawned child process */
|
||||
process: ChildProcess | null;
|
||||
/** When the test run started */
|
||||
startedAt: Date;
|
||||
/** When the test run finished (if completed) */
|
||||
finishedAt: Date | null;
|
||||
/** Current status of the test run */
|
||||
status: TestRunStatus;
|
||||
/** Exit code from the process (if completed) */
|
||||
exitCode: number | null;
|
||||
/** Specific test file being run (optional) */
|
||||
testFile?: string;
|
||||
/** Scrollback buffer for log history (replay on reconnect) */
|
||||
scrollbackBuffer: string;
|
||||
/** Pending output to be flushed to subscribers */
|
||||
outputBuffer: string;
|
||||
/** Throttle timer for batching output */
|
||||
flushTimeout: NodeJS.Timeout | null;
|
||||
/** Flag to indicate session is stopping (prevents output after stop) */
|
||||
stopping: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a test run operation
|
||||
*/
|
||||
export interface TestRunResult {
|
||||
success: boolean;
|
||||
result?: {
|
||||
sessionId: string;
|
||||
worktreePath: string;
|
||||
command: string;
|
||||
status: TestRunStatus;
|
||||
testFile?: string;
|
||||
message: string;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test Runner Service class
|
||||
* Manages test execution processes across worktrees
|
||||
*/
|
||||
class TestRunnerService {
|
||||
private sessions: Map<string, TestRunSession> = new Map();
|
||||
private emitter: EventEmitter | null = null;
|
||||
|
||||
/**
|
||||
* Set the event emitter for streaming log events
|
||||
* Called during service initialization with the global event emitter
|
||||
*/
|
||||
setEventEmitter(emitter: EventEmitter): void {
|
||||
this.emitter = emitter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to check if a file exists using secureFs
|
||||
*/
|
||||
private async fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await secureFs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append data to scrollback buffer with size limit enforcement
|
||||
* Evicts oldest data when buffer exceeds MAX_SCROLLBACK_SIZE
|
||||
*/
|
||||
private appendToScrollback(session: TestRunSession, data: string): void {
|
||||
session.scrollbackBuffer += data;
|
||||
if (session.scrollbackBuffer.length > MAX_SCROLLBACK_SIZE) {
|
||||
session.scrollbackBuffer = session.scrollbackBuffer.slice(-MAX_SCROLLBACK_SIZE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush buffered output to WebSocket subscribers
|
||||
* Sends batched output to prevent overwhelming clients under heavy load
|
||||
*/
|
||||
private flushOutput(session: TestRunSession): void {
|
||||
// Skip flush if session is stopping or buffer is empty
|
||||
if (session.stopping || session.outputBuffer.length === 0) {
|
||||
session.flushTimeout = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let dataToSend = session.outputBuffer;
|
||||
if (dataToSend.length > OUTPUT_BATCH_SIZE) {
|
||||
// Send in batches if buffer is large
|
||||
dataToSend = session.outputBuffer.slice(0, OUTPUT_BATCH_SIZE);
|
||||
session.outputBuffer = session.outputBuffer.slice(OUTPUT_BATCH_SIZE);
|
||||
// Schedule another flush for remaining data
|
||||
session.flushTimeout = setTimeout(() => this.flushOutput(session), OUTPUT_THROTTLE_MS);
|
||||
} else {
|
||||
session.outputBuffer = '';
|
||||
session.flushTimeout = null;
|
||||
}
|
||||
|
||||
// Emit output event for WebSocket streaming
|
||||
if (this.emitter) {
|
||||
this.emitter.emit('test-runner:output', {
|
||||
sessionId: session.id,
|
||||
worktreePath: session.worktreePath,
|
||||
content: dataToSend,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming stdout/stderr data from test process
|
||||
* Buffers data for scrollback replay and schedules throttled emission
|
||||
*/
|
||||
private handleProcessOutput(session: TestRunSession, data: Buffer): void {
|
||||
// Skip output if session is stopping
|
||||
if (session.stopping) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = data.toString();
|
||||
|
||||
// Append to scrollback buffer for replay on reconnect
|
||||
this.appendToScrollback(session, content);
|
||||
|
||||
// Buffer output for throttled live delivery
|
||||
session.outputBuffer += content;
|
||||
|
||||
// Schedule flush if not already scheduled
|
||||
if (!session.flushTimeout) {
|
||||
session.flushTimeout = setTimeout(() => this.flushOutput(session), OUTPUT_THROTTLE_MS);
|
||||
}
|
||||
|
||||
// Also log for debugging (existing behavior)
|
||||
logger.debug(`[${session.id}] ${content.trim()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill any process running (platform-specific cleanup)
|
||||
*/
|
||||
private killProcessTree(pid: number): void {
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
// Windows: use taskkill to kill process tree
|
||||
execSync(`taskkill /F /T /PID ${pid}`, { stdio: 'ignore' });
|
||||
} else {
|
||||
// Unix: kill the process group
|
||||
try {
|
||||
process.kill(-pid, 'SIGTERM');
|
||||
} catch {
|
||||
// Fallback to killing just the process
|
||||
process.kill(pid, 'SIGTERM');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(`Error killing process ${pid}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique session ID
|
||||
*/
|
||||
private generateSessionId(): string {
|
||||
return `test-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a test file path to prevent command injection
|
||||
* Allows only safe characters for file paths
|
||||
*/
|
||||
private sanitizeTestFile(testFile: string): string {
|
||||
// Remove any shell metacharacters and normalize path
|
||||
// Allow only alphanumeric, dots, slashes, hyphens, underscores, colons (for Windows paths)
|
||||
return testFile.replace(/[^a-zA-Z0-9.\\/_\-:]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start tests in a worktree using the provided command
|
||||
*
|
||||
* @param worktreePath - Path to the worktree where tests should run
|
||||
* @param options - Configuration for the test run
|
||||
* @returns TestRunResult with session info or error
|
||||
*/
|
||||
async startTests(
|
||||
worktreePath: string,
|
||||
options: {
|
||||
command: string;
|
||||
testFile?: string;
|
||||
}
|
||||
): Promise<TestRunResult> {
|
||||
const { command, testFile } = options;
|
||||
|
||||
// Check if already running
|
||||
const existingSession = this.getActiveSession(worktreePath);
|
||||
if (existingSession) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Tests are already running for this worktree (session: ${existingSession.id})`,
|
||||
};
|
||||
}
|
||||
|
||||
// Verify the worktree exists
|
||||
if (!(await this.fileExists(worktreePath))) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Worktree path does not exist: ${worktreePath}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!command) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No test command provided',
|
||||
};
|
||||
}
|
||||
|
||||
// Build the final command (append test file if specified)
|
||||
let finalCommand = command;
|
||||
if (testFile) {
|
||||
// Sanitize test file path to prevent command injection
|
||||
const sanitizedFile = this.sanitizeTestFile(testFile);
|
||||
// Append the test file to the command
|
||||
// Most test runners support: command -- file or command file
|
||||
finalCommand = `${command} -- ${sanitizedFile}`;
|
||||
}
|
||||
|
||||
// Parse command into cmd and args (shell execution)
|
||||
// We use shell: true to support complex commands like "npm run test:server"
|
||||
logger.info(`Starting tests in ${worktreePath}`);
|
||||
logger.info(`Command: ${finalCommand}`);
|
||||
|
||||
// Create session
|
||||
const sessionId = this.generateSessionId();
|
||||
const session: TestRunSession = {
|
||||
id: sessionId,
|
||||
worktreePath,
|
||||
command: finalCommand,
|
||||
process: null,
|
||||
startedAt: new Date(),
|
||||
finishedAt: null,
|
||||
status: 'pending',
|
||||
exitCode: null,
|
||||
testFile,
|
||||
scrollbackBuffer: '',
|
||||
outputBuffer: '',
|
||||
flushTimeout: null,
|
||||
stopping: false,
|
||||
};
|
||||
|
||||
// Spawn the test process using shell
|
||||
const env = {
|
||||
...process.env,
|
||||
FORCE_COLOR: '1',
|
||||
COLORTERM: 'truecolor',
|
||||
TERM: 'xterm-256color',
|
||||
CI: 'true', // Helps some test runners format output better
|
||||
};
|
||||
|
||||
const testProcess = spawn(finalCommand, [], {
|
||||
cwd: worktreePath,
|
||||
env,
|
||||
shell: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: process.platform !== 'win32', // Use process groups on Unix for cleanup
|
||||
});
|
||||
|
||||
session.process = testProcess;
|
||||
session.status = 'running';
|
||||
|
||||
// Track if process failed early
|
||||
const status = { error: null as string | null, exited: false };
|
||||
|
||||
// Helper to clean up resources and emit events
|
||||
const cleanupAndFinish = (
|
||||
exitCode: number | null,
|
||||
finalStatus: TestRunStatus,
|
||||
errorMessage?: string
|
||||
) => {
|
||||
session.finishedAt = new Date();
|
||||
session.exitCode = exitCode;
|
||||
session.status = finalStatus;
|
||||
|
||||
if (session.flushTimeout) {
|
||||
clearTimeout(session.flushTimeout);
|
||||
session.flushTimeout = null;
|
||||
}
|
||||
|
||||
// Flush any remaining output
|
||||
if (session.outputBuffer.length > 0 && this.emitter && !session.stopping) {
|
||||
this.emitter.emit('test-runner:output', {
|
||||
sessionId: session.id,
|
||||
worktreePath: session.worktreePath,
|
||||
content: session.outputBuffer,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
session.outputBuffer = '';
|
||||
}
|
||||
|
||||
// Emit completed event
|
||||
if (this.emitter && !session.stopping) {
|
||||
this.emitter.emit('test-runner:completed', {
|
||||
sessionId: session.id,
|
||||
worktreePath: session.worktreePath,
|
||||
command: session.command,
|
||||
status: finalStatus,
|
||||
exitCode,
|
||||
error: errorMessage,
|
||||
duration: session.finishedAt.getTime() - session.startedAt.getTime(),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Capture stdout
|
||||
if (testProcess.stdout) {
|
||||
testProcess.stdout.on('data', (data: Buffer) => {
|
||||
this.handleProcessOutput(session, data);
|
||||
});
|
||||
}
|
||||
|
||||
// Capture stderr
|
||||
if (testProcess.stderr) {
|
||||
testProcess.stderr.on('data', (data: Buffer) => {
|
||||
this.handleProcessOutput(session, data);
|
||||
});
|
||||
}
|
||||
|
||||
testProcess.on('error', (error) => {
|
||||
logger.error(`Process error for ${sessionId}:`, error);
|
||||
status.error = error.message;
|
||||
cleanupAndFinish(null, 'error', error.message);
|
||||
});
|
||||
|
||||
testProcess.on('exit', (code) => {
|
||||
logger.info(`Test process for ${worktreePath} exited with code ${code}`);
|
||||
status.exited = true;
|
||||
|
||||
// Determine final status based on exit code
|
||||
let finalStatus: TestRunStatus;
|
||||
if (session.stopping) {
|
||||
finalStatus = 'cancelled';
|
||||
} else if (code === 0) {
|
||||
finalStatus = 'passed';
|
||||
} else {
|
||||
finalStatus = 'failed';
|
||||
}
|
||||
|
||||
cleanupAndFinish(code, finalStatus);
|
||||
});
|
||||
|
||||
// Store session
|
||||
this.sessions.set(sessionId, session);
|
||||
|
||||
// Wait a moment to see if the process fails immediately
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
if (status.error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to start tests: ${status.error}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (status.exited) {
|
||||
// Process already exited - check if it was immediate failure
|
||||
const exitedSession = this.sessions.get(sessionId);
|
||||
if (exitedSession && exitedSession.status === 'error') {
|
||||
return {
|
||||
success: false,
|
||||
error: `Test process exited immediately. Check output for details.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Emit started event
|
||||
if (this.emitter) {
|
||||
this.emitter.emit('test-runner:started', {
|
||||
sessionId,
|
||||
worktreePath,
|
||||
command: finalCommand,
|
||||
testFile,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
sessionId,
|
||||
worktreePath,
|
||||
command: finalCommand,
|
||||
status: 'running',
|
||||
testFile,
|
||||
message: `Tests started: ${finalCommand}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a running test session
|
||||
*
|
||||
* @param sessionId - The ID of the test session to stop
|
||||
* @returns Result with success status and message
|
||||
*/
|
||||
async stopTests(sessionId: string): Promise<{
|
||||
success: boolean;
|
||||
result?: { sessionId: string; message: string };
|
||||
error?: string;
|
||||
}> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
|
||||
if (!session) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Test session not found: ${sessionId}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (session.status !== 'running') {
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
sessionId,
|
||||
message: `Tests already finished (status: ${session.status})`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
logger.info(`Cancelling test session ${sessionId}`);
|
||||
|
||||
// Mark as stopping to prevent further output events
|
||||
session.stopping = true;
|
||||
|
||||
// Clean up flush timeout
|
||||
if (session.flushTimeout) {
|
||||
clearTimeout(session.flushTimeout);
|
||||
session.flushTimeout = null;
|
||||
}
|
||||
|
||||
// Kill the process
|
||||
if (session.process && !session.process.killed && session.process.pid) {
|
||||
this.killProcessTree(session.process.pid);
|
||||
}
|
||||
|
||||
session.status = 'cancelled';
|
||||
session.finishedAt = new Date();
|
||||
|
||||
// Emit cancelled event
|
||||
if (this.emitter) {
|
||||
this.emitter.emit('test-runner:completed', {
|
||||
sessionId,
|
||||
worktreePath: session.worktreePath,
|
||||
command: session.command,
|
||||
status: 'cancelled',
|
||||
exitCode: null,
|
||||
duration: session.finishedAt.getTime() - session.startedAt.getTime(),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
sessionId,
|
||||
message: 'Test run cancelled',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active test session for a worktree
|
||||
*/
|
||||
getActiveSession(worktreePath: string): TestRunSession | undefined {
|
||||
for (const session of this.sessions.values()) {
|
||||
if (session.worktreePath === worktreePath && session.status === 'running') {
|
||||
return session;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a test session by ID
|
||||
*/
|
||||
getSession(sessionId: string): TestRunSession | undefined {
|
||||
return this.sessions.get(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get buffered output for a test session
|
||||
*/
|
||||
getSessionOutput(sessionId: string): {
|
||||
success: boolean;
|
||||
result?: {
|
||||
sessionId: string;
|
||||
output: string;
|
||||
status: TestRunStatus;
|
||||
startedAt: string;
|
||||
finishedAt: string | null;
|
||||
};
|
||||
error?: string;
|
||||
} {
|
||||
const session = this.sessions.get(sessionId);
|
||||
|
||||
if (!session) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Test session not found: ${sessionId}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
sessionId,
|
||||
output: session.scrollbackBuffer,
|
||||
status: session.status,
|
||||
startedAt: session.startedAt.toISOString(),
|
||||
finishedAt: session.finishedAt?.toISOString() || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List all test sessions (optionally filter by worktree)
|
||||
*/
|
||||
listSessions(worktreePath?: string): {
|
||||
success: boolean;
|
||||
result: {
|
||||
sessions: Array<{
|
||||
sessionId: string;
|
||||
worktreePath: string;
|
||||
command: string;
|
||||
status: TestRunStatus;
|
||||
testFile?: string;
|
||||
startedAt: string;
|
||||
finishedAt: string | null;
|
||||
exitCode: number | null;
|
||||
}>;
|
||||
};
|
||||
} {
|
||||
let sessions = Array.from(this.sessions.values());
|
||||
|
||||
if (worktreePath) {
|
||||
sessions = sessions.filter((s) => s.worktreePath === worktreePath);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
sessions: sessions.map((s) => ({
|
||||
sessionId: s.id,
|
||||
worktreePath: s.worktreePath,
|
||||
command: s.command,
|
||||
status: s.status,
|
||||
testFile: s.testFile,
|
||||
startedAt: s.startedAt.toISOString(),
|
||||
finishedAt: s.finishedAt?.toISOString() || null,
|
||||
exitCode: s.exitCode,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a worktree has an active test run
|
||||
*/
|
||||
isRunning(worktreePath: string): boolean {
|
||||
return this.getActiveSession(worktreePath) !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old completed sessions (keep only recent ones)
|
||||
*/
|
||||
cleanupOldSessions(maxAgeMs: number = 30 * 60 * 1000): void {
|
||||
const now = Date.now();
|
||||
for (const [sessionId, session] of this.sessions.entries()) {
|
||||
if (session.status !== 'running' && session.finishedAt) {
|
||||
if (now - session.finishedAt.getTime() > maxAgeMs) {
|
||||
this.sessions.delete(sessionId);
|
||||
logger.debug(`Cleaned up old test session: ${sessionId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all running test sessions (for cleanup)
|
||||
*/
|
||||
async cancelAll(): Promise<void> {
|
||||
logger.info(`Cancelling all ${this.sessions.size} test sessions`);
|
||||
|
||||
for (const session of this.sessions.values()) {
|
||||
if (session.status === 'running') {
|
||||
await this.stopTests(session.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup service resources
|
||||
*/
|
||||
async cleanup(): Promise<void> {
|
||||
await this.cancelAll();
|
||||
this.sessions.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let testRunnerServiceInstance: TestRunnerService | null = null;
|
||||
|
||||
export function getTestRunnerService(): TestRunnerService {
|
||||
if (!testRunnerServiceInstance) {
|
||||
testRunnerServiceInstance = new TestRunnerService();
|
||||
}
|
||||
return testRunnerServiceInstance;
|
||||
}
|
||||
|
||||
// Cleanup on process exit
|
||||
process.on('SIGTERM', () => {
|
||||
if (testRunnerServiceInstance) {
|
||||
testRunnerServiceInstance.cleanup().catch((err) => {
|
||||
logger.error('Cleanup failed on SIGTERM:', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
if (testRunnerServiceInstance) {
|
||||
testRunnerServiceInstance.cleanup().catch((err) => {
|
||||
logger.error('Cleanup failed on SIGINT:', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Export the class for testing purposes
|
||||
export { TestRunnerService };
|
||||
@@ -23,6 +23,16 @@ export type {
|
||||
PhaseModelConfig,
|
||||
PhaseModelKey,
|
||||
PhaseModelEntry,
|
||||
// Claude-compatible provider types
|
||||
ApiKeySource,
|
||||
ClaudeCompatibleProviderType,
|
||||
ClaudeModelAlias,
|
||||
ProviderModel,
|
||||
ClaudeCompatibleProvider,
|
||||
ClaudeCompatibleProviderTemplate,
|
||||
// Legacy profile types (deprecated)
|
||||
ClaudeApiProfile,
|
||||
ClaudeApiProfileTemplate,
|
||||
} from '@automaker/types';
|
||||
|
||||
export {
|
||||
|
||||
@@ -41,13 +41,14 @@ describe('model-resolver.ts', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should treat unknown models as falling back to default', () => {
|
||||
// Note: Don't include valid Cursor model IDs here (e.g., 'gpt-5.2' is in CURSOR_MODEL_MAP)
|
||||
const models = ['o1', 'o1-mini', 'o3', 'unknown-model', 'fake-model-123'];
|
||||
it('should pass through unknown models unchanged (may be provider models)', () => {
|
||||
// Unknown models now pass through unchanged to support ClaudeCompatibleProvider models
|
||||
// like GLM-4.7, MiniMax-M2.1, o1, etc.
|
||||
const models = ['o1', 'o1-mini', 'o3', 'unknown-model', 'fake-model-123', 'GLM-4.7'];
|
||||
models.forEach((model) => {
|
||||
const result = resolveModelString(model);
|
||||
// Should fall back to default since these aren't supported
|
||||
expect(result).toBe(DEFAULT_MODELS.claude);
|
||||
// Should pass through unchanged (could be provider models)
|
||||
expect(result).toBe(model);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,12 +74,12 @@ describe('model-resolver.ts', () => {
|
||||
expect(result).toBe(customDefault);
|
||||
});
|
||||
|
||||
it('should return default for unknown model key', () => {
|
||||
it('should pass through unknown model key unchanged (no warning)', () => {
|
||||
const result = resolveModelString('unknown-model');
|
||||
expect(result).toBe(DEFAULT_MODELS.claude);
|
||||
expect(consoleSpy.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Unknown model key "unknown-model"')
|
||||
);
|
||||
// Unknown models pass through unchanged (could be provider models)
|
||||
expect(result).toBe('unknown-model');
|
||||
// No warning - unknown models are valid for providers
|
||||
expect(consoleSpy.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
|
||||
@@ -325,8 +325,12 @@ describe('codex-provider.ts', () => {
|
||||
);
|
||||
|
||||
const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0];
|
||||
// xhigh reasoning effort should have 4x the default timeout (120000ms)
|
||||
expect(call.timeout).toBe(DEFAULT_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.xhigh);
|
||||
// xhigh reasoning effort uses 5-minute base timeout (300000ms) for feature generation
|
||||
// then applies 4x multiplier: 300000 * 4.0 = 1200000ms (20 minutes)
|
||||
const CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS = 300000;
|
||||
expect(call.timeout).toBe(
|
||||
CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS * REASONING_TIMEOUT_MULTIPLIERS.xhigh
|
||||
);
|
||||
});
|
||||
|
||||
it('uses default timeout when no reasoning effort is specified', async () => {
|
||||
|
||||
517
apps/server/tests/unit/providers/copilot-provider.test.ts
Normal file
517
apps/server/tests/unit/providers/copilot-provider.test.ts
Normal file
@@ -0,0 +1,517 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { CopilotProvider, CopilotErrorCode } from '@/providers/copilot-provider.js';
|
||||
|
||||
// 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(),
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock child_process with all needed exports
|
||||
vi.mock('child_process', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('child_process')>();
|
||||
return {
|
||||
...actual,
|
||||
execSync: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock fs (synchronous) for CLI detection (existsSync)
|
||||
vi.mock('fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('fs')>();
|
||||
return {
|
||||
...actual,
|
||||
existsSync: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock fs/promises
|
||||
vi.mock('fs/promises', () => ({
|
||||
access: vi.fn().mockRejectedValue(new Error('Not found')),
|
||||
readFile: vi.fn().mockRejectedValue(new Error('Not found')),
|
||||
mkdir: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Import execSync after mocking
|
||||
import { execSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
|
||||
describe('copilot-provider.ts', () => {
|
||||
let provider: CopilotProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Mock fs.existsSync for CLI path validation
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
|
||||
// Mock CLI detection to find the CLI
|
||||
// The CliProvider base class uses 'which copilot' (Unix) or 'where copilot' (Windows)
|
||||
// to find the CLI path, then validates with fs.existsSync
|
||||
vi.mocked(execSync).mockImplementation((cmd: string) => {
|
||||
// CLI path detection (which/where command)
|
||||
if (cmd.startsWith('which ') || cmd.startsWith('where ')) {
|
||||
return '/usr/local/bin/copilot';
|
||||
}
|
||||
if (cmd.includes('--version')) {
|
||||
return '1.0.0';
|
||||
}
|
||||
if (cmd.includes('gh auth status')) {
|
||||
return 'Logged in to github.com account testuser';
|
||||
}
|
||||
if (cmd.includes('models list')) {
|
||||
return JSON.stringify([{ id: 'claude-sonnet-4.5', name: 'Claude Sonnet 4.5' }]);
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
provider = new CopilotProvider();
|
||||
delete process.env.GITHUB_TOKEN;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('getName', () => {
|
||||
it("should return 'copilot' as provider name", () => {
|
||||
expect(provider.getName()).toBe('copilot');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCliName', () => {
|
||||
it("should return 'copilot' as CLI name", () => {
|
||||
expect(provider.getCliName()).toBe('copilot');
|
||||
});
|
||||
});
|
||||
|
||||
describe('supportsFeature', () => {
|
||||
it('should support tools feature', () => {
|
||||
expect(provider.supportsFeature('tools')).toBe(true);
|
||||
});
|
||||
|
||||
it('should support text feature', () => {
|
||||
expect(provider.supportsFeature('text')).toBe(true);
|
||||
});
|
||||
|
||||
it('should support streaming feature', () => {
|
||||
expect(provider.supportsFeature('streaming')).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT support vision feature (not implemented yet)', () => {
|
||||
expect(provider.supportsFeature('vision')).toBe(false);
|
||||
});
|
||||
|
||||
it('should not support unknown feature', () => {
|
||||
expect(provider.supportsFeature('unknown')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvailableModels', () => {
|
||||
it('should return static model definitions', () => {
|
||||
const models = provider.getAvailableModels();
|
||||
expect(Array.isArray(models)).toBe(true);
|
||||
expect(models.length).toBeGreaterThan(0);
|
||||
|
||||
// All models should have required fields
|
||||
models.forEach((model) => {
|
||||
expect(model.id).toBeDefined();
|
||||
expect(model.name).toBeDefined();
|
||||
expect(model.provider).toBe('copilot');
|
||||
});
|
||||
});
|
||||
|
||||
it('should include copilot- prefix in model IDs', () => {
|
||||
const models = provider.getAvailableModels();
|
||||
models.forEach((model) => {
|
||||
expect(model.id).toMatch(/^copilot-/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkAuth', () => {
|
||||
it('should return authenticated status when gh CLI is logged in', async () => {
|
||||
// Set up mocks BEFORE creating provider to ensure CLI detection succeeds
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(execSync).mockImplementation((cmd: string) => {
|
||||
// CLI path detection (which/where command)
|
||||
if (cmd.startsWith('which ') || cmd.startsWith('where ')) {
|
||||
return '/usr/local/bin/copilot';
|
||||
}
|
||||
if (cmd.includes('--version')) {
|
||||
return '1.0.0';
|
||||
}
|
||||
if (cmd.includes('gh auth status')) {
|
||||
return 'Logged in to github.com account testuser';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
// Create fresh provider with the mock in place
|
||||
const freshProvider = new CopilotProvider();
|
||||
const status = await freshProvider.checkAuth();
|
||||
expect(status.authenticated).toBe(true);
|
||||
expect(status.method).toBe('oauth');
|
||||
expect(status.login).toBe('testuser');
|
||||
});
|
||||
|
||||
it('should return unauthenticated when gh auth fails', async () => {
|
||||
// Set up mocks BEFORE creating provider
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(execSync).mockImplementation((cmd: string) => {
|
||||
// CLI path detection (which/where command)
|
||||
if (cmd.startsWith('which ') || cmd.startsWith('where ')) {
|
||||
return '/usr/local/bin/copilot';
|
||||
}
|
||||
if (cmd.includes('--version')) {
|
||||
return '1.0.0';
|
||||
}
|
||||
if (cmd.includes('gh auth status')) {
|
||||
throw new Error('Not logged in');
|
||||
}
|
||||
if (cmd.includes('copilot auth status')) {
|
||||
throw new Error('Not logged in');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
// Create fresh provider with the mock in place
|
||||
const freshProvider = new CopilotProvider();
|
||||
const status = await freshProvider.checkAuth();
|
||||
expect(status.authenticated).toBe(false);
|
||||
expect(status.method).toBe('none');
|
||||
});
|
||||
|
||||
it('should detect GITHUB_TOKEN environment variable', async () => {
|
||||
process.env.GITHUB_TOKEN = 'test-token';
|
||||
|
||||
// Set up mocks BEFORE creating provider
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(execSync).mockImplementation((cmd: string) => {
|
||||
// CLI path detection (which/where command)
|
||||
if (cmd.startsWith('which ') || cmd.startsWith('where ')) {
|
||||
return '/usr/local/bin/copilot';
|
||||
}
|
||||
if (cmd.includes('--version')) {
|
||||
return '1.0.0';
|
||||
}
|
||||
if (cmd.includes('gh auth status')) {
|
||||
throw new Error('Not logged in');
|
||||
}
|
||||
if (cmd.includes('copilot auth status')) {
|
||||
throw new Error('Not logged in');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
// Create fresh provider with the mock in place
|
||||
const freshProvider = new CopilotProvider();
|
||||
const status = await freshProvider.checkAuth();
|
||||
expect(status.authenticated).toBe(true);
|
||||
expect(status.method).toBe('oauth');
|
||||
|
||||
delete process.env.GITHUB_TOKEN;
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectInstallation', () => {
|
||||
it('should detect installed CLI', async () => {
|
||||
// Set up mocks BEFORE creating provider
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(execSync).mockImplementation((cmd: string) => {
|
||||
// CLI path detection (which/where command)
|
||||
if (cmd.startsWith('which ') || cmd.startsWith('where ')) {
|
||||
return '/usr/local/bin/copilot';
|
||||
}
|
||||
if (cmd.includes('--version')) {
|
||||
return '1.2.3';
|
||||
}
|
||||
if (cmd.includes('gh auth status')) {
|
||||
return 'Logged in to github.com account testuser';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
// Create fresh provider with the mock in place
|
||||
const freshProvider = new CopilotProvider();
|
||||
const status = await freshProvider.detectInstallation();
|
||||
expect(status.installed).toBe(true);
|
||||
expect(status.version).toBe('1.2.3');
|
||||
expect(status.authenticated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeEvent', () => {
|
||||
it('should normalize assistant.message event', () => {
|
||||
const event = {
|
||||
type: 'assistant.message',
|
||||
data: { content: 'Hello, world!' },
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result).toEqual({
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'Hello, world!' }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip assistant.message_delta event', () => {
|
||||
const event = {
|
||||
type: 'assistant.message_delta',
|
||||
data: { delta: 'partial' },
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should normalize tool.execution_start event', () => {
|
||||
const event = {
|
||||
type: 'tool.execution_start',
|
||||
data: {
|
||||
toolName: 'read_file',
|
||||
toolCallId: 'call-123',
|
||||
input: { path: '/test/file.txt' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result).toEqual({
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
name: 'Read', // Normalized from read_file
|
||||
tool_use_id: 'call-123',
|
||||
input: { path: '/test/file.txt', file_path: '/test/file.txt' }, // Path normalized
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should normalize tool.execution_end event', () => {
|
||||
const event = {
|
||||
type: 'tool.execution_end',
|
||||
data: {
|
||||
toolName: 'read_file',
|
||||
toolCallId: 'call-123',
|
||||
result: 'file content',
|
||||
},
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result).toEqual({
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'call-123',
|
||||
content: 'file content',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle tool.execution_end with error', () => {
|
||||
const event = {
|
||||
type: 'tool.execution_end',
|
||||
data: {
|
||||
toolName: 'bash',
|
||||
toolCallId: 'call-456',
|
||||
error: 'Command failed',
|
||||
},
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result?.message?.content?.[0]).toMatchObject({
|
||||
type: 'tool_result',
|
||||
content: '[ERROR] Command failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('should normalize session.idle to success result', () => {
|
||||
const event = { type: 'session.idle' };
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result).toEqual({
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
});
|
||||
});
|
||||
|
||||
it('should normalize session.error to error event', () => {
|
||||
const event = {
|
||||
type: 'session.error',
|
||||
data: { message: 'Something went wrong' },
|
||||
};
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result).toEqual({
|
||||
type: 'error',
|
||||
error: 'Something went wrong',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null for unknown event types', () => {
|
||||
const event = { type: 'unknown.event' };
|
||||
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapError', () => {
|
||||
it('should map authentication errors', () => {
|
||||
const errorInfo = (provider as any).mapError('not authenticated', null);
|
||||
expect(errorInfo.code).toBe(CopilotErrorCode.NOT_AUTHENTICATED);
|
||||
expect(errorInfo.recoverable).toBe(true);
|
||||
});
|
||||
|
||||
it('should map rate limit errors', () => {
|
||||
const errorInfo = (provider as any).mapError('rate limit exceeded', null);
|
||||
expect(errorInfo.code).toBe(CopilotErrorCode.RATE_LIMITED);
|
||||
expect(errorInfo.recoverable).toBe(true);
|
||||
});
|
||||
|
||||
it('should map model unavailable errors', () => {
|
||||
const errorInfo = (provider as any).mapError('model not available', null);
|
||||
expect(errorInfo.code).toBe(CopilotErrorCode.MODEL_UNAVAILABLE);
|
||||
expect(errorInfo.recoverable).toBe(true);
|
||||
});
|
||||
|
||||
it('should map network errors', () => {
|
||||
const errorInfo = (provider as any).mapError('connection refused', null);
|
||||
expect(errorInfo.code).toBe(CopilotErrorCode.NETWORK_ERROR);
|
||||
expect(errorInfo.recoverable).toBe(true);
|
||||
});
|
||||
|
||||
it('should map process crash (exit code 137)', () => {
|
||||
const errorInfo = (provider as any).mapError('', 137);
|
||||
expect(errorInfo.code).toBe(CopilotErrorCode.PROCESS_CRASHED);
|
||||
expect(errorInfo.recoverable).toBe(true);
|
||||
});
|
||||
|
||||
it('should return unknown error for unrecognized errors', () => {
|
||||
const errorInfo = (provider as any).mapError('some random error', 1);
|
||||
expect(errorInfo.code).toBe(CopilotErrorCode.UNKNOWN);
|
||||
expect(errorInfo.recoverable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('model cache', () => {
|
||||
it('should indicate when cache is empty', () => {
|
||||
expect(provider.hasCachedModels()).toBe(false);
|
||||
});
|
||||
|
||||
it('should clear model cache', () => {
|
||||
provider.clearModelCache();
|
||||
expect(provider.hasCachedModels()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool name normalization', () => {
|
||||
it('should normalize read_file to Read', () => {
|
||||
const event = {
|
||||
type: 'tool.execution_start',
|
||||
data: { toolName: 'read_file', toolCallId: 'id', input: {} },
|
||||
};
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result?.message?.content?.[0]).toMatchObject({ name: 'Read' });
|
||||
});
|
||||
|
||||
it('should normalize write_file to Write', () => {
|
||||
const event = {
|
||||
type: 'tool.execution_start',
|
||||
data: { toolName: 'write_file', toolCallId: 'id', input: {} },
|
||||
};
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result?.message?.content?.[0]).toMatchObject({ name: 'Write' });
|
||||
});
|
||||
|
||||
it('should normalize run_shell to Bash', () => {
|
||||
const event = {
|
||||
type: 'tool.execution_start',
|
||||
data: { toolName: 'run_shell', toolCallId: 'id', input: {} },
|
||||
};
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result?.message?.content?.[0]).toMatchObject({ name: 'Bash' });
|
||||
});
|
||||
|
||||
it('should normalize search to Grep', () => {
|
||||
const event = {
|
||||
type: 'tool.execution_start',
|
||||
data: { toolName: 'search', toolCallId: 'id', input: {} },
|
||||
};
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result?.message?.content?.[0]).toMatchObject({ name: 'Grep' });
|
||||
});
|
||||
|
||||
it('should normalize todo_write to TodoWrite', () => {
|
||||
const event = {
|
||||
type: 'tool.execution_start',
|
||||
data: {
|
||||
toolName: 'todo_write',
|
||||
toolCallId: 'id',
|
||||
input: {
|
||||
todos: [{ description: 'Test task', status: 'pending' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = provider.normalizeEvent(event);
|
||||
expect(result?.message?.content?.[0]).toMatchObject({ name: 'TodoWrite' });
|
||||
});
|
||||
|
||||
it('should normalize todo content from description', () => {
|
||||
const event = {
|
||||
type: 'tool.execution_start',
|
||||
data: {
|
||||
toolName: 'todo_write',
|
||||
toolCallId: 'id',
|
||||
input: {
|
||||
todos: [{ description: 'Test task', status: 'pending' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = provider.normalizeEvent(event);
|
||||
const todoInput = (result?.message?.content?.[0] as any)?.input;
|
||||
expect(todoInput.todos[0]).toMatchObject({
|
||||
content: 'Test task',
|
||||
status: 'pending',
|
||||
activeForm: 'Test task',
|
||||
});
|
||||
});
|
||||
|
||||
it('should map cancelled status to completed', () => {
|
||||
const event = {
|
||||
type: 'tool.execution_start',
|
||||
data: {
|
||||
toolName: 'todo_write',
|
||||
toolCallId: 'id',
|
||||
input: {
|
||||
todos: [{ description: 'Cancelled task', status: 'cancelled' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = provider.normalizeEvent(event);
|
||||
const todoInput = (result?.message?.content?.[0] as any)?.input;
|
||||
expect(todoInput.todos[0].status).toBe('completed');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1311,4 +1311,317 @@ describe('opencode-provider.ts', () => {
|
||||
expect(args[modelIndex + 1]).toBe('provider/model-v1.2.3-beta');
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// parseProvidersOutput Tests
|
||||
// ==========================================================================
|
||||
|
||||
describe('parseProvidersOutput', () => {
|
||||
// Helper function to access private method
|
||||
function parseProviders(output: string) {
|
||||
return (
|
||||
provider as unknown as {
|
||||
parseProvidersOutput: (output: string) => Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
authenticated: boolean;
|
||||
authMethod?: 'oauth' | 'api_key';
|
||||
}>;
|
||||
}
|
||||
).parseProvidersOutput(output);
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// Critical Fix Validation
|
||||
// =======================================================================
|
||||
|
||||
describe('Critical Fix Validation', () => {
|
||||
it('should map "z.ai coding plan" to "zai-coding-plan" (NOT "z-ai")', () => {
|
||||
const output = '● z.ai coding plan oauth';
|
||||
const result = parseProviders(output);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('zai-coding-plan');
|
||||
expect(result[0].name).toBe('z.ai coding plan');
|
||||
expect(result[0].authMethod).toBe('oauth');
|
||||
});
|
||||
|
||||
it('should map "z.ai" to "z-ai" (different from coding plan)', () => {
|
||||
const output = '● z.ai api';
|
||||
const result = parseProviders(output);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('z-ai');
|
||||
expect(result[0].name).toBe('z.ai');
|
||||
expect(result[0].authMethod).toBe('api_key');
|
||||
});
|
||||
|
||||
it('should distinguish between "z.ai coding plan" and "z.ai"', () => {
|
||||
const output = '● z.ai coding plan oauth\n● z.ai api';
|
||||
const result = parseProviders(output);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].id).toBe('zai-coding-plan');
|
||||
expect(result[0].name).toBe('z.ai coding plan');
|
||||
expect(result[1].id).toBe('z-ai');
|
||||
expect(result[1].name).toBe('z.ai');
|
||||
});
|
||||
});
|
||||
|
||||
// =======================================================================
|
||||
// Provider Name Mapping
|
||||
// =======================================================================
|
||||
|
||||
describe('Provider Name Mapping', () => {
|
||||
it('should map all 12 providers correctly', () => {
|
||||
const output = `● anthropic oauth
|
||||
● github copilot oauth
|
||||
● google api
|
||||
● openai api
|
||||
● openrouter api
|
||||
● azure api
|
||||
● amazon bedrock oauth
|
||||
● ollama api
|
||||
● lm studio api
|
||||
● opencode oauth
|
||||
● z.ai coding plan oauth
|
||||
● z.ai api`;
|
||||
|
||||
const result = parseProviders(output);
|
||||
|
||||
expect(result).toHaveLength(12);
|
||||
expect(result.map((p) => p.id)).toEqual([
|
||||
'anthropic',
|
||||
'github-copilot',
|
||||
'google',
|
||||
'openai',
|
||||
'openrouter',
|
||||
'azure',
|
||||
'amazon-bedrock',
|
||||
'ollama',
|
||||
'lmstudio',
|
||||
'opencode',
|
||||
'zai-coding-plan',
|
||||
'z-ai',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle case-insensitive provider names and preserve original casing', () => {
|
||||
const output = '● Anthropic api\n● OPENAI oauth\n● GitHub Copilot oauth';
|
||||
const result = parseProviders(output);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].id).toBe('anthropic');
|
||||
expect(result[0].name).toBe('Anthropic'); // Preserves casing
|
||||
expect(result[1].id).toBe('openai');
|
||||
expect(result[1].name).toBe('OPENAI'); // Preserves casing
|
||||
expect(result[2].id).toBe('github-copilot');
|
||||
expect(result[2].name).toBe('GitHub Copilot'); // Preserves casing
|
||||
});
|
||||
|
||||
it('should handle multi-word provider names with spaces', () => {
|
||||
const output = '● Amazon Bedrock oauth\n● LM Studio api\n● GitHub Copilot oauth';
|
||||
const result = parseProviders(output);
|
||||
|
||||
expect(result[0].id).toBe('amazon-bedrock');
|
||||
expect(result[0].name).toBe('Amazon Bedrock');
|
||||
expect(result[1].id).toBe('lmstudio');
|
||||
expect(result[1].name).toBe('LM Studio');
|
||||
expect(result[2].id).toBe('github-copilot');
|
||||
expect(result[2].name).toBe('GitHub Copilot');
|
||||
});
|
||||
});
|
||||
|
||||
// =======================================================================
|
||||
// Duplicate Aliases
|
||||
// =======================================================================
|
||||
|
||||
describe('Duplicate Aliases', () => {
|
||||
it('should map provider aliases to the same ID', () => {
|
||||
// Test copilot variants
|
||||
const copilot1 = parseProviders('● copilot oauth');
|
||||
const copilot2 = parseProviders('● github copilot oauth');
|
||||
expect(copilot1[0].id).toBe('github-copilot');
|
||||
expect(copilot2[0].id).toBe('github-copilot');
|
||||
|
||||
// Test bedrock variants
|
||||
const bedrock1 = parseProviders('● bedrock oauth');
|
||||
const bedrock2 = parseProviders('● amazon bedrock oauth');
|
||||
expect(bedrock1[0].id).toBe('amazon-bedrock');
|
||||
expect(bedrock2[0].id).toBe('amazon-bedrock');
|
||||
|
||||
// Test lmstudio variants
|
||||
const lm1 = parseProviders('● lmstudio api');
|
||||
const lm2 = parseProviders('● lm studio api');
|
||||
expect(lm1[0].id).toBe('lmstudio');
|
||||
expect(lm2[0].id).toBe('lmstudio');
|
||||
});
|
||||
});
|
||||
|
||||
// =======================================================================
|
||||
// Authentication Methods
|
||||
// =======================================================================
|
||||
|
||||
describe('Authentication Methods', () => {
|
||||
it('should detect oauth and api_key auth methods', () => {
|
||||
const output = '● anthropic oauth\n● openai api\n● google api_key';
|
||||
const result = parseProviders(output);
|
||||
|
||||
expect(result[0].authMethod).toBe('oauth');
|
||||
expect(result[1].authMethod).toBe('api_key');
|
||||
expect(result[2].authMethod).toBe('api_key');
|
||||
});
|
||||
|
||||
it('should set authenticated to true and handle case-insensitive auth methods', () => {
|
||||
const output = '● anthropic OAuth\n● openai API';
|
||||
const result = parseProviders(output);
|
||||
|
||||
expect(result[0].authenticated).toBe(true);
|
||||
expect(result[0].authMethod).toBe('oauth');
|
||||
expect(result[1].authenticated).toBe(true);
|
||||
expect(result[1].authMethod).toBe('api_key');
|
||||
});
|
||||
|
||||
it('should return undefined authMethod for unknown auth types', () => {
|
||||
const output = '● anthropic unknown-auth';
|
||||
const result = parseProviders(output);
|
||||
|
||||
expect(result[0].authenticated).toBe(true);
|
||||
expect(result[0].authMethod).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// =======================================================================
|
||||
// ANSI Escape Sequences
|
||||
// =======================================================================
|
||||
|
||||
describe('ANSI Escape Sequences', () => {
|
||||
it('should strip ANSI color codes from output', () => {
|
||||
const output = '\x1b[32m● anthropic oauth\x1b[0m';
|
||||
const result = parseProviders(output);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('anthropic');
|
||||
expect(result[0].name).toBe('anthropic');
|
||||
});
|
||||
|
||||
it('should handle complex ANSI sequences and codes in provider names', () => {
|
||||
const output =
|
||||
'\x1b[1;32m●\x1b[0m \x1b[33mgit\x1b[32mhub\x1b[0m copilot\x1b[0m \x1b[36moauth\x1b[0m';
|
||||
const result = parseProviders(output);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('github-copilot');
|
||||
});
|
||||
});
|
||||
|
||||
// =======================================================================
|
||||
// Edge Cases
|
||||
// =======================================================================
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should return empty array for empty output or no ● symbols', () => {
|
||||
expect(parseProviders('')).toEqual([]);
|
||||
expect(parseProviders('anthropic oauth\nopenai api')).toEqual([]);
|
||||
expect(parseProviders('No authenticated providers')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should skip malformed lines with ● but insufficient content', () => {
|
||||
const output = '●\n● \n● anthropic\n● openai api';
|
||||
const result = parseProviders(output);
|
||||
|
||||
// Only the last line has both provider name and auth method
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('openai');
|
||||
});
|
||||
|
||||
it('should use fallback for unknown providers (spaces to hyphens)', () => {
|
||||
const output = '● unknown provider name oauth';
|
||||
const result = parseProviders(output);
|
||||
|
||||
expect(result[0].id).toBe('unknown-provider-name');
|
||||
expect(result[0].name).toBe('unknown provider name');
|
||||
});
|
||||
|
||||
it('should handle extra whitespace and mixed case', () => {
|
||||
const output = '● AnThRoPiC oauth';
|
||||
const result = parseProviders(output);
|
||||
|
||||
expect(result[0].id).toBe('anthropic');
|
||||
expect(result[0].name).toBe('AnThRoPiC');
|
||||
});
|
||||
|
||||
it('should handle multiple ● symbols on same line', () => {
|
||||
const output = '● ● anthropic oauth';
|
||||
const result = parseProviders(output);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('anthropic');
|
||||
});
|
||||
|
||||
it('should handle different newline formats and trailing newlines', () => {
|
||||
const outputUnix = '● anthropic oauth\n● openai api';
|
||||
const outputWindows = '● anthropic oauth\r\n● openai api\r\n\r\n';
|
||||
|
||||
const resultUnix = parseProviders(outputUnix);
|
||||
const resultWindows = parseProviders(outputWindows);
|
||||
|
||||
expect(resultUnix).toHaveLength(2);
|
||||
expect(resultWindows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should handle provider names with numbers and special characters', () => {
|
||||
const output = '● gpt-4o api';
|
||||
const result = parseProviders(output);
|
||||
|
||||
expect(result[0].id).toBe('gpt-4o');
|
||||
expect(result[0].name).toBe('gpt-4o');
|
||||
});
|
||||
});
|
||||
|
||||
// =======================================================================
|
||||
// Real-world CLI Output
|
||||
// =======================================================================
|
||||
|
||||
describe('Real-world CLI Output', () => {
|
||||
it('should parse CLI output with box drawing characters and decorations', () => {
|
||||
const output = `┌─────────────────────────────────────────────────┐
|
||||
│ Authenticated Providers │
|
||||
├─────────────────────────────────────────────────┤
|
||||
● anthropic oauth
|
||||
● openai api
|
||||
└─────────────────────────────────────────────────┘`;
|
||||
|
||||
const result = parseProviders(output);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].id).toBe('anthropic');
|
||||
expect(result[1].id).toBe('openai');
|
||||
});
|
||||
|
||||
it('should parse output with ANSI colors and box characters', () => {
|
||||
const output = `\x1b[1m┌─────────────────────────────────────────────────┐\x1b[0m
|
||||
\x1b[1m│ Authenticated Providers │\x1b[0m
|
||||
\x1b[1m├─────────────────────────────────────────────────┤\x1b[0m
|
||||
\x1b[32m●\x1b[0m \x1b[33manthropic\x1b[0m \x1b[36moauth\x1b[0m
|
||||
\x1b[32m●\x1b[0m \x1b[33mgoogle\x1b[0m \x1b[36mapi\x1b[0m
|
||||
\x1b[1m└─────────────────────────────────────────────────┘\x1b[0m`;
|
||||
|
||||
const result = parseProviders(output);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].id).toBe('anthropic');
|
||||
expect(result[1].id).toBe('google');
|
||||
});
|
||||
|
||||
it('should handle "no authenticated providers" message', () => {
|
||||
const output = `┌─────────────────────────────────────────────────┐
|
||||
│ No authenticated providers found │
|
||||
└─────────────────────────────────────────────────┘`;
|
||||
|
||||
const result = parseProviders(output);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,8 @@ import { ClaudeProvider } from '@/providers/claude-provider.js';
|
||||
import { CursorProvider } from '@/providers/cursor-provider.js';
|
||||
import { CodexProvider } from '@/providers/codex-provider.js';
|
||||
import { OpencodeProvider } from '@/providers/opencode-provider.js';
|
||||
import { GeminiProvider } from '@/providers/gemini-provider.js';
|
||||
import { CopilotProvider } from '@/providers/copilot-provider.js';
|
||||
|
||||
describe('provider-factory.ts', () => {
|
||||
let consoleSpy: any;
|
||||
@@ -11,6 +13,8 @@ describe('provider-factory.ts', () => {
|
||||
let detectCursorSpy: any;
|
||||
let detectCodexSpy: any;
|
||||
let detectOpencodeSpy: any;
|
||||
let detectGeminiSpy: any;
|
||||
let detectCopilotSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleSpy = {
|
||||
@@ -30,6 +34,12 @@ describe('provider-factory.ts', () => {
|
||||
detectOpencodeSpy = vi
|
||||
.spyOn(OpencodeProvider.prototype, 'detectInstallation')
|
||||
.mockResolvedValue({ installed: true });
|
||||
detectGeminiSpy = vi
|
||||
.spyOn(GeminiProvider.prototype, 'detectInstallation')
|
||||
.mockResolvedValue({ installed: true });
|
||||
detectCopilotSpy = vi
|
||||
.spyOn(CopilotProvider.prototype, 'detectInstallation')
|
||||
.mockResolvedValue({ installed: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -38,6 +48,8 @@ describe('provider-factory.ts', () => {
|
||||
detectCursorSpy.mockRestore();
|
||||
detectCodexSpy.mockRestore();
|
||||
detectOpencodeSpy.mockRestore();
|
||||
detectGeminiSpy.mockRestore();
|
||||
detectCopilotSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('getProviderForModel', () => {
|
||||
@@ -166,9 +178,21 @@ describe('provider-factory.ts', () => {
|
||||
expect(hasClaudeProvider).toBe(true);
|
||||
});
|
||||
|
||||
it('should return exactly 4 providers', () => {
|
||||
it('should return exactly 6 providers', () => {
|
||||
const providers = ProviderFactory.getAllProviders();
|
||||
expect(providers).toHaveLength(4);
|
||||
expect(providers).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('should include CopilotProvider', () => {
|
||||
const providers = ProviderFactory.getAllProviders();
|
||||
const hasCopilotProvider = providers.some((p) => p instanceof CopilotProvider);
|
||||
expect(hasCopilotProvider).toBe(true);
|
||||
});
|
||||
|
||||
it('should include GeminiProvider', () => {
|
||||
const providers = ProviderFactory.getAllProviders();
|
||||
const hasGeminiProvider = providers.some((p) => p instanceof GeminiProvider);
|
||||
expect(hasGeminiProvider).toBe(true);
|
||||
});
|
||||
|
||||
it('should include CursorProvider', () => {
|
||||
@@ -206,7 +230,9 @@ describe('provider-factory.ts', () => {
|
||||
expect(keys).toContain('cursor');
|
||||
expect(keys).toContain('codex');
|
||||
expect(keys).toContain('opencode');
|
||||
expect(keys).toHaveLength(4);
|
||||
expect(keys).toContain('gemini');
|
||||
expect(keys).toContain('copilot');
|
||||
expect(keys).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('should include cursor status', async () => {
|
||||
|
||||
565
apps/server/tests/unit/routes/worktree/add-remote.test.ts
Normal file
565
apps/server/tests/unit/routes/worktree/add-remote.test.ts
Normal file
@@ -0,0 +1,565 @@
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import type { Request, Response } from 'express';
|
||||
import { createMockExpressContext } from '../../../utils/mocks.js';
|
||||
|
||||
// Mock child_process with importOriginal to keep other exports
|
||||
vi.mock('child_process', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('child_process')>();
|
||||
return {
|
||||
...actual,
|
||||
execFile: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock util.promisify to return the function as-is so we can mock execFile
|
||||
vi.mock('util', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('util')>();
|
||||
return {
|
||||
...actual,
|
||||
promisify: (fn: unknown) => fn,
|
||||
};
|
||||
});
|
||||
|
||||
// Import handler after mocks are set up
|
||||
import { createAddRemoteHandler } from '@/routes/worktree/routes/add-remote.js';
|
||||
import { execFile } from 'child_process';
|
||||
|
||||
// Get the mocked execFile
|
||||
const mockExecFile = execFile as Mock;
|
||||
|
||||
/**
|
||||
* Helper to create a standard mock implementation for git commands
|
||||
*/
|
||||
function createGitMock(options: {
|
||||
existingRemotes?: string[];
|
||||
addRemoteFails?: boolean;
|
||||
addRemoteError?: string;
|
||||
fetchFails?: boolean;
|
||||
}): (command: string, args: string[]) => Promise<{ stdout: string; stderr: string }> {
|
||||
const {
|
||||
existingRemotes = [],
|
||||
addRemoteFails = false,
|
||||
addRemoteError = 'git remote add failed',
|
||||
fetchFails = false,
|
||||
} = options;
|
||||
|
||||
return (command: string, args: string[]) => {
|
||||
if (command === 'git' && args[0] === 'remote' && args.length === 1) {
|
||||
return Promise.resolve({ stdout: existingRemotes.join('\n'), stderr: '' });
|
||||
}
|
||||
if (command === 'git' && args[0] === 'remote' && args[1] === 'add') {
|
||||
if (addRemoteFails) {
|
||||
return Promise.reject(new Error(addRemoteError));
|
||||
}
|
||||
return Promise.resolve({ stdout: '', stderr: '' });
|
||||
}
|
||||
if (command === 'git' && args[0] === 'fetch') {
|
||||
if (fetchFails) {
|
||||
return Promise.reject(new Error('fetch failed'));
|
||||
}
|
||||
return Promise.resolve({ stdout: '', stderr: '' });
|
||||
}
|
||||
return Promise.resolve({ stdout: '', stderr: '' });
|
||||
};
|
||||
}
|
||||
|
||||
describe('add-remote route', () => {
|
||||
let req: Request;
|
||||
let res: Response;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
const context = createMockExpressContext();
|
||||
req = context.req;
|
||||
res = context.res;
|
||||
});
|
||||
|
||||
describe('input validation', () => {
|
||||
it('should return 400 if worktreePath is missing', async () => {
|
||||
req.body = { remoteName: 'origin', remoteUrl: 'https://github.com/user/repo.git' };
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'worktreePath required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if remoteName is missing', async () => {
|
||||
req.body = { worktreePath: '/test/path', remoteUrl: 'https://github.com/user/repo.git' };
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'remoteName required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if remoteUrl is missing', async () => {
|
||||
req.body = { worktreePath: '/test/path', remoteName: 'origin' };
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'remoteUrl required',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote name validation', () => {
|
||||
it('should return 400 for empty remote name', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: '',
|
||||
remoteUrl: 'https://github.com/user/repo.git',
|
||||
};
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'remoteName required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 for remote name starting with dash', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: '-invalid',
|
||||
remoteUrl: 'https://github.com/user/repo.git',
|
||||
};
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error:
|
||||
'Invalid remote name. Must start with alphanumeric character and contain only letters, numbers, dashes, underscores, or periods.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 for remote name starting with period', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: '.invalid',
|
||||
remoteUrl: 'https://github.com/user/repo.git',
|
||||
};
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error:
|
||||
'Invalid remote name. Must start with alphanumeric character and contain only letters, numbers, dashes, underscores, or periods.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 for remote name with invalid characters', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: 'invalid name',
|
||||
remoteUrl: 'https://github.com/user/repo.git',
|
||||
};
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error:
|
||||
'Invalid remote name. Must start with alphanumeric character and contain only letters, numbers, dashes, underscores, or periods.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 for remote name exceeding 250 characters', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: 'a'.repeat(251),
|
||||
remoteUrl: 'https://github.com/user/repo.git',
|
||||
};
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error:
|
||||
'Invalid remote name. Must start with alphanumeric character and contain only letters, numbers, dashes, underscores, or periods.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should accept valid remote names with alphanumeric, dashes, underscores, and periods', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: 'my-remote_name.1',
|
||||
remoteUrl: 'https://github.com/user/repo.git',
|
||||
};
|
||||
|
||||
// Mock git remote to return empty list (no existing remotes)
|
||||
mockExecFile.mockImplementation(createGitMock({ existingRemotes: [] }));
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
// Should not return 400 for invalid name
|
||||
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote URL validation', () => {
|
||||
it('should return 400 for empty remote URL', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: '',
|
||||
};
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'remoteUrl required',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 for invalid remote URL', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'not-a-valid-url',
|
||||
};
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'Invalid remote URL. Must be a valid git URL (HTTPS, SSH, or git:// protocol).',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 for URL exceeding 2048 characters', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'https://github.com/' + 'a'.repeat(2049) + '.git',
|
||||
};
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'Invalid remote URL. Must be a valid git URL (HTTPS, SSH, or git:// protocol).',
|
||||
});
|
||||
});
|
||||
|
||||
it('should accept HTTPS URLs', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'https://github.com/user/repo.git',
|
||||
};
|
||||
|
||||
mockExecFile.mockImplementation(createGitMock({ existingRemotes: [] }));
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||
});
|
||||
|
||||
it('should accept HTTP URLs', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'http://github.com/user/repo.git',
|
||||
};
|
||||
|
||||
mockExecFile.mockImplementation(createGitMock({ existingRemotes: [] }));
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||
});
|
||||
|
||||
it('should accept SSH URLs', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'git@github.com:user/repo.git',
|
||||
};
|
||||
|
||||
mockExecFile.mockImplementation(createGitMock({ existingRemotes: [] }));
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||
});
|
||||
|
||||
it('should accept git:// protocol URLs', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'git://github.com/user/repo.git',
|
||||
};
|
||||
|
||||
mockExecFile.mockImplementation(createGitMock({ existingRemotes: [] }));
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||
});
|
||||
|
||||
it('should accept ssh:// protocol URLs', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'ssh://git@github.com/user/repo.git',
|
||||
};
|
||||
|
||||
mockExecFile.mockImplementation(createGitMock({ existingRemotes: [] }));
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote already exists check', () => {
|
||||
it('should return 400 with REMOTE_EXISTS code when remote already exists', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'https://github.com/user/repo.git',
|
||||
};
|
||||
|
||||
mockExecFile.mockImplementation(createGitMock({ existingRemotes: ['origin', 'upstream'] }));
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: "Remote 'origin' already exists",
|
||||
code: 'REMOTE_EXISTS',
|
||||
});
|
||||
});
|
||||
|
||||
it('should proceed if remote does not exist', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: 'new-remote',
|
||||
remoteUrl: 'https://github.com/user/repo.git',
|
||||
};
|
||||
|
||||
mockExecFile.mockImplementation(createGitMock({ existingRemotes: ['origin'] }));
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
// Should call git remote add with array arguments
|
||||
expect(mockExecFile).toHaveBeenCalledWith(
|
||||
'git',
|
||||
['remote', 'add', 'new-remote', 'https://github.com/user/repo.git'],
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('successful remote addition', () => {
|
||||
it('should add remote successfully with successful fetch', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: 'upstream',
|
||||
remoteUrl: 'https://github.com/other/repo.git',
|
||||
};
|
||||
|
||||
mockExecFile.mockImplementation(
|
||||
createGitMock({ existingRemotes: ['origin'], fetchFails: false })
|
||||
);
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
result: {
|
||||
remoteName: 'upstream',
|
||||
remoteUrl: 'https://github.com/other/repo.git',
|
||||
fetched: true,
|
||||
message: "Successfully added remote 'upstream' and fetched its branches",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should add remote successfully even if fetch fails', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: 'upstream',
|
||||
remoteUrl: 'https://github.com/other/repo.git',
|
||||
};
|
||||
|
||||
mockExecFile.mockImplementation(
|
||||
createGitMock({ existingRemotes: ['origin'], fetchFails: true })
|
||||
);
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
result: {
|
||||
remoteName: 'upstream',
|
||||
remoteUrl: 'https://github.com/other/repo.git',
|
||||
fetched: false,
|
||||
message:
|
||||
"Successfully added remote 'upstream' (fetch failed - you may need to fetch manually)",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass correct cwd option to git commands', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/custom/worktree/path',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'https://github.com/user/repo.git',
|
||||
};
|
||||
|
||||
const execCalls: { command: string; args: string[]; options: unknown }[] = [];
|
||||
mockExecFile.mockImplementation((command: string, args: string[], options: unknown) => {
|
||||
execCalls.push({ command, args, options });
|
||||
if (command === 'git' && args[0] === 'remote' && args.length === 1) {
|
||||
return Promise.resolve({ stdout: '', stderr: '' });
|
||||
}
|
||||
return Promise.resolve({ stdout: '', stderr: '' });
|
||||
});
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
// Check that git remote was called with correct cwd
|
||||
expect((execCalls[0].options as { cwd: string }).cwd).toBe('/custom/worktree/path');
|
||||
// Check that git remote add was called with correct cwd
|
||||
expect((execCalls[1].options as { cwd: string }).cwd).toBe('/custom/worktree/path');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should return 500 when git remote add fails', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'https://github.com/user/repo.git',
|
||||
};
|
||||
|
||||
mockExecFile.mockImplementation(
|
||||
createGitMock({
|
||||
existingRemotes: [],
|
||||
addRemoteFails: true,
|
||||
addRemoteError: 'git remote add failed',
|
||||
})
|
||||
);
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: 'git remote add failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('should continue adding remote if git remote check fails', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'https://github.com/user/repo.git',
|
||||
};
|
||||
|
||||
mockExecFile.mockImplementation((command: string, args: string[]) => {
|
||||
if (command === 'git' && args[0] === 'remote' && args.length === 1) {
|
||||
return Promise.reject(new Error('not a git repo'));
|
||||
}
|
||||
if (command === 'git' && args[0] === 'remote' && args[1] === 'add') {
|
||||
return Promise.resolve({ stdout: '', stderr: '' });
|
||||
}
|
||||
if (command === 'git' && args[0] === 'fetch') {
|
||||
return Promise.resolve({ stdout: '', stderr: '' });
|
||||
}
|
||||
return Promise.resolve({ stdout: '', stderr: '' });
|
||||
});
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
// Should still try to add remote with array arguments
|
||||
expect(mockExecFile).toHaveBeenCalledWith(
|
||||
'git',
|
||||
['remote', 'add', 'origin', 'https://github.com/user/repo.git'],
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
result: expect.objectContaining({
|
||||
remoteName: 'origin',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle non-Error exceptions', async () => {
|
||||
req.body = {
|
||||
worktreePath: '/test/path',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'https://github.com/user/repo.git',
|
||||
};
|
||||
|
||||
mockExecFile.mockImplementation((command: string, args: string[]) => {
|
||||
if (command === 'git' && args[0] === 'remote' && args.length === 1) {
|
||||
return Promise.resolve({ stdout: '', stderr: '' });
|
||||
}
|
||||
if (command === 'git' && args[0] === 'remote' && args[1] === 'add') {
|
||||
return Promise.reject('String error');
|
||||
}
|
||||
return Promise.resolve({ stdout: '', stderr: '' });
|
||||
});
|
||||
|
||||
const handler = createAddRemoteHandler();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
error: expect.any(String),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -315,4 +315,531 @@ describe('auto-mode-service.ts', () => {
|
||||
expect(duration).toBeLessThan(40);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectOrphanedFeatures', () => {
|
||||
// Helper to mock featureLoader.getAll
|
||||
const mockFeatureLoaderGetAll = (svc: AutoModeService, mockFn: ReturnType<typeof vi.fn>) => {
|
||||
(svc as any).featureLoader = { getAll: mockFn };
|
||||
};
|
||||
|
||||
// Helper to mock getExistingBranches
|
||||
const mockGetExistingBranches = (svc: AutoModeService, branches: string[]) => {
|
||||
(svc as any).getExistingBranches = vi.fn().mockResolvedValue(new Set(branches));
|
||||
};
|
||||
|
||||
it('should return empty array when no features have branch names', async () => {
|
||||
const getAllMock = vi.fn().mockResolvedValue([
|
||||
{ id: 'f1', title: 'Feature 1', description: 'desc', category: 'test' },
|
||||
{ id: 'f2', title: 'Feature 2', description: 'desc', category: 'test' },
|
||||
] satisfies Feature[]);
|
||||
mockFeatureLoaderGetAll(service, getAllMock);
|
||||
mockGetExistingBranches(service, ['main', 'develop']);
|
||||
|
||||
const result = await service.detectOrphanedFeatures('/test/project');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array when all feature branches exist', async () => {
|
||||
const getAllMock = vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'f1',
|
||||
title: 'Feature 1',
|
||||
description: 'desc',
|
||||
category: 'test',
|
||||
branchName: 'feature-1',
|
||||
},
|
||||
{
|
||||
id: 'f2',
|
||||
title: 'Feature 2',
|
||||
description: 'desc',
|
||||
category: 'test',
|
||||
branchName: 'feature-2',
|
||||
},
|
||||
] satisfies Feature[]);
|
||||
mockFeatureLoaderGetAll(service, getAllMock);
|
||||
mockGetExistingBranches(service, ['main', 'feature-1', 'feature-2']);
|
||||
|
||||
const result = await service.detectOrphanedFeatures('/test/project');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should detect orphaned features with missing branches', async () => {
|
||||
const features: Feature[] = [
|
||||
{
|
||||
id: 'f1',
|
||||
title: 'Feature 1',
|
||||
description: 'desc',
|
||||
category: 'test',
|
||||
branchName: 'feature-1',
|
||||
},
|
||||
{
|
||||
id: 'f2',
|
||||
title: 'Feature 2',
|
||||
description: 'desc',
|
||||
category: 'test',
|
||||
branchName: 'deleted-branch',
|
||||
},
|
||||
{ id: 'f3', title: 'Feature 3', description: 'desc', category: 'test' }, // No branch
|
||||
];
|
||||
const getAllMock = vi.fn().mockResolvedValue(features);
|
||||
mockFeatureLoaderGetAll(service, getAllMock);
|
||||
mockGetExistingBranches(service, ['main', 'feature-1']); // deleted-branch not in list
|
||||
|
||||
const result = await service.detectOrphanedFeatures('/test/project');
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].feature.id).toBe('f2');
|
||||
expect(result[0].missingBranch).toBe('deleted-branch');
|
||||
});
|
||||
|
||||
it('should detect multiple orphaned features', async () => {
|
||||
const features: Feature[] = [
|
||||
{
|
||||
id: 'f1',
|
||||
title: 'Feature 1',
|
||||
description: 'desc',
|
||||
category: 'test',
|
||||
branchName: 'orphan-1',
|
||||
},
|
||||
{
|
||||
id: 'f2',
|
||||
title: 'Feature 2',
|
||||
description: 'desc',
|
||||
category: 'test',
|
||||
branchName: 'orphan-2',
|
||||
},
|
||||
{
|
||||
id: 'f3',
|
||||
title: 'Feature 3',
|
||||
description: 'desc',
|
||||
category: 'test',
|
||||
branchName: 'valid-branch',
|
||||
},
|
||||
];
|
||||
const getAllMock = vi.fn().mockResolvedValue(features);
|
||||
mockFeatureLoaderGetAll(service, getAllMock);
|
||||
mockGetExistingBranches(service, ['main', 'valid-branch']);
|
||||
|
||||
const result = await service.detectOrphanedFeatures('/test/project');
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((r) => r.feature.id)).toContain('f1');
|
||||
expect(result.map((r) => r.feature.id)).toContain('f2');
|
||||
});
|
||||
|
||||
it('should return empty array when getAll throws error', async () => {
|
||||
const getAllMock = vi.fn().mockRejectedValue(new Error('Failed to load features'));
|
||||
mockFeatureLoaderGetAll(service, getAllMock);
|
||||
|
||||
const result = await service.detectOrphanedFeatures('/test/project');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should ignore empty branchName strings', async () => {
|
||||
const features: Feature[] = [
|
||||
{ id: 'f1', title: 'Feature 1', description: 'desc', category: 'test', branchName: '' },
|
||||
{ id: 'f2', title: 'Feature 2', description: 'desc', category: 'test', branchName: ' ' },
|
||||
];
|
||||
const getAllMock = vi.fn().mockResolvedValue(features);
|
||||
mockFeatureLoaderGetAll(service, getAllMock);
|
||||
mockGetExistingBranches(service, ['main']);
|
||||
|
||||
const result = await service.detectOrphanedFeatures('/test/project');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should skip features whose branchName matches the primary branch', async () => {
|
||||
const features: Feature[] = [
|
||||
{ id: 'f1', title: 'Feature 1', description: 'desc', category: 'test', branchName: 'main' },
|
||||
{
|
||||
id: 'f2',
|
||||
title: 'Feature 2',
|
||||
description: 'desc',
|
||||
category: 'test',
|
||||
branchName: 'orphaned',
|
||||
},
|
||||
];
|
||||
const getAllMock = vi.fn().mockResolvedValue(features);
|
||||
mockFeatureLoaderGetAll(service, getAllMock);
|
||||
mockGetExistingBranches(service, ['main', 'develop']);
|
||||
// Mock getCurrentBranch to return 'main'
|
||||
(service as any).getCurrentBranch = vi.fn().mockResolvedValue('main');
|
||||
|
||||
const result = await service.detectOrphanedFeatures('/test/project');
|
||||
|
||||
// Only f2 should be orphaned (orphaned branch doesn't exist)
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].feature.id).toBe('f2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('markFeatureInterrupted', () => {
|
||||
// Helper to mock updateFeatureStatus
|
||||
const mockUpdateFeatureStatus = (svc: AutoModeService, mockFn: ReturnType<typeof vi.fn>) => {
|
||||
(svc as any).updateFeatureStatus = mockFn;
|
||||
};
|
||||
|
||||
// Helper to mock loadFeature
|
||||
const mockLoadFeature = (svc: AutoModeService, mockFn: ReturnType<typeof vi.fn>) => {
|
||||
(svc as any).loadFeature = mockFn;
|
||||
};
|
||||
|
||||
it('should call updateFeatureStatus with interrupted status for non-pipeline features', async () => {
|
||||
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-123', status: 'in_progress' });
|
||||
const updateMock = vi.fn().mockResolvedValue(undefined);
|
||||
mockLoadFeature(service, loadMock);
|
||||
mockUpdateFeatureStatus(service, updateMock);
|
||||
|
||||
await service.markFeatureInterrupted('/test/project', 'feature-123');
|
||||
|
||||
expect(updateMock).toHaveBeenCalledWith('/test/project', 'feature-123', 'interrupted');
|
||||
});
|
||||
|
||||
it('should call updateFeatureStatus with reason when provided', async () => {
|
||||
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-123', status: 'in_progress' });
|
||||
const updateMock = vi.fn().mockResolvedValue(undefined);
|
||||
mockLoadFeature(service, loadMock);
|
||||
mockUpdateFeatureStatus(service, updateMock);
|
||||
|
||||
await service.markFeatureInterrupted('/test/project', 'feature-123', 'server shutdown');
|
||||
|
||||
expect(updateMock).toHaveBeenCalledWith('/test/project', 'feature-123', 'interrupted');
|
||||
});
|
||||
|
||||
it('should propagate errors from updateFeatureStatus', async () => {
|
||||
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-123', status: 'in_progress' });
|
||||
const updateMock = vi.fn().mockRejectedValue(new Error('Update failed'));
|
||||
mockLoadFeature(service, loadMock);
|
||||
mockUpdateFeatureStatus(service, updateMock);
|
||||
|
||||
await expect(service.markFeatureInterrupted('/test/project', 'feature-123')).rejects.toThrow(
|
||||
'Update failed'
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve pipeline_implementation status instead of marking as interrupted', async () => {
|
||||
const loadMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ id: 'feature-123', status: 'pipeline_implementation' });
|
||||
const updateMock = vi.fn().mockResolvedValue(undefined);
|
||||
mockLoadFeature(service, loadMock);
|
||||
mockUpdateFeatureStatus(service, updateMock);
|
||||
|
||||
await service.markFeatureInterrupted('/test/project', 'feature-123', 'server shutdown');
|
||||
|
||||
// updateFeatureStatus should NOT be called for pipeline statuses
|
||||
expect(updateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should preserve pipeline_testing status instead of marking as interrupted', async () => {
|
||||
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-123', status: 'pipeline_testing' });
|
||||
const updateMock = vi.fn().mockResolvedValue(undefined);
|
||||
mockLoadFeature(service, loadMock);
|
||||
mockUpdateFeatureStatus(service, updateMock);
|
||||
|
||||
await service.markFeatureInterrupted('/test/project', 'feature-123');
|
||||
|
||||
expect(updateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should preserve pipeline_review status instead of marking as interrupted', async () => {
|
||||
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-123', status: 'pipeline_review' });
|
||||
const updateMock = vi.fn().mockResolvedValue(undefined);
|
||||
mockLoadFeature(service, loadMock);
|
||||
mockUpdateFeatureStatus(service, updateMock);
|
||||
|
||||
await service.markFeatureInterrupted('/test/project', 'feature-123');
|
||||
|
||||
expect(updateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should mark feature as interrupted when loadFeature returns null', async () => {
|
||||
const loadMock = vi.fn().mockResolvedValue(null);
|
||||
const updateMock = vi.fn().mockResolvedValue(undefined);
|
||||
mockLoadFeature(service, loadMock);
|
||||
mockUpdateFeatureStatus(service, updateMock);
|
||||
|
||||
await service.markFeatureInterrupted('/test/project', 'feature-123');
|
||||
|
||||
expect(updateMock).toHaveBeenCalledWith('/test/project', 'feature-123', 'interrupted');
|
||||
});
|
||||
|
||||
it('should mark feature as interrupted for pending status', async () => {
|
||||
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-123', status: 'pending' });
|
||||
const updateMock = vi.fn().mockResolvedValue(undefined);
|
||||
mockLoadFeature(service, loadMock);
|
||||
mockUpdateFeatureStatus(service, updateMock);
|
||||
|
||||
await service.markFeatureInterrupted('/test/project', 'feature-123');
|
||||
|
||||
expect(updateMock).toHaveBeenCalledWith('/test/project', 'feature-123', 'interrupted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('markAllRunningFeaturesInterrupted', () => {
|
||||
// Helper to access private runningFeatures Map
|
||||
const getRunningFeaturesMap = (svc: AutoModeService) =>
|
||||
(svc as any).runningFeatures as Map<
|
||||
string,
|
||||
{ featureId: string; projectPath: string; isAutoMode: boolean }
|
||||
>;
|
||||
|
||||
// Helper to mock updateFeatureStatus
|
||||
const mockUpdateFeatureStatus = (svc: AutoModeService, mockFn: ReturnType<typeof vi.fn>) => {
|
||||
(svc as any).updateFeatureStatus = mockFn;
|
||||
};
|
||||
|
||||
// Helper to mock loadFeature
|
||||
const mockLoadFeature = (svc: AutoModeService, mockFn: ReturnType<typeof vi.fn>) => {
|
||||
(svc as any).loadFeature = mockFn;
|
||||
};
|
||||
|
||||
it('should do nothing when no features are running', async () => {
|
||||
const updateMock = vi.fn().mockResolvedValue(undefined);
|
||||
mockUpdateFeatureStatus(service, updateMock);
|
||||
|
||||
await service.markAllRunningFeaturesInterrupted();
|
||||
|
||||
expect(updateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should mark a single running feature as interrupted', async () => {
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
runningFeaturesMap.set('feature-1', {
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/project/path',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-1', status: 'in_progress' });
|
||||
const updateMock = vi.fn().mockResolvedValue(undefined);
|
||||
mockLoadFeature(service, loadMock);
|
||||
mockUpdateFeatureStatus(service, updateMock);
|
||||
|
||||
await service.markAllRunningFeaturesInterrupted();
|
||||
|
||||
expect(updateMock).toHaveBeenCalledWith('/project/path', 'feature-1', 'interrupted');
|
||||
});
|
||||
|
||||
it('should mark multiple running features as interrupted', async () => {
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
runningFeaturesMap.set('feature-1', {
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/project-a',
|
||||
isAutoMode: true,
|
||||
});
|
||||
runningFeaturesMap.set('feature-2', {
|
||||
featureId: 'feature-2',
|
||||
projectPath: '/project-b',
|
||||
isAutoMode: false,
|
||||
});
|
||||
runningFeaturesMap.set('feature-3', {
|
||||
featureId: 'feature-3',
|
||||
projectPath: '/project-a',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
const loadMock = vi.fn().mockResolvedValue({ status: 'in_progress' });
|
||||
const updateMock = vi.fn().mockResolvedValue(undefined);
|
||||
mockLoadFeature(service, loadMock);
|
||||
mockUpdateFeatureStatus(service, updateMock);
|
||||
|
||||
await service.markAllRunningFeaturesInterrupted();
|
||||
|
||||
expect(updateMock).toHaveBeenCalledTimes(3);
|
||||
expect(updateMock).toHaveBeenCalledWith('/project-a', 'feature-1', 'interrupted');
|
||||
expect(updateMock).toHaveBeenCalledWith('/project-b', 'feature-2', 'interrupted');
|
||||
expect(updateMock).toHaveBeenCalledWith('/project-a', 'feature-3', 'interrupted');
|
||||
});
|
||||
|
||||
it('should mark features in parallel', async () => {
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
runningFeaturesMap.set(`feature-${i}`, {
|
||||
featureId: `feature-${i}`,
|
||||
projectPath: `/project-${i}`,
|
||||
isAutoMode: true,
|
||||
});
|
||||
}
|
||||
|
||||
const loadMock = vi.fn().mockResolvedValue({ status: 'in_progress' });
|
||||
const callOrder: string[] = [];
|
||||
const updateMock = vi.fn().mockImplementation(async (_path: string, featureId: string) => {
|
||||
callOrder.push(featureId);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
mockLoadFeature(service, loadMock);
|
||||
mockUpdateFeatureStatus(service, updateMock);
|
||||
|
||||
const startTime = Date.now();
|
||||
await service.markAllRunningFeaturesInterrupted();
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
expect(updateMock).toHaveBeenCalledTimes(5);
|
||||
// If executed in parallel, total time should be ~10ms
|
||||
// If sequential, it would be ~50ms (5 * 10ms)
|
||||
expect(duration).toBeLessThan(40);
|
||||
});
|
||||
|
||||
it('should continue marking other features when one fails', async () => {
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
runningFeaturesMap.set('feature-1', {
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/project-a',
|
||||
isAutoMode: true,
|
||||
});
|
||||
runningFeaturesMap.set('feature-2', {
|
||||
featureId: 'feature-2',
|
||||
projectPath: '/project-b',
|
||||
isAutoMode: false,
|
||||
});
|
||||
|
||||
const loadMock = vi.fn().mockResolvedValue({ status: 'in_progress' });
|
||||
const updateMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error('Failed to update'));
|
||||
mockLoadFeature(service, loadMock);
|
||||
mockUpdateFeatureStatus(service, updateMock);
|
||||
|
||||
// Should not throw even though one feature failed
|
||||
await expect(service.markAllRunningFeaturesInterrupted()).resolves.not.toThrow();
|
||||
|
||||
expect(updateMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should use provided reason in logging', async () => {
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
runningFeaturesMap.set('feature-1', {
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/project/path',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-1', status: 'in_progress' });
|
||||
const updateMock = vi.fn().mockResolvedValue(undefined);
|
||||
mockLoadFeature(service, loadMock);
|
||||
mockUpdateFeatureStatus(service, updateMock);
|
||||
|
||||
await service.markAllRunningFeaturesInterrupted('manual stop');
|
||||
|
||||
expect(updateMock).toHaveBeenCalledWith('/project/path', 'feature-1', 'interrupted');
|
||||
});
|
||||
|
||||
it('should use default reason when none provided', async () => {
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
runningFeaturesMap.set('feature-1', {
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/project/path',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
const loadMock = vi.fn().mockResolvedValue({ id: 'feature-1', status: 'in_progress' });
|
||||
const updateMock = vi.fn().mockResolvedValue(undefined);
|
||||
mockLoadFeature(service, loadMock);
|
||||
mockUpdateFeatureStatus(service, updateMock);
|
||||
|
||||
await service.markAllRunningFeaturesInterrupted();
|
||||
|
||||
expect(updateMock).toHaveBeenCalledWith('/project/path', 'feature-1', 'interrupted');
|
||||
});
|
||||
|
||||
it('should preserve pipeline statuses for running features', async () => {
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
runningFeaturesMap.set('feature-1', {
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/project-a',
|
||||
isAutoMode: true,
|
||||
});
|
||||
runningFeaturesMap.set('feature-2', {
|
||||
featureId: 'feature-2',
|
||||
projectPath: '/project-b',
|
||||
isAutoMode: false,
|
||||
});
|
||||
runningFeaturesMap.set('feature-3', {
|
||||
featureId: 'feature-3',
|
||||
projectPath: '/project-c',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
// feature-1 has in_progress (should be interrupted)
|
||||
// feature-2 has pipeline_testing (should be preserved)
|
||||
// feature-3 has pipeline_implementation (should be preserved)
|
||||
const loadMock = vi
|
||||
.fn()
|
||||
.mockImplementation(async (_projectPath: string, featureId: string) => {
|
||||
if (featureId === 'feature-1') return { id: 'feature-1', status: 'in_progress' };
|
||||
if (featureId === 'feature-2') return { id: 'feature-2', status: 'pipeline_testing' };
|
||||
if (featureId === 'feature-3')
|
||||
return { id: 'feature-3', status: 'pipeline_implementation' };
|
||||
return null;
|
||||
});
|
||||
const updateMock = vi.fn().mockResolvedValue(undefined);
|
||||
mockLoadFeature(service, loadMock);
|
||||
mockUpdateFeatureStatus(service, updateMock);
|
||||
|
||||
await service.markAllRunningFeaturesInterrupted();
|
||||
|
||||
// Only feature-1 should be marked as interrupted
|
||||
expect(updateMock).toHaveBeenCalledTimes(1);
|
||||
expect(updateMock).toHaveBeenCalledWith('/project-a', 'feature-1', 'interrupted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFeatureRunning', () => {
|
||||
// Helper to access private runningFeatures Map
|
||||
const getRunningFeaturesMap = (svc: AutoModeService) =>
|
||||
(svc as any).runningFeatures as Map<
|
||||
string,
|
||||
{ featureId: string; projectPath: string; isAutoMode: boolean }
|
||||
>;
|
||||
|
||||
it('should return false when no features are running', () => {
|
||||
expect(service.isFeatureRunning('feature-123')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when the feature is running', () => {
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
runningFeaturesMap.set('feature-123', {
|
||||
featureId: 'feature-123',
|
||||
projectPath: '/project/path',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
expect(service.isFeatureRunning('feature-123')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-running feature when others are running', () => {
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
runningFeaturesMap.set('feature-other', {
|
||||
featureId: 'feature-other',
|
||||
projectPath: '/project/path',
|
||||
isAutoMode: true,
|
||||
});
|
||||
|
||||
expect(service.isFeatureRunning('feature-123')).toBe(false);
|
||||
});
|
||||
|
||||
it('should correctly track multiple running features', () => {
|
||||
const runningFeaturesMap = getRunningFeaturesMap(service);
|
||||
runningFeaturesMap.set('feature-1', {
|
||||
featureId: 'feature-1',
|
||||
projectPath: '/project-a',
|
||||
isAutoMode: true,
|
||||
});
|
||||
runningFeaturesMap.set('feature-2', {
|
||||
featureId: 'feature-2',
|
||||
projectPath: '/project-b',
|
||||
isAutoMode: false,
|
||||
});
|
||||
|
||||
expect(service.isFeatureRunning('feature-1')).toBe(true);
|
||||
expect(service.isFeatureRunning('feature-2')).toBe(true);
|
||||
expect(service.isFeatureRunning('feature-3')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { ParsedTask } from '@automaker/types';
|
||||
|
||||
/**
|
||||
* Test the task parsing logic by reimplementing the parsing functions
|
||||
* These mirror the logic in auto-mode-service.ts parseTasksFromSpec and parseTaskLine
|
||||
*/
|
||||
|
||||
interface ParsedTask {
|
||||
id: string;
|
||||
description: string;
|
||||
filePath?: string;
|
||||
phase?: string;
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
}
|
||||
|
||||
function parseTaskLine(line: string, currentPhase?: string): ParsedTask | null {
|
||||
// Match pattern: - [ ] T###: Description | File: path
|
||||
const taskMatch = line.match(/- \[ \] (T\d{3}):\s*([^|]+)(?:\|\s*File:\s*(.+))?$/);
|
||||
@@ -342,4 +335,236 @@ Some other text
|
||||
expect(fullModeOutput).toContain('[SPEC_GENERATED]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectSpecFallback - non-Claude model support', () => {
|
||||
/**
|
||||
* Reimplementation of detectSpecFallback for testing
|
||||
* This mirrors the logic in auto-mode-service.ts for detecting specs
|
||||
* when the [SPEC_GENERATED] marker is missing (common with non-Claude models)
|
||||
*/
|
||||
function detectSpecFallback(text: string): boolean {
|
||||
// Check for key structural elements of a spec
|
||||
const hasTasksBlock = /```tasks[\s\S]*```/.test(text);
|
||||
const hasTaskLines = /- \[ \] T\d{3}:/.test(text);
|
||||
|
||||
// Check for common spec sections (case-insensitive)
|
||||
const hasAcceptanceCriteria = /acceptance criteria/i.test(text);
|
||||
const hasTechnicalContext = /technical context/i.test(text);
|
||||
const hasProblemStatement = /problem statement/i.test(text);
|
||||
const hasUserStory = /user story/i.test(text);
|
||||
// Additional patterns for different model outputs
|
||||
const hasGoal = /\*\*Goal\*\*:/i.test(text);
|
||||
const hasSolution = /\*\*Solution\*\*:/i.test(text);
|
||||
const hasImplementation = /implementation\s*(plan|steps|approach)/i.test(text);
|
||||
const hasOverview = /##\s*(overview|summary)/i.test(text);
|
||||
|
||||
// Spec is detected if we have task structure AND at least some spec content
|
||||
const hasTaskStructure = hasTasksBlock || hasTaskLines;
|
||||
const hasSpecContent =
|
||||
hasAcceptanceCriteria ||
|
||||
hasTechnicalContext ||
|
||||
hasProblemStatement ||
|
||||
hasUserStory ||
|
||||
hasGoal ||
|
||||
hasSolution ||
|
||||
hasImplementation ||
|
||||
hasOverview;
|
||||
|
||||
return hasTaskStructure && hasSpecContent;
|
||||
}
|
||||
|
||||
it('should detect spec with tasks block and acceptance criteria', () => {
|
||||
const content = `
|
||||
## Acceptance Criteria
|
||||
- GIVEN a user, WHEN they login, THEN they see the dashboard
|
||||
|
||||
\`\`\`tasks
|
||||
- [ ] T001: Create login form | File: src/Login.tsx
|
||||
\`\`\`
|
||||
`;
|
||||
expect(detectSpecFallback(content)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect spec with task lines and problem statement', () => {
|
||||
const content = `
|
||||
## Problem Statement
|
||||
Users cannot currently log in to the application.
|
||||
|
||||
## Implementation Plan
|
||||
- [ ] T001: Add authentication endpoint
|
||||
- [ ] T002: Create login UI
|
||||
`;
|
||||
expect(detectSpecFallback(content)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect spec with Goal section (lite planning mode style)', () => {
|
||||
const content = `
|
||||
**Goal**: Implement user authentication
|
||||
|
||||
**Solution**: Use JWT tokens for session management
|
||||
|
||||
- [ ] T001: Setup auth middleware
|
||||
- [ ] T002: Create token service
|
||||
`;
|
||||
expect(detectSpecFallback(content)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect spec with User Story format', () => {
|
||||
const content = `
|
||||
## User Story
|
||||
As a user, I want to reset my password, so that I can regain access.
|
||||
|
||||
## Technical Context
|
||||
This will modify the auth module.
|
||||
|
||||
\`\`\`tasks
|
||||
- [ ] T001: Add reset endpoint
|
||||
\`\`\`
|
||||
`;
|
||||
expect(detectSpecFallback(content)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect spec with Overview section', () => {
|
||||
const content = `
|
||||
## Overview
|
||||
This feature adds dark mode support.
|
||||
|
||||
\`\`\`tasks
|
||||
- [ ] T001: Add theme toggle
|
||||
- [ ] T002: Update CSS variables
|
||||
\`\`\`
|
||||
`;
|
||||
expect(detectSpecFallback(content)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect spec with Summary section', () => {
|
||||
const content = `
|
||||
## Summary
|
||||
Adding a new dashboard component.
|
||||
|
||||
- [ ] T001: Create dashboard layout
|
||||
- [ ] T002: Add widgets
|
||||
`;
|
||||
expect(detectSpecFallback(content)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect spec with implementation plan', () => {
|
||||
const content = `
|
||||
## Implementation Plan
|
||||
We will add the feature in two phases.
|
||||
|
||||
- [ ] T001: Phase 1 setup
|
||||
- [ ] T002: Phase 2 implementation
|
||||
`;
|
||||
expect(detectSpecFallback(content)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect spec with implementation steps', () => {
|
||||
const content = `
|
||||
## Implementation Steps
|
||||
Follow these steps:
|
||||
|
||||
- [ ] T001: Step one
|
||||
- [ ] T002: Step two
|
||||
`;
|
||||
expect(detectSpecFallback(content)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect spec with implementation approach', () => {
|
||||
const content = `
|
||||
## Implementation Approach
|
||||
We will use a modular approach.
|
||||
|
||||
- [ ] T001: Create modules
|
||||
`;
|
||||
expect(detectSpecFallback(content)).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT detect spec without task structure', () => {
|
||||
const content = `
|
||||
## Problem Statement
|
||||
Users cannot log in.
|
||||
|
||||
## Acceptance Criteria
|
||||
- GIVEN a user, WHEN they try to login, THEN it works
|
||||
`;
|
||||
expect(detectSpecFallback(content)).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT detect spec without spec content sections', () => {
|
||||
const content = `
|
||||
Here are some tasks:
|
||||
|
||||
- [ ] T001: Do something
|
||||
- [ ] T002: Do another thing
|
||||
`;
|
||||
expect(detectSpecFallback(content)).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT detect random text as spec', () => {
|
||||
const content = 'Just some random text without any structure';
|
||||
expect(detectSpecFallback(content)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle case-insensitive matching for spec sections', () => {
|
||||
const content = `
|
||||
## ACCEPTANCE CRITERIA
|
||||
All caps section header
|
||||
|
||||
- [ ] T001: Task
|
||||
`;
|
||||
expect(detectSpecFallback(content)).toBe(true);
|
||||
|
||||
const content2 = `
|
||||
## acceptance criteria
|
||||
Lower case section header
|
||||
|
||||
- [ ] T001: Task
|
||||
`;
|
||||
expect(detectSpecFallback(content2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect OpenAI-style output without explicit marker', () => {
|
||||
// Non-Claude models may format specs differently but still have the key elements
|
||||
const openAIStyleOutput = `
|
||||
# Feature Specification: User Authentication
|
||||
|
||||
**Goal**: Allow users to securely log into the application
|
||||
|
||||
**Solution**: Implement JWT-based authentication with refresh tokens
|
||||
|
||||
## Acceptance Criteria
|
||||
1. Users can log in with email and password
|
||||
2. Invalid credentials show error message
|
||||
3. Sessions persist across page refreshes
|
||||
|
||||
## Implementation Tasks
|
||||
\`\`\`tasks
|
||||
- [ ] T001: Create auth service | File: src/services/auth.ts
|
||||
- [ ] T002: Build login component | File: src/components/Login.tsx
|
||||
- [ ] T003: Add protected routes | File: src/App.tsx
|
||||
\`\`\`
|
||||
`;
|
||||
expect(detectSpecFallback(openAIStyleOutput)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect Gemini-style output without explicit marker', () => {
|
||||
const geminiStyleOutput = `
|
||||
## Overview
|
||||
|
||||
This specification describes the implementation of a user profile page.
|
||||
|
||||
## Technical Context
|
||||
- Framework: React
|
||||
- State: Redux
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] T001: Create ProfilePage component
|
||||
- [ ] T002: Add profile API endpoint
|
||||
- [ ] T003: Style the profile page
|
||||
`;
|
||||
expect(detectSpecFallback(geminiStyleOutput)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,6 +124,59 @@ describe('claude-usage-service.ts', () => {
|
||||
|
||||
expect(result).toBe('Plain text');
|
||||
});
|
||||
|
||||
it('should strip OSC sequences (window title, etc.)', () => {
|
||||
const service = new ClaudeUsageService();
|
||||
// OSC sequence to set window title: ESC ] 0 ; title BEL
|
||||
const input = '\x1B]0;Claude Code\x07Regular text';
|
||||
// @ts-expect-error - accessing private method for testing
|
||||
const result = service.stripAnsiCodes(input);
|
||||
|
||||
expect(result).toBe('Regular text');
|
||||
});
|
||||
|
||||
it('should strip DEC private mode sequences', () => {
|
||||
const service = new ClaudeUsageService();
|
||||
// DEC private mode sequences like ESC[?2026h and ESC[?2026l
|
||||
const input = '\x1B[?2026lClaude Code\x1B[?2026h more text';
|
||||
// @ts-expect-error - accessing private method for testing
|
||||
const result = service.stripAnsiCodes(input);
|
||||
|
||||
expect(result).toBe('Claude Code more text');
|
||||
});
|
||||
|
||||
it('should handle complex terminal output with mixed escape sequences', () => {
|
||||
const service = new ClaudeUsageService();
|
||||
// Simulate the garbled output seen in the bug: "[?2026l ]0;❇ Claude Code [?2026h"
|
||||
// This contains OSC (set title) and DEC private mode sequences
|
||||
const input =
|
||||
'\x1B[?2026l\x1B]0;❇ Claude Code\x07\x1B[?2026hCurrent session 0%used Resets3am';
|
||||
// @ts-expect-error - accessing private method for testing
|
||||
const result = service.stripAnsiCodes(input);
|
||||
|
||||
expect(result).toBe('Current session 0%used Resets3am');
|
||||
});
|
||||
|
||||
it('should strip single character escape sequences', () => {
|
||||
const service = new ClaudeUsageService();
|
||||
// ESC c is the reset terminal command
|
||||
const input = '\x1BcReset text';
|
||||
// @ts-expect-error - accessing private method for testing
|
||||
const result = service.stripAnsiCodes(input);
|
||||
|
||||
expect(result).toBe('Reset text');
|
||||
});
|
||||
|
||||
it('should remove control characters but preserve newlines and tabs', () => {
|
||||
const service = new ClaudeUsageService();
|
||||
// BEL character (\x07) should be stripped, but the word "Bell" is regular text
|
||||
const input = 'Line 1\nLine 2\tTabbed\x07 with bell';
|
||||
// @ts-expect-error - accessing private method for testing
|
||||
const result = service.stripAnsiCodes(input);
|
||||
|
||||
// BEL is stripped, newlines and tabs preserved
|
||||
expect(result).toBe('Line 1\nLine 2\tTabbed with bell');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseResetTime', () => {
|
||||
|
||||
623
apps/server/tests/unit/services/feature-export-service.test.ts
Normal file
623
apps/server/tests/unit/services/feature-export-service.test.ts
Normal file
@@ -0,0 +1,623 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { FeatureExportService, FEATURE_EXPORT_VERSION } from '@/services/feature-export-service.js';
|
||||
import type { Feature, FeatureExport } from '@automaker/types';
|
||||
import type { FeatureLoader } from '@/services/feature-loader.js';
|
||||
|
||||
describe('feature-export-service.ts', () => {
|
||||
let exportService: FeatureExportService;
|
||||
let mockFeatureLoader: {
|
||||
get: ReturnType<typeof vi.fn>;
|
||||
getAll: ReturnType<typeof vi.fn>;
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
generateFeatureId: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
const testProjectPath = '/test/project';
|
||||
|
||||
const sampleFeature: Feature = {
|
||||
id: 'feature-123-abc',
|
||||
title: 'Test Feature',
|
||||
category: 'UI',
|
||||
description: 'A test feature description',
|
||||
status: 'pending',
|
||||
priority: 1,
|
||||
dependencies: ['feature-456'],
|
||||
descriptionHistory: [
|
||||
{
|
||||
description: 'Initial description',
|
||||
timestamp: '2024-01-01T00:00:00.000Z',
|
||||
source: 'initial',
|
||||
},
|
||||
],
|
||||
planSpec: {
|
||||
status: 'generated',
|
||||
content: 'Plan content',
|
||||
version: 1,
|
||||
reviewedByUser: false,
|
||||
},
|
||||
imagePaths: ['/tmp/image1.png', '/tmp/image2.jpg'],
|
||||
textFilePaths: [
|
||||
{
|
||||
id: 'file-1',
|
||||
path: '/tmp/doc.txt',
|
||||
filename: 'doc.txt',
|
||||
mimeType: 'text/plain',
|
||||
content: 'Some content',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Create mock FeatureLoader instance
|
||||
mockFeatureLoader = {
|
||||
get: vi.fn(),
|
||||
getAll: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
generateFeatureId: vi.fn().mockReturnValue('feature-mock-id'),
|
||||
};
|
||||
|
||||
// Inject mock via constructor
|
||||
exportService = new FeatureExportService(mockFeatureLoader as unknown as FeatureLoader);
|
||||
});
|
||||
|
||||
describe('exportFeatureData', () => {
|
||||
it('should export feature to JSON format', () => {
|
||||
const result = exportService.exportFeatureData(sampleFeature, { format: 'json' });
|
||||
|
||||
const parsed = JSON.parse(result) as FeatureExport;
|
||||
expect(parsed.version).toBe(FEATURE_EXPORT_VERSION);
|
||||
expect(parsed.feature.id).toBe(sampleFeature.id);
|
||||
expect(parsed.feature.title).toBe(sampleFeature.title);
|
||||
expect(parsed.exportedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should export feature to YAML format', () => {
|
||||
const result = exportService.exportFeatureData(sampleFeature, { format: 'yaml' });
|
||||
|
||||
expect(result).toContain('version:');
|
||||
expect(result).toContain('feature:');
|
||||
expect(result).toContain('Test Feature');
|
||||
expect(result).toContain('exportedAt:');
|
||||
});
|
||||
|
||||
it('should exclude description history when option is false', () => {
|
||||
const result = exportService.exportFeatureData(sampleFeature, {
|
||||
format: 'json',
|
||||
includeHistory: false,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result) as FeatureExport;
|
||||
expect(parsed.feature.descriptionHistory).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should include description history by default', () => {
|
||||
const result = exportService.exportFeatureData(sampleFeature, { format: 'json' });
|
||||
|
||||
const parsed = JSON.parse(result) as FeatureExport;
|
||||
expect(parsed.feature.descriptionHistory).toBeDefined();
|
||||
expect(parsed.feature.descriptionHistory).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should exclude plan spec when option is false', () => {
|
||||
const result = exportService.exportFeatureData(sampleFeature, {
|
||||
format: 'json',
|
||||
includePlanSpec: false,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result) as FeatureExport;
|
||||
expect(parsed.feature.planSpec).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should include plan spec by default', () => {
|
||||
const result = exportService.exportFeatureData(sampleFeature, { format: 'json' });
|
||||
|
||||
const parsed = JSON.parse(result) as FeatureExport;
|
||||
expect(parsed.feature.planSpec).toBeDefined();
|
||||
});
|
||||
|
||||
it('should include metadata when provided', () => {
|
||||
const result = exportService.exportFeatureData(sampleFeature, {
|
||||
format: 'json',
|
||||
metadata: { projectName: 'TestProject', branch: 'main' },
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result) as FeatureExport;
|
||||
expect(parsed.metadata).toEqual({ projectName: 'TestProject', branch: 'main' });
|
||||
});
|
||||
|
||||
it('should include exportedBy when provided', () => {
|
||||
const result = exportService.exportFeatureData(sampleFeature, {
|
||||
format: 'json',
|
||||
exportedBy: 'test-user',
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result) as FeatureExport;
|
||||
expect(parsed.exportedBy).toBe('test-user');
|
||||
});
|
||||
|
||||
it('should remove transient fields (titleGenerating, error)', () => {
|
||||
const featureWithTransient: Feature = {
|
||||
...sampleFeature,
|
||||
titleGenerating: true,
|
||||
error: 'Some error',
|
||||
};
|
||||
|
||||
const result = exportService.exportFeatureData(featureWithTransient, { format: 'json' });
|
||||
|
||||
const parsed = JSON.parse(result) as FeatureExport;
|
||||
expect(parsed.feature.titleGenerating).toBeUndefined();
|
||||
expect(parsed.feature.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should support compact JSON (prettyPrint: false)', () => {
|
||||
const prettyResult = exportService.exportFeatureData(sampleFeature, {
|
||||
format: 'json',
|
||||
prettyPrint: true,
|
||||
});
|
||||
const compactResult = exportService.exportFeatureData(sampleFeature, {
|
||||
format: 'json',
|
||||
prettyPrint: false,
|
||||
});
|
||||
|
||||
// Compact should have no newlines/indentation
|
||||
expect(compactResult).not.toContain('\n');
|
||||
// Pretty should have newlines
|
||||
expect(prettyResult).toContain('\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('exportFeature', () => {
|
||||
it('should fetch and export feature by ID', async () => {
|
||||
mockFeatureLoader.get.mockResolvedValue(sampleFeature);
|
||||
|
||||
const result = await exportService.exportFeature(testProjectPath, 'feature-123-abc');
|
||||
|
||||
expect(mockFeatureLoader.get).toHaveBeenCalledWith(testProjectPath, 'feature-123-abc');
|
||||
const parsed = JSON.parse(result) as FeatureExport;
|
||||
expect(parsed.feature.id).toBe(sampleFeature.id);
|
||||
});
|
||||
|
||||
it('should throw when feature not found', async () => {
|
||||
mockFeatureLoader.get.mockResolvedValue(null);
|
||||
|
||||
await expect(exportService.exportFeature(testProjectPath, 'nonexistent')).rejects.toThrow(
|
||||
'Feature nonexistent not found'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exportFeatures', () => {
|
||||
const features: Feature[] = [
|
||||
{ ...sampleFeature, id: 'feature-1', category: 'UI' },
|
||||
{ ...sampleFeature, id: 'feature-2', category: 'Backend', status: 'completed' },
|
||||
{ ...sampleFeature, id: 'feature-3', category: 'UI', status: 'pending' },
|
||||
];
|
||||
|
||||
it('should export all features', async () => {
|
||||
mockFeatureLoader.getAll.mockResolvedValue(features);
|
||||
|
||||
const result = await exportService.exportFeatures(testProjectPath);
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.count).toBe(3);
|
||||
expect(parsed.features).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should filter by category', async () => {
|
||||
mockFeatureLoader.getAll.mockResolvedValue(features);
|
||||
|
||||
const result = await exportService.exportFeatures(testProjectPath, { category: 'UI' });
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.count).toBe(2);
|
||||
expect(parsed.features.every((f: FeatureExport) => f.feature.category === 'UI')).toBe(true);
|
||||
});
|
||||
|
||||
it('should filter by status', async () => {
|
||||
mockFeatureLoader.getAll.mockResolvedValue(features);
|
||||
|
||||
const result = await exportService.exportFeatures(testProjectPath, { status: 'completed' });
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.count).toBe(1);
|
||||
expect(parsed.features[0].feature.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('should filter by feature IDs', async () => {
|
||||
mockFeatureLoader.getAll.mockResolvedValue(features);
|
||||
|
||||
const result = await exportService.exportFeatures(testProjectPath, {
|
||||
featureIds: ['feature-1', 'feature-3'],
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.count).toBe(2);
|
||||
const ids = parsed.features.map((f: FeatureExport) => f.feature.id);
|
||||
expect(ids).toContain('feature-1');
|
||||
expect(ids).toContain('feature-3');
|
||||
expect(ids).not.toContain('feature-2');
|
||||
});
|
||||
|
||||
it('should export to YAML format', async () => {
|
||||
mockFeatureLoader.getAll.mockResolvedValue(features);
|
||||
|
||||
const result = await exportService.exportFeatures(testProjectPath, { format: 'yaml' });
|
||||
|
||||
expect(result).toContain('version:');
|
||||
expect(result).toContain('count:');
|
||||
expect(result).toContain('features:');
|
||||
});
|
||||
|
||||
it('should include metadata when provided', async () => {
|
||||
mockFeatureLoader.getAll.mockResolvedValue(features);
|
||||
|
||||
const result = await exportService.exportFeatures(testProjectPath, {
|
||||
metadata: { projectName: 'TestProject' },
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.metadata).toEqual({ projectName: 'TestProject' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseImportData', () => {
|
||||
it('should parse valid JSON', () => {
|
||||
const json = JSON.stringify(sampleFeature);
|
||||
const result = exportService.parseImportData(json);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect((result as Feature).id).toBe(sampleFeature.id);
|
||||
});
|
||||
|
||||
it('should parse valid YAML', () => {
|
||||
const yaml = `
|
||||
id: feature-yaml-123
|
||||
title: YAML Feature
|
||||
category: Testing
|
||||
description: A YAML feature
|
||||
`;
|
||||
const result = exportService.parseImportData(yaml);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect((result as Feature).id).toBe('feature-yaml-123');
|
||||
expect((result as Feature).title).toBe('YAML Feature');
|
||||
});
|
||||
|
||||
it('should return null for invalid data', () => {
|
||||
const result = exportService.parseImportData('not valid {json} or yaml: [');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should parse FeatureExport wrapper', () => {
|
||||
const exportData: FeatureExport = {
|
||||
version: '1.0.0',
|
||||
feature: sampleFeature,
|
||||
exportedAt: new Date().toISOString(),
|
||||
};
|
||||
const json = JSON.stringify(exportData);
|
||||
|
||||
const result = exportService.parseImportData(json) as FeatureExport;
|
||||
|
||||
expect(result.version).toBe('1.0.0');
|
||||
expect(result.feature.id).toBe(sampleFeature.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectFormat', () => {
|
||||
it('should detect JSON format', () => {
|
||||
const json = JSON.stringify({ id: 'test' });
|
||||
expect(exportService.detectFormat(json)).toBe('json');
|
||||
});
|
||||
|
||||
it('should detect YAML format', () => {
|
||||
const yaml = `
|
||||
id: test
|
||||
title: Test
|
||||
`;
|
||||
expect(exportService.detectFormat(yaml)).toBe('yaml');
|
||||
});
|
||||
|
||||
it('should detect YAML for plain text (YAML is very permissive)', () => {
|
||||
// YAML parses any plain text as a string, so this is detected as valid YAML
|
||||
// The actual validation happens in parseImportData which checks for required fields
|
||||
expect(exportService.detectFormat('not valid {[')).toBe('yaml');
|
||||
});
|
||||
|
||||
it('should handle whitespace', () => {
|
||||
const json = ' { "id": "test" } ';
|
||||
expect(exportService.detectFormat(json)).toBe('json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('importFeature', () => {
|
||||
it('should import feature from raw Feature data', async () => {
|
||||
mockFeatureLoader.get.mockResolvedValue(null);
|
||||
mockFeatureLoader.create.mockResolvedValue(sampleFeature);
|
||||
|
||||
const result = await exportService.importFeature(testProjectPath, {
|
||||
data: sampleFeature,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.featureId).toBe(sampleFeature.id);
|
||||
expect(mockFeatureLoader.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should import feature from FeatureExport wrapper', async () => {
|
||||
mockFeatureLoader.get.mockResolvedValue(null);
|
||||
mockFeatureLoader.create.mockResolvedValue(sampleFeature);
|
||||
|
||||
const exportData: FeatureExport = {
|
||||
version: '1.0.0',
|
||||
feature: sampleFeature,
|
||||
exportedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const result = await exportService.importFeature(testProjectPath, {
|
||||
data: exportData,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.featureId).toBe(sampleFeature.id);
|
||||
});
|
||||
|
||||
it('should use custom ID when provided', async () => {
|
||||
mockFeatureLoader.get.mockResolvedValue(null);
|
||||
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
|
||||
...sampleFeature,
|
||||
id: data.id!,
|
||||
}));
|
||||
|
||||
const result = await exportService.importFeature(testProjectPath, {
|
||||
data: sampleFeature,
|
||||
newId: 'custom-id-123',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.featureId).toBe('custom-id-123');
|
||||
});
|
||||
|
||||
it('should fail when feature exists and overwrite is false', async () => {
|
||||
mockFeatureLoader.get.mockResolvedValue(sampleFeature);
|
||||
|
||||
const result = await exportService.importFeature(testProjectPath, {
|
||||
data: sampleFeature,
|
||||
overwrite: false,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
`Feature with ID ${sampleFeature.id} already exists. Set overwrite: true to replace.`
|
||||
);
|
||||
});
|
||||
|
||||
it('should overwrite when overwrite is true', async () => {
|
||||
mockFeatureLoader.get.mockResolvedValue(sampleFeature);
|
||||
mockFeatureLoader.update.mockResolvedValue(sampleFeature);
|
||||
|
||||
const result = await exportService.importFeature(testProjectPath, {
|
||||
data: sampleFeature,
|
||||
overwrite: true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.wasOverwritten).toBe(true);
|
||||
expect(mockFeatureLoader.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should apply target category override', async () => {
|
||||
mockFeatureLoader.get.mockResolvedValue(null);
|
||||
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
|
||||
...sampleFeature,
|
||||
...data,
|
||||
}));
|
||||
|
||||
await exportService.importFeature(testProjectPath, {
|
||||
data: sampleFeature,
|
||||
targetCategory: 'NewCategory',
|
||||
});
|
||||
|
||||
const createCall = mockFeatureLoader.create.mock.calls[0];
|
||||
expect(createCall[1].category).toBe('NewCategory');
|
||||
});
|
||||
|
||||
it('should clear branch info when preserveBranchInfo is false', async () => {
|
||||
const featureWithBranch: Feature = {
|
||||
...sampleFeature,
|
||||
branchName: 'feature/test-branch',
|
||||
};
|
||||
mockFeatureLoader.get.mockResolvedValue(null);
|
||||
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
|
||||
...featureWithBranch,
|
||||
...data,
|
||||
}));
|
||||
|
||||
await exportService.importFeature(testProjectPath, {
|
||||
data: featureWithBranch,
|
||||
preserveBranchInfo: false,
|
||||
});
|
||||
|
||||
const createCall = mockFeatureLoader.create.mock.calls[0];
|
||||
expect(createCall[1].branchName).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should preserve branch info when preserveBranchInfo is true', async () => {
|
||||
const featureWithBranch: Feature = {
|
||||
...sampleFeature,
|
||||
branchName: 'feature/test-branch',
|
||||
};
|
||||
mockFeatureLoader.get.mockResolvedValue(null);
|
||||
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
|
||||
...featureWithBranch,
|
||||
...data,
|
||||
}));
|
||||
|
||||
await exportService.importFeature(testProjectPath, {
|
||||
data: featureWithBranch,
|
||||
preserveBranchInfo: true,
|
||||
});
|
||||
|
||||
const createCall = mockFeatureLoader.create.mock.calls[0];
|
||||
expect(createCall[1].branchName).toBe('feature/test-branch');
|
||||
});
|
||||
|
||||
it('should warn and clear image paths', async () => {
|
||||
mockFeatureLoader.get.mockResolvedValue(null);
|
||||
mockFeatureLoader.create.mockResolvedValue(sampleFeature);
|
||||
|
||||
const result = await exportService.importFeature(testProjectPath, {
|
||||
data: sampleFeature,
|
||||
});
|
||||
|
||||
expect(result.warnings).toBeDefined();
|
||||
expect(result.warnings).toContainEqual(expect.stringContaining('image path'));
|
||||
const createCall = mockFeatureLoader.create.mock.calls[0];
|
||||
expect(createCall[1].imagePaths).toEqual([]);
|
||||
});
|
||||
|
||||
it('should warn and clear text file paths', async () => {
|
||||
mockFeatureLoader.get.mockResolvedValue(null);
|
||||
mockFeatureLoader.create.mockResolvedValue(sampleFeature);
|
||||
|
||||
const result = await exportService.importFeature(testProjectPath, {
|
||||
data: sampleFeature,
|
||||
});
|
||||
|
||||
expect(result.warnings).toBeDefined();
|
||||
expect(result.warnings).toContainEqual(expect.stringContaining('text file path'));
|
||||
const createCall = mockFeatureLoader.create.mock.calls[0];
|
||||
expect(createCall[1].textFilePaths).toEqual([]);
|
||||
});
|
||||
|
||||
it('should fail with validation error for missing required fields', async () => {
|
||||
const invalidFeature = {
|
||||
id: 'feature-invalid',
|
||||
// Missing description, title, and category
|
||||
} as Feature;
|
||||
|
||||
const result = await exportService.importFeature(testProjectPath, {
|
||||
data: invalidFeature,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toBeDefined();
|
||||
expect(result.errors!.some((e) => e.includes('title or description'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should generate ID when none provided', async () => {
|
||||
const featureWithoutId = {
|
||||
title: 'No ID Feature',
|
||||
category: 'Testing',
|
||||
description: 'Feature without ID',
|
||||
} as Feature;
|
||||
|
||||
mockFeatureLoader.get.mockResolvedValue(null);
|
||||
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
|
||||
...featureWithoutId,
|
||||
id: data.id!,
|
||||
}));
|
||||
|
||||
const result = await exportService.importFeature(testProjectPath, {
|
||||
data: featureWithoutId,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.featureId).toBe('feature-mock-id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('importFeatures', () => {
|
||||
const bulkExport = {
|
||||
version: '1.0.0',
|
||||
exportedAt: new Date().toISOString(),
|
||||
count: 2,
|
||||
features: [
|
||||
{
|
||||
version: '1.0.0',
|
||||
feature: { ...sampleFeature, id: 'feature-1' },
|
||||
exportedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
version: '1.0.0',
|
||||
feature: { ...sampleFeature, id: 'feature-2' },
|
||||
exportedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('should import multiple features from JSON string', async () => {
|
||||
mockFeatureLoader.get.mockResolvedValue(null);
|
||||
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
|
||||
...sampleFeature,
|
||||
id: data.id!,
|
||||
}));
|
||||
|
||||
const results = await exportService.importFeatures(
|
||||
testProjectPath,
|
||||
JSON.stringify(bulkExport)
|
||||
);
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0].success).toBe(true);
|
||||
expect(results[1].success).toBe(true);
|
||||
});
|
||||
|
||||
it('should import multiple features from parsed data', async () => {
|
||||
mockFeatureLoader.get.mockResolvedValue(null);
|
||||
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
|
||||
...sampleFeature,
|
||||
id: data.id!,
|
||||
}));
|
||||
|
||||
const results = await exportService.importFeatures(testProjectPath, bulkExport);
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.every((r) => r.success)).toBe(true);
|
||||
});
|
||||
|
||||
it('should apply options to all features', async () => {
|
||||
mockFeatureLoader.get.mockResolvedValue(null);
|
||||
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
|
||||
...sampleFeature,
|
||||
...data,
|
||||
}));
|
||||
|
||||
await exportService.importFeatures(testProjectPath, bulkExport, {
|
||||
targetCategory: 'ImportedCategory',
|
||||
});
|
||||
|
||||
const createCalls = mockFeatureLoader.create.mock.calls;
|
||||
expect(createCalls[0][1].category).toBe('ImportedCategory');
|
||||
expect(createCalls[1][1].category).toBe('ImportedCategory');
|
||||
});
|
||||
|
||||
it('should return error for invalid bulk format', async () => {
|
||||
const results = await exportService.importFeatures(testProjectPath, '{ "invalid": "data" }');
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].success).toBe(false);
|
||||
expect(results[0].errors).toContainEqual(expect.stringContaining('Invalid bulk import data'));
|
||||
});
|
||||
|
||||
it('should handle partial failures', async () => {
|
||||
mockFeatureLoader.get.mockResolvedValueOnce(null).mockResolvedValueOnce(sampleFeature); // Second feature exists
|
||||
|
||||
mockFeatureLoader.create.mockImplementation(async (_, data) => ({
|
||||
...sampleFeature,
|
||||
id: data.id!,
|
||||
}));
|
||||
|
||||
const results = await exportService.importFeatures(testProjectPath, bulkExport, {
|
||||
overwrite: false,
|
||||
});
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0].success).toBe(true);
|
||||
expect(results[1].success).toBe(false); // Exists without overwrite
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
} from '@automaker/types';
|
||||
import { ProviderFactory } from '@/providers/provider-factory.js';
|
||||
|
||||
// Create a shared mock logger instance for assertions using vi.hoisted
|
||||
// Create shared mock instances for assertions using vi.hoisted
|
||||
const mockLogger = vi.hoisted(() => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
@@ -23,6 +23,13 @@ const mockLogger = vi.hoisted(() => ({
|
||||
debug: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockCreateChatOptions = vi.hoisted(() =>
|
||||
vi.fn(() => ({
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
systemPrompt: 'test prompt',
|
||||
}))
|
||||
);
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/lib/secure-fs.js');
|
||||
vi.mock('@automaker/platform');
|
||||
@@ -37,10 +44,7 @@ vi.mock('@automaker/utils', async () => {
|
||||
});
|
||||
vi.mock('@/providers/provider-factory.js');
|
||||
vi.mock('@/lib/sdk-options.js', () => ({
|
||||
createChatOptions: vi.fn(() => ({
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
systemPrompt: 'test prompt',
|
||||
})),
|
||||
createChatOptions: mockCreateChatOptions,
|
||||
validateWorkingDirectory: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -63,7 +67,10 @@ describe('IdeationService', () => {
|
||||
} as unknown as EventEmitter;
|
||||
|
||||
// Create mock settings service
|
||||
mockSettingsService = {} as SettingsService;
|
||||
mockSettingsService = {
|
||||
getCredentials: vi.fn().mockResolvedValue({}),
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
// Create mock feature loader
|
||||
mockFeatureLoader = {
|
||||
@@ -783,6 +790,143 @@ describe('IdeationService', () => {
|
||||
service.generateSuggestions(testProjectPath, 'non-existent', 'features', 5)
|
||||
).rejects.toThrow('Prompt non-existent not found');
|
||||
});
|
||||
|
||||
it('should include app spec context when useAppSpec is enabled', async () => {
|
||||
const mockAppSpec = `
|
||||
<project_specification>
|
||||
<project_name>Test Project</project_name>
|
||||
<overview>A test application for unit testing</overview>
|
||||
<core_capabilities>
|
||||
<capability>User authentication</capability>
|
||||
<capability>Data visualization</capability>
|
||||
</core_capabilities>
|
||||
<implemented_features>
|
||||
<feature>
|
||||
<name>Login System</name>
|
||||
<description>Basic auth with email/password</description>
|
||||
</feature>
|
||||
</implemented_features>
|
||||
</project_specification>
|
||||
`;
|
||||
|
||||
vi.mocked(platform.getAppSpecPath).mockReturnValue('/test/project/.automaker/app_spec.txt');
|
||||
|
||||
// First call returns app spec, subsequent calls return empty JSON
|
||||
vi.mocked(secureFs.readFile)
|
||||
.mockResolvedValueOnce(mockAppSpec)
|
||||
.mockResolvedValue(JSON.stringify({}));
|
||||
|
||||
const mockProvider = {
|
||||
executeQuery: vi.fn().mockReturnValue({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
result: JSON.stringify([{ title: 'Test', description: 'Test' }]),
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
|
||||
|
||||
const prompts = service.getAllPrompts();
|
||||
await service.generateSuggestions(testProjectPath, prompts[0].id, 'feature', 5, {
|
||||
useAppSpec: true,
|
||||
useContextFiles: false,
|
||||
useMemoryFiles: false,
|
||||
useExistingFeatures: false,
|
||||
useExistingIdeas: false,
|
||||
});
|
||||
|
||||
// Verify createChatOptions was called with systemPrompt containing app spec info
|
||||
expect(mockCreateChatOptions).toHaveBeenCalled();
|
||||
const chatOptionsCall = mockCreateChatOptions.mock.calls[0][0];
|
||||
expect(chatOptionsCall.systemPrompt).toContain('Test Project');
|
||||
expect(chatOptionsCall.systemPrompt).toContain('A test application for unit testing');
|
||||
expect(chatOptionsCall.systemPrompt).toContain('User authentication');
|
||||
expect(chatOptionsCall.systemPrompt).toContain('Login System');
|
||||
});
|
||||
|
||||
it('should exclude app spec context when useAppSpec is disabled', async () => {
|
||||
const mockAppSpec = `
|
||||
<project_specification>
|
||||
<project_name>Hidden Project</project_name>
|
||||
<overview>This should not appear</overview>
|
||||
</project_specification>
|
||||
`;
|
||||
|
||||
vi.mocked(platform.getAppSpecPath).mockReturnValue('/test/project/.automaker/app_spec.txt');
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(mockAppSpec);
|
||||
|
||||
const mockProvider = {
|
||||
executeQuery: vi.fn().mockReturnValue({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
result: JSON.stringify([{ title: 'Test', description: 'Test' }]),
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
|
||||
|
||||
const prompts = service.getAllPrompts();
|
||||
await service.generateSuggestions(testProjectPath, prompts[0].id, 'feature', 5, {
|
||||
useAppSpec: false,
|
||||
useContextFiles: false,
|
||||
useMemoryFiles: false,
|
||||
useExistingFeatures: false,
|
||||
useExistingIdeas: false,
|
||||
});
|
||||
|
||||
// Verify createChatOptions was called with systemPrompt NOT containing app spec info
|
||||
expect(mockCreateChatOptions).toHaveBeenCalled();
|
||||
const chatOptionsCall = mockCreateChatOptions.mock.calls[0][0];
|
||||
expect(chatOptionsCall.systemPrompt).not.toContain('Hidden Project');
|
||||
expect(chatOptionsCall.systemPrompt).not.toContain('This should not appear');
|
||||
});
|
||||
|
||||
it('should handle missing app spec file gracefully', async () => {
|
||||
vi.mocked(platform.getAppSpecPath).mockReturnValue('/test/project/.automaker/app_spec.txt');
|
||||
|
||||
const enoentError = new Error('ENOENT: no such file or directory') as NodeJS.ErrnoException;
|
||||
enoentError.code = 'ENOENT';
|
||||
|
||||
// First call fails with ENOENT for app spec, subsequent calls return empty JSON
|
||||
vi.mocked(secureFs.readFile)
|
||||
.mockRejectedValueOnce(enoentError)
|
||||
.mockResolvedValue(JSON.stringify({}));
|
||||
|
||||
const mockProvider = {
|
||||
executeQuery: vi.fn().mockReturnValue({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
result: JSON.stringify([{ title: 'Test', description: 'Test' }]),
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
|
||||
|
||||
const prompts = service.getAllPrompts();
|
||||
|
||||
// Should not throw
|
||||
await expect(
|
||||
service.generateSuggestions(testProjectPath, prompts[0].id, 'feature', 5, {
|
||||
useAppSpec: true,
|
||||
useContextFiles: false,
|
||||
useMemoryFiles: false,
|
||||
useExistingFeatures: false,
|
||||
useExistingIdeas: false,
|
||||
})
|
||||
).resolves.toBeDefined();
|
||||
|
||||
// Should not log warning for ENOENT
|
||||
expect(mockLogger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -788,6 +788,367 @@ describe('pipeline-service.ts', () => {
|
||||
const nextStatus = pipelineService.getNextStatus('in_progress', config, false);
|
||||
expect(nextStatus).toBe('pipeline_step1'); // Should use step1 (order 0), not step2
|
||||
});
|
||||
|
||||
describe('with exclusions', () => {
|
||||
it('should skip excluded step when coming from in_progress', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 1,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const nextStatus = pipelineService.getNextStatus('in_progress', config, false, ['step1']);
|
||||
expect(nextStatus).toBe('pipeline_step2'); // Should skip step1 and go to step2
|
||||
});
|
||||
|
||||
it('should skip excluded step when moving between steps', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 1,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step3',
|
||||
name: 'Step 3',
|
||||
order: 2,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'red',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false, [
|
||||
'step2',
|
||||
]);
|
||||
expect(nextStatus).toBe('pipeline_step3'); // Should skip step2 and go to step3
|
||||
});
|
||||
|
||||
it('should go to final status when all remaining steps are excluded', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 1,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false, [
|
||||
'step2',
|
||||
]);
|
||||
expect(nextStatus).toBe('verified'); // No more steps after exclusion
|
||||
});
|
||||
|
||||
it('should go to waiting_approval when all remaining steps excluded and skipTests is true', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 1,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, true, ['step2']);
|
||||
expect(nextStatus).toBe('waiting_approval');
|
||||
});
|
||||
|
||||
it('should go to final status when all steps are excluded from in_progress', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 1,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const nextStatus = pipelineService.getNextStatus('in_progress', config, false, [
|
||||
'step1',
|
||||
'step2',
|
||||
]);
|
||||
expect(nextStatus).toBe('verified');
|
||||
});
|
||||
|
||||
it('should handle empty exclusions array like no exclusions', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const nextStatus = pipelineService.getNextStatus('in_progress', config, false, []);
|
||||
expect(nextStatus).toBe('pipeline_step1');
|
||||
});
|
||||
|
||||
it('should handle undefined exclusions like no exclusions', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const nextStatus = pipelineService.getNextStatus('in_progress', config, false, undefined);
|
||||
expect(nextStatus).toBe('pipeline_step1');
|
||||
});
|
||||
|
||||
it('should skip multiple excluded steps in sequence', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 1,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step3',
|
||||
name: 'Step 3',
|
||||
order: 2,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'red',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step4',
|
||||
name: 'Step 4',
|
||||
order: 3,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'yellow',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Exclude step2 and step3
|
||||
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false, [
|
||||
'step2',
|
||||
'step3',
|
||||
]);
|
||||
expect(nextStatus).toBe('pipeline_step4'); // Should skip step2 and step3
|
||||
});
|
||||
|
||||
it('should handle exclusion of non-existent step IDs gracefully', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 1,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Exclude a non-existent step - should have no effect
|
||||
const nextStatus = pipelineService.getNextStatus('in_progress', config, false, [
|
||||
'nonexistent',
|
||||
]);
|
||||
expect(nextStatus).toBe('pipeline_step1');
|
||||
});
|
||||
|
||||
it('should find next valid step when current step becomes excluded mid-flow', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 1,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step3',
|
||||
name: 'Step 3',
|
||||
order: 2,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'red',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Feature is at step1 but step1 is now excluded - should find next valid step
|
||||
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false, [
|
||||
'step1',
|
||||
'step2',
|
||||
]);
|
||||
expect(nextStatus).toBe('pipeline_step3');
|
||||
});
|
||||
|
||||
it('should go to final status when current step is excluded and no steps remain', () => {
|
||||
const config: PipelineConfig = {
|
||||
version: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
order: 0,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'blue',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'step2',
|
||||
name: 'Step 2',
|
||||
order: 1,
|
||||
instructions: 'Instructions',
|
||||
colorClass: 'green',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Feature is at step1 but both steps are excluded
|
||||
const nextStatus = pipelineService.getNextStatus('pipeline_step1', config, false, [
|
||||
'step1',
|
||||
'step2',
|
||||
]);
|
||||
expect(nextStatus).toBe('verified');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStep', () => {
|
||||
|
||||
@@ -14,8 +14,13 @@ const eslintConfig = defineConfig([
|
||||
require: 'readonly',
|
||||
__dirname: 'readonly',
|
||||
__filename: 'readonly',
|
||||
setTimeout: 'readonly',
|
||||
clearTimeout: 'readonly',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['warn', { argsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
@@ -45,6 +50,8 @@ const eslintConfig = defineConfig([
|
||||
confirm: 'readonly',
|
||||
getComputedStyle: 'readonly',
|
||||
requestAnimationFrame: 'readonly',
|
||||
cancelAnimationFrame: 'readonly',
|
||||
alert: 'readonly',
|
||||
// DOM Element Types
|
||||
HTMLElement: 'readonly',
|
||||
HTMLInputElement: 'readonly',
|
||||
@@ -56,6 +63,8 @@ const eslintConfig = defineConfig([
|
||||
HTMLParagraphElement: 'readonly',
|
||||
HTMLImageElement: 'readonly',
|
||||
Element: 'readonly',
|
||||
SVGElement: 'readonly',
|
||||
SVGSVGElement: 'readonly',
|
||||
// Event Types
|
||||
Event: 'readonly',
|
||||
KeyboardEvent: 'readonly',
|
||||
@@ -64,14 +73,24 @@ const eslintConfig = defineConfig([
|
||||
CustomEvent: 'readonly',
|
||||
ClipboardEvent: 'readonly',
|
||||
WheelEvent: 'readonly',
|
||||
MouseEvent: 'readonly',
|
||||
UIEvent: 'readonly',
|
||||
MediaQueryListEvent: 'readonly',
|
||||
DataTransfer: 'readonly',
|
||||
// Web APIs
|
||||
ResizeObserver: 'readonly',
|
||||
AbortSignal: 'readonly',
|
||||
AbortController: 'readonly',
|
||||
IntersectionObserver: 'readonly',
|
||||
Audio: 'readonly',
|
||||
HTMLAudioElement: 'readonly',
|
||||
ScrollBehavior: 'readonly',
|
||||
URL: 'readonly',
|
||||
URLSearchParams: 'readonly',
|
||||
XMLHttpRequest: 'readonly',
|
||||
Response: 'readonly',
|
||||
RequestInit: 'readonly',
|
||||
RequestCache: 'readonly',
|
||||
// Timers
|
||||
setTimeout: 'readonly',
|
||||
setInterval: 'readonly',
|
||||
@@ -90,6 +109,8 @@ const eslintConfig = defineConfig([
|
||||
Electron: 'readonly',
|
||||
// Console
|
||||
console: 'readonly',
|
||||
// Vite defines
|
||||
__APP_VERSION__: 'readonly',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
@@ -99,6 +120,13 @@ const eslintConfig = defineConfig([
|
||||
...ts.configs.recommended.rules,
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/ban-ts-comment': [
|
||||
'error',
|
||||
{
|
||||
'ts-nocheck': 'allow-with-description',
|
||||
minimumDescriptionLength: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
globalIgnores([
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user