Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"@openai/codex-sdk": "^0.98.0",
"cookie-parser": "1.4.7",
"cors": "2.8.5",
"cron-parser": "^5.5.0",
"dotenv": "17.2.3",
"express": "5.2.1",
"morgan": "1.10.1",
Expand Down
116 changes: 73 additions & 43 deletions apps/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ import cookieParser from 'cookie-parser';
import cookie from 'cookie';
import { WebSocketServer, WebSocket } from 'ws';
import { createServer } from 'http';
import net from 'net';
import dotenv from 'dotenv';

import { createEventEmitter, type EventEmitter } from './lib/events.js';
import { initAllowedPaths, getClaudeAuthIndicators } from '@automaker/platform';
import { createLogger, setLogLevel, LogLevel } from '@automaker/utils';
import { registerRuntimePort } from '@automaker/types';

const logger = createLogger('Server');

Expand Down Expand Up @@ -66,6 +68,9 @@ import { createCodexRoutes } from './routes/codex/index.js';
import { CodexUsageService } from './services/codex-usage-service.js';
import { CodexAppServerService } from './services/codex-app-server-service.js';
import { CodexModelCacheService } from './services/codex-model-cache-service.js';
import { createZaiRoutes } from './routes/zai/index.js';
import { ZaiUsageService } from './services/zai-usage-service.js';
import { createGeminiRoutes } from './routes/gemini/index.js';
import { createGitHubRoutes } from './routes/github/index.js';
import { createContextRoutes } from './routes/context/index.js';
import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js';
Expand All @@ -74,6 +79,7 @@ import { createMCPRoutes } from './routes/mcp/index.js';
import { MCPTestService } from './services/mcp-test-service.js';
import { createPipelineRoutes } from './routes/pipeline/index.js';
import { pipelineService } from './services/pipeline-service.js';
import { createScheduleRoutes } from './routes/schedule/index.js';
import { createIdeationRoutes } from './routes/ideation/index.js';
import { IdeationService } from './services/ideation-service.js';
import { getDevServerService } from './services/dev-server-service.js';
Expand All @@ -84,6 +90,7 @@ 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';
import { SchedulerService, setSchedulerService } from './services/scheduler-service.js';

// Load environment variables
dotenv.config();
Expand Down Expand Up @@ -324,10 +331,18 @@ const featureLoader = new FeatureLoader();

// Auto-mode services: compatibility layer provides old interface while using new architecture
const autoModeService = new AutoModeServiceCompat(events, settingsService, featureLoader);
const schedulerService = new SchedulerService(
events,
featureLoader,
autoModeService,
settingsService
);
setSchedulerService(schedulerService);
const claudeUsageService = new ClaudeUsageService();
const codexAppServerService = new CodexAppServerService();
const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServerService);
const codexUsageService = new CodexUsageService(codexAppServerService);
const zaiUsageService = new ZaiUsageService();
const mcpTestService = new MCPTestService(settingsService);
const ideationService = new IdeationService(events, settingsService, featureLoader);

Expand Down Expand Up @@ -436,6 +451,8 @@ app.use('/api/terminal', createTerminalRoutes());
app.use('/api/settings', createSettingsRoutes(settingsService));
app.use('/api/claude', createClaudeRoutes(claudeUsageService));
app.use('/api/codex', createCodexRoutes(codexUsageService, codexModelCacheService));
app.use('/api/zai', createZaiRoutes(zaiUsageService, settingsService));
app.use('/api/gemini', createGeminiRoutes());
app.use('/api/github', createGitHubRoutes(events, settingsService));
app.use('/api/context', createContextRoutes(settingsService));
app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
Expand All @@ -448,6 +465,7 @@ app.use(
'/api/projects',
createProjectsRoutes(featureLoader, autoModeService, settingsService, notificationService)
);
app.use('/api/schedule', createScheduleRoutes());

// Create HTTP server
const server = createServer(app);
Expand Down Expand Up @@ -758,8 +776,52 @@ terminalWss.on('connection', (ws: WebSocket, req: import('http').IncomingMessage
});
});

// Start server with error handling for port conflicts
const startServer = (port: number, host: string) => {
// Port conflict resolution: find an available port instead of crashing
const MAX_PORT_SEARCH_ATTEMPTS = 100;

function isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const testServer = net.createServer();
testServer.once('error', () => resolve(false));
testServer.once('listening', () => {
testServer.close(() => resolve(true));
});
testServer.listen(port);
});
}

async function findAvailablePort(preferredPort: number): Promise<number> {
for (let offset = 0; offset < MAX_PORT_SEARCH_ATTEMPTS; offset++) {
const port = preferredPort + offset;
if (await isPortAvailable(port)) {
return port;
}
}
throw new Error(
`Could not find an available port in range ${preferredPort}-${preferredPort + MAX_PORT_SEARCH_ATTEMPTS - 1}`
);
}

// Start server with automatic port conflict resolution
const startServer = async (preferredPort: number, host: string) => {
let port: number;
try {
port = await findAvailablePort(preferredPort);
} catch {
logger.error(
`Could not find an available port starting from ${preferredPort}. All ports in range ${preferredPort}-${preferredPort + MAX_PORT_SEARCH_ATTEMPTS - 1} are in use.`
);
process.exit(1);
return; // unreachable, but satisfies TypeScript
}

if (port !== preferredPort) {
logger.info(`Default port ${preferredPort} is in use, using port ${port} instead`);
}

// Register the actual port so dev-server-service won't kill it
registerRuntimePort(port);

server.listen(port, host, () => {
const terminalStatus = isTerminalEnabled()
? isTerminalPasswordRequired()
Expand Down Expand Up @@ -796,50 +858,17 @@ const startServer = (port: number, host: string) => {
║ ║
╚═════════════════════════════════════════════════════════════════════╝
`);

// Start the scheduler service for recurring tasks
schedulerService.start();
schedulerService.recalculateNextRunTimes().catch((err) => {
logger.error('Error recalculating scheduled task run times:', err);
});
});

server.on('error', (error: NodeJS.ErrnoException) => {
if (error.code === 'EADDRINUSE') {
const portStr = port.toString();
const nextPortStr = (port + 1).toString();
const killCmd = `lsof -ti:${portStr} | xargs kill -9`;
const altCmd = `PORT=${nextPortStr} npm run dev:server`;

const eHeader = `❌ ERROR: Port ${portStr} is already in use`.padEnd(BOX_CONTENT_WIDTH);
const e1 = 'Another process is using this port.'.padEnd(BOX_CONTENT_WIDTH);
const e2 = 'To fix this, try one of:'.padEnd(BOX_CONTENT_WIDTH);
const e3 = '1. Kill the process using the port:'.padEnd(BOX_CONTENT_WIDTH);
const e4 = ` ${killCmd}`.padEnd(BOX_CONTENT_WIDTH);
const e5 = '2. Use a different port:'.padEnd(BOX_CONTENT_WIDTH);
const e6 = ` ${altCmd}`.padEnd(BOX_CONTENT_WIDTH);
const e7 = '3. Use the init.sh script which handles this:'.padEnd(BOX_CONTENT_WIDTH);
const e8 = ' ./init.sh'.padEnd(BOX_CONTENT_WIDTH);

logger.error(`
╔═════════════════════════════════════════════════════════════════════╗
║ ${eHeader}║
╠═════════════════════════════════════════════════════════════════════╣
║ ║
║ ${e1}║
║ ║
║ ${e2}║
║ ║
║ ${e3}║
║ ${e4}║
║ ║
║ ${e5}║
║ ${e6}║
║ ║
║ ${e7}║
║ ${e8}║
║ ║
╚═════════════════════════════════════════════════════════════════════╝
`);
process.exit(1);
} else {
logger.error('Error starting server:', error);
process.exit(1);
}
logger.error('Error starting server:', error);
process.exit(1);
});
};

Expand Down Expand Up @@ -883,6 +912,7 @@ const gracefulShutdown = async (signal: string) => {
// Note: markAllRunningFeaturesInterrupted handles errors internally and never rejects
await autoModeService.markAllRunningFeaturesInterrupted(`${signal} signal received`);

schedulerService.stop();
terminalService.cleanup();
server.close(() => {
clearTimeout(forceExitTimeout);
Expand Down
33 changes: 24 additions & 9 deletions apps/server/src/routes/features/routes/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@
*/

import type { Request, Response } from 'express';
import { CronExpressionParser } from 'cron-parser';
import { FeatureLoader } from '../../../services/feature-loader.js';
import type { EventEmitter } from '../../../lib/events.js';
import type { Feature } from '@automaker/types';
import { getErrorMessage, logError } from '../common.js';
import { createLogger } from '@automaker/utils';

const logger = createLogger('features/create');

export function createCreateHandler(featureLoader: FeatureLoader, events?: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
Expand All @@ -24,16 +28,27 @@ export function createCreateHandler(featureLoader: FeatureLoader, events?: Event
return;
}

// Check for duplicate title if title is provided
if (feature.title && feature.title.trim()) {
const duplicate = await featureLoader.findDuplicateTitle(projectPath, feature.title);
if (duplicate) {
res.status(409).json({
success: false,
error: `A feature with title "${feature.title}" already exists`,
duplicateFeatureId: duplicate.id,
// Calculate nextRun and set status to 'scheduled' if schedule is provided and enabled
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The logic to check for and prevent duplicate feature titles appears to have been removed in this change. This could lead to multiple features having the same name, causing confusion. This check should be restored.

      // Check for duplicate title if title is provided
      if (feature.title && feature.title.trim()) {
        const duplicate = await featureLoader.findDuplicateTitle(projectPath, feature.title);
        if (duplicate) {
          res.status(409).json({
            success: false,
            error: `A feature with title "${feature.title}" already exists`,
            duplicateFeatureId: duplicate.id,
          });
          return;
        }
      }

      // Calculate nextRun and set status to 'scheduled' if schedule is provided and enabled

if (feature.schedule?.enabled && feature.schedule?.crontab) {
try {
const interval = CronExpressionParser.parse(feature.schedule.crontab, {
currentDate: new Date(),
});
return;
const nextRun = interval.next().toDate();
feature.schedule = {
...feature.schedule,
nextRun: nextRun.toISOString(),
};
// Set status to 'scheduled' so the scheduler will pick it up
feature.status = 'scheduled';
logger.debug(
`Calculated nextRun for new feature: ${nextRun.toISOString()}, status set to 'scheduled'`
);
} catch (err) {
logger.warn(
`Invalid crontab expression in new feature: ${feature.schedule.crontab}`,
err
);
}
}

Expand Down
62 changes: 45 additions & 17 deletions apps/server/src/routes/features/routes/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/

import type { Request, Response } from 'express';
import { CronExpressionParser } from 'cron-parser';
import { FeatureLoader } from '../../../services/feature-loader.js';
import type { Feature, FeatureStatus } from '@automaker/types';
import { getErrorMessage, logError } from '../common.js';
Expand Down Expand Up @@ -40,28 +41,55 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
return;
}

// Check for duplicate title if title is being updated
if (updates.title && updates.title.trim()) {
const duplicate = await featureLoader.findDuplicateTitle(
projectPath,
updates.title,
featureId // Exclude the current feature from duplicate check
);
if (duplicate) {
res.status(409).json({
success: false,
error: `A feature with title "${updates.title}" already exists`,
duplicateFeatureId: duplicate.id,
});
return;
}
}

// Get the current feature to detect status changes
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The logic to check for duplicate feature titles during an update seems to have been removed. This is important to prevent renaming a feature to a title that is already in use by another feature. Please restore this validation.

      // Check for duplicate title if title is being updated
      if (updates.title && updates.title.trim()) {
        const duplicate = await featureLoader.findDuplicateTitle(
          projectPath,
          updates.title,
          featureId // Exclude the current feature from duplicate check
        );
        if (duplicate) {
          res.status(409).json({
            success: false,
            error: `A feature with title "${updates.title}" already exists`,
            duplicateFeatureId: duplicate.id,
          });
          return;
        }
      }

      // Get the current feature to detect status changes

const currentFeature = await featureLoader.get(projectPath, featureId);
const previousStatus = currentFeature?.status as FeatureStatus | undefined;
const newStatus = updates.status as FeatureStatus | undefined;

// Handle schedule updates
// Check if schedule is being removed or disabled
const isScheduleBeingRemoved =
'schedule' in updates &&
(updates.schedule === undefined ||
updates.schedule === null ||
updates.schedule?.enabled === false);

if (isScheduleBeingRemoved) {
// If currently scheduled, move back to backlog
if (currentFeature?.status === 'scheduled') {
updates.status = 'backlog';
logger.debug(
`Moving feature ${featureId} from 'scheduled' to 'backlog' (schedule removed/disabled)`
);
}
// Clear the schedule
updates.schedule = undefined;
} else if (updates.schedule?.enabled && updates.schedule?.crontab) {
// Calculate nextRun if schedule is being updated and enabled
try {
const interval = CronExpressionParser.parse(updates.schedule.crontab, {
currentDate: new Date(),
});
const nextRun = interval.next().toDate();
updates.schedule = {
...updates.schedule,
nextRun: nextRun.toISOString(),
};
// If enabling a schedule on a feature that's not in_progress, move it to 'scheduled'
const currentStatus = currentFeature?.status;
if (currentStatus && currentStatus !== 'in_progress' && currentStatus !== 'scheduled') {
updates.status = 'scheduled';
logger.debug(`Moving feature ${featureId} to 'scheduled' status`);
}
logger.debug(`Calculated nextRun for feature ${featureId}: ${nextRun.toISOString()}`);
} catch (err) {
logger.warn(
`Invalid crontab expression for feature ${featureId}: ${updates.schedule.crontab}`,
err
);
}
}

const updated = await featureLoader.update(
projectPath,
featureId,
Expand Down
12 changes: 12 additions & 0 deletions apps/server/src/routes/fs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ import { createBrowseHandler } from './routes/browse.js';
import { createImageHandler } from './routes/image.js';
import { createSaveBoardBackgroundHandler } from './routes/save-board-background.js';
import { createDeleteBoardBackgroundHandler } from './routes/delete-board-background.js';
import { createRenameHandler } from './routes/rename.js';
import { createGitStatusHandler } from './routes/git-status.js';
import { createGitDiffHandler } from './routes/git-diff.js';
import { createGitStageHandler } from './routes/git-stage.js';
import { createSearchFilesHandler } from './routes/search-files.js';
import { createSearchContentHandler } from './routes/search-content.js';

export function createFsRoutes(_events: EventEmitter): Router {
const router = Router();
Expand All @@ -30,13 +36,19 @@ export function createFsRoutes(_events: EventEmitter): Router {
router.post('/exists', createExistsHandler());
router.post('/stat', createStatHandler());
router.post('/delete', createDeleteHandler());
router.post('/rename', createRenameHandler());
router.post('/validate-path', createValidatePathHandler());
router.post('/resolve-directory', createResolveDirectoryHandler());
router.post('/save-image', createSaveImageHandler());
router.post('/browse', createBrowseHandler());
router.get('/image', createImageHandler());
router.post('/save-board-background', createSaveBoardBackgroundHandler());
router.post('/delete-board-background', createDeleteBoardBackgroundHandler());
router.post('/git-status', createGitStatusHandler());
router.post('/git-diff', createGitDiffHandler());
router.post('/git-stage', createGitStageHandler());
router.post('/search-files', createSearchFilesHandler());
router.post('/search-content', createSearchContentHandler());

return router;
}
Loading
Loading