mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
feat: implement notifications and event history features
- Added Notification Service to manage project-level notifications, including creation, listing, marking as read, and dismissing notifications. - Introduced Event History Service to store and manage historical events, allowing for listing, retrieval, deletion, and replaying of events. - Integrated notifications into the server and UI, providing real-time updates for feature statuses and operations. - Enhanced sidebar and project switcher components to display unread notifications count. - Created dedicated views for managing notifications and event history, improving user experience and accessibility. These changes enhance the application's ability to inform users about important events and statuses, improving overall usability and responsiveness.
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
19
apps/server/src/routes/event-history/common.ts
Normal file
19
apps/server/src/routes/event-history/common.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Common utilities for event history routes
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||
|
||||
/** Logger instance for event history operations */
|
||||
export const logger = createLogger('EventHistory');
|
||||
|
||||
/**
|
||||
* Extract user-friendly error message from error objects
|
||||
*/
|
||||
export { getErrorMessageShared as getErrorMessage };
|
||||
|
||||
/**
|
||||
* Log error with automatic logger binding
|
||||
*/
|
||||
export const logError = createLogError(logger);
|
||||
68
apps/server/src/routes/event-history/index.ts
Normal file
68
apps/server/src/routes/event-history/index.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Event History routes - HTTP API for event history management
|
||||
*
|
||||
* Provides endpoints for:
|
||||
* - Listing events with filtering
|
||||
* - Getting individual event details
|
||||
* - Deleting events
|
||||
* - Clearing all events
|
||||
* - Replaying events to test hooks
|
||||
*
|
||||
* Mounted at /api/event-history in the main server.
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { EventHistoryService } from '../../services/event-history-service.js';
|
||||
import type { SettingsService } from '../../services/settings-service.js';
|
||||
import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||
import { createListHandler } from './routes/list.js';
|
||||
import { createGetHandler } from './routes/get.js';
|
||||
import { createDeleteHandler } from './routes/delete.js';
|
||||
import { createClearHandler } from './routes/clear.js';
|
||||
import { createReplayHandler } from './routes/replay.js';
|
||||
|
||||
/**
|
||||
* Create event history router with all endpoints
|
||||
*
|
||||
* Endpoints:
|
||||
* - POST /list - List events with optional filtering
|
||||
* - POST /get - Get a single event by ID
|
||||
* - POST /delete - Delete an event by ID
|
||||
* - POST /clear - Clear all events for a project
|
||||
* - POST /replay - Replay an event to trigger hooks
|
||||
*
|
||||
* @param eventHistoryService - Instance of EventHistoryService
|
||||
* @param settingsService - Instance of SettingsService (for replay)
|
||||
* @returns Express Router configured with all event history endpoints
|
||||
*/
|
||||
export function createEventHistoryRoutes(
|
||||
eventHistoryService: EventHistoryService,
|
||||
settingsService: SettingsService
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
// List events with filtering
|
||||
router.post('/list', validatePathParams('projectPath'), createListHandler(eventHistoryService));
|
||||
|
||||
// Get single event
|
||||
router.post('/get', validatePathParams('projectPath'), createGetHandler(eventHistoryService));
|
||||
|
||||
// Delete event
|
||||
router.post(
|
||||
'/delete',
|
||||
validatePathParams('projectPath'),
|
||||
createDeleteHandler(eventHistoryService)
|
||||
);
|
||||
|
||||
// Clear all events
|
||||
router.post('/clear', validatePathParams('projectPath'), createClearHandler(eventHistoryService));
|
||||
|
||||
// Replay event
|
||||
router.post(
|
||||
'/replay',
|
||||
validatePathParams('projectPath'),
|
||||
createReplayHandler(eventHistoryService, settingsService)
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
33
apps/server/src/routes/event-history/routes/clear.ts
Normal file
33
apps/server/src/routes/event-history/routes/clear.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* POST /api/event-history/clear - Clear all events for a project
|
||||
*
|
||||
* Request body: { projectPath: string }
|
||||
* Response: { success: true, cleared: number }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { EventHistoryService } from '../../../services/event-history-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createClearHandler(eventHistoryService: EventHistoryService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body as { projectPath: string };
|
||||
|
||||
if (!projectPath || typeof projectPath !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const cleared = await eventHistoryService.clearEvents(projectPath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
cleared,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Clear events failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
43
apps/server/src/routes/event-history/routes/delete.ts
Normal file
43
apps/server/src/routes/event-history/routes/delete.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* POST /api/event-history/delete - Delete an event by ID
|
||||
*
|
||||
* Request body: { projectPath: string, eventId: string }
|
||||
* Response: { success: true } or { success: false, error: string }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { EventHistoryService } from '../../../services/event-history-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createDeleteHandler(eventHistoryService: EventHistoryService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, eventId } = req.body as {
|
||||
projectPath: string;
|
||||
eventId: string;
|
||||
};
|
||||
|
||||
if (!projectPath || typeof projectPath !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!eventId || typeof eventId !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'eventId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const deleted = await eventHistoryService.deleteEvent(projectPath, eventId);
|
||||
|
||||
if (!deleted) {
|
||||
res.status(404).json({ success: false, error: 'Event not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logError(error, 'Delete event failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
46
apps/server/src/routes/event-history/routes/get.ts
Normal file
46
apps/server/src/routes/event-history/routes/get.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* POST /api/event-history/get - Get a single event by ID
|
||||
*
|
||||
* Request body: { projectPath: string, eventId: string }
|
||||
* Response: { success: true, event: StoredEvent } or { success: false, error: string }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { EventHistoryService } from '../../../services/event-history-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createGetHandler(eventHistoryService: EventHistoryService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, eventId } = req.body as {
|
||||
projectPath: string;
|
||||
eventId: string;
|
||||
};
|
||||
|
||||
if (!projectPath || typeof projectPath !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!eventId || typeof eventId !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'eventId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const event = await eventHistoryService.getEvent(projectPath, eventId);
|
||||
|
||||
if (!event) {
|
||||
res.status(404).json({ success: false, error: 'Event not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
event,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get event failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
53
apps/server/src/routes/event-history/routes/list.ts
Normal file
53
apps/server/src/routes/event-history/routes/list.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* POST /api/event-history/list - List events for a project
|
||||
*
|
||||
* Request body: {
|
||||
* projectPath: string,
|
||||
* filter?: {
|
||||
* trigger?: EventHookTrigger,
|
||||
* featureId?: string,
|
||||
* since?: string,
|
||||
* until?: string,
|
||||
* limit?: number,
|
||||
* offset?: number
|
||||
* }
|
||||
* }
|
||||
* Response: { success: true, events: StoredEventSummary[], total: number }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { EventHistoryService } from '../../../services/event-history-service.js';
|
||||
import type { EventHistoryFilter } from '@automaker/types';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createListHandler(eventHistoryService: EventHistoryService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, filter } = req.body as {
|
||||
projectPath: string;
|
||||
filter?: EventHistoryFilter;
|
||||
};
|
||||
|
||||
if (!projectPath || typeof projectPath !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const events = await eventHistoryService.getEvents(projectPath, filter);
|
||||
const total = await eventHistoryService.getEventCount(projectPath, {
|
||||
...filter,
|
||||
limit: undefined,
|
||||
offset: undefined,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
events,
|
||||
total,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'List events failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
234
apps/server/src/routes/event-history/routes/replay.ts
Normal file
234
apps/server/src/routes/event-history/routes/replay.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* POST /api/event-history/replay - Replay an event to trigger hooks
|
||||
*
|
||||
* Request body: {
|
||||
* projectPath: string,
|
||||
* eventId: string,
|
||||
* hookIds?: string[] // Optional: specific hooks to run (if not provided, runs all enabled matching hooks)
|
||||
* }
|
||||
* Response: { success: true, result: EventReplayResult }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { EventHistoryService } from '../../../services/event-history-service.js';
|
||||
import type { SettingsService } from '../../../services/settings-service.js';
|
||||
import type { EventReplayResult, EventReplayHookResult, EventHook } from '@automaker/types';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { getErrorMessage, logError, logger } from '../common.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/** Default timeout for shell commands (30 seconds) */
|
||||
const DEFAULT_SHELL_TIMEOUT = 30000;
|
||||
|
||||
/** Default timeout for HTTP requests (10 seconds) */
|
||||
const DEFAULT_HTTP_TIMEOUT = 10000;
|
||||
|
||||
interface HookContext {
|
||||
featureId?: string;
|
||||
featureName?: string;
|
||||
projectPath?: string;
|
||||
projectName?: string;
|
||||
error?: string;
|
||||
errorType?: string;
|
||||
timestamp: string;
|
||||
eventType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute {{variable}} placeholders in a string
|
||||
*/
|
||||
function substituteVariables(template: string, context: HookContext): string {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (match, variable) => {
|
||||
const value = context[variable as keyof HookContext];
|
||||
if (value === undefined || value === null) {
|
||||
return '';
|
||||
}
|
||||
return String(value);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single hook and return the result
|
||||
*/
|
||||
async function executeHook(hook: EventHook, context: HookContext): Promise<EventReplayHookResult> {
|
||||
const hookName = hook.name || hook.id;
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
if (hook.action.type === 'shell') {
|
||||
const command = substituteVariables(hook.action.command, context);
|
||||
const timeout = hook.action.timeout || DEFAULT_SHELL_TIMEOUT;
|
||||
|
||||
logger.info(`Replaying shell hook "${hookName}": ${command}`);
|
||||
|
||||
await execAsync(command, {
|
||||
timeout,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
|
||||
return {
|
||||
hookId: hook.id,
|
||||
hookName: hook.name,
|
||||
success: true,
|
||||
durationMs: Date.now() - startTime,
|
||||
};
|
||||
} else if (hook.action.type === 'http') {
|
||||
const url = substituteVariables(hook.action.url, context);
|
||||
const method = hook.action.method || 'POST';
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (hook.action.headers) {
|
||||
for (const [key, value] of Object.entries(hook.action.headers)) {
|
||||
headers[key] = substituteVariables(value, context);
|
||||
}
|
||||
}
|
||||
|
||||
let body: string | undefined;
|
||||
if (hook.action.body) {
|
||||
body = substituteVariables(hook.action.body, context);
|
||||
} else if (method !== 'GET') {
|
||||
body = JSON.stringify({
|
||||
eventType: context.eventType,
|
||||
timestamp: context.timestamp,
|
||||
featureId: context.featureId,
|
||||
projectPath: context.projectPath,
|
||||
projectName: context.projectName,
|
||||
error: context.error,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`Replaying HTTP hook "${hookName}": ${method} ${url}`);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), DEFAULT_HTTP_TIMEOUT);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: method !== 'GET' ? body : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
hookId: hook.id,
|
||||
hookName: hook.name,
|
||||
success: false,
|
||||
error: `HTTP ${response.status}: ${response.statusText}`,
|
||||
durationMs: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hookId: hook.id,
|
||||
hookName: hook.name,
|
||||
success: true,
|
||||
durationMs: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hookId: hook.id,
|
||||
hookName: hook.name,
|
||||
success: false,
|
||||
error: 'Unknown hook action type',
|
||||
durationMs: Date.now() - startTime,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.name === 'AbortError'
|
||||
? 'Request timed out'
|
||||
: error.message
|
||||
: String(error);
|
||||
|
||||
return {
|
||||
hookId: hook.id,
|
||||
hookName: hook.name,
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
durationMs: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createReplayHandler(
|
||||
eventHistoryService: EventHistoryService,
|
||||
settingsService: SettingsService
|
||||
) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, eventId, hookIds } = req.body as {
|
||||
projectPath: string;
|
||||
eventId: string;
|
||||
hookIds?: string[];
|
||||
};
|
||||
|
||||
if (!projectPath || typeof projectPath !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!eventId || typeof eventId !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'eventId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the event
|
||||
const event = await eventHistoryService.getEvent(projectPath, eventId);
|
||||
if (!event) {
|
||||
res.status(404).json({ success: false, error: 'Event not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get hooks from settings
|
||||
const settings = await settingsService.getGlobalSettings();
|
||||
let hooks = settings.eventHooks || [];
|
||||
|
||||
// Filter to matching trigger and enabled hooks
|
||||
hooks = hooks.filter((h) => h.enabled && h.trigger === event.trigger);
|
||||
|
||||
// If specific hook IDs requested, filter to those
|
||||
if (hookIds && hookIds.length > 0) {
|
||||
hooks = hooks.filter((h) => hookIds.includes(h.id));
|
||||
}
|
||||
|
||||
// Build context for variable substitution
|
||||
const context: HookContext = {
|
||||
featureId: event.featureId,
|
||||
featureName: event.featureName,
|
||||
projectPath: event.projectPath,
|
||||
projectName: event.projectName,
|
||||
error: event.error,
|
||||
errorType: event.errorType,
|
||||
timestamp: event.timestamp,
|
||||
eventType: event.trigger,
|
||||
};
|
||||
|
||||
// Execute all hooks in parallel
|
||||
const hookResults = await Promise.all(hooks.map((hook) => executeHook(hook, context)));
|
||||
|
||||
const result: EventReplayResult = {
|
||||
eventId,
|
||||
hooksTriggered: hooks.length,
|
||||
hookResults,
|
||||
};
|
||||
|
||||
logger.info(`Replayed event ${eventId}: ${hooks.length} hooks triggered`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Replay event failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import type { EventEmitter } from '../../../lib/events.js';
|
||||
import type { Feature } from '@automaker/types';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createCreateHandler(featureLoader: FeatureLoader) {
|
||||
export function createCreateHandler(featureLoader: FeatureLoader, events?: EventEmitter) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, feature } = req.body as {
|
||||
@@ -24,6 +25,16 @@ export function createCreateHandler(featureLoader: FeatureLoader) {
|
||||
}
|
||||
|
||||
const created = await featureLoader.create(projectPath, feature);
|
||||
|
||||
// Emit feature_created event for hooks
|
||||
if (events) {
|
||||
events.emit('feature:created', {
|
||||
featureId: created.id,
|
||||
featureName: created.name,
|
||||
projectPath,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ success: true, feature: created });
|
||||
} catch (error) {
|
||||
logError(error, 'Create feature failed');
|
||||
|
||||
21
apps/server/src/routes/notifications/common.ts
Normal file
21
apps/server/src/routes/notifications/common.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Common utilities for notification routes
|
||||
*
|
||||
* Provides logger and error handling utilities shared across all notification endpoints.
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||
|
||||
/** Logger instance for notification-related operations */
|
||||
export const logger = createLogger('Notifications');
|
||||
|
||||
/**
|
||||
* Extract user-friendly error message from error objects
|
||||
*/
|
||||
export { getErrorMessageShared as getErrorMessage };
|
||||
|
||||
/**
|
||||
* Log error with automatic logger binding
|
||||
*/
|
||||
export const logError = createLogError(logger);
|
||||
62
apps/server/src/routes/notifications/index.ts
Normal file
62
apps/server/src/routes/notifications/index.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Notifications routes - HTTP API for project-level notifications
|
||||
*
|
||||
* Provides endpoints for:
|
||||
* - Listing notifications
|
||||
* - Getting unread count
|
||||
* - Marking notifications as read
|
||||
* - Dismissing notifications
|
||||
*
|
||||
* All endpoints use handler factories that receive the NotificationService instance.
|
||||
* Mounted at /api/notifications in the main server.
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { NotificationService } from '../../services/notification-service.js';
|
||||
import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||
import { createListHandler } from './routes/list.js';
|
||||
import { createUnreadCountHandler } from './routes/unread-count.js';
|
||||
import { createMarkReadHandler } from './routes/mark-read.js';
|
||||
import { createDismissHandler } from './routes/dismiss.js';
|
||||
|
||||
/**
|
||||
* Create notifications router with all endpoints
|
||||
*
|
||||
* Endpoints:
|
||||
* - POST /list - List all notifications for a project
|
||||
* - POST /unread-count - Get unread notification count
|
||||
* - POST /mark-read - Mark notification(s) as read
|
||||
* - POST /dismiss - Dismiss notification(s)
|
||||
*
|
||||
* @param notificationService - Instance of NotificationService
|
||||
* @returns Express Router configured with all notification endpoints
|
||||
*/
|
||||
export function createNotificationsRoutes(notificationService: NotificationService): Router {
|
||||
const router = Router();
|
||||
|
||||
// List notifications
|
||||
router.post('/list', validatePathParams('projectPath'), createListHandler(notificationService));
|
||||
|
||||
// Get unread count
|
||||
router.post(
|
||||
'/unread-count',
|
||||
validatePathParams('projectPath'),
|
||||
createUnreadCountHandler(notificationService)
|
||||
);
|
||||
|
||||
// Mark as read (single or all)
|
||||
router.post(
|
||||
'/mark-read',
|
||||
validatePathParams('projectPath'),
|
||||
createMarkReadHandler(notificationService)
|
||||
);
|
||||
|
||||
// Dismiss (single or all)
|
||||
router.post(
|
||||
'/dismiss',
|
||||
validatePathParams('projectPath'),
|
||||
createDismissHandler(notificationService)
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
53
apps/server/src/routes/notifications/routes/dismiss.ts
Normal file
53
apps/server/src/routes/notifications/routes/dismiss.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* POST /api/notifications/dismiss - Dismiss notification(s)
|
||||
*
|
||||
* Request body: { projectPath: string, notificationId?: string }
|
||||
* - If notificationId provided: dismisses that notification
|
||||
* - If notificationId not provided: dismisses all notifications
|
||||
*
|
||||
* Response: { success: true, dismissed: boolean | count: number }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { NotificationService } from '../../../services/notification-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
/**
|
||||
* Create handler for POST /api/notifications/dismiss
|
||||
*
|
||||
* @param notificationService - Instance of NotificationService
|
||||
* @returns Express request handler
|
||||
*/
|
||||
export function createDismissHandler(notificationService: NotificationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, notificationId } = req.body;
|
||||
|
||||
if (!projectPath || typeof projectPath !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// If notificationId provided, dismiss single notification
|
||||
if (notificationId) {
|
||||
const dismissed = await notificationService.dismissNotification(
|
||||
projectPath,
|
||||
notificationId
|
||||
);
|
||||
if (!dismissed) {
|
||||
res.status(404).json({ success: false, error: 'Notification not found' });
|
||||
return;
|
||||
}
|
||||
res.json({ success: true, dismissed: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise dismiss all
|
||||
const count = await notificationService.dismissAll(projectPath);
|
||||
res.json({ success: true, count });
|
||||
} catch (error) {
|
||||
logError(error, 'Dismiss failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
39
apps/server/src/routes/notifications/routes/list.ts
Normal file
39
apps/server/src/routes/notifications/routes/list.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* POST /api/notifications/list - List all notifications for a project
|
||||
*
|
||||
* Request body: { projectPath: string }
|
||||
* Response: { success: true, notifications: Notification[] }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { NotificationService } from '../../../services/notification-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
/**
|
||||
* Create handler for POST /api/notifications/list
|
||||
*
|
||||
* @param notificationService - Instance of NotificationService
|
||||
* @returns Express request handler
|
||||
*/
|
||||
export function createListHandler(notificationService: NotificationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body;
|
||||
|
||||
if (!projectPath || typeof projectPath !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const notifications = await notificationService.getNotifications(projectPath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
notifications,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'List notifications failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
50
apps/server/src/routes/notifications/routes/mark-read.ts
Normal file
50
apps/server/src/routes/notifications/routes/mark-read.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* POST /api/notifications/mark-read - Mark notification(s) as read
|
||||
*
|
||||
* Request body: { projectPath: string, notificationId?: string }
|
||||
* - If notificationId provided: marks that notification as read
|
||||
* - If notificationId not provided: marks all notifications as read
|
||||
*
|
||||
* Response: { success: true, count?: number, notification?: Notification }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { NotificationService } from '../../../services/notification-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
/**
|
||||
* Create handler for POST /api/notifications/mark-read
|
||||
*
|
||||
* @param notificationService - Instance of NotificationService
|
||||
* @returns Express request handler
|
||||
*/
|
||||
export function createMarkReadHandler(notificationService: NotificationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, notificationId } = req.body;
|
||||
|
||||
if (!projectPath || typeof projectPath !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// If notificationId provided, mark single notification
|
||||
if (notificationId) {
|
||||
const notification = await notificationService.markAsRead(projectPath, notificationId);
|
||||
if (!notification) {
|
||||
res.status(404).json({ success: false, error: 'Notification not found' });
|
||||
return;
|
||||
}
|
||||
res.json({ success: true, notification });
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise mark all as read
|
||||
const count = await notificationService.markAllAsRead(projectPath);
|
||||
res.json({ success: true, count });
|
||||
} catch (error) {
|
||||
logError(error, 'Mark read failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
39
apps/server/src/routes/notifications/routes/unread-count.ts
Normal file
39
apps/server/src/routes/notifications/routes/unread-count.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* POST /api/notifications/unread-count - Get unread notification count
|
||||
*
|
||||
* Request body: { projectPath: string }
|
||||
* Response: { success: true, count: number }
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { NotificationService } from '../../../services/notification-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
/**
|
||||
* Create handler for POST /api/notifications/unread-count
|
||||
*
|
||||
* @param notificationService - Instance of NotificationService
|
||||
* @returns Express request handler
|
||||
*/
|
||||
export function createUnreadCountHandler(notificationService: NotificationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body;
|
||||
|
||||
if (!projectPath || typeof projectPath !== 'string') {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const count = await notificationService.getUnreadCount(projectPath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
count,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get unread count failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
338
apps/server/src/services/event-history-service.ts
Normal file
338
apps/server/src/services/event-history-service.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* Event History Service - Stores and retrieves event records for debugging and replay
|
||||
*
|
||||
* Provides persistent storage for events in {projectPath}/.automaker/events/
|
||||
* Each event is stored as a separate JSON file with an index for quick listing.
|
||||
*
|
||||
* Features:
|
||||
* - Store events when they occur
|
||||
* - List and filter historical events
|
||||
* - Replay events to test hook configurations
|
||||
* - Delete old events to manage disk space
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
import {
|
||||
getEventHistoryDir,
|
||||
getEventHistoryIndexPath,
|
||||
getEventPath,
|
||||
ensureEventHistoryDir,
|
||||
} from '@automaker/platform';
|
||||
import type {
|
||||
StoredEvent,
|
||||
StoredEventIndex,
|
||||
StoredEventSummary,
|
||||
EventHistoryFilter,
|
||||
EventHookTrigger,
|
||||
} from '@automaker/types';
|
||||
import { DEFAULT_EVENT_HISTORY_INDEX } from '@automaker/types';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
const logger = createLogger('EventHistoryService');
|
||||
|
||||
/** Maximum events to keep in the index (oldest are pruned) */
|
||||
const MAX_EVENTS_IN_INDEX = 1000;
|
||||
|
||||
/**
|
||||
* Atomic file write - write to temp file then rename
|
||||
*/
|
||||
async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
|
||||
const tempPath = `${filePath}.tmp.${Date.now()}`;
|
||||
const content = JSON.stringify(data, null, 2);
|
||||
|
||||
try {
|
||||
await secureFs.writeFile(tempPath, content, 'utf-8');
|
||||
await secureFs.rename(tempPath, filePath);
|
||||
} catch (error) {
|
||||
try {
|
||||
await secureFs.unlink(tempPath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely read JSON file with fallback to default
|
||||
*/
|
||||
async function readJsonFile<T>(filePath: string, defaultValue: T): Promise<T> {
|
||||
try {
|
||||
const content = (await secureFs.readFile(filePath, 'utf-8')) as string;
|
||||
return JSON.parse(content) as T;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return defaultValue;
|
||||
}
|
||||
logger.error(`Error reading ${filePath}:`, error);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for storing a new event
|
||||
*/
|
||||
export interface StoreEventInput {
|
||||
trigger: EventHookTrigger;
|
||||
projectPath: string;
|
||||
featureId?: string;
|
||||
featureName?: string;
|
||||
error?: string;
|
||||
errorType?: string;
|
||||
passes?: boolean;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* EventHistoryService - Manages persistent storage of events
|
||||
*/
|
||||
export class EventHistoryService {
|
||||
/**
|
||||
* Store a new event to history
|
||||
*
|
||||
* @param input - Event data to store
|
||||
* @returns Promise resolving to the stored event
|
||||
*/
|
||||
async storeEvent(input: StoreEventInput): Promise<StoredEvent> {
|
||||
const { projectPath, trigger, featureId, featureName, error, errorType, passes, metadata } =
|
||||
input;
|
||||
|
||||
// Ensure events directory exists
|
||||
await ensureEventHistoryDir(projectPath);
|
||||
|
||||
const eventId = `evt-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
||||
const timestamp = new Date().toISOString();
|
||||
const projectName = this.extractProjectName(projectPath);
|
||||
|
||||
const event: StoredEvent = {
|
||||
id: eventId,
|
||||
trigger,
|
||||
timestamp,
|
||||
projectPath,
|
||||
projectName,
|
||||
featureId,
|
||||
featureName,
|
||||
error,
|
||||
errorType,
|
||||
passes,
|
||||
metadata,
|
||||
};
|
||||
|
||||
// Write the full event to its own file
|
||||
const eventPath = getEventPath(projectPath, eventId);
|
||||
await atomicWriteJson(eventPath, event);
|
||||
|
||||
// Update the index
|
||||
await this.addToIndex(projectPath, event);
|
||||
|
||||
logger.info(`Stored event ${eventId} (${trigger}) for project ${projectName}`);
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all events for a project with optional filtering
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @param filter - Optional filter criteria
|
||||
* @returns Promise resolving to array of event summaries
|
||||
*/
|
||||
async getEvents(projectPath: string, filter?: EventHistoryFilter): Promise<StoredEventSummary[]> {
|
||||
const indexPath = getEventHistoryIndexPath(projectPath);
|
||||
const index = await readJsonFile<StoredEventIndex>(indexPath, DEFAULT_EVENT_HISTORY_INDEX);
|
||||
|
||||
let events = [...index.events];
|
||||
|
||||
// Apply filters
|
||||
if (filter) {
|
||||
if (filter.trigger) {
|
||||
events = events.filter((e) => e.trigger === filter.trigger);
|
||||
}
|
||||
if (filter.featureId) {
|
||||
events = events.filter((e) => e.featureId === filter.featureId);
|
||||
}
|
||||
if (filter.since) {
|
||||
const sinceDate = new Date(filter.since).getTime();
|
||||
events = events.filter((e) => new Date(e.timestamp).getTime() >= sinceDate);
|
||||
}
|
||||
if (filter.until) {
|
||||
const untilDate = new Date(filter.until).getTime();
|
||||
events = events.filter((e) => new Date(e.timestamp).getTime() <= untilDate);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by timestamp (newest first)
|
||||
events.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||
|
||||
// Apply pagination
|
||||
if (filter?.offset) {
|
||||
events = events.slice(filter.offset);
|
||||
}
|
||||
if (filter?.limit) {
|
||||
events = events.slice(0, filter.limit);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single event by ID
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @param eventId - Event identifier
|
||||
* @returns Promise resolving to the full event or null if not found
|
||||
*/
|
||||
async getEvent(projectPath: string, eventId: string): Promise<StoredEvent | null> {
|
||||
const eventPath = getEventPath(projectPath, eventId);
|
||||
try {
|
||||
const content = (await secureFs.readFile(eventPath, 'utf-8')) as string;
|
||||
return JSON.parse(content) as StoredEvent;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
logger.error(`Error reading event ${eventId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an event by ID
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @param eventId - Event identifier
|
||||
* @returns Promise resolving to true if deleted
|
||||
*/
|
||||
async deleteEvent(projectPath: string, eventId: string): Promise<boolean> {
|
||||
// Remove from index
|
||||
const indexPath = getEventHistoryIndexPath(projectPath);
|
||||
const index = await readJsonFile<StoredEventIndex>(indexPath, DEFAULT_EVENT_HISTORY_INDEX);
|
||||
|
||||
const initialLength = index.events.length;
|
||||
index.events = index.events.filter((e) => e.id !== eventId);
|
||||
|
||||
if (index.events.length === initialLength) {
|
||||
return false; // Event not found in index
|
||||
}
|
||||
|
||||
await atomicWriteJson(indexPath, index);
|
||||
|
||||
// Delete the event file
|
||||
const eventPath = getEventPath(projectPath, eventId);
|
||||
try {
|
||||
await secureFs.unlink(eventPath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
logger.error(`Error deleting event file ${eventId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Deleted event ${eventId}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all events for a project
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @returns Promise resolving to number of events cleared
|
||||
*/
|
||||
async clearEvents(projectPath: string): Promise<number> {
|
||||
const indexPath = getEventHistoryIndexPath(projectPath);
|
||||
const index = await readJsonFile<StoredEventIndex>(indexPath, DEFAULT_EVENT_HISTORY_INDEX);
|
||||
|
||||
const count = index.events.length;
|
||||
|
||||
// Delete all event files
|
||||
for (const event of index.events) {
|
||||
const eventPath = getEventPath(projectPath, event.id);
|
||||
try {
|
||||
await secureFs.unlink(eventPath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
logger.error(`Error deleting event file ${event.id}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the index
|
||||
await atomicWriteJson(indexPath, DEFAULT_EVENT_HISTORY_INDEX);
|
||||
|
||||
logger.info(`Cleared ${count} events for project`);
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get event count for a project
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @param filter - Optional filter criteria
|
||||
* @returns Promise resolving to event count
|
||||
*/
|
||||
async getEventCount(projectPath: string, filter?: EventHistoryFilter): Promise<number> {
|
||||
const events = await this.getEvents(projectPath, {
|
||||
...filter,
|
||||
limit: undefined,
|
||||
offset: undefined,
|
||||
});
|
||||
return events.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an event to the index (internal)
|
||||
*/
|
||||
private async addToIndex(projectPath: string, event: StoredEvent): Promise<void> {
|
||||
const indexPath = getEventHistoryIndexPath(projectPath);
|
||||
const index = await readJsonFile<StoredEventIndex>(indexPath, DEFAULT_EVENT_HISTORY_INDEX);
|
||||
|
||||
const summary: StoredEventSummary = {
|
||||
id: event.id,
|
||||
trigger: event.trigger,
|
||||
timestamp: event.timestamp,
|
||||
featureName: event.featureName,
|
||||
featureId: event.featureId,
|
||||
};
|
||||
|
||||
// Add to beginning (newest first)
|
||||
index.events.unshift(summary);
|
||||
|
||||
// Prune old events if over limit
|
||||
if (index.events.length > MAX_EVENTS_IN_INDEX) {
|
||||
const removed = index.events.splice(MAX_EVENTS_IN_INDEX);
|
||||
// Delete the pruned event files
|
||||
for (const oldEvent of removed) {
|
||||
const eventPath = getEventPath(projectPath, oldEvent.id);
|
||||
try {
|
||||
await secureFs.unlink(eventPath);
|
||||
} catch {
|
||||
// Ignore deletion errors for pruned events
|
||||
}
|
||||
}
|
||||
logger.info(`Pruned ${removed.length} old events from history`);
|
||||
}
|
||||
|
||||
await atomicWriteJson(indexPath, index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract project name from path
|
||||
*/
|
||||
private extractProjectName(projectPath: string): string {
|
||||
const parts = projectPath.split(/[/\\]/);
|
||||
return parts[parts.length - 1] || projectPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let eventHistoryServiceInstance: EventHistoryService | null = null;
|
||||
|
||||
/**
|
||||
* Get the singleton event history service instance
|
||||
*/
|
||||
export function getEventHistoryService(): EventHistoryService {
|
||||
if (!eventHistoryServiceInstance) {
|
||||
eventHistoryServiceInstance = new EventHistoryService();
|
||||
}
|
||||
return eventHistoryServiceInstance;
|
||||
}
|
||||
@@ -5,7 +5,10 @@
|
||||
* - Shell commands: Executed with configurable timeout
|
||||
* - HTTP webhooks: POST/GET/PUT/PATCH requests with variable substitution
|
||||
*
|
||||
* Also stores events to history for debugging and replay.
|
||||
*
|
||||
* Supported events:
|
||||
* - feature_created: A new feature was created
|
||||
* - feature_success: Feature completed successfully
|
||||
* - feature_error: Feature failed with an error
|
||||
* - auto_mode_complete: Auto mode finished all features (idle state)
|
||||
@@ -17,6 +20,7 @@ import { promisify } from 'util';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import type { EventEmitter } from '../lib/events.js';
|
||||
import type { SettingsService } from './settings-service.js';
|
||||
import type { EventHistoryService } from './event-history-service.js';
|
||||
import type {
|
||||
EventHook,
|
||||
EventHookTrigger,
|
||||
@@ -60,27 +64,45 @@ interface AutoModeEventPayload {
|
||||
projectPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature created event payload structure
|
||||
*/
|
||||
interface FeatureCreatedPayload {
|
||||
featureId: string;
|
||||
featureName?: string;
|
||||
projectPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event Hook Service
|
||||
*
|
||||
* Manages execution of user-configured event hooks in response to system events.
|
||||
* Also stores events to history for debugging and replay.
|
||||
*/
|
||||
export class EventHookService {
|
||||
private emitter: EventEmitter | null = null;
|
||||
private settingsService: SettingsService | null = null;
|
||||
private eventHistoryService: EventHistoryService | null = null;
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
|
||||
/**
|
||||
* Initialize the service with event emitter and settings service
|
||||
* Initialize the service with event emitter, settings service, and event history service
|
||||
*/
|
||||
initialize(emitter: EventEmitter, settingsService: SettingsService): void {
|
||||
initialize(
|
||||
emitter: EventEmitter,
|
||||
settingsService: SettingsService,
|
||||
eventHistoryService?: EventHistoryService
|
||||
): void {
|
||||
this.emitter = emitter;
|
||||
this.settingsService = settingsService;
|
||||
this.eventHistoryService = eventHistoryService || null;
|
||||
|
||||
// Subscribe to auto-mode events
|
||||
// Subscribe to events
|
||||
this.unsubscribe = emitter.subscribe((type, payload) => {
|
||||
if (type === 'auto-mode:event') {
|
||||
this.handleAutoModeEvent(payload as AutoModeEventPayload);
|
||||
} else if (type === 'feature:created') {
|
||||
this.handleFeatureCreatedEvent(payload as FeatureCreatedPayload);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -97,6 +119,7 @@ export class EventHookService {
|
||||
}
|
||||
this.emitter = null;
|
||||
this.settingsService = null;
|
||||
this.eventHistoryService = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,17 +160,51 @@ export class EventHookService {
|
||||
eventType: trigger,
|
||||
};
|
||||
|
||||
// Execute matching hooks
|
||||
await this.executeHooksForTrigger(trigger, context);
|
||||
// Execute matching hooks (pass passes for feature completion events)
|
||||
await this.executeHooksForTrigger(trigger, context, { passes: payload.passes });
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute all enabled hooks matching the given trigger
|
||||
* Handle feature:created events and trigger matching hooks
|
||||
*/
|
||||
private async handleFeatureCreatedEvent(payload: FeatureCreatedPayload): Promise<void> {
|
||||
const context: HookContext = {
|
||||
featureId: payload.featureId,
|
||||
featureName: payload.featureName,
|
||||
projectPath: payload.projectPath,
|
||||
projectName: this.extractProjectName(payload.projectPath),
|
||||
timestamp: new Date().toISOString(),
|
||||
eventType: 'feature_created',
|
||||
};
|
||||
|
||||
await this.executeHooksForTrigger('feature_created', context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute all enabled hooks matching the given trigger and store event to history
|
||||
*/
|
||||
private async executeHooksForTrigger(
|
||||
trigger: EventHookTrigger,
|
||||
context: HookContext
|
||||
context: HookContext,
|
||||
additionalData?: { passes?: boolean }
|
||||
): Promise<void> {
|
||||
// Store event to history (even if no hooks match)
|
||||
if (this.eventHistoryService && context.projectPath) {
|
||||
try {
|
||||
await this.eventHistoryService.storeEvent({
|
||||
trigger,
|
||||
projectPath: context.projectPath,
|
||||
featureId: context.featureId,
|
||||
featureName: context.featureName,
|
||||
error: context.error,
|
||||
errorType: context.errorType,
|
||||
passes: additionalData?.passes,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to store event to history:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.settingsService) {
|
||||
logger.warn('Settings service not available');
|
||||
return;
|
||||
|
||||
280
apps/server/src/services/notification-service.ts
Normal file
280
apps/server/src/services/notification-service.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Notification Service - Handles reading/writing notifications to JSON files
|
||||
*
|
||||
* Provides persistent storage for project-level notifications in
|
||||
* {projectPath}/.automaker/notifications.json
|
||||
*
|
||||
* Notifications alert users when:
|
||||
* - Features reach specific statuses (waiting_approval, verified)
|
||||
* - Long-running operations complete (spec generation)
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import * as secureFs from '../lib/secure-fs.js';
|
||||
import { getNotificationsPath, ensureAutomakerDir } from '@automaker/platform';
|
||||
import type { Notification, NotificationsFile, NotificationType } from '@automaker/types';
|
||||
import { DEFAULT_NOTIFICATIONS_FILE } from '@automaker/types';
|
||||
import type { EventEmitter } from '../lib/events.js';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
const logger = createLogger('NotificationService');
|
||||
|
||||
/**
|
||||
* Atomic file write - write to temp file then rename
|
||||
*/
|
||||
async function atomicWriteJson(filePath: string, data: unknown): Promise<void> {
|
||||
const tempPath = `${filePath}.tmp.${Date.now()}`;
|
||||
const content = JSON.stringify(data, null, 2);
|
||||
|
||||
try {
|
||||
await secureFs.writeFile(tempPath, content, 'utf-8');
|
||||
await secureFs.rename(tempPath, filePath);
|
||||
} catch (error) {
|
||||
// Clean up temp file if it exists
|
||||
try {
|
||||
await secureFs.unlink(tempPath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely read JSON file with fallback to default
|
||||
*/
|
||||
async function readJsonFile<T>(filePath: string, defaultValue: T): Promise<T> {
|
||||
try {
|
||||
const content = (await secureFs.readFile(filePath, 'utf-8')) as string;
|
||||
return JSON.parse(content) as T;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return defaultValue;
|
||||
}
|
||||
logger.error(`Error reading ${filePath}:`, error);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for creating a new notification
|
||||
*/
|
||||
export interface CreateNotificationInput {
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
message: string;
|
||||
featureId?: string;
|
||||
projectPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* NotificationService - Manages persistent storage of notifications
|
||||
*
|
||||
* Handles reading and writing notifications to JSON files with atomic operations
|
||||
* for reliability. Each project has its own notifications.json file.
|
||||
*/
|
||||
export class NotificationService {
|
||||
private events: EventEmitter | null = null;
|
||||
|
||||
/**
|
||||
* Set the event emitter for broadcasting notification events
|
||||
*/
|
||||
setEventEmitter(events: EventEmitter): void {
|
||||
this.events = events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all notifications for a project
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @returns Promise resolving to array of notifications
|
||||
*/
|
||||
async getNotifications(projectPath: string): Promise<Notification[]> {
|
||||
const notificationsPath = getNotificationsPath(projectPath);
|
||||
const file = await readJsonFile<NotificationsFile>(
|
||||
notificationsPath,
|
||||
DEFAULT_NOTIFICATIONS_FILE
|
||||
);
|
||||
// Filter out dismissed notifications and sort by date (newest first)
|
||||
return file.notifications
|
||||
.filter((n) => !n.dismissed)
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unread notification count for a project
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @returns Promise resolving to unread count
|
||||
*/
|
||||
async getUnreadCount(projectPath: string): Promise<number> {
|
||||
const notifications = await this.getNotifications(projectPath);
|
||||
return notifications.filter((n) => !n.read).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new notification
|
||||
*
|
||||
* @param input - Notification creation input
|
||||
* @returns Promise resolving to the created notification
|
||||
*/
|
||||
async createNotification(input: CreateNotificationInput): Promise<Notification> {
|
||||
const { projectPath, type, title, message, featureId } = input;
|
||||
|
||||
// Ensure automaker directory exists
|
||||
await ensureAutomakerDir(projectPath);
|
||||
|
||||
const notificationsPath = getNotificationsPath(projectPath);
|
||||
const file = await readJsonFile<NotificationsFile>(
|
||||
notificationsPath,
|
||||
DEFAULT_NOTIFICATIONS_FILE
|
||||
);
|
||||
|
||||
const notification: Notification = {
|
||||
id: randomUUID(),
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
createdAt: new Date().toISOString(),
|
||||
read: false,
|
||||
dismissed: false,
|
||||
featureId,
|
||||
projectPath,
|
||||
};
|
||||
|
||||
file.notifications.push(notification);
|
||||
await atomicWriteJson(notificationsPath, file);
|
||||
|
||||
logger.info(`Created notification: ${title} for project ${projectPath}`);
|
||||
|
||||
// Emit event for real-time updates
|
||||
if (this.events) {
|
||||
this.events.emit('notification:created', notification);
|
||||
}
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a notification as read
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @param notificationId - ID of the notification to mark as read
|
||||
* @returns Promise resolving to the updated notification or null if not found
|
||||
*/
|
||||
async markAsRead(projectPath: string, notificationId: string): Promise<Notification | null> {
|
||||
const notificationsPath = getNotificationsPath(projectPath);
|
||||
const file = await readJsonFile<NotificationsFile>(
|
||||
notificationsPath,
|
||||
DEFAULT_NOTIFICATIONS_FILE
|
||||
);
|
||||
|
||||
const notification = file.notifications.find((n) => n.id === notificationId);
|
||||
if (!notification) {
|
||||
return null;
|
||||
}
|
||||
|
||||
notification.read = true;
|
||||
await atomicWriteJson(notificationsPath, file);
|
||||
|
||||
logger.info(`Marked notification ${notificationId} as read`);
|
||||
return notification;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all notifications as read for a project
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @returns Promise resolving to number of notifications marked as read
|
||||
*/
|
||||
async markAllAsRead(projectPath: string): Promise<number> {
|
||||
const notificationsPath = getNotificationsPath(projectPath);
|
||||
const file = await readJsonFile<NotificationsFile>(
|
||||
notificationsPath,
|
||||
DEFAULT_NOTIFICATIONS_FILE
|
||||
);
|
||||
|
||||
let count = 0;
|
||||
for (const notification of file.notifications) {
|
||||
if (!notification.read && !notification.dismissed) {
|
||||
notification.read = true;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
await atomicWriteJson(notificationsPath, file);
|
||||
logger.info(`Marked ${count} notifications as read`);
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss a notification
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @param notificationId - ID of the notification to dismiss
|
||||
* @returns Promise resolving to true if notification was dismissed
|
||||
*/
|
||||
async dismissNotification(projectPath: string, notificationId: string): Promise<boolean> {
|
||||
const notificationsPath = getNotificationsPath(projectPath);
|
||||
const file = await readJsonFile<NotificationsFile>(
|
||||
notificationsPath,
|
||||
DEFAULT_NOTIFICATIONS_FILE
|
||||
);
|
||||
|
||||
const notification = file.notifications.find((n) => n.id === notificationId);
|
||||
if (!notification) {
|
||||
return false;
|
||||
}
|
||||
|
||||
notification.dismissed = true;
|
||||
await atomicWriteJson(notificationsPath, file);
|
||||
|
||||
logger.info(`Dismissed notification ${notificationId}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss all notifications for a project
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @returns Promise resolving to number of notifications dismissed
|
||||
*/
|
||||
async dismissAll(projectPath: string): Promise<number> {
|
||||
const notificationsPath = getNotificationsPath(projectPath);
|
||||
const file = await readJsonFile<NotificationsFile>(
|
||||
notificationsPath,
|
||||
DEFAULT_NOTIFICATIONS_FILE
|
||||
);
|
||||
|
||||
let count = 0;
|
||||
for (const notification of file.notifications) {
|
||||
if (!notification.dismissed) {
|
||||
notification.dismissed = true;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
await atomicWriteJson(notificationsPath, file);
|
||||
logger.info(`Dismissed ${count} notifications`);
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let notificationServiceInstance: NotificationService | null = null;
|
||||
|
||||
/**
|
||||
* Get the singleton notification service instance
|
||||
*/
|
||||
export function getNotificationService(): NotificationService {
|
||||
if (!notificationServiceInstance) {
|
||||
notificationServiceInstance = new NotificationService();
|
||||
}
|
||||
return notificationServiceInstance;
|
||||
}
|
||||
@@ -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 <Bell className="h-4 w-4 text-yellow-500" />;
|
||||
case 'feature_verified':
|
||||
return <Check className="h-4 w-4 text-green-500" />;
|
||||
case 'spec_regeneration_complete':
|
||||
return <Check className="h-4 w-4 text-blue-500" />;
|
||||
default:
|
||||
return <Bell className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
// Show recent 3 notifications in popover
|
||||
const recentNotifications = notifications.slice(0, 3);
|
||||
|
||||
if (!projectPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={isPopoverOpen} onOpenChange={setPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'relative flex items-center justify-center w-8 h-8 rounded-md',
|
||||
'hover:bg-accent transition-colors',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2'
|
||||
)}
|
||||
title="Notifications"
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-primary-foreground">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-0" align="start" side="right">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b">
|
||||
<h4 className="font-medium text-sm">Notifications</h4>
|
||||
{unreadCount > 0 && (
|
||||
<span className="text-xs text-muted-foreground">{unreadCount} unread</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{recentNotifications.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 px-4">
|
||||
<Bell className="h-8 w-8 text-muted-foreground/50 mb-2" />
|
||||
<p className="text-sm text-muted-foreground">No notifications</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
{recentNotifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={cn(
|
||||
'flex items-start gap-3 px-4 py-3 cursor-pointer hover:bg-accent/50 border-b last:border-b-0',
|
||||
!notification.read && 'bg-primary/5'
|
||||
)}
|
||||
onClick={() => handleNotificationClick(notification)}
|
||||
>
|
||||
<div className="flex-shrink-0 mt-0.5">{getNotificationIcon(notification.type)}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="text-sm font-medium truncate">{notification.title}</p>
|
||||
{!notification.read && (
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-primary flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 mt-0.5">
|
||||
{notification.message}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
{formatRelativeTime(new Date(notification.createdAt))}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0 flex flex-col gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDismiss(notification.id);
|
||||
}}
|
||||
title="Dismiss"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notifications.length > 0 && (
|
||||
<div className="border-t px-4 py-2">
|
||||
<Button variant="ghost" size="sm" className="w-full text-xs" onClick={handleViewAll}>
|
||||
View all notifications
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Notification Bell */}
|
||||
<div className="flex justify-center mt-2">
|
||||
<NotificationBell projectPath={currentProject?.path ?? null} />
|
||||
</div>
|
||||
<div className="w-full h-px bg-border mt-3" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
272
apps/ui/src/components/views/notifications-view.tsx
Normal file
272
apps/ui/src/components/views/notifications-view.tsx
Normal file
@@ -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 <Bell className="h-5 w-5 text-yellow-500" />;
|
||||
case 'feature_verified':
|
||||
return <Check className="h-5 w-5 text-green-500" />;
|
||||
case 'spec_regeneration_complete':
|
||||
return <Check className="h-5 w-5 text-blue-500" />;
|
||||
case 'agent_complete':
|
||||
return <Check className="h-5 w-5 text-purple-500" />;
|
||||
default:
|
||||
return <Bell className="h-5 w-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center p-8">
|
||||
<Bell className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<p className="text-muted-foreground">Select a project to view notifications</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center p-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<p className="text-muted-foreground mt-4">Loading notifications...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center p-8">
|
||||
<p className="text-destructive">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col p-6 overflow-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Notifications</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{unreadCount > 0 ? `${unreadCount} unread` : 'All caught up!'}
|
||||
</p>
|
||||
</div>
|
||||
{notifications.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleMarkAllAsRead}
|
||||
disabled={unreadCount === 0}
|
||||
>
|
||||
<CheckCheck className="h-4 w-4 mr-2" />
|
||||
Mark all as read
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleDismissAll}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Dismiss all
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{notifications.length === 0 ? (
|
||||
<Card className="flex-1">
|
||||
<CardContent className="flex flex-col items-center justify-center h-full min-h-[300px]">
|
||||
<Bell className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<p className="text-muted-foreground text-lg">No notifications</p>
|
||||
<p className="text-muted-foreground text-sm mt-2">
|
||||
Notifications will appear here when features are ready for review or operations
|
||||
complete.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{notifications.map((notification) => (
|
||||
<Card
|
||||
key={notification.id}
|
||||
className={`transition-colors cursor-pointer hover:bg-accent/50 ${
|
||||
!notification.read ? 'border-primary/50 bg-primary/5' : ''
|
||||
}`}
|
||||
onClick={() => handleNotificationClick(notification)}
|
||||
>
|
||||
<CardContent className="flex items-start gap-4 p-4">
|
||||
<div className="flex-shrink-0 mt-1">{getNotificationIcon(notification.type)}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-base">{notification.title}</CardTitle>
|
||||
{!notification.read && (
|
||||
<span className="h-2 w-2 rounded-full bg-primary flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<CardDescription className="mt-1">{notification.message}</CardDescription>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{formatRelativeTime(new Date(notification.createdAt))}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{!notification.read && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMarkAsRead(notification.id);
|
||||
}}
|
||||
title="Mark as read"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDismiss(notification.id);
|
||||
}}
|
||||
title="Dismiss"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{notification.featureId && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleNotificationClick(notification);
|
||||
}}
|
||||
title="Go to feature"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<StoredEventSummary[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expandedEvent, setExpandedEvent] = useState<string | null>(null);
|
||||
const [expandedEventData, setExpandedEventData] = useState<StoredEvent | null>(null);
|
||||
const [replayingEvent, setReplayingEvent] = useState<string | null>(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 <Clock className="w-4 h-4 text-blue-500" />;
|
||||
case 'feature_success':
|
||||
return <CheckCircle className="w-4 h-4 text-green-500" />;
|
||||
case 'feature_error':
|
||||
return <XCircle className="w-4 h-4 text-red-500" />;
|
||||
case 'auto_mode_complete':
|
||||
return <CheckCircle className="w-4 h-4 text-purple-500" />;
|
||||
case 'auto_mode_error':
|
||||
return <AlertCircle className="w-4 h-4 text-orange-500" />;
|
||||
default:
|
||||
return <History className="w-4 h-4 text-muted-foreground" />;
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<History className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm">Select a project to view event history</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header with actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{events.length} event{events.length !== 1 ? 's' : ''} recorded
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={loadEvents} disabled={loading}>
|
||||
<RefreshCw className={cn('w-4 h-4 mr-2', loading && 'animate-spin')} />
|
||||
Refresh
|
||||
</Button>
|
||||
{events.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => setClearDialogOpen(true)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Clear All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Events list */}
|
||||
{events.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<History className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm">No events recorded yet</p>
|
||||
<p className="text-xs mt-1">
|
||||
Events will appear here when features are created or completed
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{events.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className={cn(
|
||||
'rounded-lg border bg-background/50',
|
||||
expandedEvent === event.id && 'ring-1 ring-brand-500/30'
|
||||
)}
|
||||
>
|
||||
{/* Event header */}
|
||||
<div
|
||||
className="flex items-center gap-3 p-3 cursor-pointer hover:bg-muted/30 transition-colors"
|
||||
onClick={() => handleExpand(event.id)}
|
||||
>
|
||||
<button className="p-0.5">
|
||||
{expandedEvent === event.id ? (
|
||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{getTriggerIcon(event.trigger)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{EVENT_HOOK_TRIGGER_LABELS[event.trigger]}
|
||||
</p>
|
||||
{event.featureName && (
|
||||
<p className="text-xs text-muted-foreground truncate">{event.featureName}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatTimestamp(event.timestamp)}
|
||||
</span>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => handleReplay(event.id)}
|
||||
disabled={replayingEvent === event.id}
|
||||
title="Replay event (trigger matching hooks)"
|
||||
>
|
||||
<Play
|
||||
className={cn('w-3.5 h-3.5', replayingEvent === event.id && 'animate-pulse')}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||
onClick={() => handleDelete(event.id)}
|
||||
title="Delete event"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded details */}
|
||||
{expandedEvent === event.id && expandedEventData && (
|
||||
<div className="px-4 pb-4 pt-0 border-t border-border/50">
|
||||
<div className="mt-3 space-y-2 text-xs">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Event ID:</span>
|
||||
<p className="font-mono text-[10px] truncate">{expandedEventData.id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Timestamp:</span>
|
||||
<p>{new Date(expandedEventData.timestamp).toLocaleString()}</p>
|
||||
</div>
|
||||
{expandedEventData.featureId && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Feature ID:</span>
|
||||
<p className="font-mono text-[10px] truncate">
|
||||
{expandedEventData.featureId}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{expandedEventData.passes !== undefined && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Passed:</span>
|
||||
<p>{expandedEventData.passes ? 'Yes' : 'No'}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{expandedEventData.error && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Error:</span>
|
||||
<p className="text-red-400 mt-1 p-2 bg-red-500/10 rounded text-[10px] font-mono whitespace-pre-wrap">
|
||||
{expandedEventData.error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-muted-foreground">Project:</span>
|
||||
<p className="font-mono text-[10px] truncate">
|
||||
{expandedEventData.projectPath}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Clear confirmation dialog */}
|
||||
<ConfirmDialog
|
||||
open={clearDialogOpen}
|
||||
onOpenChange={setClearDialogOpen}
|
||||
onConfirm={handleClearAll}
|
||||
title="Clear Event History"
|
||||
description={`This will permanently delete all ${events.length} recorded events. This action cannot be undone.`}
|
||||
icon={Trash2}
|
||||
iconClassName="text-destructive"
|
||||
confirmText="Clear All"
|
||||
confirmVariant="destructive"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,7 @@ interface EventHookDialogProps {
|
||||
type ActionType = 'shell' | 'http';
|
||||
|
||||
const TRIGGER_OPTIONS: EventHookTrigger[] = [
|
||||
'feature_created',
|
||||
'feature_success',
|
||||
'feature_error',
|
||||
'auto_mode_complete',
|
||||
|
||||
@@ -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<EventHook | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'hooks' | 'history'>('hooks');
|
||||
|
||||
const handleAddHook = () => {
|
||||
setEditingHook(null);
|
||||
@@ -78,58 +81,85 @@ export function EventHooksSection() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleAddHook} size="sm" className="gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Hook
|
||||
</Button>
|
||||
{activeTab === 'hooks' && (
|
||||
<Button onClick={handleAddHook} size="sm" className="gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Hook
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
{eventHooks.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Webhook className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm">No event hooks configured</p>
|
||||
<p className="text-xs mt-1">
|
||||
Add hooks to run commands or send webhooks when features complete
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Group by trigger type */}
|
||||
{Object.entries(hooksByTrigger).map(([trigger, hooks]) => (
|
||||
<div key={trigger} className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">
|
||||
{EVENT_HOOK_TRIGGER_LABELS[trigger as EventHookTrigger]}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{hooks.map((hook) => (
|
||||
<HookCard
|
||||
key={hook.id}
|
||||
hook={hook}
|
||||
onEdit={() => handleEditHook(hook)}
|
||||
onDelete={() => handleDeleteHook(hook.id)}
|
||||
onToggle={(enabled) => handleToggleHook(hook.id, enabled)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'hooks' | 'history')}>
|
||||
<div className="px-6 pt-4">
|
||||
<TabsList className="grid w-full max-w-xs grid-cols-2">
|
||||
<TabsTrigger value="hooks" className="gap-2">
|
||||
<Webhook className="w-4 h-4" />
|
||||
Hooks
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="history" className="gap-2">
|
||||
<History className="w-4 h-4" />
|
||||
History
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* Hooks Tab */}
|
||||
<TabsContent value="hooks" className="m-0">
|
||||
<div className="p-6 pt-4">
|
||||
{eventHooks.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Webhook className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm">No event hooks configured</p>
|
||||
<p className="text-xs mt-1">
|
||||
Add hooks to run commands or send webhooks when features complete
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Group by trigger type */}
|
||||
{Object.entries(hooksByTrigger).map(([trigger, hooks]) => (
|
||||
<div key={trigger} className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">
|
||||
{EVENT_HOOK_TRIGGER_LABELS[trigger as EventHookTrigger]}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{hooks.map((hook) => (
|
||||
<HookCard
|
||||
key={hook.id}
|
||||
hook={hook}
|
||||
onEdit={() => handleEditHook(hook)}
|
||||
onDelete={() => handleDeleteHook(hook.id)}
|
||||
onToggle={(enabled) => handleToggleHook(hook.id, enabled)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Variable reference */}
|
||||
<div className="px-6 pb-6">
|
||||
<div className="rounded-lg bg-muted/30 p-4 text-xs text-muted-foreground">
|
||||
<p className="font-medium mb-2">Available variables:</p>
|
||||
<code className="text-[10px] leading-relaxed">
|
||||
{'{{featureId}}'} {'{{featureName}}'} {'{{projectPath}}'} {'{{projectName}}'}{' '}
|
||||
{'{{error}}'} {'{{timestamp}}'} {'{{eventType}}'}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
{/* Variable reference */}
|
||||
<div className="px-6 pb-6">
|
||||
<div className="rounded-lg bg-muted/30 p-4 text-xs text-muted-foreground">
|
||||
<p className="font-medium mb-2">Available variables:</p>
|
||||
<code className="text-[10px] leading-relaxed">
|
||||
{'{{featureId}}'} {'{{featureName}}'} {'{{projectPath}}'} {'{{projectName}}'}{' '}
|
||||
{'{{error}}'} {'{{timestamp}}'} {'{{eventType}}'}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* History Tab */}
|
||||
<TabsContent value="history" className="m-0">
|
||||
<div className="p-6 pt-4">
|
||||
<EventHistoryView />
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Dialog */}
|
||||
<EventHookDialog
|
||||
|
||||
78
apps/ui/src/hooks/use-notification-events.ts
Normal file
78
apps/ui/src/hooks/use-notification-events.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Hook to subscribe to notification WebSocket events and update the store.
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useNotificationsStore } from '@/store/notifications-store';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { pathsEqual } from '@/lib/utils';
|
||||
import type { Notification } from '@automaker/types';
|
||||
|
||||
/**
|
||||
* Hook to subscribe to notification events and update the store.
|
||||
* Should be used in a component that's always mounted when a project is open.
|
||||
*/
|
||||
export function useNotificationEvents(projectPath: string | null) {
|
||||
const addNotification = useNotificationsStore((s) => 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]);
|
||||
}
|
||||
@@ -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<string>;
|
||||
getApiKey?: () => Promise<string | null>;
|
||||
@@ -760,6 +842,8 @@ export interface ElectronAPI {
|
||||
}>;
|
||||
};
|
||||
ideation?: IdeationAPI;
|
||||
notifications?: NotificationsAPI;
|
||||
eventHistory?: EventHistoryAPI;
|
||||
codex?: {
|
||||
getUsage: () => Promise<CodexUsageResponse>;
|
||||
getModels: (refresh?: boolean) => Promise<{
|
||||
|
||||
@@ -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.
|
||||
|
||||
6
apps/ui/src/routes/notifications.tsx
Normal file
6
apps/ui/src/routes/notifications.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { NotificationsView } from '@/components/views/notifications-view';
|
||||
|
||||
export const Route = createFileRoute('/notifications')({
|
||||
component: NotificationsView,
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
|
||||
129
apps/ui/src/store/notifications-store.ts
Normal file
129
apps/ui/src/store/notifications-store.ts
Normal file
@@ -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<NotificationsState & NotificationsActions>(
|
||||
(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),
|
||||
})
|
||||
);
|
||||
@@ -19,6 +19,12 @@ export {
|
||||
getAppSpecPath,
|
||||
getBranchTrackingPath,
|
||||
getExecutionStatePath,
|
||||
getNotificationsPath,
|
||||
// Event history paths
|
||||
getEventHistoryDir,
|
||||
getEventHistoryIndexPath,
|
||||
getEventPath,
|
||||
ensureEventHistoryDir,
|
||||
ensureAutomakerDir,
|
||||
getGlobalSettingsPath,
|
||||
getCredentialsPath,
|
||||
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
const eventsDir = getEventHistoryDir(projectPath);
|
||||
await secureFs.mkdir(eventsDir, { recursive: true });
|
||||
return eventsDir;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Global Settings Paths (stored in DATA_DIR from app.getPath('userData'))
|
||||
// ============================================================================
|
||||
|
||||
123
libs/types/src/event-history.ts
Normal file
123
libs/types/src/event-history.ts
Normal file
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: [],
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
58
libs/types/src/notification.ts
Normal file
58
libs/types/src/notification.ts
Normal file
@@ -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: [],
|
||||
};
|
||||
@@ -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<EventHookTrigger, string> = {
|
||||
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',
|
||||
|
||||
Reference in New Issue
Block a user