feat: implement notifications and event history features

- Added Notification Service to manage project-level notifications, including creation, listing, marking as read, and dismissing notifications.
- Introduced Event History Service to store and manage historical events, allowing for listing, retrieval, deletion, and replaying of events.
- Integrated notifications into the server and UI, providing real-time updates for feature statuses and operations.
- Enhanced sidebar and project switcher components to display unread notifications count.
- Created dedicated views for managing notifications and event history, improving user experience and accessibility.

These changes enhance the application's ability to inform users about important events and statuses, improving overall usability and responsiveness.
This commit is contained in:
webdevcody
2026-01-16 18:37:11 -05:00
parent 3bdf3cbb5c
commit bd3999416b
42 changed files with 3056 additions and 62 deletions

View File

@@ -79,6 +79,10 @@ import { createIdeationRoutes } from './routes/ideation/index.js';
import { IdeationService } from './services/ideation-service.js';
import { getDevServerService } from './services/dev-server-service.js';
import { eventHookService } from './services/event-hook-service.js';
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';
// Load environment variables
dotenv.config();
@@ -208,8 +212,15 @@ const ideationService = new IdeationService(events, settingsService, featureLoad
const devServerService = getDevServerService();
devServerService.setEventEmitter(events);
// Initialize Event Hook Service for custom event triggers
eventHookService.initialize(events, settingsService);
// Initialize Notification Service with event emitter for real-time updates
const notificationService = getNotificationService();
notificationService.setEventEmitter(events);
// Initialize Event History Service
const eventHistoryService = getEventHistoryService();
// Initialize Event Hook Service for custom event triggers (with history storage)
eventHookService.initialize(events, settingsService, eventHistoryService);
// Initialize services
(async () => {
@@ -264,7 +275,7 @@ 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));
app.use('/api/features', createFeaturesRoutes(featureLoader, settingsService, events));
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
app.use('/api/worktree', createWorktreeRoutes(events, settingsService));
@@ -285,6 +296,8 @@ app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
app.use('/api/mcp', createMCPRoutes(mcpTestService));
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));
// Create HTTP server
const server = createServer(app);

View File

@@ -8,6 +8,7 @@ import type { EventEmitter } from '../../lib/events.js';
import { createLogger } from '@automaker/utils';
import { getFeaturesDir } from '@automaker/platform';
import { extractJsonWithArray } from '../../lib/json-extractor.js';
import { getNotificationService } from '../../services/notification-service.js';
const logger = createLogger('SpecRegeneration');
@@ -88,6 +89,15 @@ export async function parseAndCreateFeatures(
message: `Spec regeneration complete! Created ${createdFeatures.length} features.`,
projectPath: projectPath,
});
// Create notification for spec generation completion
const notificationService = getNotificationService();
await notificationService.createNotification({
type: 'spec_regeneration_complete',
title: 'Spec Generation Complete',
message: `Created ${createdFeatures.length} features from the project specification.`,
projectPath: projectPath,
});
} catch (error) {
logger.error('❌ parseAndCreateFeatures() failed:');
logger.error('Error:', error);

View File

@@ -0,0 +1,19 @@
/**
* Common utilities for event history routes
*/
import { createLogger } from '@automaker/utils';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
/** Logger instance for event history operations */
export const logger = createLogger('EventHistory');
/**
* Extract user-friendly error message from error objects
*/
export { getErrorMessageShared as getErrorMessage };
/**
* Log error with automatic logger binding
*/
export const logError = createLogError(logger);

View File

@@ -0,0 +1,68 @@
/**
* Event History routes - HTTP API for event history management
*
* Provides endpoints for:
* - Listing events with filtering
* - Getting individual event details
* - Deleting events
* - Clearing all events
* - Replaying events to test hooks
*
* Mounted at /api/event-history in the main server.
*/
import { Router } from 'express';
import type { EventHistoryService } from '../../services/event-history-service.js';
import type { SettingsService } from '../../services/settings-service.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { createListHandler } from './routes/list.js';
import { createGetHandler } from './routes/get.js';
import { createDeleteHandler } from './routes/delete.js';
import { createClearHandler } from './routes/clear.js';
import { createReplayHandler } from './routes/replay.js';
/**
* Create event history router with all endpoints
*
* Endpoints:
* - POST /list - List events with optional filtering
* - POST /get - Get a single event by ID
* - POST /delete - Delete an event by ID
* - POST /clear - Clear all events for a project
* - POST /replay - Replay an event to trigger hooks
*
* @param eventHistoryService - Instance of EventHistoryService
* @param settingsService - Instance of SettingsService (for replay)
* @returns Express Router configured with all event history endpoints
*/
export function createEventHistoryRoutes(
eventHistoryService: EventHistoryService,
settingsService: SettingsService
): Router {
const router = Router();
// List events with filtering
router.post('/list', validatePathParams('projectPath'), createListHandler(eventHistoryService));
// Get single event
router.post('/get', validatePathParams('projectPath'), createGetHandler(eventHistoryService));
// Delete event
router.post(
'/delete',
validatePathParams('projectPath'),
createDeleteHandler(eventHistoryService)
);
// Clear all events
router.post('/clear', validatePathParams('projectPath'), createClearHandler(eventHistoryService));
// Replay event
router.post(
'/replay',
validatePathParams('projectPath'),
createReplayHandler(eventHistoryService, settingsService)
);
return router;
}

View File

@@ -0,0 +1,33 @@
/**
* POST /api/event-history/clear - Clear all events for a project
*
* Request body: { projectPath: string }
* Response: { success: true, cleared: number }
*/
import type { Request, Response } from 'express';
import type { EventHistoryService } from '../../../services/event-history-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createClearHandler(eventHistoryService: EventHistoryService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body as { projectPath: string };
if (!projectPath || typeof projectPath !== 'string') {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
const cleared = await eventHistoryService.clearEvents(projectPath);
res.json({
success: true,
cleared,
});
} catch (error) {
logError(error, 'Clear events failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,43 @@
/**
* POST /api/event-history/delete - Delete an event by ID
*
* Request body: { projectPath: string, eventId: string }
* Response: { success: true } or { success: false, error: string }
*/
import type { Request, Response } from 'express';
import type { EventHistoryService } from '../../../services/event-history-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createDeleteHandler(eventHistoryService: EventHistoryService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, eventId } = req.body as {
projectPath: string;
eventId: string;
};
if (!projectPath || typeof projectPath !== 'string') {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!eventId || typeof eventId !== 'string') {
res.status(400).json({ success: false, error: 'eventId is required' });
return;
}
const deleted = await eventHistoryService.deleteEvent(projectPath, eventId);
if (!deleted) {
res.status(404).json({ success: false, error: 'Event not found' });
return;
}
res.json({ success: true });
} catch (error) {
logError(error, 'Delete event failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,46 @@
/**
* POST /api/event-history/get - Get a single event by ID
*
* Request body: { projectPath: string, eventId: string }
* Response: { success: true, event: StoredEvent } or { success: false, error: string }
*/
import type { Request, Response } from 'express';
import type { EventHistoryService } from '../../../services/event-history-service.js';
import { getErrorMessage, logError } from '../common.js';
export function createGetHandler(eventHistoryService: EventHistoryService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, eventId } = req.body as {
projectPath: string;
eventId: string;
};
if (!projectPath || typeof projectPath !== 'string') {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!eventId || typeof eventId !== 'string') {
res.status(400).json({ success: false, error: 'eventId is required' });
return;
}
const event = await eventHistoryService.getEvent(projectPath, eventId);
if (!event) {
res.status(404).json({ success: false, error: 'Event not found' });
return;
}
res.json({
success: true,
event,
});
} catch (error) {
logError(error, 'Get event failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,53 @@
/**
* POST /api/event-history/list - List events for a project
*
* Request body: {
* projectPath: string,
* filter?: {
* trigger?: EventHookTrigger,
* featureId?: string,
* since?: string,
* until?: string,
* limit?: number,
* offset?: number
* }
* }
* Response: { success: true, events: StoredEventSummary[], total: number }
*/
import type { Request, Response } from 'express';
import type { EventHistoryService } from '../../../services/event-history-service.js';
import type { EventHistoryFilter } from '@automaker/types';
import { getErrorMessage, logError } from '../common.js';
export function createListHandler(eventHistoryService: EventHistoryService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, filter } = req.body as {
projectPath: string;
filter?: EventHistoryFilter;
};
if (!projectPath || typeof projectPath !== 'string') {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
const events = await eventHistoryService.getEvents(projectPath, filter);
const total = await eventHistoryService.getEventCount(projectPath, {
...filter,
limit: undefined,
offset: undefined,
});
res.json({
success: true,
events,
total,
});
} catch (error) {
logError(error, 'List events failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,234 @@
/**
* POST /api/event-history/replay - Replay an event to trigger hooks
*
* Request body: {
* projectPath: string,
* eventId: string,
* hookIds?: string[] // Optional: specific hooks to run (if not provided, runs all enabled matching hooks)
* }
* Response: { success: true, result: EventReplayResult }
*/
import type { Request, Response } from 'express';
import type { EventHistoryService } from '../../../services/event-history-service.js';
import type { SettingsService } from '../../../services/settings-service.js';
import type { EventReplayResult, EventReplayHookResult, EventHook } from '@automaker/types';
import { exec } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logError, logger } from '../common.js';
const execAsync = promisify(exec);
/** Default timeout for shell commands (30 seconds) */
const DEFAULT_SHELL_TIMEOUT = 30000;
/** Default timeout for HTTP requests (10 seconds) */
const DEFAULT_HTTP_TIMEOUT = 10000;
interface HookContext {
featureId?: string;
featureName?: string;
projectPath?: string;
projectName?: string;
error?: string;
errorType?: string;
timestamp: string;
eventType: string;
}
/**
* Substitute {{variable}} placeholders in a string
*/
function substituteVariables(template: string, context: HookContext): string {
return template.replace(/\{\{(\w+)\}\}/g, (match, variable) => {
const value = context[variable as keyof HookContext];
if (value === undefined || value === null) {
return '';
}
return String(value);
});
}
/**
* Execute a single hook and return the result
*/
async function executeHook(hook: EventHook, context: HookContext): Promise<EventReplayHookResult> {
const hookName = hook.name || hook.id;
const startTime = Date.now();
try {
if (hook.action.type === 'shell') {
const command = substituteVariables(hook.action.command, context);
const timeout = hook.action.timeout || DEFAULT_SHELL_TIMEOUT;
logger.info(`Replaying shell hook "${hookName}": ${command}`);
await execAsync(command, {
timeout,
maxBuffer: 1024 * 1024,
});
return {
hookId: hook.id,
hookName: hook.name,
success: true,
durationMs: Date.now() - startTime,
};
} else if (hook.action.type === 'http') {
const url = substituteVariables(hook.action.url, context);
const method = hook.action.method || 'POST';
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (hook.action.headers) {
for (const [key, value] of Object.entries(hook.action.headers)) {
headers[key] = substituteVariables(value, context);
}
}
let body: string | undefined;
if (hook.action.body) {
body = substituteVariables(hook.action.body, context);
} else if (method !== 'GET') {
body = JSON.stringify({
eventType: context.eventType,
timestamp: context.timestamp,
featureId: context.featureId,
projectPath: context.projectPath,
projectName: context.projectName,
error: context.error,
});
}
logger.info(`Replaying HTTP hook "${hookName}": ${method} ${url}`);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), DEFAULT_HTTP_TIMEOUT);
const response = await fetch(url, {
method,
headers,
body: method !== 'GET' ? body : undefined,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
return {
hookId: hook.id,
hookName: hook.name,
success: false,
error: `HTTP ${response.status}: ${response.statusText}`,
durationMs: Date.now() - startTime,
};
}
return {
hookId: hook.id,
hookName: hook.name,
success: true,
durationMs: Date.now() - startTime,
};
}
return {
hookId: hook.id,
hookName: hook.name,
success: false,
error: 'Unknown hook action type',
durationMs: Date.now() - startTime,
};
} catch (error) {
const errorMessage =
error instanceof Error
? error.name === 'AbortError'
? 'Request timed out'
: error.message
: String(error);
return {
hookId: hook.id,
hookName: hook.name,
success: false,
error: errorMessage,
durationMs: Date.now() - startTime,
};
}
}
export function createReplayHandler(
eventHistoryService: EventHistoryService,
settingsService: SettingsService
) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, eventId, hookIds } = req.body as {
projectPath: string;
eventId: string;
hookIds?: string[];
};
if (!projectPath || typeof projectPath !== 'string') {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!eventId || typeof eventId !== 'string') {
res.status(400).json({ success: false, error: 'eventId is required' });
return;
}
// Get the event
const event = await eventHistoryService.getEvent(projectPath, eventId);
if (!event) {
res.status(404).json({ success: false, error: 'Event not found' });
return;
}
// Get hooks from settings
const settings = await settingsService.getGlobalSettings();
let hooks = settings.eventHooks || [];
// Filter to matching trigger and enabled hooks
hooks = hooks.filter((h) => h.enabled && h.trigger === event.trigger);
// If specific hook IDs requested, filter to those
if (hookIds && hookIds.length > 0) {
hooks = hooks.filter((h) => hookIds.includes(h.id));
}
// Build context for variable substitution
const context: HookContext = {
featureId: event.featureId,
featureName: event.featureName,
projectPath: event.projectPath,
projectName: event.projectName,
error: event.error,
errorType: event.errorType,
timestamp: event.timestamp,
eventType: event.trigger,
};
// Execute all hooks in parallel
const hookResults = await Promise.all(hooks.map((hook) => executeHook(hook, context)));
const result: EventReplayResult = {
eventId,
hooksTriggered: hooks.length,
hookResults,
};
logger.info(`Replayed event ${eventId}: ${hooks.length} hooks triggered`);
res.json({
success: true,
result,
});
} catch (error) {
logError(error, 'Replay event failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -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 { EventEmitter } from '../../lib/events.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { createListHandler } from './routes/list.js';
import { createGetHandler } from './routes/get.js';
@@ -18,13 +19,18 @@ import { createGenerateTitleHandler } from './routes/generate-title.js';
export function createFeaturesRoutes(
featureLoader: FeatureLoader,
settingsService?: SettingsService
settingsService?: SettingsService,
events?: EventEmitter
): Router {
const router = Router();
router.post('/list', validatePathParams('projectPath'), createListHandler(featureLoader));
router.post('/get', validatePathParams('projectPath'), createGetHandler(featureLoader));
router.post('/create', validatePathParams('projectPath'), createCreateHandler(featureLoader));
router.post(
'/create',
validatePathParams('projectPath'),
createCreateHandler(featureLoader, events)
);
router.post('/update', validatePathParams('projectPath'), createUpdateHandler(featureLoader));
router.post(
'/bulk-update',

View File

@@ -4,10 +4,11 @@
import type { Request, Response } from 'express';
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';
export function createCreateHandler(featureLoader: FeatureLoader) {
export function createCreateHandler(featureLoader: FeatureLoader, events?: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, feature } = req.body as {
@@ -24,6 +25,16 @@ export function createCreateHandler(featureLoader: FeatureLoader) {
}
const created = await featureLoader.create(projectPath, feature);
// Emit feature_created event for hooks
if (events) {
events.emit('feature:created', {
featureId: created.id,
featureName: created.name,
projectPath,
});
}
res.json({ success: true, feature: created });
} catch (error) {
logError(error, 'Create feature failed');

View File

@@ -0,0 +1,21 @@
/**
* Common utilities for notification routes
*
* Provides logger and error handling utilities shared across all notification endpoints.
*/
import { createLogger } from '@automaker/utils';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
/** Logger instance for notification-related operations */
export const logger = createLogger('Notifications');
/**
* Extract user-friendly error message from error objects
*/
export { getErrorMessageShared as getErrorMessage };
/**
* Log error with automatic logger binding
*/
export const logError = createLogError(logger);

View File

@@ -0,0 +1,62 @@
/**
* Notifications routes - HTTP API for project-level notifications
*
* Provides endpoints for:
* - Listing notifications
* - Getting unread count
* - Marking notifications as read
* - Dismissing notifications
*
* All endpoints use handler factories that receive the NotificationService instance.
* Mounted at /api/notifications in the main server.
*/
import { Router } from 'express';
import type { NotificationService } from '../../services/notification-service.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { createListHandler } from './routes/list.js';
import { createUnreadCountHandler } from './routes/unread-count.js';
import { createMarkReadHandler } from './routes/mark-read.js';
import { createDismissHandler } from './routes/dismiss.js';
/**
* Create notifications router with all endpoints
*
* Endpoints:
* - POST /list - List all notifications for a project
* - POST /unread-count - Get unread notification count
* - POST /mark-read - Mark notification(s) as read
* - POST /dismiss - Dismiss notification(s)
*
* @param notificationService - Instance of NotificationService
* @returns Express Router configured with all notification endpoints
*/
export function createNotificationsRoutes(notificationService: NotificationService): Router {
const router = Router();
// List notifications
router.post('/list', validatePathParams('projectPath'), createListHandler(notificationService));
// Get unread count
router.post(
'/unread-count',
validatePathParams('projectPath'),
createUnreadCountHandler(notificationService)
);
// Mark as read (single or all)
router.post(
'/mark-read',
validatePathParams('projectPath'),
createMarkReadHandler(notificationService)
);
// Dismiss (single or all)
router.post(
'/dismiss',
validatePathParams('projectPath'),
createDismissHandler(notificationService)
);
return router;
}

View File

@@ -0,0 +1,53 @@
/**
* POST /api/notifications/dismiss - Dismiss notification(s)
*
* Request body: { projectPath: string, notificationId?: string }
* - If notificationId provided: dismisses that notification
* - If notificationId not provided: dismisses all notifications
*
* Response: { success: true, dismissed: boolean | count: number }
*/
import type { Request, Response } from 'express';
import type { NotificationService } from '../../../services/notification-service.js';
import { getErrorMessage, logError } from '../common.js';
/**
* Create handler for POST /api/notifications/dismiss
*
* @param notificationService - Instance of NotificationService
* @returns Express request handler
*/
export function createDismissHandler(notificationService: NotificationService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, notificationId } = req.body;
if (!projectPath || typeof projectPath !== 'string') {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
// If notificationId provided, dismiss single notification
if (notificationId) {
const dismissed = await notificationService.dismissNotification(
projectPath,
notificationId
);
if (!dismissed) {
res.status(404).json({ success: false, error: 'Notification not found' });
return;
}
res.json({ success: true, dismissed: true });
return;
}
// Otherwise dismiss all
const count = await notificationService.dismissAll(projectPath);
res.json({ success: true, count });
} catch (error) {
logError(error, 'Dismiss failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,39 @@
/**
* POST /api/notifications/list - List all notifications for a project
*
* Request body: { projectPath: string }
* Response: { success: true, notifications: Notification[] }
*/
import type { Request, Response } from 'express';
import type { NotificationService } from '../../../services/notification-service.js';
import { getErrorMessage, logError } from '../common.js';
/**
* Create handler for POST /api/notifications/list
*
* @param notificationService - Instance of NotificationService
* @returns Express request handler
*/
export function createListHandler(notificationService: NotificationService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body;
if (!projectPath || typeof projectPath !== 'string') {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
const notifications = await notificationService.getNotifications(projectPath);
res.json({
success: true,
notifications,
});
} catch (error) {
logError(error, 'List notifications failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,50 @@
/**
* POST /api/notifications/mark-read - Mark notification(s) as read
*
* Request body: { projectPath: string, notificationId?: string }
* - If notificationId provided: marks that notification as read
* - If notificationId not provided: marks all notifications as read
*
* Response: { success: true, count?: number, notification?: Notification }
*/
import type { Request, Response } from 'express';
import type { NotificationService } from '../../../services/notification-service.js';
import { getErrorMessage, logError } from '../common.js';
/**
* Create handler for POST /api/notifications/mark-read
*
* @param notificationService - Instance of NotificationService
* @returns Express request handler
*/
export function createMarkReadHandler(notificationService: NotificationService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, notificationId } = req.body;
if (!projectPath || typeof projectPath !== 'string') {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
// If notificationId provided, mark single notification
if (notificationId) {
const notification = await notificationService.markAsRead(projectPath, notificationId);
if (!notification) {
res.status(404).json({ success: false, error: 'Notification not found' });
return;
}
res.json({ success: true, notification });
return;
}
// Otherwise mark all as read
const count = await notificationService.markAllAsRead(projectPath);
res.json({ success: true, count });
} catch (error) {
logError(error, 'Mark read failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,39 @@
/**
* POST /api/notifications/unread-count - Get unread notification count
*
* Request body: { projectPath: string }
* Response: { success: true, count: number }
*/
import type { Request, Response } from 'express';
import type { NotificationService } from '../../../services/notification-service.js';
import { getErrorMessage, logError } from '../common.js';
/**
* Create handler for POST /api/notifications/unread-count
*
* @param notificationService - Instance of NotificationService
* @returns Express request handler
*/
export function createUnreadCountHandler(notificationService: NotificationService) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body;
if (!projectPath || typeof projectPath !== 'string') {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
const count = await notificationService.getUnreadCount(projectPath);
res.json({
success: true,
count,
});
} catch (error) {
logError(error, 'Get unread count failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -60,6 +60,7 @@ import {
getMCPServersFromSettings,
getPromptCustomization,
} from '../lib/settings-helpers.js';
import { getNotificationService } from './notification-service.js';
const execAsync = promisify(exec);
@@ -386,6 +387,7 @@ export class AutoModeService {
this.emitAutoModeEvent('auto_mode_error', {
error: errorInfo.message,
errorType: errorInfo.type,
projectPath,
});
});
}
@@ -1547,6 +1549,7 @@ Address the follow-up instructions above. Review the previous work and make the
message: allPassed
? 'All verification checks passed'
: `Verification failed: ${results.find((r) => !r.passed)?.check || 'Unknown'}`,
projectPath,
});
return allPassed;
@@ -1620,6 +1623,7 @@ Address the follow-up instructions above. Review the previous work and make the
featureId,
passes: true,
message: `Changes committed: ${hash.trim().substring(0, 8)}`,
projectPath,
});
return hash.trim();
@@ -2101,6 +2105,26 @@ Format your response as a structured markdown document.`;
feature.justFinishedAt = undefined;
}
await secureFs.writeFile(featurePath, JSON.stringify(feature, null, 2));
// Create notifications for important status changes
const notificationService = getNotificationService();
if (status === 'waiting_approval') {
await notificationService.createNotification({
type: 'feature_waiting_approval',
title: 'Feature Ready for Review',
message: `"${feature.name || featureId}" is ready for your review and approval.`,
featureId,
projectPath,
});
} else if (status === 'verified') {
await notificationService.createNotification({
type: 'feature_verified',
title: 'Feature Verified',
message: `"${feature.name || featureId}" has been verified and is complete.`,
featureId,
projectPath,
});
}
} catch {
// Feature file may not exist
}

View File

@@ -0,0 +1,338 @@
/**
* Event History Service - Stores and retrieves event records for debugging and replay
*
* Provides persistent storage for events in {projectPath}/.automaker/events/
* Each event is stored as a separate JSON file with an index for quick listing.
*
* Features:
* - Store events when they occur
* - List and filter historical events
* - Replay events to test hook configurations
* - Delete old events to manage disk space
*/
import { createLogger } from '@automaker/utils';
import * as secureFs from '../lib/secure-fs.js';
import {
getEventHistoryDir,
getEventHistoryIndexPath,
getEventPath,
ensureEventHistoryDir,
} from '@automaker/platform';
import type {
StoredEvent,
StoredEventIndex,
StoredEventSummary,
EventHistoryFilter,
EventHookTrigger,
} from '@automaker/types';
import { DEFAULT_EVENT_HISTORY_INDEX } from '@automaker/types';
import { randomUUID } from 'crypto';
const logger = createLogger('EventHistoryService');
/** Maximum events to keep in the index (oldest are pruned) */
const MAX_EVENTS_IN_INDEX = 1000;
/**
* Atomic file write - write to temp file then rename
*/
async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
const tempPath = `${filePath}.tmp.${Date.now()}`;
const content = JSON.stringify(data, null, 2);
try {
await secureFs.writeFile(tempPath, content, 'utf-8');
await secureFs.rename(tempPath, filePath);
} catch (error) {
try {
await secureFs.unlink(tempPath);
} catch {
// Ignore cleanup errors
}
throw error;
}
}
/**
* Safely read JSON file with fallback to default
*/
async function readJsonFile<T>(filePath: string, defaultValue: T): Promise<T> {
try {
const content = (await secureFs.readFile(filePath, 'utf-8')) as string;
return JSON.parse(content) as T;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return defaultValue;
}
logger.error(`Error reading ${filePath}:`, error);
return defaultValue;
}
}
/**
* Input for storing a new event
*/
export interface StoreEventInput {
trigger: EventHookTrigger;
projectPath: string;
featureId?: string;
featureName?: string;
error?: string;
errorType?: string;
passes?: boolean;
metadata?: Record<string, unknown>;
}
/**
* EventHistoryService - Manages persistent storage of events
*/
export class EventHistoryService {
/**
* Store a new event to history
*
* @param input - Event data to store
* @returns Promise resolving to the stored event
*/
async storeEvent(input: StoreEventInput): Promise<StoredEvent> {
const { projectPath, trigger, featureId, featureName, error, errorType, passes, metadata } =
input;
// Ensure events directory exists
await ensureEventHistoryDir(projectPath);
const eventId = `evt-${Date.now()}-${randomUUID().slice(0, 8)}`;
const timestamp = new Date().toISOString();
const projectName = this.extractProjectName(projectPath);
const event: StoredEvent = {
id: eventId,
trigger,
timestamp,
projectPath,
projectName,
featureId,
featureName,
error,
errorType,
passes,
metadata,
};
// Write the full event to its own file
const eventPath = getEventPath(projectPath, eventId);
await atomicWriteJson(eventPath, event);
// Update the index
await this.addToIndex(projectPath, event);
logger.info(`Stored event ${eventId} (${trigger}) for project ${projectName}`);
return event;
}
/**
* Get all events for a project with optional filtering
*
* @param projectPath - Absolute path to project directory
* @param filter - Optional filter criteria
* @returns Promise resolving to array of event summaries
*/
async getEvents(projectPath: string, filter?: EventHistoryFilter): Promise<StoredEventSummary[]> {
const indexPath = getEventHistoryIndexPath(projectPath);
const index = await readJsonFile<StoredEventIndex>(indexPath, DEFAULT_EVENT_HISTORY_INDEX);
let events = [...index.events];
// Apply filters
if (filter) {
if (filter.trigger) {
events = events.filter((e) => e.trigger === filter.trigger);
}
if (filter.featureId) {
events = events.filter((e) => e.featureId === filter.featureId);
}
if (filter.since) {
const sinceDate = new Date(filter.since).getTime();
events = events.filter((e) => new Date(e.timestamp).getTime() >= sinceDate);
}
if (filter.until) {
const untilDate = new Date(filter.until).getTime();
events = events.filter((e) => new Date(e.timestamp).getTime() <= untilDate);
}
}
// Sort by timestamp (newest first)
events.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
// Apply pagination
if (filter?.offset) {
events = events.slice(filter.offset);
}
if (filter?.limit) {
events = events.slice(0, filter.limit);
}
return events;
}
/**
* Get a single event by ID
*
* @param projectPath - Absolute path to project directory
* @param eventId - Event identifier
* @returns Promise resolving to the full event or null if not found
*/
async getEvent(projectPath: string, eventId: string): Promise<StoredEvent | null> {
const eventPath = getEventPath(projectPath, eventId);
try {
const content = (await secureFs.readFile(eventPath, 'utf-8')) as string;
return JSON.parse(content) as StoredEvent;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
logger.error(`Error reading event ${eventId}:`, error);
return null;
}
}
/**
* Delete an event by ID
*
* @param projectPath - Absolute path to project directory
* @param eventId - Event identifier
* @returns Promise resolving to true if deleted
*/
async deleteEvent(projectPath: string, eventId: string): Promise<boolean> {
// Remove from index
const indexPath = getEventHistoryIndexPath(projectPath);
const index = await readJsonFile<StoredEventIndex>(indexPath, DEFAULT_EVENT_HISTORY_INDEX);
const initialLength = index.events.length;
index.events = index.events.filter((e) => e.id !== eventId);
if (index.events.length === initialLength) {
return false; // Event not found in index
}
await atomicWriteJson(indexPath, index);
// Delete the event file
const eventPath = getEventPath(projectPath, eventId);
try {
await secureFs.unlink(eventPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.error(`Error deleting event file ${eventId}:`, error);
}
}
logger.info(`Deleted event ${eventId}`);
return true;
}
/**
* Clear all events for a project
*
* @param projectPath - Absolute path to project directory
* @returns Promise resolving to number of events cleared
*/
async clearEvents(projectPath: string): Promise<number> {
const indexPath = getEventHistoryIndexPath(projectPath);
const index = await readJsonFile<StoredEventIndex>(indexPath, DEFAULT_EVENT_HISTORY_INDEX);
const count = index.events.length;
// Delete all event files
for (const event of index.events) {
const eventPath = getEventPath(projectPath, event.id);
try {
await secureFs.unlink(eventPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.error(`Error deleting event file ${event.id}:`, error);
}
}
}
// Reset the index
await atomicWriteJson(indexPath, DEFAULT_EVENT_HISTORY_INDEX);
logger.info(`Cleared ${count} events for project`);
return count;
}
/**
* Get event count for a project
*
* @param projectPath - Absolute path to project directory
* @param filter - Optional filter criteria
* @returns Promise resolving to event count
*/
async getEventCount(projectPath: string, filter?: EventHistoryFilter): Promise<number> {
const events = await this.getEvents(projectPath, {
...filter,
limit: undefined,
offset: undefined,
});
return events.length;
}
/**
* Add an event to the index (internal)
*/
private async addToIndex(projectPath: string, event: StoredEvent): Promise<void> {
const indexPath = getEventHistoryIndexPath(projectPath);
const index = await readJsonFile<StoredEventIndex>(indexPath, DEFAULT_EVENT_HISTORY_INDEX);
const summary: StoredEventSummary = {
id: event.id,
trigger: event.trigger,
timestamp: event.timestamp,
featureName: event.featureName,
featureId: event.featureId,
};
// Add to beginning (newest first)
index.events.unshift(summary);
// Prune old events if over limit
if (index.events.length > MAX_EVENTS_IN_INDEX) {
const removed = index.events.splice(MAX_EVENTS_IN_INDEX);
// Delete the pruned event files
for (const oldEvent of removed) {
const eventPath = getEventPath(projectPath, oldEvent.id);
try {
await secureFs.unlink(eventPath);
} catch {
// Ignore deletion errors for pruned events
}
}
logger.info(`Pruned ${removed.length} old events from history`);
}
await atomicWriteJson(indexPath, index);
}
/**
* Extract project name from path
*/
private extractProjectName(projectPath: string): string {
const parts = projectPath.split(/[/\\]/);
return parts[parts.length - 1] || projectPath;
}
}
// Singleton instance
let eventHistoryServiceInstance: EventHistoryService | null = null;
/**
* Get the singleton event history service instance
*/
export function getEventHistoryService(): EventHistoryService {
if (!eventHistoryServiceInstance) {
eventHistoryServiceInstance = new EventHistoryService();
}
return eventHistoryServiceInstance;
}

View File

@@ -5,7 +5,10 @@
* - Shell commands: Executed with configurable timeout
* - HTTP webhooks: POST/GET/PUT/PATCH requests with variable substitution
*
* Also stores events to history for debugging and replay.
*
* Supported events:
* - feature_created: A new feature was created
* - feature_success: Feature completed successfully
* - feature_error: Feature failed with an error
* - auto_mode_complete: Auto mode finished all features (idle state)
@@ -17,6 +20,7 @@ import { promisify } from 'util';
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 {
EventHook,
EventHookTrigger,
@@ -60,27 +64,45 @@ interface AutoModeEventPayload {
projectPath?: string;
}
/**
* Feature created event payload structure
*/
interface FeatureCreatedPayload {
featureId: string;
featureName?: string;
projectPath: string;
}
/**
* Event Hook Service
*
* Manages execution of user-configured event hooks in response to system events.
* Also stores events to history for debugging and replay.
*/
export class EventHookService {
private emitter: EventEmitter | null = null;
private settingsService: SettingsService | null = null;
private eventHistoryService: EventHistoryService | null = null;
private unsubscribe: (() => void) | null = null;
/**
* Initialize the service with event emitter and settings service
* Initialize the service with event emitter, settings service, and event history service
*/
initialize(emitter: EventEmitter, settingsService: SettingsService): void {
initialize(
emitter: EventEmitter,
settingsService: SettingsService,
eventHistoryService?: EventHistoryService
): void {
this.emitter = emitter;
this.settingsService = settingsService;
this.eventHistoryService = eventHistoryService || null;
// Subscribe to auto-mode events
// Subscribe to events
this.unsubscribe = emitter.subscribe((type, payload) => {
if (type === 'auto-mode:event') {
this.handleAutoModeEvent(payload as AutoModeEventPayload);
} else if (type === 'feature:created') {
this.handleFeatureCreatedEvent(payload as FeatureCreatedPayload);
}
});
@@ -97,6 +119,7 @@ export class EventHookService {
}
this.emitter = null;
this.settingsService = null;
this.eventHistoryService = null;
}
/**
@@ -137,17 +160,51 @@ export class EventHookService {
eventType: trigger,
};
// Execute matching hooks
await this.executeHooksForTrigger(trigger, context);
// Execute matching hooks (pass passes for feature completion events)
await this.executeHooksForTrigger(trigger, context, { passes: payload.passes });
}
/**
* Execute all enabled hooks matching the given trigger
* Handle feature:created events and trigger matching hooks
*/
private async handleFeatureCreatedEvent(payload: FeatureCreatedPayload): Promise<void> {
const context: HookContext = {
featureId: payload.featureId,
featureName: payload.featureName,
projectPath: payload.projectPath,
projectName: this.extractProjectName(payload.projectPath),
timestamp: new Date().toISOString(),
eventType: 'feature_created',
};
await this.executeHooksForTrigger('feature_created', context);
}
/**
* Execute all enabled hooks matching the given trigger and store event to history
*/
private async executeHooksForTrigger(
trigger: EventHookTrigger,
context: HookContext
context: HookContext,
additionalData?: { passes?: boolean }
): Promise<void> {
// Store event to history (even if no hooks match)
if (this.eventHistoryService && context.projectPath) {
try {
await this.eventHistoryService.storeEvent({
trigger,
projectPath: context.projectPath,
featureId: context.featureId,
featureName: context.featureName,
error: context.error,
errorType: context.errorType,
passes: additionalData?.passes,
});
} catch (error) {
logger.error('Failed to store event to history:', error);
}
}
if (!this.settingsService) {
logger.warn('Settings service not available');
return;

View File

@@ -0,0 +1,280 @@
/**
* Notification Service - Handles reading/writing notifications to JSON files
*
* Provides persistent storage for project-level notifications in
* {projectPath}/.automaker/notifications.json
*
* Notifications alert users when:
* - Features reach specific statuses (waiting_approval, verified)
* - Long-running operations complete (spec generation)
*/
import { createLogger } from '@automaker/utils';
import * as secureFs from '../lib/secure-fs.js';
import { getNotificationsPath, ensureAutomakerDir } from '@automaker/platform';
import type { Notification, NotificationsFile, NotificationType } from '@automaker/types';
import { DEFAULT_NOTIFICATIONS_FILE } from '@automaker/types';
import type { EventEmitter } from '../lib/events.js';
import { randomUUID } from 'crypto';
const logger = createLogger('NotificationService');
/**
* Atomic file write - write to temp file then rename
*/
async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
const tempPath = `${filePath}.tmp.${Date.now()}`;
const content = JSON.stringify(data, null, 2);
try {
await secureFs.writeFile(tempPath, content, 'utf-8');
await secureFs.rename(tempPath, filePath);
} catch (error) {
// Clean up temp file if it exists
try {
await secureFs.unlink(tempPath);
} catch {
// Ignore cleanup errors
}
throw error;
}
}
/**
* Safely read JSON file with fallback to default
*/
async function readJsonFile<T>(filePath: string, defaultValue: T): Promise<T> {
try {
const content = (await secureFs.readFile(filePath, 'utf-8')) as string;
return JSON.parse(content) as T;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return defaultValue;
}
logger.error(`Error reading ${filePath}:`, error);
return defaultValue;
}
}
/**
* Input for creating a new notification
*/
export interface CreateNotificationInput {
type: NotificationType;
title: string;
message: string;
featureId?: string;
projectPath: string;
}
/**
* NotificationService - Manages persistent storage of notifications
*
* Handles reading and writing notifications to JSON files with atomic operations
* for reliability. Each project has its own notifications.json file.
*/
export class NotificationService {
private events: EventEmitter | null = null;
/**
* Set the event emitter for broadcasting notification events
*/
setEventEmitter(events: EventEmitter): void {
this.events = events;
}
/**
* Get all notifications for a project
*
* @param projectPath - Absolute path to project directory
* @returns Promise resolving to array of notifications
*/
async getNotifications(projectPath: string): Promise<Notification[]> {
const notificationsPath = getNotificationsPath(projectPath);
const file = await readJsonFile<NotificationsFile>(
notificationsPath,
DEFAULT_NOTIFICATIONS_FILE
);
// Filter out dismissed notifications and sort by date (newest first)
return file.notifications
.filter((n) => !n.dismissed)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
}
/**
* Get unread notification count for a project
*
* @param projectPath - Absolute path to project directory
* @returns Promise resolving to unread count
*/
async getUnreadCount(projectPath: string): Promise<number> {
const notifications = await this.getNotifications(projectPath);
return notifications.filter((n) => !n.read).length;
}
/**
* Create a new notification
*
* @param input - Notification creation input
* @returns Promise resolving to the created notification
*/
async createNotification(input: CreateNotificationInput): Promise<Notification> {
const { projectPath, type, title, message, featureId } = input;
// Ensure automaker directory exists
await ensureAutomakerDir(projectPath);
const notificationsPath = getNotificationsPath(projectPath);
const file = await readJsonFile<NotificationsFile>(
notificationsPath,
DEFAULT_NOTIFICATIONS_FILE
);
const notification: Notification = {
id: randomUUID(),
type,
title,
message,
createdAt: new Date().toISOString(),
read: false,
dismissed: false,
featureId,
projectPath,
};
file.notifications.push(notification);
await atomicWriteJson(notificationsPath, file);
logger.info(`Created notification: ${title} for project ${projectPath}`);
// Emit event for real-time updates
if (this.events) {
this.events.emit('notification:created', notification);
}
return notification;
}
/**
* Mark a notification as read
*
* @param projectPath - Absolute path to project directory
* @param notificationId - ID of the notification to mark as read
* @returns Promise resolving to the updated notification or null if not found
*/
async markAsRead(projectPath: string, notificationId: string): Promise<Notification | null> {
const notificationsPath = getNotificationsPath(projectPath);
const file = await readJsonFile<NotificationsFile>(
notificationsPath,
DEFAULT_NOTIFICATIONS_FILE
);
const notification = file.notifications.find((n) => n.id === notificationId);
if (!notification) {
return null;
}
notification.read = true;
await atomicWriteJson(notificationsPath, file);
logger.info(`Marked notification ${notificationId} as read`);
return notification;
}
/**
* Mark all notifications as read for a project
*
* @param projectPath - Absolute path to project directory
* @returns Promise resolving to number of notifications marked as read
*/
async markAllAsRead(projectPath: string): Promise<number> {
const notificationsPath = getNotificationsPath(projectPath);
const file = await readJsonFile<NotificationsFile>(
notificationsPath,
DEFAULT_NOTIFICATIONS_FILE
);
let count = 0;
for (const notification of file.notifications) {
if (!notification.read && !notification.dismissed) {
notification.read = true;
count++;
}
}
if (count > 0) {
await atomicWriteJson(notificationsPath, file);
logger.info(`Marked ${count} notifications as read`);
}
return count;
}
/**
* Dismiss a notification
*
* @param projectPath - Absolute path to project directory
* @param notificationId - ID of the notification to dismiss
* @returns Promise resolving to true if notification was dismissed
*/
async dismissNotification(projectPath: string, notificationId: string): Promise<boolean> {
const notificationsPath = getNotificationsPath(projectPath);
const file = await readJsonFile<NotificationsFile>(
notificationsPath,
DEFAULT_NOTIFICATIONS_FILE
);
const notification = file.notifications.find((n) => n.id === notificationId);
if (!notification) {
return false;
}
notification.dismissed = true;
await atomicWriteJson(notificationsPath, file);
logger.info(`Dismissed notification ${notificationId}`);
return true;
}
/**
* Dismiss all notifications for a project
*
* @param projectPath - Absolute path to project directory
* @returns Promise resolving to number of notifications dismissed
*/
async dismissAll(projectPath: string): Promise<number> {
const notificationsPath = getNotificationsPath(projectPath);
const file = await readJsonFile<NotificationsFile>(
notificationsPath,
DEFAULT_NOTIFICATIONS_FILE
);
let count = 0;
for (const notification of file.notifications) {
if (!notification.dismissed) {
notification.dismissed = true;
count++;
}
}
if (count > 0) {
await atomicWriteJson(notificationsPath, file);
logger.info(`Dismissed ${count} notifications`);
}
return count;
}
}
// Singleton instance
let notificationServiceInstance: NotificationService | null = null;
/**
* Get the singleton notification service instance
*/
export function getNotificationService(): NotificationService {
if (!notificationServiceInstance) {
notificationServiceInstance = new NotificationService();
}
return notificationServiceInstance;
}