Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
5 changes: 5 additions & 0 deletions apps/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ 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();
Expand Down Expand Up @@ -347,6 +348,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);
Expand Down
12 changes: 12 additions & 0 deletions apps/server/src/routes/projects/common.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 27 additions & 0 deletions apps/server/src/routes/projects/index.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 317 additions & 0 deletions apps/server/src/routes/projects/routes/overview.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
Comment on lines +121 to +143
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The logic for finding the latest timestamp is repeated for startedAt, planSpec.generatedAt, and planSpec.approvedAt. This repetition can be reduced to make the code more concise and easier to maintain. Consider iterating through a list of potential timestamp properties to find the maximum value.

  for (const feature of features) {
    const potentialTimestamps = [
      feature.startedAt,
      feature.planSpec?.generatedAt,
      feature.planSpec?.approvedAt,
    ];

    for (const ts of potentialTimestamps) {
      if (ts) {
        const timestamp = new Date(ts).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;

Comment on lines +175 to +181
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Keep feature counts consistent after overriding running.

Overwriting featureCounts.running with liveRunningCount can desync counts from totalFeatures and can mark a project completed even if there are still features in non‑completed states. Consider reconciling the counts when you override running, or keep live running count separate.

🛠️ Possible fix to keep totals consistent
-          const liveRunningCount = allRunningAgents.filter(
+          const liveRunningCount = allRunningAgents.filter(
             (agent) => agent.projectPath === projectRef.path
           ).length;
-          featureCounts.running = liveRunningCount;
+          if (liveRunningCount !== featureCounts.running) {
+            const delta = liveRunningCount - featureCounts.running;
+            featureCounts.running = liveRunningCount;
+            if (delta > 0) {
+              featureCounts.pending = Math.max(0, featureCounts.pending - delta);
+            } else if (delta < 0) {
+              featureCounts.pending += -delta;
+            }
+          }
🤖 Prompt for AI Agents
In `@apps/server/src/routes/projects/routes/overview.ts` around lines 175 - 181,
The code overwrites featureCounts.running with liveRunningCount which can desync
totals and incorrectly mark projects completed; instead either (A) keep the live
count separate by assigning liveRunningCount to a new key (e.g.,
featureCounts.liveRunning) and leave featureCounts.running as the state-derived
running count, or (B) if you must replace running, recompute featureCounts.total
and any derived completion logic immediately after using all state counts so
totalFeatures remains consistent; locate usage of allRunningAgents,
projectRef.path, and featureCounts in this block and apply one of these fixes so
totals and completion status stay correct.

// 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
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

In this catch block, you're gracefully handling errors for individual project loading by returning a minimal status. However, the error itself is not logged. For better observability and easier debugging of server-side issues, it would be beneficial to log the error that occurred. You can use the logError utility that's already available in this file's scope.

          logError(error, `Failed to load status for project ${projectRef.path}`);
          // 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) });
}
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export function AutomakerLogo({ sidebarOpen, navigate }: AutomakerLogoProps) {
'flex items-center gap-3 titlebar-no-drag cursor-pointer group',
!sidebarOpen && 'flex-col gap-1'
)}
onClick={() => navigate({ to: '/dashboard' })}
onClick={() => navigate({ to: '/overview' })}
data-testid="logo-button"
>
{/* Collapsed logo - only shown when sidebar is closed */}
Expand Down
Loading
Loading