diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 3a59d4d3..e6f9d0d2 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -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); diff --git a/apps/server/src/routes/app-spec/parse-and-create-features.ts b/apps/server/src/routes/app-spec/parse-and-create-features.ts index 78137a73..080486fb 100644 --- a/apps/server/src/routes/app-spec/parse-and-create-features.ts +++ b/apps/server/src/routes/app-spec/parse-and-create-features.ts @@ -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); diff --git a/apps/server/src/routes/event-history/common.ts b/apps/server/src/routes/event-history/common.ts new file mode 100644 index 00000000..bd0ad3fe --- /dev/null +++ b/apps/server/src/routes/event-history/common.ts @@ -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); diff --git a/apps/server/src/routes/event-history/index.ts b/apps/server/src/routes/event-history/index.ts new file mode 100644 index 00000000..93297ddd --- /dev/null +++ b/apps/server/src/routes/event-history/index.ts @@ -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; +} diff --git a/apps/server/src/routes/event-history/routes/clear.ts b/apps/server/src/routes/event-history/routes/clear.ts new file mode 100644 index 00000000..c6e6bb58 --- /dev/null +++ b/apps/server/src/routes/event-history/routes/clear.ts @@ -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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/routes/event-history/routes/delete.ts b/apps/server/src/routes/event-history/routes/delete.ts new file mode 100644 index 00000000..ea3f6b16 --- /dev/null +++ b/apps/server/src/routes/event-history/routes/delete.ts @@ -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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/routes/event-history/routes/get.ts b/apps/server/src/routes/event-history/routes/get.ts new file mode 100644 index 00000000..f892fd41 --- /dev/null +++ b/apps/server/src/routes/event-history/routes/get.ts @@ -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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/routes/event-history/routes/list.ts b/apps/server/src/routes/event-history/routes/list.ts new file mode 100644 index 00000000..551594f2 --- /dev/null +++ b/apps/server/src/routes/event-history/routes/list.ts @@ -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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/routes/event-history/routes/replay.ts b/apps/server/src/routes/event-history/routes/replay.ts new file mode 100644 index 00000000..c6f27a40 --- /dev/null +++ b/apps/server/src/routes/event-history/routes/replay.ts @@ -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 { + 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 = { + '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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/routes/features/index.ts b/apps/server/src/routes/features/index.ts index dd58e4aa..439ab6a9 100644 --- a/apps/server/src/routes/features/index.ts +++ b/apps/server/src/routes/features/index.ts @@ -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', diff --git a/apps/server/src/routes/features/routes/create.ts b/apps/server/src/routes/features/routes/create.ts index 5f04ecdb..e7a11f83 100644 --- a/apps/server/src/routes/features/routes/create.ts +++ b/apps/server/src/routes/features/routes/create.ts @@ -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 => { 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'); diff --git a/apps/server/src/routes/notifications/common.ts b/apps/server/src/routes/notifications/common.ts new file mode 100644 index 00000000..707e3a0d --- /dev/null +++ b/apps/server/src/routes/notifications/common.ts @@ -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); diff --git a/apps/server/src/routes/notifications/index.ts b/apps/server/src/routes/notifications/index.ts new file mode 100644 index 00000000..2def111a --- /dev/null +++ b/apps/server/src/routes/notifications/index.ts @@ -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; +} diff --git a/apps/server/src/routes/notifications/routes/dismiss.ts b/apps/server/src/routes/notifications/routes/dismiss.ts new file mode 100644 index 00000000..c609f170 --- /dev/null +++ b/apps/server/src/routes/notifications/routes/dismiss.ts @@ -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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/routes/notifications/routes/list.ts b/apps/server/src/routes/notifications/routes/list.ts new file mode 100644 index 00000000..46197fe9 --- /dev/null +++ b/apps/server/src/routes/notifications/routes/list.ts @@ -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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/routes/notifications/routes/mark-read.ts b/apps/server/src/routes/notifications/routes/mark-read.ts new file mode 100644 index 00000000..4c9bfeb5 --- /dev/null +++ b/apps/server/src/routes/notifications/routes/mark-read.ts @@ -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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/routes/notifications/routes/unread-count.ts b/apps/server/src/routes/notifications/routes/unread-count.ts new file mode 100644 index 00000000..98d8e198 --- /dev/null +++ b/apps/server/src/routes/notifications/routes/unread-count.ts @@ -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 => { + 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) }); + } + }; +} diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 05722181..d97e3402 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -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 } diff --git a/apps/server/src/services/event-history-service.ts b/apps/server/src/services/event-history-service.ts new file mode 100644 index 00000000..b983af09 --- /dev/null +++ b/apps/server/src/services/event-history-service.ts @@ -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 { + 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(filePath: string, defaultValue: T): Promise { + 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; +} + +/** + * 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 { + 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 { + const indexPath = getEventHistoryIndexPath(projectPath); + const index = await readJsonFile(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 { + 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 { + // Remove from index + const indexPath = getEventHistoryIndexPath(projectPath); + const index = await readJsonFile(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 { + const indexPath = getEventHistoryIndexPath(projectPath); + const index = await readJsonFile(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 { + 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 { + const indexPath = getEventHistoryIndexPath(projectPath); + const index = await readJsonFile(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; +} diff --git a/apps/server/src/services/event-hook-service.ts b/apps/server/src/services/event-hook-service.ts index d6d2d0b8..08da71dd 100644 --- a/apps/server/src/services/event-hook-service.ts +++ b/apps/server/src/services/event-hook-service.ts @@ -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 { + 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 { + // 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; diff --git a/apps/server/src/services/notification-service.ts b/apps/server/src/services/notification-service.ts new file mode 100644 index 00000000..21685308 --- /dev/null +++ b/apps/server/src/services/notification-service.ts @@ -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 { + 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(filePath: string, defaultValue: T): Promise { + 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 { + const notificationsPath = getNotificationsPath(projectPath); + const file = await readJsonFile( + 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 { + 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 { + const { projectPath, type, title, message, featureId } = input; + + // Ensure automaker directory exists + await ensureAutomakerDir(projectPath); + + const notificationsPath = getNotificationsPath(projectPath); + const file = await readJsonFile( + 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 { + const notificationsPath = getNotificationsPath(projectPath); + const file = await readJsonFile( + 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 { + const notificationsPath = getNotificationsPath(projectPath); + const file = await readJsonFile( + 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 { + const notificationsPath = getNotificationsPath(projectPath); + const file = await readJsonFile( + 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 { + const notificationsPath = getNotificationsPath(projectPath); + const file = await readJsonFile( + 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; +} diff --git a/apps/ui/src/components/layout/project-switcher/components/notification-bell.tsx b/apps/ui/src/components/layout/project-switcher/components/notification-bell.tsx new file mode 100644 index 00000000..adcd7b64 --- /dev/null +++ b/apps/ui/src/components/layout/project-switcher/components/notification-bell.tsx @@ -0,0 +1,207 @@ +/** + * Notification Bell - Bell icon with unread count and popover + */ + +import { useCallback } from 'react'; +import { Bell, Check, Trash2, ExternalLink } from 'lucide-react'; +import { useNavigate } from '@tanstack/react-router'; +import { useNotificationsStore } from '@/store/notifications-store'; +import { useLoadNotifications, useNotificationEvents } from '@/hooks/use-notification-events'; +import { getHttpApiClient } from '@/lib/http-api-client'; +import { Button } from '@/components/ui/button'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import type { Notification } from '@automaker/types'; +import { cn } from '@/lib/utils'; + +/** + * Format a date as relative time (e.g., "2 minutes ago", "3 hours ago") + */ +function formatRelativeTime(date: Date): string { + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffSec = Math.floor(diffMs / 1000); + const diffMin = Math.floor(diffSec / 60); + const diffHour = Math.floor(diffMin / 60); + const diffDay = Math.floor(diffHour / 24); + + if (diffSec < 60) return 'just now'; + if (diffMin < 60) return `${diffMin} minute${diffMin === 1 ? '' : 's'} ago`; + if (diffHour < 24) return `${diffHour} hour${diffHour === 1 ? '' : 's'} ago`; + if (diffDay < 7) return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`; + return date.toLocaleDateString(); +} + +interface NotificationBellProps { + projectPath: string | null; +} + +export function NotificationBell({ projectPath }: NotificationBellProps) { + const navigate = useNavigate(); + const { + notifications, + unreadCount, + isPopoverOpen, + setPopoverOpen, + markAsRead, + dismissNotification, + } = useNotificationsStore(); + + // Load notifications and subscribe to events + useLoadNotifications(projectPath); + useNotificationEvents(projectPath); + + const handleMarkAsRead = useCallback( + async (notificationId: string) => { + if (!projectPath) return; + + // Optimistic update + markAsRead(notificationId); + + // Sync with server + const api = getHttpApiClient(); + await api.notifications.markAsRead(projectPath, notificationId); + }, + [projectPath, markAsRead] + ); + + const handleDismiss = useCallback( + async (notificationId: string) => { + if (!projectPath) return; + + // Optimistic update + dismissNotification(notificationId); + + // Sync with server + const api = getHttpApiClient(); + await api.notifications.dismiss(projectPath, notificationId); + }, + [projectPath, dismissNotification] + ); + + const handleNotificationClick = useCallback( + (notification: Notification) => { + // Mark as read + handleMarkAsRead(notification.id); + setPopoverOpen(false); + + // Navigate to the relevant view based on notification type + if (notification.featureId) { + navigate({ to: '/board' }); + } + }, + [handleMarkAsRead, setPopoverOpen, navigate] + ); + + const handleViewAll = useCallback(() => { + setPopoverOpen(false); + navigate({ to: '/notifications' }); + }, [setPopoverOpen, navigate]); + + const getNotificationIcon = (type: string) => { + switch (type) { + case 'feature_waiting_approval': + return ; + case 'feature_verified': + return ; + case 'spec_regeneration_complete': + return ; + default: + return ; + } + }; + + // Show recent 3 notifications in popover + const recentNotifications = notifications.slice(0, 3); + + if (!projectPath) { + return null; + } + + return ( + + + + + +
+

Notifications

+ {unreadCount > 0 && ( + {unreadCount} unread + )} +
+ + {recentNotifications.length === 0 ? ( +
+ +

No notifications

+
+ ) : ( +
+ {recentNotifications.map((notification) => ( +
handleNotificationClick(notification)} + > +
{getNotificationIcon(notification.type)}
+
+
+

{notification.title}

+ {!notification.read && ( + + )} +
+

+ {notification.message} +

+

+ {formatRelativeTime(new Date(notification.createdAt))} +

+
+
+ +
+
+ ))} +
+ )} + + {notifications.length > 0 && ( +
+ +
+ )} +
+
+ ); +} diff --git a/apps/ui/src/components/layout/project-switcher/project-switcher.tsx b/apps/ui/src/components/layout/project-switcher/project-switcher.tsx index 0713df72..426777b5 100644 --- a/apps/ui/src/components/layout/project-switcher/project-switcher.tsx +++ b/apps/ui/src/components/layout/project-switcher/project-switcher.tsx @@ -7,6 +7,7 @@ import { useOSDetection } from '@/hooks/use-os-detection'; import { ProjectSwitcherItem } from './components/project-switcher-item'; import { ProjectContextMenu } from './components/project-context-menu'; import { EditProjectDialog } from './components/edit-project-dialog'; +import { NotificationBell } from './components/notification-bell'; import { NewProjectModal } from '@/components/dialogs/new-project-modal'; import { OnboardingDialog } from '@/components/layout/sidebar/dialogs'; import { useProjectCreation, useProjectTheme } from '@/components/layout/sidebar/hooks'; @@ -327,6 +328,11 @@ export function ProjectSwitcher() { v{appVersion} {versionSuffix} + + {/* Notification Bell */} +
+ +
diff --git a/apps/ui/src/components/layout/sidebar.tsx b/apps/ui/src/components/layout/sidebar.tsx index 5bdf8a92..6cdb32cd 100644 --- a/apps/ui/src/components/layout/sidebar.tsx +++ b/apps/ui/src/components/layout/sidebar.tsx @@ -5,6 +5,7 @@ import { useNavigate, useLocation } from '@tanstack/react-router'; const logger = createLogger('Sidebar'); import { cn } from '@/lib/utils'; import { useAppStore, type ThemeMode } from '@/store/app-store'; +import { useNotificationsStore } from '@/store/notifications-store'; import { useKeyboardShortcuts, useKeyboardShortcutsConfig } from '@/hooks/use-keyboard-shortcuts'; import { getElectronAPI } from '@/lib/electron'; import { initializeProject, hasAppSpec, hasAutomakerDir } from '@/lib/project-init'; @@ -62,6 +63,9 @@ export function Sidebar() { // Get customizable keyboard shortcuts const shortcuts = useKeyboardShortcutsConfig(); + // Get unread notifications count + const unreadNotificationsCount = useNotificationsStore((s) => s.unreadCount); + // State for delete project confirmation dialog const [showDeleteProjectDialog, setShowDeleteProjectDialog] = useState(false); @@ -238,6 +242,7 @@ export function Sidebar() { cyclePrevProject, cycleNextProject, unviewedValidationsCount, + unreadNotificationsCount, isSpecGenerating: isCurrentProjectGeneratingSpec, }); diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts b/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts index 110fa26c..ff8b7b0b 100644 --- a/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts +++ b/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts @@ -11,6 +11,7 @@ import { Lightbulb, Brain, Network, + Bell, } from 'lucide-react'; import type { NavSection, NavItem } from '../types'; import type { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts'; @@ -35,6 +36,7 @@ interface UseNavigationProps { ideation: string; githubIssues: string; githubPrs: string; + notifications: string; }; hideSpecEditor: boolean; hideContext: boolean; @@ -49,6 +51,8 @@ interface UseNavigationProps { cycleNextProject: () => void; /** Count of unviewed validations to show on GitHub Issues nav item */ unviewedValidationsCount?: number; + /** Count of unread notifications to show on Notifications nav item */ + unreadNotificationsCount?: number; /** Whether spec generation is currently running for the current project */ isSpecGenerating?: boolean; } @@ -67,6 +71,7 @@ export function useNavigation({ cyclePrevProject, cycleNextProject, unviewedValidationsCount, + unreadNotificationsCount, isSpecGenerating, }: UseNavigationProps) { // Track if current project has a GitHub remote @@ -199,6 +204,20 @@ export function useNavigation({ }); } + // Add Other section with notifications + sections.push({ + label: 'Other', + items: [ + { + id: 'notifications', + label: 'Notifications', + icon: Bell, + shortcut: shortcuts.notifications, + count: unreadNotificationsCount, + }, + ], + }); + return sections; }, [ shortcuts, @@ -207,6 +226,7 @@ export function useNavigation({ hideTerminal, hasGitHubRemote, unviewedValidationsCount, + unreadNotificationsCount, isSpecGenerating, ]); diff --git a/apps/ui/src/components/views/notifications-view.tsx b/apps/ui/src/components/views/notifications-view.tsx new file mode 100644 index 00000000..aaffb011 --- /dev/null +++ b/apps/ui/src/components/views/notifications-view.tsx @@ -0,0 +1,272 @@ +/** + * Notifications View - Full page view for all notifications + */ + +import { useEffect, useCallback } from 'react'; +import { useAppStore } from '@/store/app-store'; +import { useNotificationsStore } from '@/store/notifications-store'; +import { useLoadNotifications, useNotificationEvents } from '@/hooks/use-notification-events'; +import { getHttpApiClient } from '@/lib/http-api-client'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Bell, Check, CheckCheck, Trash2, ExternalLink, Loader2 } from 'lucide-react'; +import { useNavigate } from '@tanstack/react-router'; +import type { Notification } from '@automaker/types'; + +/** + * Format a date as relative time (e.g., "2 minutes ago", "3 hours ago") + */ +function formatRelativeTime(date: Date): string { + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffSec = Math.floor(diffMs / 1000); + const diffMin = Math.floor(diffSec / 60); + const diffHour = Math.floor(diffMin / 60); + const diffDay = Math.floor(diffHour / 24); + + if (diffSec < 60) return 'just now'; + if (diffMin < 60) return `${diffMin} minute${diffMin === 1 ? '' : 's'} ago`; + if (diffHour < 24) return `${diffHour} hour${diffHour === 1 ? '' : 's'} ago`; + if (diffDay < 7) return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`; + return date.toLocaleDateString(); +} + +export function NotificationsView() { + const { currentProject } = useAppStore(); + const projectPath = currentProject?.path ?? null; + const navigate = useNavigate(); + + const { + notifications, + unreadCount, + isLoading, + error, + setNotifications, + setUnreadCount, + markAsRead, + dismissNotification, + markAllAsRead, + dismissAll, + } = useNotificationsStore(); + + // Load notifications when project changes + useLoadNotifications(projectPath); + + // Subscribe to real-time notification events + useNotificationEvents(projectPath); + + const handleMarkAsRead = useCallback( + async (notificationId: string) => { + if (!projectPath) return; + + // Optimistic update + markAsRead(notificationId); + + // Sync with server + const api = getHttpApiClient(); + await api.notifications.markAsRead(projectPath, notificationId); + }, + [projectPath, markAsRead] + ); + + const handleDismiss = useCallback( + async (notificationId: string) => { + if (!projectPath) return; + + // Optimistic update + dismissNotification(notificationId); + + // Sync with server + const api = getHttpApiClient(); + await api.notifications.dismiss(projectPath, notificationId); + }, + [projectPath, dismissNotification] + ); + + const handleMarkAllAsRead = useCallback(async () => { + if (!projectPath) return; + + // Optimistic update + markAllAsRead(); + + // Sync with server + const api = getHttpApiClient(); + await api.notifications.markAsRead(projectPath); + }, [projectPath, markAllAsRead]); + + const handleDismissAll = useCallback(async () => { + if (!projectPath) return; + + // Optimistic update + dismissAll(); + + // Sync with server + const api = getHttpApiClient(); + await api.notifications.dismiss(projectPath); + }, [projectPath, dismissAll]); + + const handleNotificationClick = useCallback( + (notification: Notification) => { + // Mark as read + handleMarkAsRead(notification.id); + + // Navigate to the relevant view based on notification type + if (notification.featureId) { + // Navigate to board view - feature will be selected + navigate({ to: '/board' }); + } + }, + [handleMarkAsRead, navigate] + ); + + const getNotificationIcon = (type: string) => { + switch (type) { + case 'feature_waiting_approval': + return ; + case 'feature_verified': + return ; + case 'spec_regeneration_complete': + return ; + case 'agent_complete': + return ; + default: + return ; + } + }; + + if (!projectPath) { + return ( +
+ +

Select a project to view notifications

+
+ ); + } + + if (isLoading) { + return ( +
+ +

Loading notifications...

+
+ ); + } + + if (error) { + return ( +
+

{error}

+
+ ); + } + + return ( +
+
+
+

Notifications

+

+ {unreadCount > 0 ? `${unreadCount} unread` : 'All caught up!'} +

+
+ {notifications.length > 0 && ( +
+ + +
+ )} +
+ + {notifications.length === 0 ? ( + + + +

No notifications

+

+ Notifications will appear here when features are ready for review or operations + complete. +

+
+
+ ) : ( +
+ {notifications.map((notification) => ( + handleNotificationClick(notification)} + > + +
{getNotificationIcon(notification.type)}
+
+
+ {notification.title} + {!notification.read && ( + + )} +
+ {notification.message} +

+ {formatRelativeTime(new Date(notification.createdAt))} +

+
+
+ {!notification.read && ( + + )} + + {notification.featureId && ( + + )} +
+
+
+ ))} +
+ )} +
+ ); +} diff --git a/apps/ui/src/components/views/settings-view/event-hooks/event-history-view.tsx b/apps/ui/src/components/views/settings-view/event-hooks/event-history-view.tsx new file mode 100644 index 00000000..780f5f98 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/event-hooks/event-history-view.tsx @@ -0,0 +1,341 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { + History, + RefreshCw, + Trash2, + Play, + ChevronDown, + ChevronRight, + CheckCircle, + XCircle, + Clock, + AlertCircle, +} from 'lucide-react'; +import { useAppStore } from '@/store/app-store'; +import type { StoredEventSummary, StoredEvent, EventHookTrigger } from '@automaker/types'; +import { EVENT_HOOK_TRIGGER_LABELS } from '@automaker/types'; +import { getHttpApiClient } from '@/lib/http-api-client'; +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; + +export function EventHistoryView() { + const currentProject = useAppStore((state) => state.currentProject); + const projectPath = currentProject?.path; + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(false); + const [expandedEvent, setExpandedEvent] = useState(null); + const [expandedEventData, setExpandedEventData] = useState(null); + const [replayingEvent, setReplayingEvent] = useState(null); + const [clearDialogOpen, setClearDialogOpen] = useState(false); + + const loadEvents = useCallback(async () => { + if (!projectPath) return; + + setLoading(true); + try { + const api = getHttpApiClient(); + const result = await api.eventHistory.list(projectPath, { limit: 100 }); + if (result.success && result.events) { + setEvents(result.events); + } + } catch (error) { + console.error('Failed to load events:', error); + } finally { + setLoading(false); + } + }, [projectPath]); + + useEffect(() => { + loadEvents(); + }, [loadEvents]); + + const handleExpand = async (eventId: string) => { + if (expandedEvent === eventId) { + setExpandedEvent(null); + setExpandedEventData(null); + return; + } + + if (!projectPath) return; + + setExpandedEvent(eventId); + try { + const api = getHttpApiClient(); + const result = await api.eventHistory.get(projectPath, eventId); + if (result.success && result.event) { + setExpandedEventData(result.event); + } + } catch (error) { + console.error('Failed to load event details:', error); + } + }; + + const handleReplay = async (eventId: string) => { + if (!projectPath) return; + + setReplayingEvent(eventId); + try { + const api = getHttpApiClient(); + const result = await api.eventHistory.replay(projectPath, eventId); + if (result.success && result.result) { + const { hooksTriggered, hookResults } = result.result; + const successCount = hookResults.filter((r) => r.success).length; + const failCount = hookResults.filter((r) => !r.success).length; + + if (hooksTriggered === 0) { + alert('No matching hooks found for this event trigger.'); + } else if (failCount === 0) { + alert(`Successfully ran ${successCount} hook(s).`); + } else { + alert(`Ran ${hooksTriggered} hook(s): ${successCount} succeeded, ${failCount} failed.`); + } + } + } catch (error) { + console.error('Failed to replay event:', error); + alert('Failed to replay event. Check console for details.'); + } finally { + setReplayingEvent(null); + } + }; + + const handleDelete = async (eventId: string) => { + if (!projectPath) return; + + try { + const api = getHttpApiClient(); + const result = await api.eventHistory.delete(projectPath, eventId); + if (result.success) { + setEvents((prev) => prev.filter((e) => e.id !== eventId)); + if (expandedEvent === eventId) { + setExpandedEvent(null); + setExpandedEventData(null); + } + } + } catch (error) { + console.error('Failed to delete event:', error); + } + }; + + const handleClearAll = async () => { + if (!projectPath) return; + + try { + const api = getHttpApiClient(); + const result = await api.eventHistory.clear(projectPath); + if (result.success) { + setEvents([]); + setExpandedEvent(null); + setExpandedEventData(null); + } + } catch (error) { + console.error('Failed to clear events:', error); + } + setClearDialogOpen(false); + }; + + const getTriggerIcon = (trigger: EventHookTrigger) => { + switch (trigger) { + case 'feature_created': + return ; + case 'feature_success': + return ; + case 'feature_error': + return ; + case 'auto_mode_complete': + return ; + case 'auto_mode_error': + return ; + default: + return ; + } + }; + + const formatTimestamp = (timestamp: string) => { + const date = new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return 'Just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return date.toLocaleDateString(); + }; + + if (!projectPath) { + return ( +
+ +

Select a project to view event history

+
+ ); + } + + return ( +
+ {/* Header with actions */} +
+

+ {events.length} event{events.length !== 1 ? 's' : ''} recorded +

+
+ + {events.length > 0 && ( + + )} +
+
+ + {/* Events list */} + {events.length === 0 ? ( +
+ +

No events recorded yet

+

+ Events will appear here when features are created or completed +

+
+ ) : ( +
+ {events.map((event) => ( +
+ {/* Event header */} +
handleExpand(event.id)} + > + + + {getTriggerIcon(event.trigger)} + +
+

+ {EVENT_HOOK_TRIGGER_LABELS[event.trigger]} +

+ {event.featureName && ( +

{event.featureName}

+ )} +
+ + + {formatTimestamp(event.timestamp)} + + + {/* Actions */} +
e.stopPropagation()}> + + +
+
+ + {/* Expanded details */} + {expandedEvent === event.id && expandedEventData && ( +
+
+
+
+ Event ID: +

{expandedEventData.id}

+
+
+ Timestamp: +

{new Date(expandedEventData.timestamp).toLocaleString()}

+
+ {expandedEventData.featureId && ( +
+ Feature ID: +

+ {expandedEventData.featureId} +

+
+ )} + {expandedEventData.passes !== undefined && ( +
+ Passed: +

{expandedEventData.passes ? 'Yes' : 'No'}

+
+ )} +
+ {expandedEventData.error && ( +
+ Error: +

+ {expandedEventData.error} +

+
+ )} +
+ Project: +

+ {expandedEventData.projectPath} +

+
+
+
+ )} +
+ ))} +
+ )} + + {/* Clear confirmation dialog */} + +
+ ); +} diff --git a/apps/ui/src/components/views/settings-view/event-hooks/event-hook-dialog.tsx b/apps/ui/src/components/views/settings-view/event-hooks/event-hook-dialog.tsx index 68233b5a..857efb33 100644 --- a/apps/ui/src/components/views/settings-view/event-hooks/event-hook-dialog.tsx +++ b/apps/ui/src/components/views/settings-view/event-hooks/event-hook-dialog.tsx @@ -39,6 +39,7 @@ interface EventHookDialogProps { type ActionType = 'shell' | 'http'; const TRIGGER_OPTIONS: EventHookTrigger[] = [ + 'feature_created', 'feature_success', 'feature_error', 'auto_mode_complete', diff --git a/apps/ui/src/components/views/settings-view/event-hooks/event-hooks-section.tsx b/apps/ui/src/components/views/settings-view/event-hooks/event-hooks-section.tsx index dce34433..519ca370 100644 --- a/apps/ui/src/components/views/settings-view/event-hooks/event-hooks-section.tsx +++ b/apps/ui/src/components/views/settings-view/event-hooks/event-hooks-section.tsx @@ -1,17 +1,20 @@ import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { Switch } from '@/components/ui/switch'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { cn } from '@/lib/utils'; -import { Webhook, Plus, Trash2, Pencil, Terminal, Globe } from 'lucide-react'; +import { Webhook, Plus, Trash2, Pencil, Terminal, Globe, History } from 'lucide-react'; import { useAppStore } from '@/store/app-store'; import type { EventHook, EventHookTrigger } from '@automaker/types'; import { EVENT_HOOK_TRIGGER_LABELS } from '@automaker/types'; import { EventHookDialog } from './event-hook-dialog'; +import { EventHistoryView } from './event-history-view'; export function EventHooksSection() { const { eventHooks, setEventHooks } = useAppStore(); const [dialogOpen, setDialogOpen] = useState(false); const [editingHook, setEditingHook] = useState(null); + const [activeTab, setActiveTab] = useState<'hooks' | 'history'>('hooks'); const handleAddHook = () => { setEditingHook(null); @@ -78,58 +81,85 @@ export function EventHooksSection() {

- + {activeTab === 'hooks' && ( + + )} - {/* Content */} -
- {eventHooks.length === 0 ? ( -
- -

No event hooks configured

-

- Add hooks to run commands or send webhooks when features complete -

-
- ) : ( -
- {/* Group by trigger type */} - {Object.entries(hooksByTrigger).map(([trigger, hooks]) => ( -
-

- {EVENT_HOOK_TRIGGER_LABELS[trigger as EventHookTrigger]} -

-
- {hooks.map((hook) => ( - handleEditHook(hook)} - onDelete={() => handleDeleteHook(hook.id)} - onToggle={(enabled) => handleToggleHook(hook.id, enabled)} - /> - ))} -
+ {/* Tabs */} + setActiveTab(v as 'hooks' | 'history')}> +
+ + + + Hooks + + + + History + + +
+ + {/* Hooks Tab */} + +
+ {eventHooks.length === 0 ? ( +
+ +

No event hooks configured

+

+ Add hooks to run commands or send webhooks when features complete +

- ))} + ) : ( +
+ {/* Group by trigger type */} + {Object.entries(hooksByTrigger).map(([trigger, hooks]) => ( +
+

+ {EVENT_HOOK_TRIGGER_LABELS[trigger as EventHookTrigger]} +

+
+ {hooks.map((hook) => ( + handleEditHook(hook)} + onDelete={() => handleDeleteHook(hook.id)} + onToggle={(enabled) => handleToggleHook(hook.id, enabled)} + /> + ))} +
+
+ ))} +
+ )}
- )} -
- {/* Variable reference */} -
-
-

Available variables:

- - {'{{featureId}}'} {'{{featureName}}'} {'{{projectPath}}'} {'{{projectName}}'}{' '} - {'{{error}}'} {'{{timestamp}}'} {'{{eventType}}'} - -
-
+ {/* Variable reference */} +
+
+

Available variables:

+ + {'{{featureId}}'} {'{{featureName}}'} {'{{projectPath}}'} {'{{projectName}}'}{' '} + {'{{error}}'} {'{{timestamp}}'} {'{{eventType}}'} + +
+
+ + + {/* History Tab */} + +
+ +
+
+ {/* Dialog */} s.addNotification); + + useEffect(() => { + if (!projectPath) return; + + const api = getHttpApiClient(); + + const unsubscribe = api.notifications.onNotificationCreated((notification: Notification) => { + // Only handle notifications for the current project + if (!pathsEqual(notification.projectPath, projectPath)) return; + + addNotification(notification); + }); + + return unsubscribe; + }, [projectPath, addNotification]); +} + +/** + * Hook to load notifications for a project. + * Should be called when switching projects or on initial load. + */ +export function useLoadNotifications(projectPath: string | null) { + const setNotifications = useNotificationsStore((s) => s.setNotifications); + const setUnreadCount = useNotificationsStore((s) => s.setUnreadCount); + const setLoading = useNotificationsStore((s) => s.setLoading); + const setError = useNotificationsStore((s) => s.setError); + const reset = useNotificationsStore((s) => s.reset); + + useEffect(() => { + if (!projectPath) { + reset(); + return; + } + + const loadNotifications = async () => { + setLoading(true); + setError(null); + + try { + const api = getHttpApiClient(); + const [listResult, countResult] = await Promise.all([ + api.notifications.list(projectPath), + api.notifications.getUnreadCount(projectPath), + ]); + + if (listResult.success && listResult.notifications) { + setNotifications(listResult.notifications); + } + + if (countResult.success && countResult.count !== undefined) { + setUnreadCount(countResult.count); + } + } catch (error) { + setError(error instanceof Error ? error.message : 'Failed to load notifications'); + } finally { + setLoading(false); + } + }; + + loadNotifications(); + }, [projectPath, setNotifications, setUnreadCount, setLoading, setError, reset]); +} diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 773fdd82..fd9f8588 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -550,6 +550,88 @@ export interface SaveImageResult { error?: string; } +// Notifications API interface +import type { + Notification, + StoredEvent, + StoredEventSummary, + EventHistoryFilter, + EventReplayResult, +} from '@automaker/types'; + +export interface NotificationsAPI { + list: (projectPath: string) => Promise<{ + success: boolean; + notifications?: Notification[]; + error?: string; + }>; + getUnreadCount: (projectPath: string) => Promise<{ + success: boolean; + count?: number; + error?: string; + }>; + markAsRead: ( + projectPath: string, + notificationId?: string + ) => Promise<{ + success: boolean; + notification?: Notification; + count?: number; + error?: string; + }>; + dismiss: ( + projectPath: string, + notificationId?: string + ) => Promise<{ + success: boolean; + dismissed?: boolean; + count?: number; + error?: string; + }>; +} + +// Event History API interface +export interface EventHistoryAPI { + list: ( + projectPath: string, + filter?: EventHistoryFilter + ) => Promise<{ + success: boolean; + events?: StoredEventSummary[]; + total?: number; + error?: string; + }>; + get: ( + projectPath: string, + eventId: string + ) => Promise<{ + success: boolean; + event?: StoredEvent; + error?: string; + }>; + delete: ( + projectPath: string, + eventId: string + ) => Promise<{ + success: boolean; + error?: string; + }>; + clear: (projectPath: string) => Promise<{ + success: boolean; + cleared?: number; + error?: string; + }>; + replay: ( + projectPath: string, + eventId: string, + hookIds?: string[] + ) => Promise<{ + success: boolean; + result?: EventReplayResult; + error?: string; + }>; +} + export interface ElectronAPI { ping: () => Promise; getApiKey?: () => Promise; @@ -760,6 +842,8 @@ export interface ElectronAPI { }>; }; ideation?: IdeationAPI; + notifications?: NotificationsAPI; + eventHistory?: EventHistoryAPI; codex?: { getUsage: () => Promise; getModels: (refresh?: boolean) => Promise<{ diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 90781b59..f8a12c14 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -32,7 +32,10 @@ import type { CreateIdeaInput, UpdateIdeaInput, ConvertToFeatureOptions, + NotificationsAPI, + EventHistoryAPI, } from './electron'; +import type { EventHistoryFilter } from '@automaker/types'; import type { Message, SessionListItem } from '@/types/electron'; import type { Feature, ClaudeUsageResponse, CodexUsageResponse } from '@/store/app-store'; import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/types/electron'; @@ -514,7 +517,8 @@ type EventType = | 'worktree:init-completed' | 'dev-server:started' | 'dev-server:output' - | 'dev-server:stopped'; + | 'dev-server:stopped' + | 'notification:created'; /** * Dev server log event payloads for WebSocket streaming @@ -2440,6 +2444,43 @@ export class HttpApiClient implements ElectronAPI { }, }; + // Notifications API - project-level notifications + notifications: NotificationsAPI & { + onNotificationCreated: (callback: (notification: any) => void) => () => void; + } = { + list: (projectPath: string) => this.post('/api/notifications/list', { projectPath }), + + getUnreadCount: (projectPath: string) => + this.post('/api/notifications/unread-count', { projectPath }), + + markAsRead: (projectPath: string, notificationId?: string) => + this.post('/api/notifications/mark-read', { projectPath, notificationId }), + + dismiss: (projectPath: string, notificationId?: string) => + this.post('/api/notifications/dismiss', { projectPath, notificationId }), + + onNotificationCreated: (callback: (notification: any) => void): (() => void) => { + return this.subscribeToEvent('notification:created', callback as EventCallback); + }, + }; + + // Event History API - stored events for debugging and replay + eventHistory: EventHistoryAPI = { + list: (projectPath: string, filter?: EventHistoryFilter) => + this.post('/api/event-history/list', { projectPath, filter }), + + get: (projectPath: string, eventId: string) => + this.post('/api/event-history/get', { projectPath, eventId }), + + delete: (projectPath: string, eventId: string) => + this.post('/api/event-history/delete', { projectPath, eventId }), + + clear: (projectPath: string) => this.post('/api/event-history/clear', { projectPath }), + + replay: (projectPath: string, eventId: string, hookIds?: string[]) => + this.post('/api/event-history/replay', { projectPath, eventId, hookIds }), + }; + // MCP API - Test MCP server connections and list tools // SECURITY: Only accepts serverId, not arbitrary serverConfig, to prevent // drive-by command execution attacks. Servers must be saved first. diff --git a/apps/ui/src/routes/notifications.tsx b/apps/ui/src/routes/notifications.tsx new file mode 100644 index 00000000..6500b8fe --- /dev/null +++ b/apps/ui/src/routes/notifications.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { NotificationsView } from '@/components/views/notifications-view'; + +export const Route = createFileRoute('/notifications')({ + component: NotificationsView, +}); diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 23fa5371..8fcbd203 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -233,6 +233,7 @@ export interface KeyboardShortcuts { settings: string; terminal: string; ideation: string; + notifications: string; githubIssues: string; githubPrs: string; @@ -268,6 +269,7 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = { settings: 'S', terminal: 'T', ideation: 'I', + notifications: 'X', githubIssues: 'G', githubPrs: 'R', diff --git a/apps/ui/src/store/notifications-store.ts b/apps/ui/src/store/notifications-store.ts new file mode 100644 index 00000000..278f645d --- /dev/null +++ b/apps/ui/src/store/notifications-store.ts @@ -0,0 +1,129 @@ +/** + * Notifications Store - State management for project-level notifications + */ + +import { create } from 'zustand'; +import type { Notification } from '@automaker/types'; + +// ============================================================================ +// State Interface +// ============================================================================ + +interface NotificationsState { + // Notifications for the current project + notifications: Notification[]; + unreadCount: number; + isLoading: boolean; + error: string | null; + + // Popover state + isPopoverOpen: boolean; +} + +// ============================================================================ +// Actions Interface +// ============================================================================ + +interface NotificationsActions { + // Data management + setNotifications: (notifications: Notification[]) => void; + setUnreadCount: (count: number) => void; + addNotification: (notification: Notification) => void; + markAsRead: (notificationId: string) => void; + markAllAsRead: () => void; + dismissNotification: (notificationId: string) => void; + dismissAll: () => void; + + // Loading state + setLoading: (loading: boolean) => void; + setError: (error: string | null) => void; + + // Popover state + setPopoverOpen: (open: boolean) => void; + + // Reset + reset: () => void; +} + +// ============================================================================ +// Initial State +// ============================================================================ + +const initialState: NotificationsState = { + notifications: [], + unreadCount: 0, + isLoading: false, + error: null, + isPopoverOpen: false, +}; + +// ============================================================================ +// Store +// ============================================================================ + +export const useNotificationsStore = create( + (set, get) => ({ + ...initialState, + + // Data management + setNotifications: (notifications) => + set({ + notifications, + unreadCount: notifications.filter((n) => !n.read).length, + }), + + setUnreadCount: (count) => set({ unreadCount: count }), + + addNotification: (notification) => + set((state) => ({ + notifications: [notification, ...state.notifications], + unreadCount: notification.read ? state.unreadCount : state.unreadCount + 1, + })), + + markAsRead: (notificationId) => + set((state) => { + const notification = state.notifications.find((n) => n.id === notificationId); + if (!notification || notification.read) return state; + + return { + notifications: state.notifications.map((n) => + n.id === notificationId ? { ...n, read: true } : n + ), + unreadCount: Math.max(0, state.unreadCount - 1), + }; + }), + + markAllAsRead: () => + set((state) => ({ + notifications: state.notifications.map((n) => ({ ...n, read: true })), + unreadCount: 0, + })), + + dismissNotification: (notificationId) => + set((state) => { + const notification = state.notifications.find((n) => n.id === notificationId); + if (!notification) return state; + + return { + notifications: state.notifications.filter((n) => n.id !== notificationId), + unreadCount: notification.read ? state.unreadCount : Math.max(0, state.unreadCount - 1), + }; + }), + + dismissAll: () => + set({ + notifications: [], + unreadCount: 0, + }), + + // Loading state + setLoading: (loading) => set({ isLoading: loading }), + setError: (error) => set({ error }), + + // Popover state + setPopoverOpen: (open) => set({ isPopoverOpen: open }), + + // Reset + reset: () => set(initialState), + }) +); diff --git a/libs/platform/src/index.ts b/libs/platform/src/index.ts index cd37da49..d51845f9 100644 --- a/libs/platform/src/index.ts +++ b/libs/platform/src/index.ts @@ -19,6 +19,12 @@ export { getAppSpecPath, getBranchTrackingPath, getExecutionStatePath, + getNotificationsPath, + // Event history paths + getEventHistoryDir, + getEventHistoryIndexPath, + getEventPath, + ensureEventHistoryDir, ensureAutomakerDir, getGlobalSettingsPath, getCredentialsPath, diff --git a/libs/platform/src/paths.ts b/libs/platform/src/paths.ts index 5a56a2a2..130f54e0 100644 --- a/libs/platform/src/paths.ts +++ b/libs/platform/src/paths.ts @@ -161,6 +161,18 @@ export function getAppSpecPath(projectPath: string): string { return path.join(getAutomakerDir(projectPath), 'app_spec.txt'); } +/** + * Get the notifications file path for a project + * + * Stores project-level notifications for feature status changes and operation completions. + * + * @param projectPath - Absolute path to project directory + * @returns Absolute path to {projectPath}/.automaker/notifications.json + */ +export function getNotificationsPath(projectPath: string): string { + return path.join(getAutomakerDir(projectPath), 'notifications.json'); +} + /** * Get the branch tracking file path for a project * @@ -335,6 +347,57 @@ export async function ensureIdeationDir(projectPath: string): Promise { return ideationDir; } +// ============================================================================ +// Event History Paths +// ============================================================================ + +/** + * Get the event history directory for a project + * + * Contains stored event records for debugging and replay. + * + * @param projectPath - Absolute path to project directory + * @returns Absolute path to {projectPath}/.automaker/events + */ +export function getEventHistoryDir(projectPath: string): string { + return path.join(getAutomakerDir(projectPath), 'events'); +} + +/** + * Get the event history index file path + * + * Stores an index of all events for quick listing without scanning directory. + * + * @param projectPath - Absolute path to project directory + * @returns Absolute path to {projectPath}/.automaker/events/index.json + */ +export function getEventHistoryIndexPath(projectPath: string): string { + return path.join(getEventHistoryDir(projectPath), 'index.json'); +} + +/** + * Get the file path for a specific event + * + * @param projectPath - Absolute path to project directory + * @param eventId - Event identifier + * @returns Absolute path to {projectPath}/.automaker/events/{eventId}.json + */ +export function getEventPath(projectPath: string, eventId: string): string { + return path.join(getEventHistoryDir(projectPath), `${eventId}.json`); +} + +/** + * Create the event history directory for a project if it doesn't exist + * + * @param projectPath - Absolute path to project directory + * @returns Promise resolving to the created events directory path + */ +export async function ensureEventHistoryDir(projectPath: string): Promise { + const eventsDir = getEventHistoryDir(projectPath); + await secureFs.mkdir(eventsDir, { recursive: true }); + return eventsDir; +} + // ============================================================================ // Global Settings Paths (stored in DATA_DIR from app.getPath('userData')) // ============================================================================ diff --git a/libs/types/src/event-history.ts b/libs/types/src/event-history.ts new file mode 100644 index 00000000..09ff92aa --- /dev/null +++ b/libs/types/src/event-history.ts @@ -0,0 +1,123 @@ +/** + * Event History Types - Stored event records for debugging and replay + * + * Events are stored on disk to allow users to: + * - View historical events for debugging + * - Replay events with custom hooks + * - Test hook configurations against past events + */ + +import type { EventHookTrigger } from './settings.js'; + +/** + * StoredEvent - A single event record stored on disk + * + * Contains all information needed to replay the event or inspect what happened. + */ +export interface StoredEvent { + /** Unique identifier for this event record */ + id: string; + /** The hook trigger type this event maps to */ + trigger: EventHookTrigger; + /** ISO timestamp when the event occurred */ + timestamp: string; + /** ID of the feature involved (if applicable) */ + featureId?: string; + /** Name of the feature involved (if applicable) */ + featureName?: string; + /** Path to the project where the event occurred */ + projectPath: string; + /** Name of the project (extracted from path) */ + projectName: string; + /** Error message if this was an error event */ + error?: string; + /** Error classification if applicable */ + errorType?: string; + /** Whether the feature passed (for completion events) */ + passes?: boolean; + /** Additional context/metadata for the event */ + metadata?: Record; +} + +/** + * StoredEventIndex - Quick lookup index for event history + * + * Stored separately for fast listing without loading full event data. + */ +export interface StoredEventIndex { + /** Version for future migrations */ + version: number; + /** Array of event summaries for quick listing */ + events: StoredEventSummary[]; +} + +/** + * StoredEventSummary - Minimal event info for listing + */ +export interface StoredEventSummary { + /** Event ID */ + id: string; + /** Trigger type */ + trigger: EventHookTrigger; + /** When it occurred */ + timestamp: string; + /** Feature name for display (if applicable) */ + featureName?: string; + /** Feature ID (if applicable) */ + featureId?: string; +} + +/** + * EventHistoryFilter - Options for filtering event history + */ +export interface EventHistoryFilter { + /** Filter by trigger type */ + trigger?: EventHookTrigger; + /** Filter by feature ID */ + featureId?: string; + /** Filter events after this timestamp */ + since?: string; + /** Filter events before this timestamp */ + until?: string; + /** Maximum number of events to return */ + limit?: number; + /** Number of events to skip (for pagination) */ + offset?: number; +} + +/** + * EventReplayResult - Result of replaying an event + */ +export interface EventReplayResult { + /** Event that was replayed */ + eventId: string; + /** Number of hooks that were triggered */ + hooksTriggered: number; + /** Results from each hook execution */ + hookResults: EventReplayHookResult[]; +} + +/** + * EventReplayHookResult - Result of a single hook execution during replay + */ +export interface EventReplayHookResult { + /** Hook ID */ + hookId: string; + /** Hook name (if set) */ + hookName?: string; + /** Whether the hook executed successfully */ + success: boolean; + /** Error message if failed */ + error?: string; + /** Execution time in milliseconds */ + durationMs: number; +} + +/** Current version of the event history index schema */ +export const EVENT_HISTORY_VERSION = 1; + +/** Default empty event history index */ +export const DEFAULT_EVENT_HISTORY_INDEX: StoredEventIndex = { + version: EVENT_HISTORY_VERSION, + events: [], +}; diff --git a/libs/types/src/event.ts b/libs/types/src/event.ts index 6e723d86..c274ffb5 100644 --- a/libs/types/src/event.ts +++ b/libs/types/src/event.ts @@ -10,6 +10,7 @@ export type EventType = | 'auto-mode:idle' | 'auto-mode:error' | 'backlog-plan:event' + | 'feature:created' | 'feature:started' | 'feature:completed' | 'feature:stopped' @@ -45,6 +46,7 @@ export type EventType = | 'worktree:init-completed' | 'dev-server:started' | 'dev-server:output' - | 'dev-server:stopped'; + | 'dev-server:stopped' + | 'notification:created'; export type EventCallback = (type: EventType, payload: unknown) => void; diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index ba09a3b2..7f06a33b 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -270,3 +270,18 @@ export type { IdeationStreamEvent, IdeationAnalysisEvent, } from './ideation.js'; + +// Notification types +export type { NotificationType, Notification, NotificationsFile } from './notification.js'; +export { NOTIFICATIONS_VERSION, DEFAULT_NOTIFICATIONS_FILE } from './notification.js'; + +// Event history types +export type { + StoredEvent, + StoredEventIndex, + StoredEventSummary, + EventHistoryFilter, + EventReplayResult, + EventReplayHookResult, +} from './event-history.js'; +export { EVENT_HISTORY_VERSION, DEFAULT_EVENT_HISTORY_INDEX } from './event-history.js'; diff --git a/libs/types/src/notification.ts b/libs/types/src/notification.ts new file mode 100644 index 00000000..e134e0ea --- /dev/null +++ b/libs/types/src/notification.ts @@ -0,0 +1,58 @@ +/** + * Notification Types - Types for project-level notification system + * + * Notifications alert users when features reach specific statuses + * or when long-running operations complete. + */ + +/** + * NotificationType - Types of notifications that can be created + */ +export type NotificationType = + | 'feature_waiting_approval' + | 'feature_verified' + | 'spec_regeneration_complete' + | 'agent_complete'; + +/** + * Notification - A single notification entry + */ +export interface Notification { + /** Unique identifier for the notification */ + id: string; + /** Type of notification */ + type: NotificationType; + /** Short title for display */ + title: string; + /** Longer descriptive message */ + message: string; + /** ISO timestamp when notification was created */ + createdAt: string; + /** Whether the notification has been read */ + read: boolean; + /** Whether the notification has been dismissed */ + dismissed: boolean; + /** Associated feature ID if applicable */ + featureId?: string; + /** Project path this notification belongs to */ + projectPath: string; +} + +/** + * NotificationsFile - Structure of the notifications.json file + */ +export interface NotificationsFile { + /** Version for future migrations */ + version: number; + /** List of notifications */ + notifications: Notification[]; +} + +/** Current version of the notifications file schema */ +export const NOTIFICATIONS_VERSION = 1; + +/** Default notifications file structure */ +export const DEFAULT_NOTIFICATIONS_FILE: NotificationsFile = { + version: NOTIFICATIONS_VERSION, + notifications: [], +}; diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 6e807f66..ee8a77a3 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -108,12 +108,14 @@ export type ModelProvider = 'claude' | 'cursor' | 'codex' | 'opencode'; /** * EventHookTrigger - Event types that can trigger custom hooks * + * - 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 processing all features * - auto_mode_error: Auto mode encountered a critical error and paused */ export type EventHookTrigger = + | 'feature_created' | 'feature_success' | 'feature_error' | 'auto_mode_complete' @@ -186,6 +188,7 @@ export interface EventHook { /** Human-readable labels for event hook triggers */ export const EVENT_HOOK_TRIGGER_LABELS: Record = { + feature_created: 'Feature created', feature_success: 'Feature completed successfully', feature_error: 'Feature failed with error', auto_mode_complete: 'Auto mode completed all features', @@ -298,6 +301,8 @@ export interface KeyboardShortcuts { settings: string; /** Open terminal */ terminal: string; + /** Open notifications */ + notifications: string; /** Toggle sidebar visibility */ toggleSidebar: string; /** Add new feature */ @@ -800,6 +805,7 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = { context: 'C', settings: 'S', terminal: 'T', + notifications: 'X', toggleSidebar: '`', addFeature: 'N', addContextFile: 'N',